DFE Observability Standard
One telemetry contract for the whole deployment: the dfe-infra platform services, dfe-engine, dfe-ui and the Rust apps (receiver, loader, fetcher, archiver, transforms). Every component reports to one OpenTelemetry Collector, the collector writes ClickHouse, and Observe reads those tables. Nothing pushes to Observe.
Where telemetry lands
The collector's ClickHouse exporter writes otel_logs, otel_traces and the otel_metrics_* tables into the DFE database. Observe, the engine's app-management queries and its KEDA shim all read them there. The dfe-hyperdx image has no OTLP listener, so the collector's otlp/hyperdx exporter is off by default.
| Deployment | Collector | Database | Set by |
|---|---|---|---|
| Kubernetes (dfe-infra) | otel-collector gateway and daemonset, in the otel namespace | dfe | clickhouse.database in argocd/values/common.yaml |
| Docker (dfe-docker) | the bundled otel-collector, on a profile that declares otel or with DFE_OTEL_ENABLED=true | dfe | DFE_OTEL_DATABASE |
Both write dfe.otel_* by default, not dfe-hyperdx's default database. dfe-schemas declares the tables and dfe-engine applies them at its startup. The collector runs with create_schema: false, so it only inserts.
On Kubernetes the gateway also reads ClickHouse system tables, the Kubernetes API and the data operators' metrics endpoints. It re-exposes every metric on a Prometheus exporter for an external scraper.
Choosing the destination
telemetry.mode in dfe-infra's argocd/values/common.yaml picks the OTLP endpoint every app exports to. telemetry.otlpEndpointTemplate in argocd/values/apps/_common.yaml resolves it into each app's OTEL_EXPORTER_OTLP_ENDPOINT. It recognises four values.
telemetry.mode | Apps export to | Override |
|---|---|---|
hyperdx (default) | the stack's collector, <project>-otel-collector-gateway.otel.svc.cluster.local:4317 | telemetry.hyperdxEndpoint, or telemetry.collectorNamespace for the namespace |
receiver | dfe-receiver.<release namespace>.svc.cluster.local:4317 | telemetry.receiverEndpoint |
external | a deployer's own OTLP backend | telemetry.externalEndpoint, empty by default |
prometheus | nothing, apps are scraped instead | none |
Any other value resolves to an empty endpoint, and an app with an empty endpoint exports nothing over OTLP. Nothing rejects an unknown mode, so a misspelt one fails silently. So does external with no externalEndpoint.
An endpoint with no scheme gets http://, or https:// when otel.tls is true. otel.endpoint overrides the resolved value for one deployment. It is exported as written, so it must carry its scheme, and scalo-service sets OTEL_EXPORTER_OTLP_PROTOCOL beside it (default grpc). dfe-ui and dfe-hyperdx export over OTLP/HTTP, so they take the resolved endpoint on port 4318.
In receiver mode, apps export to dfe-receiver's OTLP listener. It binds 4317 and 4318 only while the receiver's config.otlp.enabled is true, and with no telemetry.receiverEndpoint dfe-infra refuses to render the receiver until it is. Turning it on also serves OTLP on the receiver's load balancer. The receiver converts each record to JSON in the ClickHouse exporter's row shape (its default otlp.mode: hyperdx) and sends it down the normal data path. It lands wherever the receiver's routing and the loader's table mapping send it. No shipped rule sends it to the otel_* tables.
The mode moves only the apps' OTLP push. The collector runs in every mode, so container logs, scraped /metrics and its other inputs keep landing in the otel_* tables.
On Docker, DFE_OTEL_EXPORTER_ENDPOINT plays the same part. A profile that declares otel points it at the bundled collector, an endpoint of your own sends telemetry there instead, and empty exports nothing.
Logs
Apps write structured JSON logs to stdout. The scalo logger is the reference for the output contract, masking of sensitive values included: scalo-py's docs/core-pillars/LOGGING.md and scalo-rs's docs/core-pillars/logging.md. A component in another language matches that output, not that code.
Stdout is the log path, so an app needs no OTLP logging SDK to reach otel_logs.
- On Kubernetes, the collector daemonset's
filelogreceiver reads/var/log/podson each node and forwards to the gateway. A record with noservice.nametakes the container name, then the workload, then the pod. - On Docker, the
fluentdlog driver sends each DFE container's stdout to the collector'sfluentforwardreceiver on port 24224, and the container name becomesservice.name.DFE_CONTAINER_LOGS_ENABLED=falseputs every service back onjson-file.
The infra provider's own logs are a second feed into the same otel tables, off the DFE ingest path. The cluster annotation dfe.hyperi.io/provider_logs_flavour picks the receiver, run in a collector of its own so a failed receiver restarts alone:
| Flavour | Receiver and source |
|---|---|
none (default) | nothing renders |
aws | aws_cloudwatch, polling the EKS audit and CloudTrail log groups |
azure | azure_event_hub, fed by a diagnostic setting |
gcp | googlecloudpubsub, fed by a log sink into a Pub/Sub subscription |
rancher | filelog on the control-plane nodes, reading the kube-apiserver audit log |
Metrics, traces and identity
Apps push metrics and traces over OTLP to the endpoint telemetry.mode resolves. In the Rust apps this comes from scalo-rs, whose metrics feature includes otel-metrics and otel-tracing.
Every DFE app also serves Prometheus /metrics, whatever its OTLP push is set to. scalo-service annotates each pod with prometheus.io/scrape, port and path unless telemetry.prometheus.scrape is false (scalo-rs charts/scalo-service/templates/_telemetry.tpl). On Kubernetes the gateway scrapes annotated pods labelled app.kubernetes.io/part-of: dfe while clusterTelemetry.dfeServices.enabled is on, which is the default.
KEDA scales on ScalingPressure, which the engine's KEDA shim reads from otel_metrics_gauge (src/dfe_engine/keda_shim/queries.yaml). The engine's metric names carry the dfe_ prefix from metric_prefix in src/dfe_engine/deployment_contract.py.
scalo-service sets OTEL_SERVICE_NAME on every app from otel.serviceName, else the release's full name. It becomes ServiceName in the tables. scalo-rs also tags deployment.environment.name from the app's environment setting.
The hunt runner pushes rather than being scraped
The runner has no HTTP listener, so nothing scrapes it. It pushes on the OTLP endpoint the chart already wires and reports dfe_hunt_runs_total (hunt_id, outcome), dfe_hunt_run_duration_seconds, dfe_hunt_rows_written_total, dfe_hunt_overruns_total, dfe_hunt_claims_total (hunt_id, outcome), dfe_hunt_backlog, dfe_hunt_tick_duration_seconds, dfe_hunt_detections_capped_total (hunt_id, rule_id), dfe_hunt_detections_dropped_total (hunt_id, rule_id), dfe_hunt_clickhouse_unavailable_total (stage, reason), dfe_hunt_fire_failures_total (hunt_id, reason), dfe_hunt_fires_held_total (hunt_id), and the safe horizon and overlap metrics below. The backlog gauge runs the SAME due-count SQL the KEDA shim scales on, so a dashboard and the autoscaler cannot disagree about the queue. Each fire also logs one INFO line carrying the hunt, the rows it wrote and how long it took.
A ClickHouse the runner cannot use never ends the process. At startup an outage (reason=connection), a refused login (reason=authentication, the runner's user not made yet) or a full server (reason=capacity) is retried on the clickhouse.resilience back-off, jittered and with no budget, and a tick that hits either is skipped until the next poll. Each one adds 1 to dfe_hunt_clickhouse_unavailable_total with stage connect or tick, and the outage logs one WARN line when it starts.
A fire that fails holds its watermark, so the same fire stays due. The runner retries hunts.failure_backoff_initial_seconds (default 30, env DFE_ENGINE_HUNTS_FAILURE_BACKOFF_INITIAL_SECONDS) after the first failure, doubling to hunts.failure_backoff_max_seconds (default 600, env DFE_ENGINE_HUNTS_FAILURE_BACKOFF_MAX_SECONDS), jittered. Each failure adds 1 to dfe_hunt_fire_failures_total with reason set to the ClickHouse error category (not_found for a missing table) or the hunt's own refusal (empty_query, which one refused rule also gives, no_customers, unknown_customers), and each held fire adds 1 to dfe_hunt_fires_held_total. The failure logs one line when it starts, again when its cause changes and every hunts.failure_reminder_seconds (default 3600, env DFE_ENGINE_HUNTS_FAILURE_REMINDER_SECONDS) while it lasts, and one INFO line when the hunt runs again. A missing table logs at WARN, since the hunt runs once the table is created, and any other cause at ERROR. An operator's run-now goes through the wait once.
A rule compiled into a hunt writes at most hunts.max_detections_per_run detection rows per run (default 1000, env DFE_ENGINE_HUNTS_MAX_DETECTIONS_PER_RUN), cut to hunts.max_detections_per_run_ceiling (default 10000, env DFE_ENGINE_HUNTS_MAX_DETECTIONS_PER_RUN_CEILING). A rule that matches more writes the cap plus one summary row: nil matched_uuid, _json of {"dfe_capped": true, "matched": N, "written": cap, "rule_id": ...}, and an _org_id only when every match shares one. The run logs one WARN line with the counts, adds 1 to dfe_hunt_detections_capped_total and adds the unwritten matches to dfe_hunt_detections_dropped_total.
The watermark still advances and the run still records as completed, so the same flood is not re-run whole next fire. The next fire's overlap re-reads the end of it, and writes up to the cap again from the matches the cap left out there. A hunt that carries a direct query rather than rules runs uncapped, and says so in an INFO line each time its specs load.
A row whose _timestamp_load is inside a fire's window but which has not reached the replica the hunt reads is otherwise never matched. Two layers stop that, both on by default:
- The safe horizon is the newest
_timestamp_loadevery replica already holds, and a hunt never reads past it. The runner reads it before claiming a fire, as the server clock less the largestabsolute_delayinsystem.replicasover the hunt's source and detection tables, less a 10 s commit allowance. A table with no delay never holds a fire. While the horizon is short of the fire, the fire stays due and unclaimed, and each poll adds 1 todfe_hunt_safe_horizon_deferrals_total. Once it runs,dfe_hunt_safe_horizon_deferrals_per_firerecords how many polls it waited. A hunt held forhunts.safe_horizon_max_deferral_seconds(default 300, cut to the hunt's interval, envDFE_ENGINE_HUNTS_SAFE_HORIZON_MAX_DEFERRAL_SECONDS), counted from the first poll it held across every fire since, runs anyway. It logs one WARN line, adds 1 todfe_hunt_safe_horizon_cap_reached_total, and records the horizon it ran short of, so the next fire re-reads from there.hunts.safe_horizon(envDFE_ENGINE_HUNTS_SAFE_HORIZON) turns the layer off.dfe_hunt_safe_horizon_seconds_behindanddfe_hunt_safe_horizon_query_duration_secondsrecord each read. A read that fails adds 1 todfe_hunt_safe_horizon_unreadable_totaland holds the fire as unknown lag, timed on the runner's clock. If the cap lets it run, the next fire re-reads its whole window. - Each rule re-reads
hunts.window_overlap_secondsbefore its watermark (default 120, at least 10, envDFE_ENGINE_HUNTS_WINDOW_OVERLAP_SECONDS) and leaves out every source row it already detected, keyed on hunt, rule and the matched row's_uuid. A row that becomes readable within the overlap plus the deferral cap is never missed. A later row is, anddfe_hunt_safe_horizon_cap_reached_totalanddfe_hunt_safe_horizon_seconds_behindshow the lag that reaches past that bound. The dedupe reads the answering replica's detections, so a duplicate detection and notification is possible only when the cap lets a fire run while that replica's detection table lags.dfe_hunt_safe_horizon_cap_ran_with_target_lag_totalcounts those fires.dfe_hunt_overlap_rows_reread_totalcounts the source rows re-read,dfe_hunt_duplicates_skipped_totalthe detections already in the read window, anddfe_hunt_detection_latency_secondsthe time from a matched row's_timestamp_loadto its detection's insert. A directquerycannot skip what it saw, so it reads no overlap.
dfe-ui
dfe-ui registers OpenTelemetry through @vercel/otel once per server process when OTEL_EXPORTER_OTLP_ENDPOINT is set, with OTEL_SERVICE_NAME defaulting to dfe-ui (dfe-ui apps/dfe-core-ui/src/instrumentation.ts). An empty endpoint registers nothing, so prometheus mode starts no exporter. Its server output reaches otel_logs from stdout like any other pod.
Its server logger (dfe-ui apps/dfe-core-ui/src/core/server/logger/) writes one scalo-py-shaped JSON record per line to stdout (timestamp, level, target, message, fields), with scalo's field-name masking. LOG_LEVEL sets the threshold (default INFO), and LOG_FORMAT is json or text. Unset, it picks JSON in production or whenever an OTLP endpoint is set, and text on a dev terminal. NextAuth's logger and Next's onRequestError hook write through it, and request headers are never logged.
Acceptance tests
Both deployments ship a self-monitoring check beside the core data-path test.
- Kubernetes: dfe-infra's post-deploy smoke test (
bootstrap/smoke-test-integration.sh, CORE 1) polls for up to 180s for anotel_logsrow newer than 600s in the DFE database, by default. It proves the pipeline is streaming. It does not say which services report. - Docker: dfe-docker's
make postassertsdfe.otel_*rows newer than five minutes, and anotel_logsrow from each ofdfe-engine,dfe-loaderanddfe-receiverwritten since that container started.
References
- OpenTelemetry Collector: https://opentelemetry.io/docs/collector/
- dfe-infra:
argocd/values/common.yaml(telemetry,otel,clickhouse),argocd/values/apps/_common.yaml(telemetry.otlpEndpointTemplate),helm/charts/otel-collector/ - dfe-docker: https://github.com/hyperi-io/dfe-docker/blob/main/docs/observability.md, https://github.com/hyperi-io/dfe-docker/blob/main/config/otel-collector/config.yaml
- scalo-rs:
charts/scalo-service/templates/_telemetry.tpl,docs/core-pillars/logging.md - scalo-py:
docs/core-pillars/LOGGING.md