feat: trace ClickHouse queries and escape gateway metric labels by JayaSurya-27 · Pull Request #3574 · future-agi/future-agi · GitHub
Skip to content

feat: trace ClickHouse queries and escape gateway metric labels - #3574

Merged
JayaSurya-27 merged 2 commits into
devfrom
feat/platform-telemetry
Oct 23, 2026
Merged

JayaSurya-27 merged 2 commits into
devfrom
feat/platform-telemetry

Conversation

@JayaSurya-27

@JayaSurya-27 JayaSurya-27 commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Summary

Backend ClickHouse queries emitted no OpenTelemetry spans, so Tempo and the span-metrics RED panels only ever showed Postgres and Redis. This wires the existing instrument_clickhouse_client helper into the three places ClickHouse clients are created, so every query gets a db.system=clickhouse client span with a truncated statement, operation, row count and error status. The AgentCC gateway's Prometheus exposition did not escape label values, and the model label comes from the request body, so a quoted or multi-line model name could corrupt /-/metrics; label values are now escaped per the text exposition format.

Linked issues

Contributes to TH-8757
Linear: https://linear.app/future-agi/issue/TH-8757

AI use

AI use:

Type of change

  • ✨ New feature (non-breaking change which adds functionality)
  • 🐛 Bug fix (non-breaking change which fixes an issue)

E2E coverage

E2E: exempt (backend-internal and gateway-internal telemetry; no user-visible flow)


1) What changes were done

A. ClickHouse query spans (tfc/telemetry/instrumentation.py)

  • instrument_clickhouse_client now wraps every query entry point present on the client (execute, execute_iter, query, command, query_row_block_stream), is a no-op when OTEL_ENABLED is false, and is idempotent.
  • New trace_clickhouse_stream keeps the span open until a streaming result is consumed or closed, so execute_iter and query_row_block_stream report real durations and errors.
  • Shared helpers build span name, SpanKind.CLIENT, attributes and error recording for the sync, async and stream wrappers; db.rows_returned unwraps (rows, column_types) tuples and is omitted for command() strings.

B. Wiring at the client factories

  • tfc/utils/clickhouse.py singleton, tracer/services/clickhouse/client.py pooled native driver, and tracer/services/clickhouse/application_read_transport.py HTTP read client now return instrumented clients.

C. Gateway metrics label escaping (agentcc-gateway/internal/metrics/registry.go)

  • formatLabels escapes backslash, double quote and newline in label values.

2) Why the changes were done

  • Product/UX: Database latency and error dashboards in Grafana need ClickHouse alongside Postgres and Redis; the db.system dimension on Tempo span metrics is the only DB dimension those panels use.
  • Technical: clickhouse-driver and clickhouse-connect ship no OpenTelemetry support and the urllib3 spans carry no DB attributes, so instrumentation has to happen at the client. Wrapping at the factories covers every caller with three small edits.
  • Technical: The gateway metrics endpoint is about to be scraped by Alloy; an unescaped request-controlled label would break the whole scrape.

3) Tests written + scenarios each covers

tfc/tests/test_telemetry_clickhouse.py — span emission through the three real client factories with an in-memory exporter

  • test_singleton_client_is_instrumented — the analytics singleton returns a wrapped client
  • test_native_execute_emits_client_span — execute span has db.system, statement, operation, CLIENT kind and row count
  • test_with_column_types_counts_rows_not_tuple_parts — with_column_types=True tuple results count rows, not tuple parts
  • test_execute_iter_span_ends_when_rows_are_consumed — stream span stays open until the generator is exhausted
  • test_execute_iter_error_while_consuming_marks_span_error — errors during iteration set ERROR status
  • test_http_application_client_query_emits_span — clickhouse-connect query is traced
  • test_http_application_client_command_records_no_row_count — string results record no row count
  • test_http_application_client_block_stream_span_ends_on_exit — context-manager stream span ends on exit
  • test_otel_disabled_returns_client_untouched — no wrapping when OTel is off
  • test_instrumentation_is_idempotent — wrapping twice does not double-trace
  • plus error-status and attribute-shape tests

agentcc-gateway/internal/metrics/registry_test.go

  • TestFormatLabels_EscapesValues — quote, backslash and newline render escaped
tfc/tests/test_telemetry_clickhouse.py ............                      [100%]
============================== 12 passed in 0.69s ==============================

ok  	github.com/futureagi/agentcc-gateway/internal/metrics	0.392s
ok  	github.com/futureagi/agentcc-gateway/internal/plugins/prometheus	0.744s

4) How to run / test

cd futureagi
bin/test tfc/tests/test_telemetry_clickhouse.py -v

cd ../agentcc-gateway
go test ./internal/metrics/... ./internal/plugins/prometheus/...

