Skip to content

OpenTelemetry & OTLP Export

TruePPM will export distributed traces and metrics using OpenTelemetry (OTel), the vendor-neutral CNCF standard, over the OTLP protocol. Point it at your existing observability stack — Grafana Tempo/Alloy, Jaeger, an OpenTelemetry Collector, or a SaaS APM — with no custom exporter code.

Export is opt-in and off by default. There is no default endpoint: until you configure a collector, TruePPM installs no telemetry provider at all — a strict no-op that costs nothing per request and opens no outbound connection. Telemetry is a deliberate operator choice, never a silent egress.

  • A provider bootstrap that builds the OTel TracerProvider / MeterProvider at API startup and wires the OTLP exporter — only when an endpoint is set.
  • The opt-in configuration surface below (environment variables + Helm values).
  • A stable trueppm.* span-, metric-, and resource-attribute naming convention.
  • Trace instrumentation for HTTP requests, database queries, Celery tasks, WebSocket connections, and the scheduling engine (see Instrumented spans below).
  • Native metrics for request latency/count, Celery task duration, transactional outbox depth/lag, and database backend counts (see Instrumented metrics below).

Configure a collector now and you immediately see TruePPM’s resource identity, the trace spans, and the metrics below. Traces and metrics export over the same OTLP endpoint and can be toggled independently with TRUEPPM_OTEL_TRACES_ENABLED / TRUEPPM_OTEL_METRICS_ENABLED.

Telemetry is active only when the master switch is on and an OTLP endpoint is set. Setting the endpoint is what turns export on.

TruePPM reads the well-known upstream OTEL_* variables, so they match every other OpenTelemetry-emitting service in your cluster, plus a few TRUEPPM_OTEL_* switches for behavior the standard variables do not express.

VariableDefaultPurpose
OTEL_EXPORTER_OTLP_ENDPOINT(empty)The gate. Your collector URL, e.g. http://otel-collector:4317. Empty means telemetry is off.
OTEL_EXPORTER_OTLP_PROTOCOLgrpcgrpc (port 4317) or http/protobuf (port 4318). Match your collector.
OTEL_EXPORTER_OTLP_HEADERS(empty)Comma-separated key=value pairs sent with every export, e.g. authorization=Bearer <token> for a SaaS backend.
OTEL_SERVICE_NAMEtrueppm-apiThe service.name reported on every span and metric.
TRUEPPM_OTEL_ENABLEDtrueMaster kill switch. Set false to disable export while leaving the endpoint configured.
TRUEPPM_OTEL_TRACES_ENABLEDtrueExport traces (when telemetry is on).
TRUEPPM_OTEL_METRICS_ENABLEDtrueExport metrics (when telemetry is on).
OTEL_TRACES_SAMPLERparentbased_always_onTrace sampler. Set parentbased_traceidratio with OTEL_TRACES_SAMPLER_ARG to sample a fraction on a busy instance.
OTEL_TRACES_SAMPLER_ARG(empty)Sampler argument, e.g. 0.1 to keep 10% of root traces.
DJANGO_LOG_LEVEL(app default)Root Django log level (DEBUG | INFO | WARNING | ERROR). One knob for the api, worker, and beat tiers.

Export is enabled when TRUEPPM_OTEL_ENABLED is true and OTEL_EXPORTER_OTLP_ENDPOINT is non-empty. Leaving the endpoint empty — the default — keeps the provider a no-op regardless of the other switches.

The chart exposes these settings under observability.otlp.* and renders them into the standard environment variables for both the API and the Celery worker/beat, so traces and metrics from background work carry the same identity as the web tier.

observability:
otlp:
# Collector endpoint. Empty (the default) = telemetry off.
endpoint: "http://otel-collector:4317"
protocol: "grpc" # or "http/protobuf" (port 4318)
serviceName: "trueppm-api"
enabled: true # master switch
tracesEnabled: true
metricsEnabled: true
# Trace sampling on a busy instance. Empty keeps the SDK default
# (parentbased_always_on); these render into OTEL_TRACES_SAMPLER[_ARG].
tracesSampler: "parentbased_traceidratio"
tracesSamplerArg: "0.1" # keep 10% of root traces
# Optional OTLP headers. Prefer headersSecret for tokens so they never
# render into a plaintext manifest; `headers` is the inline fallback.
headers: ""
headersSecret:
name: "" # e.g. "trueppm-otlp"
key: "headers"

The root log level is a separate top-level value, threaded into the api, worker, and beat containers as DJANGO_LOG_LEVEL:

logging:
level: "INFO" # DEBUG | INFO | WARNING | ERROR; empty = app default

When your collector requires an authorization token, put it in a Kubernetes Secret and reference it with headersSecret rather than the inline headers field, so the token never appears in the rendered Deployment:

