feat(observability): distributed tracing via OpenTelemetry + Tempo - #50
Merged
Conversation
The third observability pillar. The API now ships OTLP/HTTP spans to a
Tempo backend that runs alongside Prometheus/Loki in the default
observability stack; Grafana has a Tempo datasource with two-way
click-through to Loki, so a slow request found in a metric / log line /
error event can be pivoted to its full trace waterfall in one click.
What's traced
-------------
- Incoming HTTP (Elysia) — parent span per request.
- Outgoing fetch / undici — Stripe, Resend, OpenAI, OAuth providers,
anything you `fetch`. Each is a child span.
- ioredis / Valkey — BullMQ enqueue + lock operations.
- BullMQ job processing — every worker (account-maintenance,
email-delivery, notification-dispatch, notification-maintenance,
web-push-delivery) wraps its `processJob` in `withQueueSpan`,
producing a `queue.<name>.process` span with messaging.* attributes
(queue name, job id, attempt #). Failures record the exception.
- DB queries — opt-in via the new `withDbSpan` helper. postgres-js
has no upstream OTel auto-instrumentation, so callers wrap hot-path
queries explicitly with `db.statement` + custom attributes.
- Browser → API trace continuation — already in place via Sentry's
`browserTracingIntegration`; the OTel SDK on the API now picks up
the `sentry-trace` / `traceparent` headers and continues the trace.
apps/api
--------
- `src/instrument.ts` (new) — side-effect import at the top of
`index.ts`. Initialises the OTel SDK *before* any other module
imports so the auto-instrumentations patch their targets at load
time. Without this, http/ioredis/undici load unpatched and no spans
are recorded.
- `src/config/otel/otel.ts` (new) — NodeSDK wrapper. OTLP/HTTP
exporter, `getNodeAutoInstrumentations` with `fs` + `dns` disabled
(they generate enormous span volume and drown the signal), service
+ version + deployment.environment resource attributes. No-op when
`OTEL_EXPORTER_OTLP_ENDPOINT` is empty.
- `src/config/env/{schema,validate}.ts` — new env vars:
`OTEL_EXPORTER_OTLP_ENDPOINT` (default empty),
`OTEL_SERVICE_NAME` (default `boringstack-api`).
- `src/config/logger/logger.ts` — Pino mixin now reads the active
span from `@opentelemetry/api` (works regardless of which SDK
created the span) rather than directly from Sentry. Filters out
the OTel sentinel `00000000…` trace_id so log records outside a
span context don't ship a useless zero id.
- `src/lib/tracing/` (new) — `withQueueSpan` (BullMQ wrapper) +
`withDbSpan` (opt-in DB wrapper) + barrel `index.ts`. Both record
exceptions on the span via `getErrorMessage` to match the
no-error-stringify lint rule.
- 5 worker files — wired through `withQueueSpan` at the Worker
constructor's processor argument.
infra/compose
-------------
- New `compose/tempo/tempo.yml` — single-binary Tempo config, OTLP
HTTP/gRPC receivers, 24h block retention, local storage at
`/var/tempo`.
- `docker-compose.observability.yml` — new `tempo` service on the
observability profile, mounted config, `tempo_data` volume,
resource limits (`TEMPO_LIMITS_CPUS/MEMORY`). Grafana now
`depends_on: tempo`.
- `docker-compose.yml` — api-dev and api services declare
`OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318` and
`OTEL_SERVICE_NAME` with sensible defaults.
- `grafana/provisioning/datasources/datasources.yml` — new Tempo
datasource + `derivedFields` on Loki (clickable trace_id link
opens Tempo) + `tracesToLogsV2` on Tempo (span → matching Loki
lines).
- `.env.example` — documents the new OTEL_* vars.
apps/docs
---------
- `topics/tracing.mdx` (new) — full topic page: why tracing, what's
auto-instrumented vs opt-in, six concrete use cases (slow-endpoint
diagnosis, N+1 detection, job lag, benchmarking, log↔trace pivots),
how to add manual spans, storage + retention + sampling.
- `topics/observability.mdx` — new "Tempo" entry in the "What ships"
block.
- `astro.config.mjs` — sidebar wires Tracing between Observability
and Alerts.
Tempo image
-----------
Pinned by tag (`grafana/tempo:2.6.1`) rather than digest because the
local Docker daemon was unavailable when this commit was prepared —
follow-up will pin the @sha256 to match the rest of the observability
stack's pattern.
Verification
------------
- `apps/api && bun run validate` → 997 pass, 2 skip, 0 fail.
- `apps/docs && bun run build` → 67 pages built (was 66, +1 for
Tracing topic), pagefind index clean.
- `STACK=dev ./dev.sh config --quiet` → exit 0 with the new Tempo
service merged in.
Scope honestly noted: Drizzle's underlying `postgres-js` driver has
no upstream OTel auto-instrumentation, so DB query spans are opt-in
via `withDbSpan`. The helper + documented pattern ship; instrumenting
specific service methods is a follow-up exercise as you find slow
queries worth surfacing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
agjs
enabled auto-merge (squash)
May 29, 2026 10:45
agjs
added a commit
that referenced
this pull request
May 29, 2026
) Two small follow-ups to the recent observability work — both pure hygiene, no behavioural changes. Tempo image pinned by digest ---------------------------- PR #50 introduced `grafana/tempo:2.6.1` as a tag-only reference because OrbStack wasn't running when the commit was prepared. Every other image in the observability stack (prometheus, grafana, loki, promtail, alertmanager, postgres-exporter, node-exporter) is pinned to a specific `@sha256:` digest for supply-chain hygiene. Tempo now matches: grafana/tempo:2.6.1@sha256:ef4384fce6e8ad22b95b243d8fc165628cda655376fd50e7850536ad89d71d50 size-diff workflow github_token ------------------------------- The `bundle-diff` workflow on every PR has been printing a red ✗ with `Parameter token or opts.auth is required` since #46 introduced the dashboard work. The action (`andresz1/size-limit-action@v1.8.0`) initialises Octokit *before* reading the env var GITHUB_TOKEN, so the env-based pass we had wasn't reaching it. Fix: pass the token explicitly as a `with:` input too. The env line stays as belt-and-braces in case a future action version flips back to env. Result: no more spurious red ✗ on PRs that touch apps/ui. Verification: docker compose config dry-run exits 0 with the pinned Tempo image; workflow YAML still parses; pre-push gate green. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The third observability pillar. The API now ships OTLP/HTTP spans to a
Tempo backend that runs alongside Prometheus/Loki in the default
observability stack; Grafana has a Tempo datasource with two-way
click-through to Loki, so a slow request found in a metric / log line /
error event can be pivoted to its full trace waterfall in one click.
What's traced
anything you
fetch. Each is a child span.email-delivery, notification-dispatch, notification-maintenance,
web-push-delivery) wraps its
processJobinwithQueueSpan,producing a
queue.<name>.processspan with messaging.* attributes(queue name, job id, attempt #). Failures record the exception.
withDbSpanhelper. postgres-jshas no upstream OTel auto-instrumentation, so callers wrap hot-path
queries explicitly with
db.statement+ custom attributes.browserTracingIntegration; the OTel SDK on the API now picks upthe
sentry-trace/traceparentheaders and continues the trace.apps/api
src/instrument.ts(new) — side-effect import at the top ofindex.ts. Initialises the OTel SDK before any other moduleimports so the auto-instrumentations patch their targets at load
time. Without this, http/ioredis/undici load unpatched and no spans
are recorded.
src/config/otel/otel.ts(new) — NodeSDK wrapper. OTLP/HTTPexporter,
getNodeAutoInstrumentationswithfs+dnsdisabled(they generate enormous span volume and drown the signal), service
OTEL_EXPORTER_OTLP_ENDPOINTis empty.src/config/env/{schema,validate}.ts— new env vars:OTEL_EXPORTER_OTLP_ENDPOINT(default empty),OTEL_SERVICE_NAME(defaultboringstack-api).src/config/logger/logger.ts— Pino mixin now reads the activespan from
@opentelemetry/api(works regardless of which SDKcreated the span) rather than directly from Sentry. Filters out
the OTel sentinel
00000000…trace_id so log records outside aspan context don't ship a useless zero id.
src/lib/tracing/(new) —withQueueSpan(BullMQ wrapper) +withDbSpan(opt-in DB wrapper) + barrelindex.ts. Both recordexceptions on the span via
getErrorMessageto match theno-error-stringify lint rule.
withQueueSpanat the Workerconstructor's processor argument.
infra/compose
compose/tempo/tempo.yml— single-binary Tempo config, OTLPHTTP/gRPC receivers, 24h block retention, local storage at
/var/tempo.docker-compose.observability.yml— newtemposervice on theobservability profile, mounted config,
tempo_datavolume,resource limits (
TEMPO_LIMITS_CPUS/MEMORY). Grafana nowdepends_on: tempo.docker-compose.yml— api-dev and api services declareOTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318andOTEL_SERVICE_NAMEwith sensible defaults.grafana/provisioning/datasources/datasources.yml— new Tempodatasource +
derivedFieldson Loki (clickable trace_id linkopens Tempo) +
tracesToLogsV2on Tempo (span → matching Lokilines).
.env.example— documents the new OTEL_* vars.apps/docs
topics/tracing.mdx(new) — full topic page: why tracing, what'sauto-instrumented vs opt-in, six concrete use cases (slow-endpoint
diagnosis, N+1 detection, job lag, benchmarking, log↔trace pivots),
how to add manual spans, storage + retention + sampling.
topics/observability.mdx— new "Tempo" entry in the "What ships"block.
astro.config.mjs— sidebar wires Tracing between Observabilityand Alerts.
Tempo image
Pinned by tag (
grafana/tempo:2.6.1) rather than digest because thelocal Docker daemon was unavailable when this commit was prepared —
follow-up will pin the @sha256 to match the rest of the observability
stack's pattern.
Verification
apps/api && bun run validate→ 997 pass, 2 skip, 0 fail.apps/docs && bun run build→ 67 pages built (was 66, +1 forTracing topic), pagefind index clean.
STACK=dev ./dev.sh config --quiet→ exit 0 with the new Temposervice merged in.
Scope honestly noted: Drizzle's underlying
postgres-jsdriver hasno upstream OTel auto-instrumentation, so DB query spans are opt-in
via
withDbSpan. The helper + documented pattern ship; instrumentingspecific service methods is a follow-up exercise as you find slow
queries worth surfacing.
Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com