5) Rollout notes

  • Ships through the normal release flow (dev to main, release-please, release-images); no migration, no new env vars, no values change. ClickHouse spans start the moment the new backend image is live because OTEL_ENABLED and the Tempo endpoint are already set in prod.
  • Expected new span volume: one client span per ClickHouse query; the busiest pattern alone is about 1,900 queries/h, well within current Tempo ingest.
  • After the new agentcc-gateway image is deployed, flip prometheus.enabled to true in the deployment repo (templates/agentcc-gateway-cm.yaml, step 5 of deployment PR fix(annotations): consolidate TH-4812 annotation fixes #401) and redeploy the umbrella chart; the Alloy scrape for /-/metrics is already live and will go from HTTP 404 to up == 1.
  • Rollback: redeploy the previous image tag; nothing persisted changes.

6) Edge cases & considerations

  • OTEL_ENABLED=false: factories return the raw client; no OTel import happens on the hot path.
  • Streaming reads: the span is started non-current so generator yields do not leak context; it ends on exhaustion, close, or exception.
  • command() returns a string: no db.rows_returned, so dashboards do not see string lengths as row counts.
  • Health-check SELECT 1 on pooled connections is traced like any query; volume is low and it is filterable by statement.

7) Pre-existing issues (NOT introduced by this PR)

  • black --check flags a hunk in tracer/services/clickhouse/client.py and ruff reports five UP035/UP045 findings in tfc/telemetry/instrumentation.py; both reproduce on dev on lines this PR does not touch.

8) Architectural / important decisions

  • Instrument at the factories, not per call site: three edits cover every ClickHouse read path and keep the helper as the single place span shape is defined.
  • Spans are CLIENT kind: matches the psycopg and redis instrumentors so DB panels can filter by kind uniformly.
  • Three-pair replacer for label escaping: the gateway has no Prometheus client dependency and strconv.Quote escapes more than the text format allows.

Checklist

  • My code follows the style guide
  • I've added tests that prove my fix is effective or that my feature works
  • Targeted tests pass locally
  • No hardcoded secrets, URLs, or PII
  • PR title follows Conventional Commits

@atharva-bhange atharva-bhange left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes because the new client wrapping breaks an existing test and backend CI is failing. The HTTP query result handling also omits the row count promised by this PR. Details and minimal fixes are in the inline comments.

Validation: both gateway metric test packages passed locally. Isolated checks against the pinned ClickHouse dependencies confirmed HTTP stream cleanup/error status and reproduced the missing HTTP row count. Reviewed the failed backend CI job; did not run the entire backend suite locally.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Update the existing test for the newly wrapped query method

Returning the instrumented client replaces client.query with a function. test_user_detail_reverse_lookup_keeps_transport_safety_not_statement_caps still accesses client.query.call_args at tracer/tests/test_session_list_bounded_view.py:841, so it now fails with AttributeError: function object has no attribute call_args. This is the failure in pytest shard 3 and causes Backend Tests Pass to fail: https://github.com/future-agi/future-agi/actions/runs/39586937859/job/119178225927

Retain the original query mock before the factory instruments the client, and inspect that mock for the existing transport-safety assertions. Please rerun the affected test and get backend CI green.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 713651a, test-only as you suggested: the test now keeps a reference to the client.query mock before the reader builds the client and asserts on that mock, since the factory wraps the method with a plain function. Kept production code mock-unaware and avoided __wrapped__ because it does not exist when tracing is disabled. tracer/tests/test_session_list_bounded_view.py and tfc/tests/test_telemetry_clickhouse.py pass locally (71 passed); CI shard 3 should be green on this push.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Record row_count from HTTP QueryResult objects

The HTTP client returns clickhouse_connect.driver.query.QueryResult. In the pinned clickhouse-connect 1.1.1 it exposes row_count, not len, so this branch never records db.rows_returned for normal HTTP queries. I reproduced this with a real QueryResult containing two rows: the query returns both rows but its span has no row-count attribute. The new HTTP query test does not assert that attribute.

Handle QueryResult.row_count alongside the native list/tuple handling, and add a test using the real result type that asserts db.rows_returned. Otherwise HTTP and native queries expose inconsistent telemetry.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 713651a. _record_clickhouse_result gains an elif that records db.rows_returned from row_count when it is an int, after the existing int and sized-result branches, so native list/tuple handling and the command() string case are unchanged. The HTTP fixture now returns a real clickhouse_connect QueryResult built from a column block and the span test asserts db.rows_returned == 2 (verified against both the 1.1.1 and 1.8.0 pins). One behavioural note: row_count materialises the rows, so the response body is now read inside the span and a mid-body read error surfaces from client.query() itself, which lands inside the readers' existing reset-and-reraise handling.

@atharva-bhange atharva-bhange left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 713651a. Both requested changes are addressed: the existing test retains the original query mock without weakening its safety assertions, and HTTP QueryResult row counts are recorded with regression coverage. Independently verified populated and empty HTTP results, native counts, command exclusion, and HTTP body-read error status. All ten backend CI shards, gateway checks, and E2E checks pass. Approved. The full backend suite was verified through CI rather than rerun locally.

@JayaSurya-27
JayaSurya-27 merged commit b9ce566 into dev Oct 23, 2026
30 checks passed
@JayaSurya-27
JayaSurya-27 deleted the feat/platform-telemetry branch October 23, 2026 07:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants