Conversation
Add ClickHouseTraceQuery, a TraceQuery plugin reading traces stored with the OpenTelemetry Collector ClickHouse exporter schema, so the Trace Table and Tracing Gantt Chart panels can be used with ClickHouse. Like the Tempo trace query, a trace ID returns the whole trace as OTLP and any other query returns search results. A search query is SQL returning one row per span, grouped by TraceId into search results. Trace ID lookups are deliberately not bounded by time, so that a trace opens from a link whatever the dashboard time range. The data model docs explain the trade-off with the exporter's trace ID lookup table. Includes the CUE schema with valid and invalid test cases, the Go SDK builder, unit tests and docs. Refs perses/perses#4202 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Erdinc Kaya <erdincka@msn.com>
| type: 'info', | ||
| message: 'Not all matching traces are currently displayed. Increase the result limit to view additional traces.', | ||
| }); | ||
| searchResult.splice(limit); |
There was a problem hiding this comment.
Although the trade-off is documented, can a query limit be added and configured by a field in the query spec?, without it we just fetch all the results to remove them after fetching.
There was a problem hiding this comment.
Good point, thanks. limit was already in the spec (the "Max traces" field) but was only applied after fetching. I have tested 2 resolutions:
- The search now runs the query as a subquery and does the grouping in ClickHouse: one row per trace (root span, start and end time, span and error counts per service), newest first, LIMIT limit + 1 to detect hasMoreResults as the Tempo plugin does. On my test data, a search over 48 traces now returns 21 rows instead of 168 span rows. The trade-off is that all seven columns are now required and the query has to be a single SELECT without its own FORMAT. Both are documented, and a FORMAT clause is rejected with an explicit error.
- A {limit} placeholder the user writes into their own SQL, like {start}/{end}. It's a much smaller change, but the pushdown only works if the user puts it in the right place, which is the trace ID subquery (at span level it would cut traces short), and the browser would still fetch every span of those traces.
Would you agree to go with Option 1?
There was a problem hiding this comment.
I'm not an expert in ClickHouse, so I lean on your expertise. But option 1 seems more reasonable as the user might not know which one is the limit they need to use.
There was a problem hiding this comment.
I've pushed a separate commit for this (including the test and doc update for the trace id thread). The search query is now used as a subquery and ClickHouse does the grouping and the limit (buildSearchQuery in get-click-house-trace-data.ts): one row per trace with the root span, start and end time, and span and error counts per service, newest first, with LIMIT limit + 1.
Review feedback: the search fetched every matching span and grouped them in the browser, so the limit only dropped traces after the fact. The search query is now used as a subquery. ClickHouse groups the spans per trace, orders them newest first and applies LIMIT limit + 1, the extra row telling the panels that more traces match. Only the traces that are displayed cross the wire, and start and end times come back as nanoseconds, so timestamps no longer depend on the server timezone. All the documented columns are therefore required, the query has to be a single SELECT, and a FORMAT clause is rejected with an explicit error. Also spell out that a trace ID is 16 or 32 hexadecimal characters, which SQL cannot match, with a test for a 16 character query. Refs perses/perses#4202 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Erdinc Kaya <erdincka@msn.com>
There was a problem hiding this comment.
🟡 Changes recommended
Search output formatting, datasource variables, JSON attributes, service aggregation, and unbounded JSON-schema lookups need correction.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds ClickHouse trace lookup and search support for Perses trace panels.
Changes:
- Implements trace retrieval, search aggregation, OTLP conversion, and editor UI.
- Adds CUE schemas, Go SDK builders, documentation, and tests.
- Registers and exposes
ClickHouseTraceQuery.
File summaries
| File | Description |
|---|---|
docs/clickhouse/README.md |
Introduces trace-query documentation. |
docs/clickhouse/model.md |
Documents configuration and data model. |
docs/clickhouse/go-sdk/trace-query.md |
Documents Go SDK usage. |
clickhouse/src/queries/index.ts |
Exports the trace query. |
clickhouse/src/queries/click-house-trace-query/index.ts |
Defines public exports. |
clickhouse/src/queries/click-house-trace-query/get-click-house-trace-data.ts |
Implements lookup, search, and conversion. |
clickhouse/src/queries/click-house-trace-query/get-click-house-trace-data.test.ts |
Tests trace data handling. |
clickhouse/src/queries/click-house-trace-query/ClickHouseTraceQueryEditor.tsx |
Adds query options UI. |
clickhouse/src/queries/click-house-trace-query/ClickHouseTraceQueryEditor.test.tsx |
Tests editor behavior. |
clickhouse/src/queries/click-house-trace-query/ClickHouseTraceQuery.tsx |
Defines the plugin. |
clickhouse/src/queries/click-house-trace-query/ClickHouseTraceQuery.test.ts |
Tests plugin options and dependencies. |
clickhouse/src/queries/click-house-trace-query/click-house-trace-query-types.ts |
Defines configuration and row types. |
clickhouse/sdk/go/query/trace/trace.go |
Adds the Go builder. |
clickhouse/sdk/go/query/trace/trace_test.go |
Tests Go serialization. |
clickhouse/sdk/go/query/trace/options.go |
Adds Go builder options. |
clickhouse/schemas/queries/click-house-trace-query/query.cue |
Defines the CUE contract. |
clickhouse/schemas/queries/click-house-trace-query/tests/valid/trace-id.json |
Covers valid trace lookup. |
clickhouse/schemas/queries/click-house-trace-query/tests/valid/search.json |
Covers valid search configuration. |
clickhouse/schemas/queries/click-house-trace-query/tests/invalid/non-positive-limit.json |
Rejects invalid limits. |
clickhouse/schemas/queries/click-house-trace-query/tests/invalid/invalid-table.json |
Rejects unsafe table names. |
clickhouse/schemas/queries/click-house-trace-query/tests/invalid/empty-query.json |
Rejects empty queries. |
clickhouse/rsbuild.config.ts |
Exposes the plugin bundle. |
clickhouse/package.json |
Registers the plugin metadata. |
Review details
- Files reviewed: 23/23 changed files
- Comments generated: 5
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| WHERE TraceId != '' | ||
| GROUP BY TraceId | ||
| ORDER BY min(toDateTime64(Timestamp, 9)) DESC | ||
| LIMIT ${limit}`; |
| // The exporter stores every attribute value as a string, so the original value types cannot be recovered | ||
| function toKeyValues(attributes: Record<string, string> = {}): otlpcommonv1.KeyValue[] { | ||
| return Object.entries(attributes).map(([key, value]) => ({ key, value: { stringValue: value } })); |
| const client = (await context.datasourceStore.getDatasourceClient( | ||
| spec.datasource ?? DEFAULT_DATASOURCE, | ||
| )) as ClickHouseClient; |
| * The lookup is deliberately not bounded by time, so that a trace opens from a link whatever the dashboard time | ||
| * range. It relies on the bloom filter index the exporter creates on TraceId. The exporter's `<table>_trace_id_ts` | ||
| * table could bound the scan on large tables, but it only exists when the exporter created the schema. See "Trace ID | ||
| * lookup" in the data model docs. |
| const serviceStats: Record<string, ServiceStats> = {}; | ||
| for (const [serviceName, spanCount] of Object.entries(row.SpanCounts)) { | ||
| serviceStats[serviceName || 'unknown'] = { spanCount: Number(spanCount) }; | ||
| } | ||
| for (const [serviceName, errorCount] of Object.entries(row.ErrorCounts)) { | ||
| const stats = serviceStats[serviceName || 'unknown']; | ||
| // ClickHouse also returns the services without errors, where the panels expect no count at all | ||
| if (stats !== undefined && Number(errorCount) > 0) { | ||
| stats.errorCount = Number(errorCount); | ||
| } | ||
| } |
Description
Adds
ClickHouseTraceQuery, aTraceQueryplugin in the ClickHouse plugin module, so traces stored in ClickHouse can be displayed with the existing Trace Table and Tracing Gantt Chart panels. Until now the module had log and time series queries only.Relates to perses/perses#4202 (ClickHouse traces as a datasource): this is the query plugin a ClickHouse trace explorer would build on.
How it works
The plugin reads the schema created by the OpenTelemetry Collector ClickHouse exporter, and follows the
TraceDatacontract used by the Tempo plugin: a trace ID returns a trace, anything else returns search results.table(defaultotel_traces, optionallydatabase.table), and converts the rows to OTLP, grouped by resource and instrumentation scope. Drill-down links such as?var-traceId=${traceId}work as they do with Tempo.TraceId,Timestamp,Duration,ParentSpanId,SpanName,ServiceName,StatusCode). The plugin uses it as a subquery, and ClickHouse groups the spans into traces: one row per trace with the root span, the start and end time, and the span and error counts per service, newest first, withLIMIT limit + 1. The extra trace is how the panels know more traces match, as in the Tempo and Jaeger plugins.{start}and{end}are replaced as in the log and time series queries.Implementation notes
toUnixTimestamp64Nano). OtherwiseDateTime64values are rendered in the server's timezone, and nanoseconds since the epoch exceedNumber.MAX_SAFE_INTEGER, so they are summed and subtracted withBigInt.tablemust be a plain identifier (checked in CUE and at runtime) before either goes into SQL. User-written search queries keep the existing behavior of the ClickHouse queries.limitis applied by ClickHouse, not after fetching: only the traces that are displayed cross the wire. On the verification data, the search panel returns 8 rows instead of 48 span rows, and a search over 48 traces withlimit: 20returns 21 rows instead of 168. The cost is that the search query is used as a subquery: all the documented columns are required, it has to be a singleSELECT, and a query ending with aFORMATclause is rejected with an explicit error. A trailing;is removed.status: 'error'and logs the server's message to the console, so the plugin throws rather than showing an empty panel. An unknown trace ID also throws, as in the Jaeger plugin.SpanKindandStatusCodeare accepted as the exporter writes them (Server,Error) and as OTLP enum names (SPAN_KIND_SERVER,STATUS_CODE_ERROR). The exporter stores attribute values as strings, so attributes are returned asstringValue.Durationis accepted as a JSON number or string, since ClickHouse quotes 64-bit integers or not depending onoutput_format_json_quote_64bit_integers.Changes
clickhouse/src/queries/click-house-trace-query/clickhouse/schemas/queries/click-house-trace-query/clickhouse/sdk/go/query/trace/Query,Datasource,Table,Limit) and testsclickhouse/package.json,rsbuild.config.ts,src/queries/index.tsdocs/clickhouse/Tests
FORMATclause, unknown root service, ClickHouse errors. Editor: table committed on blur, limit.Verification
CI-equivalent run. I ran the steps of
react.yml,cue.yml,go.yml,doc.ymlandci.ymlat this branch's commit, in a container with the versions CI pins (Go 1.27.1, Node 24, CUE v0.16.1, percli v0.54.0, golangci-lint v2.13.2, mdox). All 17 steps passed:npm ci,npm run lint,npm run format:check,npm run type-check,npm run testmake checkformat-cue,make lint-plugins,make test-schemas-plugins,make tidy-moduleswith nocue.moddiffclick-house-trace-querycases are evaluated)make test,go test ./...inclickhouse,make golangci-lint,make checklicensemake checkdocspercli plugin buildforclickhouse,tracetableandtracingganttchartThe advisory React Doctor scan (
react-doctor.yml) reports no findings in the new files.End to end. OTLP/HTTP → OpenTelemetry Collector contrib 0.160.0 (ClickHouse exporter,
create_schema: true) → ClickHouse 25.8 → Perses (mainimage of 2026-09-10), with aClickHouseDatasourcegoing through the Perses proxy and the plugin archives built from this branch. The data: 8 traces across three services (with errors, exception events and span links), plus 40 traces from telemetrygen.TraceQuery:ClickHouseTraceQuery, and a dashboard using it is provisioned.POST /api/validate/dashboardsaccepts valid specs, and rejects atablethat is not an identifier,limit: 0, an emptyqueryand an unknown field, each with the corresponding CUE error.limit: 20, it shows the "Not all matching traces are currently displayed" notice. ClickHouse'squery_logconfirms the limit is applied there: 21 rows returned for that panel, and 8 for the search on the payment service.query: $traceIdrenders the trace: span hierarchy, error statuses, attributes and events. Following a Trace Table link (?var-traceId=${traceId}) shows the selected trace.I can share the Compose setup used for this (collector config, seed script, provisioning) if it helps the review.
Design decision to review: trace ID lookups are not bounded by time
Important
A trace ID lookup reads the spans with
WHERE TraceId = '…'fromtable, with no time bound, so that a trace opens from a link whatever the time range of the dashboard. It relies on the bloom filter index the exporter creates onTraceId.The alternative is to first read the start and end time of the trace from the exporter's
<table>_trace_id_tstable (otel_traces_trace_id_tsby default), and bound the scan with them, which lets ClickHouse skip the partitions outside the trace on large tables. I did not do it because that table only exists when the exporter created the schema, so the lookup would fail on custom tables and views that otherwise have the right columns.This is the part of the design most likely to need a change, for example an option to use that table, or using it by default. The choice is documented in the data model docs ("Trace ID lookup") and in a comment on the query builder, so it is easy to revisit. Happy to change it if you prefer the other way.
Noticed while working on this, not changed in this PR
docs/clickhouse/go-sdk/datasource.md,log-query.mdandtimeseries-query.mdimportgithub.com/perses/perses-plugins/clickhouse/sdk/go/v1/..., while the SDK packages aregithub.com/perses/plugins/clickhouse/sdk/go/datasource,.../query/logand.../query/time-series. The two query pages also use builders that don't exist (query.LogQuery,query.TimeSeriesQuery,query.Format): the SDK haslog.ClickHouseLogQueryandtimeseries.ClickHouseTimeSeriesQuery, withQueryandDatasourceoptions only. Likewise,docs/clickhouse/model.mddocuments an optionalformatfield forClickHouseTimeSeriesQueryandClickHouseLogQuery, which their CUE schemas (closed, withdatasourceandqueryonly) reject. I didn't find an existing issue about it. I left these pages unchanged to keep this PR focused (the newtrace-query.mduses the actual packages), and I'm happy to fix them in a follow-up.TracingGanttChartinitializes both withuseState, and the panel doesn't key the component by trace. The lower timeline then shows offsets from the previous trace, and the details pane the previously selected span. It happens with any trace query plugin, so it isn't addressed here. The drill-down screenshot below was taken after reloading the page.Screenshots
Perses
mainwith the plugin archives built from this branch, reading traces written by the OpenTelemetry Collector ClickHouse exporter.Dashboard: two Trace Tables with search queries, and a Tracing Gantt Chart with
query: $traceIdSpan details converted from the exporter's columns: kind, status, attributes, resource, scope, events
Query editor: datasource, trace ID or SQL, trace table, max traces
Search limit:
limit: 20with 48 matching tracesA Trace Table link followed to the Gantt chart (after a page reload, see the Tracing Gantt Chart note above)
Unknown trace ID
Checklist
[<catalog_entry>] <commit message>naming convention using one of thefollowing
catalog_entryvalues:FEATURE,ENHANCEMENT,BUGFIX,BREAKINGCHANGE,DOC,IGNORE.UI Changes
🤖 Generated with Claude Code