Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/spanmetrics-connector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@hyperdx/otel-collector": minor
---

feat(otel-collector): compile in spanmetricsconnector

Available for a user's own pipeline config (e.g. via
`CUSTOM_OTELCOL_CONFIG_FILE`) to compute call-count and duration
metrics from spans - most useful alongside the existing
`datadogreceiver` support for ingesting Datadog Agent traces, where
there was previously no way to derive RED metrics from that trace data
once ingested. Purely additive: being compiled in changes no default
pipeline or behavior on its own.
173 changes: 169 additions & 4 deletions packages/otel-collector/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,11 @@ custom OTel configurations without rebuilding the collector.

### Connectors

| Component | Module | Used in |
| --------- | ------- | ------------------------------------ |
| `forward` | core | included for utility |
| `routing` | contrib | standalone configs, OpAMP controller |
| Component | Module | Used in |
| -------------- | ------- | ------------------------------------ |
| `forward` | core | included for utility |
| `routing` | contrib | standalone configs, OpAMP controller |
| `span_metrics` | contrib | user configs |

### Extensions

Expand Down Expand Up @@ -157,6 +158,170 @@ receiver authenticates against the team API keys (same as `otlp/hyperdx`) via
the `DD-API-KEY` header. Set the Datadog Agent's `DD_API_KEY` to a HyperDX
team ingestion API key.

## Computing span metrics from ingested traces