observability:
otlp:
endpoint: "https://otlp.example-apm.com:4317"
headersSecret:
name: "trueppm-otlp"
key: "headers"
Terminal window
kubectl create secret generic trueppm-otlp \
--from-literal=headers="authorization=Bearer <your-token>"

Once the pods restart with an endpoint set, a workspace admin can confirm export is actually working from Settings → Workspace → System Health → Telemetry, without shelling into a pod. The card shows the effective configuration — endpoint, protocol, service name and version, sampler, and per-signal (traces / metrics) on/off — and reports whether export is live, switched off, or unconfigured. The OTLP headers are never shown: the bearer token stays in your Secret and never reaches the browser.

The card cannot edit any of this. Configuration is env/Helm only (ADR-0223); the card is a read-only view with one action.

Test export. The Test export button proves the export path end to end:

  • When export is on, it sends a single synthetic canary span (trueppm.telemetry.canary, carrying trueppm.telemetry.test=true so you can filter it out of real traces) through a one-off exporter built from the same settings, and reports whether the collector accepted it, with the round-trip time.
  • When an endpoint is set but export is switched off (TRUEPPM_OTEL_ENABLED=false), it runs a TCP reachability probe instead — no span is sent — so you can confirm the collector is healthy before turning export on.

If the probe fails, the message names the likely cause (connection refused, timeout, or an unresolvable host) — but never echoes the collector’s auth headers, so a failing test can be shared safely. The button is admin-only and rate-limited.

When export is not configured at all, the card switches to a guided-setup view: pick your backend (Grafana Tempo, Jaeger, or a generic OTLP collector) and copy ready-to-paste environment-variable or Helm-values snippets.

Note: the card does not yet show live throughput counts (spans/metrics per minute) — the export pipeline does not record them. It reports export health, not volume.

Every span and metric carries these resource attributes identifying the process:

AttributeValue
service.nameOTEL_SERVICE_NAME (default trueppm-api)
service.versionThe running TruePPM API version
service.namespacetrueppm
trueppm.editioncommunity or enterprise

TruePPM-owned span attributes and metric dimensions live under the reserved trueppm.* namespace (for example trueppm.project.id, trueppm.task.id, trueppm.user.role, trueppm.outbox.name), so you can filter and group TruePPM signals cleanly in your backend. Attributes that follow OpenTelemetry’s own semantic conventions (http.*, db.*, messaging.*) keep their standard keys.

With a collector configured, TruePPM emits these spans out of the box. Every span is a child of the request or task that produced it, so a single trace links an HTTP request through its database queries and any Celery work it enqueues.

SourceWhat you getKey attributes
HTTP requests (Django)One server span per API request, with route, method, and statushttp.* semantic conventions
Database (psycopg)One span per SQL statementdb.* semantic conventions. SQL is never modified — comment injection is off.
Celery tasksOne span per task; trace context propagates from the enqueuing request into the worker, so a recompute links back to the request that triggered itmessaging.* semantic conventions
WebSocket (Channels/ASGI)One span per WebSocket connectionhttp.* (ASGI) semantic conventions
CPM recomputeschedule.cpm span timing a full critical-path recomputetrueppm.project.id, trueppm.schedule.recompute_reason, trueppm.schedule.task_count, trueppm.schedule.dependency_count, trueppm.schedule.critical_count
Monte Carloschedule.monte_carlo span timing a probabilistic forecast (including what-if)trueppm.project.id, trueppm.schedule.simulation_count

With a collector configured, TruePPM exports these metrics over OTLP on a periodic interval. The HTTP and Celery families follow OpenTelemetry’s semantic conventions; the outbox, database, broker, and WebSocket families are TruePPM-specific instruments under the trueppm.* namespace.

MetricTypeWhat it measuresDimensions
http.server.request.durationhistogramAPI request latencyhttp.* semantic conventions
http.server.active_requestsup/down counterIn-flight API requestshttp.* semantic conventions
flower.task.runtime.secondshistogramCelery task duration, from the auto-instrumentortask name, worker
trueppm.task.duration_secondshistogramCelery task duration, from task_prerun to task_postruntrueppm.celery.task_name, trueppm.celery.outcome (success | failure | retry | etc.)
trueppm.outbox.depthgaugeLive backlog of a transactional outbox (rows not yet done)trueppm.outbox.name (schedule | workflow), trueppm.outbox.state (pending | dispatched)
trueppm.outbox.oldest_age_secondsgaugeAge of the oldest not-yet-done outbox row — the dispatch lag (0 when empty)trueppm.outbox.name
trueppm.db.connectionsgaugeServer-side PostgreSQL backend count for the current databasetrueppm.db.state (active | idle | idle_in_transaction | other)
trueppm.broker.queue.depthgaugeCelery messages waiting in the broker (Valkey/Redis LLEN), downstream of the outboxesmessaging.destination.name (queue, e.g. celery)
trueppm.ws.connections.activeup/down counterActive WebSocket connections accepted by this process’s Channels consumers
trueppm.ws.broadcast.countcounterWebSocket board-event broadcasts fanned out to a project group

