README
¶
HyperDX OTel Collector
Custom-built OpenTelemetry Collector for HyperDX, compiled via
OCB (OpenTelemetry Collector Builder).
This replaces the pre-built otel/opentelemetry-collector-contrib image with a
binary that includes the components HyperDX needs plus commonly-used core and
contrib components, and any custom receivers/processors added in this package.
Architecture
Build process
The collector binary is built during docker build via a multi-stage
Dockerfile:
- The
ocbbinary is copied from the officialotel/opentelemetry-collector-builder:<version>image - It's placed into a
golang:<version>-alpinebase (the official OCB image may ship an older Go than the contrib modules require) - Version placeholders in
builder-config.yamlare substituted viased ocbcompiles the custom binary from the resolved manifest
Key files
| File | Purpose |
|---|---|
builder-config.yaml |
OCB manifest — declares which components to include. Uses __OTEL_COLLECTOR_VERSION__ and __OTEL_COLLECTOR_CORE_VERSION__ placeholders substituted at build time. |
cmd/migrate/main.go |
Go-based ClickHouse migration tool (goose) with full TLS support |
go.mod / go.sum |
Go module for the migration tool |
Dockerfiles that build this collector
| Dockerfile | Description |
|---|---|
docker/otel-collector/Dockerfile |
Standalone collector image (dev + prod targets) |
docker/hyperdx/Dockerfile |
All-in-one image (includes collector, ClickHouse, MongoDB, API, App) |
Version configuration
The collector version is controlled by two variables:
OTEL_COLLECTOR_VERSION— The contrib/core component version (e.g.0.149.0)OTEL_COLLECTOR_CORE_VERSION— The core confmap provider version (e.g.1.55.0)
These are defined in the root .env file and passed as Docker build args. Both
Dockerfiles also have matching ARG defaults as fallbacks for standalone
builds.
Included components
Components used by HyperDX internally are marked with their config references. Components marked "user configs" are included so users can reference them in custom OTel configurations without rebuilding the collector.
Receivers
| Component | Module | Used in |
|---|---|---|
nop |
core | OpAMP controller |
otlp |
core | standalone configs, OpAMP controller, smoke tests |
datadog |
contrib | OpAMP controller (opt-in via ENABLE_DATADOG_RECEIVER) |
dockerstats |
contrib | user configs |
filelog |
contrib | user configs |
fluentforward |
contrib | standalone configs, OpAMP controller, smoke tests |
hostmetrics |
contrib | custom.config.yaml |
k8scluster |
contrib | user configs |
kubeletstats |
contrib | user configs |
prometheus |
contrib | OpAMP controller, smoke tests |
statsd |
contrib | user configs |
Processors
| Component | Module | Used in |
|---|---|---|
batch |
core | config.yaml, standalone configs, OpAMP controller |
memory_limiter |
core | config.yaml, standalone configs, OpAMP controller |
attributes |
contrib | user configs |
cumulativetodelta |
contrib | user configs |
filter |
contrib | user configs |
groupbyattrs |
contrib | user configs |
k8sattributes |
contrib | user configs |
logdedup |
contrib | user configs |
metricstransform |
contrib | user configs |
probabilisticsampler |
contrib | user configs |
redaction |
contrib | user configs |
resourcedetection |
contrib | config.yaml |
resource |
contrib | user configs |
span |
contrib | user configs |
tailsampling |
contrib | user configs |
transform |
contrib | config.yaml, standalone configs, OpAMP controller |
Exporters
| Component | Module | Used in |
|---|---|---|
clickhouse |
contrib | standalone configs, OpAMP controller |
debug |
core | OpAMP controller |
nop |
core | OpAMP controller |
otlp |
core | included for utility |
otlphttp |
core | custom.config.yaml |
Connectors
| Component | Module | Used in |
|---|---|---|
forward |
core | included for utility |
routing |
contrib | standalone configs, OpAMP controller |
span_metrics |
contrib | user configs |
Extensions
| Component | Module | Used in |
|---|---|---|
memorylimiter |
core | user configs |
zpages |
core | user configs |
bearertokenauth |
contrib | standalone-auth config, OpAMP controller |
file_storage |
contrib | OpAMP controller (sending queue storage) |
health_check |
contrib | config.yaml, standalone-auth config |
opamp |
contrib | used by OpAMP supervisor |
pprof |
contrib | included for debugging/profiling |
Confmap Providers
| Provider | Module |
|---|---|
env |
core |
file |
core |
http |
core |
https |
core |
yaml |
core |
Ingesting Datadog traces, metrics, and logs
The datadogreceiver contrib component is compiled into the binary so a
Datadog Agent can ship traces, metrics, and logs to HyperDX. The receiver
runs a single HTTP server on :8126 that serves the Datadog intake API for
all three signals and translates them into OTLP, which flows through the
existing traces, metrics, and logs pipelines into ClickHouse. It is
opt-in:
- In OpAMP supervisor mode, set
ENABLE_DATADOG_RECEIVER=trueon the API/ OpAMP process.buildOtelCollectorConfig()then emits thedatadogreceiver (listening on0.0.0.0:8126) and attaches it to thetraces,metrics, andlogs/inpipelines. - In standalone mode, add a
datadogreceiver block to your collector config and adddatadogto thetraces,metrics, andlogs/inpipeline receivers.
Point the Datadog Agent at the collector's :8126 endpoint (e.g.
DD_APM_DD_URL for traces, DD_DD_URL for metrics, and the logs endpoint
config for logs).
Authentication
In OpAMP supervisor mode, when collector authentication is enforced the
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 above, since
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
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 theseries_expiration/aggregation_cardinality_limitpair below: expiring a series deletes its accumulator, so the next span for that same service+operation restarts itscallscounter 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 stays1forever and every diffed delta is0— anincreasechart ontraces.span.metrics.callsreads flat zero for an endpoint that's actually serving traffic. Delta temporality passesValuestraight through with no diffing (same file,AggregationTemporality = 1branch), so it isn't exposed to this at all.namespace: traces.span.metrics— this is already the connector's default (v0.155.0'sDefaultNamespace), 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—spanmetricsaggregates unbounded by default, and its in-memory dimension map is a separate piece of state from the pipeline's own data flow, somemory_limiterdoes 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 ofmemory_limiter's own limit. Set an explicit cap.series_expiration— pairs with the cap above: without it, a cap alone just changes when it hurts, since the connector never expires anything by default. Specificallyseries_expiration, notmetrics_expiration— they're different fields (both real, both default to "never"):metrics_expirationonly retires a whole service+operation's metric object once no spans for it arrive for that long, whileseries_expirationevicts 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, soseries_expirationis 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"
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:
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:
receivers: [otlp/hyperdx, span_metrics]
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:
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:
# 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:
receivers: [span_metrics]
exporters: [clickhouse]
processors: [memory_limiter, batch]
Both shapes above are verified against the real compiled binary via
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.
Ingesting StatsD metrics
statsdreceiver is compiled in so StatsD-formatted metrics can be ingested
directly, without a separate StatsD-to-OTLP bridge — this covers any StatsD
client, not just a particular vendor's dialect. It also understands the
DogStatsD tag extension: valued tags (#tag:value) work with no extra
config, but bare tags (#mytag, no value) need enable_simple_tags: true
(set below) or they're silently dropped.
This component does not support horizontally-scaled deployments — not
a HyperDX-specific limitation, it's stated directly in the upstream
component's own docs. Aggregation state lives independently in each
collector instance's memory with no cross-replica coordination, so the
same metric landing on two different replicas (e.g. behind a
load-balanced Service) gets aggregated separately by each one instead of
together, silently splitting/undercounting it. HyperDX's default
deployments (docker-compose.yml, the all-in-one image) run the
OpAMP-managed collector as a single instance, so this doesn't bite out of
the box — it only matters once some collector instance ingesting
statsd is scaled to multiple replicas, at which point that traffic
needs to go to one dedicated single-replica instance instead.
docker-compose.yml/docker-compose.dev.yml have an 8125/udp port
mapping for the otel-collector service, commented out by default —
uncomment it to enable statsd ingestion. It's opt-in rather than always
on because a fixed host port mapping fails that service's entire startup
if something else on the host (e.g. a local StatsD daemon or Datadog
Agent) already owns 8125/udp, which would otherwise break the default
stack for anyone not using this feature. The standalone/all-in-one images
also EXPOSE 8125/udp (metadata only, doesn't reserve the host port on
its own).
In standalone mode, metrics is a plain pipeline in your own config
file, so add statsd to it directly:
receivers:
statsd:
# Defaults to localhost:8125, which only accepts local traffic -
# 0.0.0.0 is required here since traffic arrives from outside the
# container (the published port above forwards to the container's
# own loopback otherwise).
endpoint: 0.0.0.0:8125
enable_simple_tags: true
service:
pipelines:
metrics:
receivers: [otlp/hyperdx, statsd]
In OpAMP supervisor mode (HyperDX's default), editing the metrics
pipeline this way will not work: receivers:/exporters: on it are set
dynamically by opampController.ts, and the remote config overwrites
(not merges) those keys — see the span metrics section
above for the same
behavior. Adding statsd to metrics.receivers from
CUSTOM_OTELCOL_CONFIG_FILE gets silently dropped: no error, the receiver
just starts and is wired to nothing. Use a separate pipeline name instead,
which the remote config never touches:
receivers:
statsd:
endpoint: 0.0.0.0:8125
enable_simple_tags: true
service:
pipelines:
metrics/statsd:
receivers: [statsd]
exporters: [clickhouse]
processors: [memory_limiter, batch]
Both shapes are verified against the real compiled binary via
otelcontribcol validate --config, and the standalone shape was also
actually run (otelcontribcol --config ..., not just validate),
reaching "Everything is ready. Begin running and processing data." —
the same rigor as the span metrics section above, using the same
multi---config merge the container actually uses.
Overriding base components via CUSTOM_OTELCOL_CONFIG_FILE
The collector ships with a default memory_limiter processor sized for a
small (~2 GiB) container. On larger pods you typically want to switch to
limit_percentage/spike_limit_percentage mode so the limit scales with
the pod's memory allocation.
The OTel confmap package merges YAML maps leaf-by-leaf rather than
replacing a block wholesale, and the memorylimiterprocessor silently
prefers limit_mib over limit_percentage when both are set. The
combination means you cannot switch the default memory_limiter to
percentage mode by leaf-merging into the existing block — your percentage
values land in effective.yaml but the inherited mib values still win at
runtime.
The supported pattern is to define a new processor with a different
name and swap the pipeline processors: lists in
CUSTOM_OTELCOL_CONFIG_FILE to reference it:
# custom.config.yaml
processors:
memory_limiter/custom:
check_interval: 5s
limit_percentage: 75
spike_limit_percentage: 25
service:
pipelines:
traces:
processors: [memory_limiter/custom, transform/session_id, batch]
metrics:
processors: [memory_limiter/custom, batch]
logs/out-default:
processors: [memory_limiter/custom, transform, transform/session_id, batch]
logs/out-rrweb:
processors: [memory_limiter/custom, transform/session_id, batch]
After restart, the collector instantiates memory_limiter/custom (and not
the unused default memory_limiter). You can confirm by checking
/etc/otel/supervisor-data/effective.yaml and the "Memory limiter configured" log line emitted at collector start.
Example: tuning the batch processor
The default batch processor is sized for ClickHouse
(send_batch_size: 10000, timeout: 5s). If your workload needs
lower-latency exports — for example, latency-sensitive traces or a
short-window smoke test that asserts shortly after emitting — define a
new batch with the tuning you want and swap the pipelines to use it:
# custom.config.yaml
processors:
batch/lowlatency:
send_batch_size: 1000
send_batch_max_size: 2000
timeout: 500ms
service:
pipelines:
traces:
processors: [memory_limiter, transform/session_id, batch/lowlatency]
logs/out-default:
processors: [memory_limiter, transform, transform/session_id, batch/lowlatency]
Notes:
- You only need to re-declare the pipelines you want to retune; pipelines
you don't mention keep the default
batchfrom the base config. In the example above,metricsandlogs/out-rrwebcontinue using the defaultbatch. - Processor list order matters. Keep
memory_limiterahead of the batch processor so back-pressure applies before batching. - Larger batches are friendlier to ClickHouse (fewer inserts, fewer
MergeTree parts). The defaults —
send_batch_size: 10000,timeout: 5s— are the recommended starting point; only tune them when you have a specific reason. - You can combine swap blocks. Define both
memory_limiter/customandbatch/lowlatency, and reference both in the same pipeline (e.g.processors: [memory_limiter/custom, batch/lowlatency]).
You can verify the new processor is actually running (not just present in
effective.yaml) by querying the collector's Prometheus metrics endpoint
on port 8888 and looking for the new processor label:
curl -s http://localhost:8888/metrics | grep 'processor="batch/lowlatency"'
# otelcol_processor_batch_batch_send_size_count{processor="batch/lowlatency"} 15
# otelcol_processor_batch_timeout_trigger_send_total{processor="batch/lowlatency"} 15
Lighter-weight option: env vars for the default batch
If you only need to tune send_batch_size, send_batch_max_size, or
timeout on the default batch processor and don't need to define a
second processor, the existing env vars also work without a custom config
file:
HYPERDX_OTEL_BATCH_SEND_BATCH_SIZE(default10000)HYPERDX_OTEL_BATCH_SEND_BATCH_MAX_SIZE(default0, meaning no upper bound)HYPERDX_OTEL_BATCH_TIMEOUT(default5s)
These are read directly from the base config.yaml, so they apply
everywhere the default batch is referenced. Use the swap pattern when
you need different settings for different pipelines, when you want to
combine batch + memory_limiter changes, or when you want to override
memory_limiter (which has no equivalent env-var path).
The same swap pattern works for any other base processor (transform,
resourcedetection, …) — define a new component with a different name
and re-declare the pipelines that should use it.
Pipeline
processors:lists live indocker/otel-collector/config.yaml(for OpAMP supervisor mode) anddocker/otel-collector/config.standalone.yaml(for standalone mode). The OpAMP remote config frompackages/api/src/opamp/controllers/opampController.tsintentionally does not setprocessors:on pipelines, so your bootstrap+custom merge is not overwritten.
Upgrading the OTel Collector version
Step 1: Look up the core version
Check the upstream contrib manifest to find the core provider version that corresponds to the new contrib version:
Look at the providers: section — the core version follows a different scheme
(e.g. contrib 0.149.0 corresponds to core 1.55.0).
Step 2: Update these files
.env (root) — primary source of truth:
OTEL_COLLECTOR_VERSION=<new_version>
OTEL_COLLECTOR_CORE_VERSION=<new_core_version>
docker/otel-collector/Dockerfile — ARG defaults:
ARG OTEL_COLLECTOR_VERSION=<new_version>
ARG OTEL_COLLECTOR_CORE_VERSION=<new_core_version>
docker/hyperdx/Dockerfile — ARG defaults:
ARG OTEL_COLLECTOR_VERSION=<new_version>
ARG OTEL_COLLECTOR_CORE_VERSION=<new_core_version>
smoke-tests/otel-collector/docker-compose.yaml — fallback defaults:
args:
OTEL_COLLECTOR_VERSION: ${OTEL_COLLECTOR_VERSION:-<new_version>}
OTEL_COLLECTOR_CORE_VERSION: ${OTEL_COLLECTOR_CORE_VERSION:-<new_core_version>}
Files you do NOT need to change
packages/otel-collector/builder-config.yaml— uses placeholders, substituted at build timedocker-compose.dev.yml— reads from.envautomaticallydocker-compose.ci.yml— reads from.envautomatically
Step 3: Verify
Build the image and check the version:
docker build -f docker/otel-collector/Dockerfile --target dev -t hdx-otel-test .
docker run --rm --entrypoint /otelcontribcol hdx-otel-test --version
docker rmi hdx-otel-test
Adding a custom component
There are two approaches for including custom components, depending on whether the component source lives in this monorepo or is published as a standalone Go module.
Approach A: Local source (monorepo development)
Best for active development — iterate on the component alongside the rest of the codebase without needing to publish a Go module tag on every change.
-
Create the Go module under this package (e.g.
packages/otel-collector/receiver/myreceiver/) with its owngo.mod. -
Add an entry to
builder-config.yamlunder the appropriate section with apath:directive pointing to the local module:receivers: - gomod: github.com/ClickHouse/ClickStack/packages/otel-collector/receiver/myreceiver v0.0.0 path: ./receiver/myreceiver -
Update the Dockerfiles to copy the component source into the OCB build stage. Currently, only
builder-config.yamlis copied. In bothdocker/otel-collector/Dockerfileanddocker/hyperdx/Dockerfile, expand theCOPYline in theocb-builderstage:# Before (only copies the manifest): COPY packages/otel-collector/builder-config.yaml . # After (also copies custom component source): COPY packages/otel-collector/builder-config.yaml . COPY packages/otel-collector/receiver/ ./receiver/Or, to copy everything at once (simpler, but any change to files in
packages/otel-collector/will invalidate the Docker layer cache for the OCB build stage):COPY packages/otel-collector/ . -
Add the component to the relevant OTel config files and/or the OpAMP controller (
packages/api/src/opamp/controllers/opampController.ts).
Approach B: Published Go module (remote reference)
Best for stable, versioned components — especially useful for components shared
across repos or distributed to external users. No Dockerfile COPY changes
needed.
-
Publish the component as a Go module with a tagged version (e.g.
github.com/hyperdxio/my-otel-receiver v0.1.0). For modules in a monorepo subdirectory, the Git tag must include the module path prefix (e.g.packages/otel-collector/receiver/myreceiver/v0.1.0). -
Add an entry to
builder-config.yamlwithout apath:directive — OCB will fetch it via the Go module proxy, just like contrib components:receivers: - gomod: github.com/hyperdxio/my-otel-receiver v0.1.0No Dockerfile changes needed — OCB downloads it during
go mod download. -
Add the component to the relevant OTel config files and/or the OpAMP controller.
Which approach to use?
Local (path:) |
Remote (gomod:) |
|
|---|---|---|
| Dockerfile COPY needed | Yes | No |
| Works with unpublished code | Yes | No — must be tagged and published |
| Dev iteration speed | Fast — rebuild image | Must push, tag, wait for proxy |
| Monorepo friendly | Yes | Requires proper Go module tagging |
| Best for | Active development | Stable/shared components |
For most HyperDX development, Approach A (local source) is recommended. Use Approach B when the component is mature and independently versioned.