`spanmetricsconnector` is compiled in so span-derived RED metrics (call
counts, a duration histogram) can be computed from any traces the collector
ingests — works with `otlp/hyperdx` directly, and is most useful alongside
[`datadogreceiver`](#ingesting-datadog-traces-metrics-and-logs) above, since
Comment thread
arj22 marked this conversation as resolved.
Resolved
Datadog Agent traces otherwise only get RED metrics the same way any other
trace source does today: computed at query time from the traces table
(`packages/app/src/serviceDashboard.ts`'s `getExpressions`). This connector
precomputes them once at the collector instead of per query, and adds an
exponential-histogram latency series HyperDX's ClickHouse schema already
has a dedicated table for (below). The component's config key is
`span_metrics` (the module is still named
Comment thread
arj22 marked this conversation as resolved.
Resolved
`spanmetricsconnector`, and `spanmetrics` still works as a deprecated
alias, but new configs should use the current name).

A connector has to be declared two places: under a top-level `connectors:`
block *and* referenced in a pipeline's `receivers`/`exporters`. Declaring
only the reference fails validation —
`service::pipelines::traces: references exporter "span_metrics" which is
not configured` — since the connector was never instantiated.

Both examples set five more fields on the connector itself, beyond the
bare minimum:

- `aggregation_temporality: AGGREGATION_TEMPORALITY_DELTA` — the
connector defaults to cumulative, which doesn't mix safely with the
`series_expiration`/`aggregation_cardinality_limit` pair below: expiring
a series deletes its accumulator, so the *next* span for that same
service+operation restarts its `calls` counter from 1 rather than
continuing it — an ordinary counter reset, except HyperDX's own
cumulative-rate SQL (`packages/common-utils/src/core/renderChartConfig.ts`,
`greatest(Value - lagInFrame(Value), 0)`) doesn't special-case resets;
it clamps the negative/zero delta and moves on. For a series that's
quiet for long enough between spans to keep expiring, every emitted
value stays `1` forever and every diffed delta is `0` — an `increase`
chart on `traces.span.metrics.calls` reads flat zero for an endpoint
that's actually serving traffic. Delta temporality passes `Value`
straight through with no diffing (same file, `AggregationTemporality =
1` branch), so it isn't exposed to this at all.
- `namespace: traces.span.metrics` — this is already the connector's
default (v0.155.0's `DefaultNamespace`), so it's set here for
explicitness rather than to avoid a collision: pinning it keeps the
emitted names (`traces.span.metrics.calls`/`.duration`) stable and
obvious from the local config even if a future version ever changes
that default.
- `histogram.exponential.max_size: 160` — without it, the connector
defaults to an *explicit*-bucket duration histogram
(`otel_metrics_histogram`), not the exponential one
(`otel_metrics_exponential_histogram`) HyperDX's ClickHouse schema
already has a dedicated table for; this is what actually gets you
that table instead of the default bucket set.
- `aggregation_cardinality_limit` — `spanmetrics` aggregates unbounded by
Comment thread
arj22 marked this conversation as resolved.
Resolved
default, and its in-memory dimension map is a separate piece of state
from the pipeline's own data flow, so `memory_limiter` does not protect
it (that only refuses *incoming* data at the pipeline boundary). On a
Datadog-firehose source in particular, high-cardinality span names grow
that map until the collector OOMs regardless of `memory_limiter`'s own
limit. Set an explicit cap.
- `series_expiration` — pairs with the cap above: without it, a cap alone
Comment thread
arj22 marked this conversation as resolved.
Resolved
Comment thread
arj22 marked this conversation as resolved.
Resolved
just changes *when* it hurts, since the connector never expires
anything by default. Specifically `series_expiration`, not
`metrics_expiration` — they're different fields (both real, both
default to "never"): `metrics_expiration` only retires a whole
service+operation's metric object once *no* spans for it arrive for
that long, while `series_expiration` evicts individual stale dimension
*combinations* from the cardinality-bounded map while other series
under the same metric stay active. A cardinality burst fills the map
with individual dimension combinations, not whole metrics, so
`series_expiration` is the one that actually frees room for new
series afterwards.

**In standalone mode**, `traces`/`metrics` are plain pipelines in your own
config file, so you can add `span_metrics` to them directly. They already
inherit `processors: [memory_limiter, batch]` from the base
`docker/otel-collector/config.yaml` (loaded first), so there's nothing to
add for that here — see ["Overriding base
components"](#overriding-base-components-via-custom_otelcol_config_file)
below for when you'd want to retune it. Confmap replaces a pipeline's
`receivers:`/`exporters:` list wholesale rather than appending to it, so
Datadog *trace* data only reaches `span_metrics` if `datadog` is re-listed
on `traces: receivers:` specifically — it's a receiver on the `traces`
pipeline that `span_metrics` consumes as an exporter there, so putting
`datadog` on `metrics: receivers:` instead (for the Datadog section's own
native-metrics ingestion) does nothing for span-derived metrics. The
example below deliberately leaves the trace-side `datadog` out: an
unconditional reference here would break this exact example for anyone
who *hasn't* configured it (`references receiver "datadog" which is not
configured`, failing the whole config, not just this pipeline) — the two
cases can't both be satisfied by one copy-pasted block, so check which one
applies to you before using it:

```yaml
Comment thread
arj22 marked this conversation as resolved.
Resolved
connectors:
span_metrics:
namespace: traces.span.metrics
aggregation_temporality: AGGREGATION_TEMPORALITY_DELTA
histogram:
exponential:
max_size: 160
aggregation_cardinality_limit: 10000
series_expiration: 5m
service:
pipelines:
traces:
# Add `datadog` here too if (and only if) you configured it per the
# Datadog section above - this list replaces the base one rather
# than adding to it.
receivers: [otlp/hyperdx]
exporters: [clickhouse, span_metrics]
metrics:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Standalone example silently drops the datadog receiver from the metrics pipeline

The traces: entry carries a "add datadog here too … this list replaces the base one" comment, but metrics: receivers: [otlp/hyperdx, span_metrics] does not — and the Datadog section above (README.md:146-148) tells standalone users to add datadog to the traces, metrics, and logs/in receivers. A Datadog user copying this block replaces their metrics receiver list and silently stops ingesting Datadog native metrics; the prose at README.md:243-245 ("putting datadog on metrics: receivers: instead … does nothing for span-derived metrics") makes that outcome more likely, since it reads as a reason to leave it off. Repeat the same warning comment on the metrics: list.

receivers: [otlp/hyperdx, span_metrics]
Comment thread
arj22 marked this conversation as resolved.
Resolved
Comment on lines +271 to +272

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Datadog metrics route is replaced

When a standalone user has configured the Datadog receiver and copies this example, the explicit metrics.receivers list replaces the existing list without preserving datadog, causing native Datadog metrics to stop reaching ClickHouse even though trace-derived metrics continue working.

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

```

**In OpAMP supervisor mode, editing the default `traces`/`metrics` pipelines
will not work** — `receivers:`/`exporters:` on those pipelines are set
dynamically by `opampController.ts`, and the remote config overwrites (not
merges) those keys, the same way it does for every pipeline it manages (see
`docker/otel-collector/config.yaml`'s note on `processors:` above). Adding
`span_metrics` there from `CUSTOM_OTELCOL_CONFIG_FILE` gets silently
dropped — no error, the connector is just compiled in and wired to nothing.
Use separate pipeline names instead, which the remote config never touches.
Unlike the standalone case, these are brand new pipeline names with nothing
to inherit from, so `processors:` does need to be declared explicitly here
— but only with `memory_limiter` on `traces/spanmetrics`, not `batch` too:
its only exporter is the connector itself, not a network call, so batching
a second copy of spans already batched in `traces` just adds latency
(up to the batch timeout) and memory for no export benefit. `batch` earns
its place on `metrics/spanmetrics`, which does export to ClickHouse:

```yaml
connectors:
span_metrics:
namespace: traces.span.metrics
aggregation_temporality: AGGREGATION_TEMPORALITY_DELTA
histogram:
exponential:
max_size: 160
aggregation_cardinality_limit: 10000
series_expiration: 5m
service:
pipelines:
traces/spanmetrics:
Comment thread
arj22 marked this conversation as resolved.
Resolved
# Add `datadog` here too if you've defined that receiver by any
# means (ENABLE_DATADOG_RECEIVER=true, or your own definition via
# CUSTOM_OTELCOL_CONFIG_FILE) - an undefined receiver fails the
# whole config (every pipeline, not just this one).
receivers: [otlp/hyperdx]
exporters: [span_metrics]
processors: [memory_limiter]
metrics/spanmetrics:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Span-derived metrics never reach the PromQL remote-write path, which the section doesn't mention

Both examples route span metrics only to the ClickHouse exporter. On deployments where PromQL is enabled, the remote-write pipeline receives only otlp/hyperdx (opampController.ts:348-352, and docker/otel-collector/config.standalone.promql.yaml:19-22), so traces.span.metrics.* is queryable from the metrics source but absent from PromQL charts with no error anywhere. Add a sentence noting that a user who wants these series in PromQL must also add span_metrics as a receiver on a pipeline exporting to prometheusremotewrite.

receivers: [span_metrics]
exporters: [clickhouse]
processors: [memory_limiter, batch]
```

Both shapes above are verified against the real compiled binary via

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Documented wiring is only verified by validate --config, though a smoke-test harness for exactly this pattern already exists

otelcontribcol validate --config proves the YAML parses and every component name resolves; it does not prove a span produces a calls/duration row in ClickHouse, which is the whole point of the section. The repo already has the harness for this: smoke-tests/otel-collector/custom-pipeline-processors.bats + custom-pipeline-config.yaml + the otel-collector-custom service (smoke-tests/otel-collector/docker-compose.yaml:83, wired into setup_suite.bash) cover the analogous documented CUSTOM_OTELCOL_CONFIG_FILE pattern end-to-end. Add a fixture that emits a span via emit_otel_data and asserts a row in otel_metrics_sum with MetricName = 'traces.span.metrics.calls' (an assert_query.sql/expected.snap pair under data/), which would also catch the temporality/expiry interaction above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same ask as the earlier open thread on this section (databaseId 3901295654) — leaving this one open alongside it rather than resolving, since it's the same deferred item (a bats fixture asserting a calls/duration row lands in ClickHouse), not a separate piece of work. Tracked in the PR description's "Open item" section.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The documented wiring has no smoke test, though the existing CUSTOM_OTELCOL_CONFIG_FILE bats harness covers exactly this case

smoke-tests/otel-collector/custom-pipeline-processors.bats plus smoke-tests/otel-collector/custom-pipeline-config.yaml already do the whole job this section needs: mount a custom config via CUSTOM_OTELCOL_CONFIG_FILE on the otel-collector-custom compose service, emit OTLP data, and assert the resulting rows in ClickHouse. Adding a fixture that emits a span and asserts a traces.span.metrics.calls row lands in otel_metrics_sum would run the documented config against the real binary in CI — which is what the two config errors above needed, since validate on the actual binary rejects both. Copy the existing fixture pattern rather than relying on the manual otelcontribcol validate claim in this paragraph.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same deferred item as the two earlier smoke-test threads on this PR - not a new piece of work, tracked in the PR description's "Open item" section. Leaving open alongside them rather than resolving.

`otelcontribcol validate --config`, including the two failure modes noted
inline and every connector field. The standalone shape was also actually
run (`otelcontribcol --config ...`, not just `validate`): it reaches
`"Everything is ready. Begin running and processing data."` and starts the
`span_metrics` connector, confirming `validate`'s static schema/graph
checks aren't hiding a startup-only failure here.

## Overriding base components via `CUSTOM_OTELCOL_CONFIG_FILE`

The collector ships with a default `memory_limiter` processor sized for a
Expand Down
3 changes: 3 additions & 0 deletions packages/otel-collector/builder-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ connectors:
- gomod:
github.com/open-telemetry/opentelemetry-collector-contrib/connector/routingconnector
v__OTEL_COLLECTOR_VERSION__
- gomod:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — No smoke test for the documented spanmetrics wiring, though the fixture harness already exists

smoke-tests/otel-collector/ already runs an otel-collector-custom service that loads custom-pipeline-config.yaml via CUSTOM_OTELCOL_CONFIG_FILE and asserts pipeline wiring took effect (custom-pipeline-processors.bats). A parallel fixture + bats test that emits a span and asserts a calls/duration row lands in ClickHouse would cost a few lines and would have caught the missing connectors: block above; add one rather than relying on components output, which only proves the module was compiled in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair point, real suggestion — didn't add the full bats smoke test yet (it wasn't as clearly a must-have as the two config bugs above, and a real cross-signal trace→metric test is a bit more involved than the existing same-signal fixtures). What I did instead, given the same underlying goal of "prove the wiring actually works" rather than just components output: validated both corrected snippets against the real compiled binary via otelcontribcol validate --config, including reproducing both failure modes from your other comments verbatim before fixing them. That covers config-shape correctness but not an actual emitted span landing as a calls/duration row in ClickHouse, which your suggested test would catch. Happy to add the bats test as a follow-up if you'd still like it — didn't want to assume the scope without checking first.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — New connector and both documented configs have no automated coverage, though the smoke suite exists precisely for this

smoke-tests/otel-collector/ already boots a standalone collector with CUSTOM_OTELCOL_CONFIG_FILE (the otel-collector-custom service, docker-compose.yaml:79-101) for the stated purpose of verifying "the swap pattern documented in packages/otel-collector/README.md works end-to-end", and metrics.bats already asserts metric rows land in ClickHouse via wait_for_rows. Add a fixture mirroring the documented standalone block plus a bats test that emits a span and waits for rows in otel_metrics_sum (traces.span.metrics.calls) and otel_metrics_exponential_histogram; without it, nothing catches the connector being dropped from the manifest or the config key/field names (span_metrics, series_expiration, aggregation_cardinality_limit) changing on the next OTEL_COLLECTOR_VERSION bump, since the README's "Upgrading the OTel Collector version" checklist doesn't mention re-verifying this section either.

github.com/open-telemetry/opentelemetry-collector-contrib/connector/spanmetricsconnector
v__OTEL_COLLECTOR_VERSION__

extensions:
# Core
Expand Down
Loading