The two trueppm.outbox.* metrics are the operational signal for TruePPM’s durable execution: a rising depth or oldest_age_seconds means the CPM-recompute or workflow-step dispatchers are falling behind. They complement the System Health overview, which reports the same backlog in the admin UI. trueppm.broker.queue.depth measures the next stage — messages the dispatchers have handed to Celery but no worker has yet picked up — so a healthy outbox paired with a rising broker depth points at under-scaled workers rather than a stuck dispatcher. The two trueppm.ws.* instruments give WebSocket real-time collaboration its first quantitative signal: how many live sockets a node holds and how much it is fanning out.

TruePPM exports metrics over OTLP only — there is no /metrics scrape endpoint, which keeps telemetry a single, deliberate, opt-in egress with no always-on surface. If your stack is Prometheus-native, run an OpenTelemetry Collector as a bridge: receive TruePPM’s OTLP metrics and re-expose them for Prometheus to scrape.

otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc: # point OTEL_EXPORTER_OTLP_ENDPOINT at this collector
exporters:
prometheus:
endpoint: "0.0.0.0:9464" # Prometheus scrapes the collector here
service:
pipelines:
metrics:
receivers: [otlp]
exporters: [prometheus]

Alongside OTLP export, TruePPM 0.4 will emit structured application logs correlated with the traces above. In production the API will write single-line JSON to stdout — one object per log record — so your log stack (Loki, Elasticsearch, CloudWatch) can index the fields directly instead of parsing free text. Development keeps human-readable console output.

Every record will carry three correlation fields:

FieldValue
trace_idThe active OpenTelemetry trace id (32-hex), or null when no span is active
span_idThe active span id (16-hex), or null
request_idA per-request id, adopted from an inbound X-Request-ID header when present and otherwise generated, and echoed back on the response X-Request-ID header

Because trace_id / span_id are formatted identically to the ids on the exported spans, a slow request seen in Tempo/Jaeger can be pivoted straight to the exact log lines emitted while that trace was active — and a user quoting the X-Request-ID from their browser’s network tab lets an operator find that request’s logs directly.

Two environment variables will control logging:

VariableDefaultPurpose
DJANGO_LOG_LEVELINFORoot log level (DEBUG, INFO, WARNING, ERROR).
TRUEPPM_LOG_JSONfalse (base) / forced true in productionEmit JSON when true; human-readable console lines when false. Production always emits JSON.

The sections above cover the API’s server-side traces and metrics. The web frontend will gain its own opt-in, off-by-default client telemetry in 0.4 (#1901), so a self-hoster can also see what breaks in the browser:

  • Uncaught render errors — when a route or section error boundary catches a crash, it will report the error message, stack, and route to your collector (alongside the console log it already writes). No user tokens, form contents, or URL query strings are ever included.
  • Core Web Vitals — CLS, LCP, INP, FCP, and TTFB, collected from the browser’s native PerformanceObserver (no third-party script and no added bundle dependency) and sent with navigator.sendBeacon.

Like the API export, frontend telemetry will be a strict no-op until you configure an endpoint — no observers are registered, and nothing leaves the browser. Point it at a collector that accepts a JSON POST (for example an OpenTelemetry Collector’s OTLP/HTTP receiver) in one of two ways:

  • Runtime config (no rebuild) — inject a global into index.html at deploy time:

    <script>
    window.__TRUEPPM_CONFIG__ = {
    telemetryEndpoint: 'https://collector.example.com/v1/client',
    };
    </script>
  • Build-time variable — set VITE_TELEMETRY_ENDPOINT when building the web bundle. Runtime config, when present, takes precedence.

The Helm chart ships a starter Grafana dashboard and a set of Prometheus alerting rules for the async/outbox subsystem. Both are off by default — they depend on tooling (a Grafana sidecar; the Prometheus Operator CRDs) that not every cluster runs — and both land in 0.4.

# Grafana dashboard as a labeled ConfigMap, auto-imported by the Grafana sidecar.
dashboards:
enabled: true
label: grafana_dashboard # the label key your sidecar watches
labelValue: "1"
# Prometheus Operator PrometheusRule (requires kube-prometheus-stack CRDs).
alerts:
enabled: true
labels:
release: kube-prometheus-stack # so the operator's ruleSelector picks it up
thresholds:
outboxDepth: 500
outboxOldestAgeSeconds: 900

The dashboard (TruePPM — Async & Outbox Health) charts outbox depth and oldest age, dead-letter parked tasks, PostgreSQL backends, and Celery Beat liveness. The rules alert on beat staleness (the /api/v1/health/beat/ endpoint returning 503), rising outbox depth, rising oldest-age, and dead-lettered tasks.

The provider is an OSS extension point. The TruePPM Enterprise edition attaches its own exporters and instrumentation against the same provider — no additional collector wiring is required on your side beyond the settings above.