docker-compose.observability.yml and its config under observability/. Everything on this page has been run and verified end-to-end (real metrics scraped, real traces linked across HTTP→Redis spans, real nginx/container logs in Loki) — this isn’t a config sketch.
Architecture
- Metrics are pulled, not pushed. Prometheus scrapes the API’s own
GET /metricsdirectly (prom-client), rather than routing metrics through the OTel Collector. This is the standard, simplest split — the collector’s job here is traces only. - Traces go through the Collector, not straight to Tempo, even though Tempo has its own OTLP receiver and would accept them directly. Routing through the collector buys batching, a memory limiter (so a traffic spike can’t OOM the trace pipeline), and a place to add processors later (sampling, PII scrubbing) without touching app code.
- Logs are scraped from Docker, not shipped via OTLP. Promtail uses Docker service discovery (mounts the Docker socket read-only) to tail every container’s stdout — simpler and more battle-tested than OTel’s logs pipeline, which is comparatively new.
src/instrumentation.ts’s OTel pino auto-instrumentation still injectstrace_id/span_idinto every log line, so Loki↔Tempo correlation works without needing logs to physically transit the collector.
What each component actually does here
Prometheus (observability/prometheus/)
Scrapes 9 targets (prometheus.yml): the API itself, node-exporter (host CPU/memory), cAdvisor (per-container CPU/memory), postgres-exporter, redis-exporter, nginx-exporter, the OTel Collector’s self-telemetry, and itself/Alertmanager. alert_rules.yml defines 10 rules across four groups — availability (FaceTrustApiDown, DependencyDown), errors (HighHttpErrorRate, ElevatedLivenessFailureRate), latency (HighIdentifyLatency, HighVectorSearchLatency), and infra (HighMemoryUsage, EventLoopLagHigh, PostgresDown, RedisDown).
No RabbitMQ or any message broker exists in this system (see Architecture — everything is synchronous HTTP + Postgres + Redis), so there’s no RabbitMQ exporter here despite it being a common pairing with Redis in generic observability-stack checklists.
src/observability/metrics.ts) split into two families:
- Generic HTTP (
facetrust_http_requests_total,facetrust_http_request_duration_seconds) — every request, labeled bymethod/route/status_code, recorded bysrc/middleware/metrics.ts.routeuses the matched Express route pattern (/users/:id), not the raw URL, so per-user cardinality never explodes the metric. - Business-specific —
facetrust_otp_requests_total/facetrust_otp_verify_total(by outcome),facetrust_verify_face_total(byregistry_type+outcome — the same outcome vocabulary as the API itself:match/face_mismatch/liveness_failed/cross_registry_mismatch),facetrust_link_total,facetrust_identify_total,facetrust_verify_self_total, plus latency histograms for the liveness stage, the HNSW vector search, and face-engine calls specifically — the same three stages Architecture’s per-stage tracing story is about.
Loki + Promtail (observability/loki/, observability/promtail/)
Promtail discovers every container via the Docker socket and tails its logs — this is what covers “Docker logs” generically. Two containers get extra structure via pipeline_stages in promtail-config.yaml: the API’s pino JSON logs get level/msg/req.url promoted, and nginx’s combined-format access logs get method/status parsed out as labels.
Tempo + OTel Collector (observability/tempo/, observability/otel-collector/)
src/instrumentation.ts is preloaded via -r ./src/instrumentation.ts in package.json’s dev/start scripts — it has to load before Express/pg/ioredis/axios are required, or auto-instrumentation can’t patch them. It auto-instruments HTTP, Express, the Prisma’s underlying pg driver, ioredis, and outbound axios calls (to face-engine and the identity registry), and exports via OTLP/HTTP to the collector.
Verified in this session: a single POST /auth/otp/request call produced a 19-span trace — the full Express middleware chain, then seven distinct ioredis spans (evalsha, ttl, multi, set×2, del, exec) each with real latency, corresponding exactly to the cooldown-check → code-generate → multi-set → OTP-send sequence in modules/auth/service.ts. This is the concrete answer to “identifying where a slow request originates” — no guessing which Redis call in a multi-step flow is slow, it’s a span.
Grafana (observability/grafana/)
Auto-provisioned (provisioning/datasources/datasources.yml) with all four backends plus Alertmanager — anonymous admin access is enabled for local dev only (see the warning in docker-compose.observability.yml, disable before this runs anywhere reachable outside localhost). Two starter dashboards auto-load into a “FaceTrust” folder:
- API Overview — request rate by status, 5xx error rate, p50/p95/p99 latency by route, dependency reachability, process memory, event-loop lag, face-engine call latency, vector search latency.
- Business Metrics — OTP outcomes, verify-face outcomes by registry type, links,
/identifyoutcomes,/verify/selfoutcomes, liveness check duration by surface/mode.
trace_id becomes a clickable “View Trace” link (Loki datasource’s derivedFields), and a trace can jump to its surrounding logs (Tempo datasource’s tracesToLogsV2).
Alertmanager (observability/alertmanager/alertmanager.yml)
Routes severity="critical" alerts to both PagerDuty and a dedicated Slack channel; severity="warning" to Slack only (no page). An inhibit rule suppresses warning-level noise while FaceTrustApiDown is already firing. Every credential in this file is a placeholder (CHANGE_ME) — supply real ones via a .env-style secrets file mounted over the config, or a secrets manager, at actual deploy time; nothing here should ever hold a live SMTP password or Slack webhook URL in version control.
Running it
api service in this command — run the API via npm run dev instead, so it isn’t fighting the compose file’s own api service for host port 3000. See the warning above.)
.env’s OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4320 points a host-run API at the collector’s host-mapped OTLP/HTTP port (4320, offset from the container-internal 4318 since Tempo’s own directly-exposed OTLP port already claims host 4318). If the collector isn’t running, the OTel SDK just fails to export silently in the background — it never blocks startup.