Observability — design
Status: design accepted (ADR 0004); drop 1 (instrumentation) shipped, drops 2–4 pending (plan at the end).
This page becomes the customer-facing docs/observability.md with the fourth drop; until then it is the
specification the drops are checked against. docs/roadmap/v1.md (M14) is the single place where status is ticked.
Goal
A cell is observable out of the box, with the customer's existing stack. Every signal is standard — OTLP for traces and logs, Prometheus exposition for metrics, JSON logs that carry the trace id — and we ship the dashboards and alert rules. Nothing needs our Grafana: a customer with kube-prometheus-stack, Grafana Cloud, Datadog or an OTel Collector of their own points one endpoint at the cell and is done. The Docker Compose quick start can bring a small stack up on the same host with one profile, for evaluation and small teams.
What a cell emits
| Signal | Producer | Transport | Default |
|---|---|---|---|
| Metrics | api, worker, runner (agent), Postgres and Redis exporters, registry, Keycloak | Prometheus exposition on a separate port 9464 per pod (/metrics, no auth, never on the ingress); optional OTLP push |
on |
| Traces | api, worker, runner | OTLP (gRPC or HTTP/protobuf) to OTEL_EXPORTER_OTLP_ENDPOINT |
off until an endpoint is set |
| Logs | api, worker, runner | JSON on stdout (pino), one schema, trace_id / span_id / request_id on every line; collected by whatever reads container logs (Promtail / Alloy / Fluent Bit); optional OTLP |
on |
Resource attributes on everything: service.name (matelot-api / matelot-worker / matelot-runner),
service.version (the image tag, from APP_VERSION), deployment.environment (DEPLOYMENT_ENVIRONMENT, default
production), matelot.cell (the tenant slug, TENANT_SLUG), k8s.* / container.* from the collector where
one runs. The runner adds matelot.runner.name and matelot.runner.pool.
Traces
The OpenTelemetry SDK (sdk-trace-node) with the instrumentations for http, express (Nest runs on it; only the
route template, no per-middleware spans), pg, ioredis and undici/fetch in the API and the worker, started by
apps/api/src/telemetry/tracing.ts, preloaded with node -r ./dist/telemetry/tracing.js before Nest (the images'
entrypoints, Compose, pnpm start) — instrumentation must patch the modules before they load. Its contract:
- A fast no-op when off. Off is the default without an OTLP endpoint, or
MATELOT_TRACING_ENABLED=false: the preload returns after one environment read, loads no SDK module and patches nothing (tracing.spec.tsassertsrequire.cache). - Never in the request path. A
BatchSpanProcessorexports asynchronously with a bounded queue (2048 spans, 512 per batch, 5 s export timeout); an unreachable collector costs dropped spans and ONE warning per failure shape (counted, reported at shutdown) — never a slow request or a failed/health/live. - Clean shutdown. SIGTERM / SIGINT and Nest's shutdown hook flush the queue with a 5 s cap, so the last spans of a pipeline job survive a pod rollout.
BullMQ needs no instrumentation package: QueueService.enqueue puts the active traceparent into the job data and
the worker's handlers run under it. The runner ships no dependencies (its image carries no node_modules), so it
has its own 300-line telemetry.ts with the same contract: W3C traceparent in, OTLP/HTTP JSON out (batched,
bounded, one warning, 5 s shutdown cap; grpc endpoints are refused with a warning), an AsyncLocalStorage current
span, child spans per phase.
One trace per pipeline, end to end. The pipeline queue is the jobs table (claimed by runners through
POST /runner/jobs/claim), not BullMQ, so propagation goes through our own protocol:
- Ids are fixed when the pipeline is created, spans are emitted when things end.
PipelineTelemetry.newTrace()draws a trace id, a root span id for the pipeline and a span id per job, stored as W3C traceparents on the rows (pipelines.trace_parent,jobs.trace_parent, migration 0049). The pipeline's root span (pipeline <name>, start = created, end = terminal status) and each job's span (job <kind>, start = created so the queue wait is in it, eventsclaimed/started, attributesmatelot.job.kind= the jobtype—repository_check,sbom,sca,sast,secrets,iac,policy_eval, … —matelot.job.id,matelot.pipeline.id, project / service / organization ids,matelot.runner.name,matelot.job.exit_code, status) are emitted with those ids by whichever process finishes them (PinnedIdGenerator,telemetry/ids.ts): nothing is held in memory between requests, so API restarts and several replicas can neither lose nor duplicate a span. A retried job gets a new span id in the same trace. - The claim response carries the job's traceparent (
Job.traceParent); the runner startsjob run <kind>under it with a child per phase (checkout,tool syft/tool trivy/tool semgrep,upload sbom,upload findings,correlate) and sends thetraceparentheader on every call back (/start,/logs,/sbom,/code-findings,/complete), so the API side of a job — SBOM ingestion, correlation, the gate — lands in the same trace. - API → worker:
QueueService.enqueueputs the active traceparent into the BullMQ job data (__traceparent), the handlers run under it (queue github-sync,queue github-webhooks). The worker's correlation loop runs as a root spanworker correlation; the gate evaluation of apolicy_evaljob runs under that job's span (gate evaluate). - Outbound integrations (GitHub, GitLab, Jira, registries, OSV / NVD mirrors) are
undiciclient spans withpeer.serviceset from the integration kind.
Sampling: OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG, default parentbased_always_on when an endpoint is
set (a cell does tens of requests per second at most). Never traced: /health/*, /metrics, the runners'
heartbeats (/runner/heartbeat, /runner/jobs/:id/heartbeat); the claim long-poll is traced (it is where a job
starts). Span attributes never carry a query string (url.path only): the redaction rule applies to spans too.
Where to search. The pipeline-level spans — pipeline <name> and the job <kind> spans — are emitted by
whichever process finishes the work (the API on a runner's /complete, the worker after a gate), so they carry a
resource of their own: service matelot-pipeline (attribute matelot.emitted_by names the process). In Jaeger
or Tempo: service matelot-pipeline, operation pipeline Security pipeline, or tag matelot.pipeline.id=<id>; the
request spans are under matelot-api, the phases under matelot-runner, the gate under matelot-worker.
Span budget. A security pipeline on kind first produced 1 824 spans, most of them Redis commands of the rate
limiter and BullMQ housekeeping, pool checkouts and sub-millisecond queries. telemetry/trim.ts drops, when a span
ends: ioredis housekeeping (rl: / bull: keys, multi / exec / pexpire / evalsha) whatever its duration and
any other Redis command under 5 ms; pg-pool.connect / pg.connect always and pg.query:* under 5 ms — a slow
query or command stays, and that is what the trace is for. The runner's log batches (/logs, dozens per job) are
sent with the trace's id but the sampled flag off, so the cell records no span for them; each batch is an event
logs shipped on the job span. The runners' claim long-poll (one request every 20 s per runner, with or without
work) is never a trace of its own: its request span is dropped by the same rules and the polling loop runs with
instrumentation suppressed, so an empty poll leaves nothing; a poll that hands out a job leaves one claim span in
the pipeline's trace under the job's span: it starts when the job became claimable (jobs.queued_at — creation for
the first stage, the stage opening for later ones, a retry or a lost lease afterwards; the job span has a queued
event there) and ends at the hand-out, so it is the job's queue wait and never starts before its parent; the runner's
own long-poll wait, which may have begun before the job existed, is only the attribute matelot.claim.waited_ms
(with matelot.claim.polls, runner, pool). Client polls are never a trace of their own: the web app marks every
background refetch (its refresh interval, a window regaining focus, an invalidation when a job finished) and the CLI
its --wait loops with x-matelot-poll: 1 (the CLI also sends a traceparent whose sampled flag is off, for older
cells); the cell's sampler (telemetry/sampler.ts) records no root for a GET that carries the header, nor for GET /pipelines/:id, /pipelines/:id/jobs, /jobs/:id, /jobs/:id/logs and the correlation job endpoint from any
client. Every other parentless GET is recorded for a share of the trace ids — MATELOT_TRACES_GET_RATIO
(observability.otlp.getRatio, default 0.1): reads are many and alike, one in ten shows the latency profile —
and mutations (POST, PUT, PATCH, DELETE) always; a request inside a sampled trace follows its parent, poll or not.
One open tab was ~150 root traces in 10 minutes (jobs/active, audit-events, /me, …) before this. Worker timers are
root spans named worker <loop> (correlation, registry reconcile, exceptions, digest, remediation reconcile,
snapshots, risk, usage, image sync, enrichment) under service matelot-worker; the every-few-seconds housekeeping
(sweeper, gauges, platform heartbeat and the policy_eval queue query) runs with instrumentation suppressed and the
sweeper emits a worker sweep span (also matelot-worker: only a pipeline's root and job spans belong to
matelot-pipeline) only when it changed something; module initialisation of the api and the worker (signing keys,
caches) runs suppressed too — so every root span is a request, a worker loop or a pipeline (the budget test fails on
any other). apps/api/test/span-budget.mjs (CI, api job) runs a security pipeline through the
built api and worker with the real instrumentation and a fake runner, and fails above 150 spans in the trace,
on any /logs span, when the root is not pipeline Security pipeline under matelot-pipeline, when any job of the
template lacks its job <kind>, claim, /start and /complete spans in the pipeline's trace (or has them in
another), when empty claim polls or --wait polls add a span, when a simulated web session (ten marked polls of four
routes) adds a root, when a span starts before or ends after its parent, when a span other than a pipeline's root or
job carries service matelot-pipeline, or when any root span is not a request, a worker <loop> or a
pipeline — today the trace holds 34 spans from the cell plus the runner's own (about 25 for the real one).
Metrics
The OTel SDK's meter (apps/api/src/telemetry/metrics.ts) with explicit histogram buckets through Views — three
shapes of latency: http 5 ms–10 s, pipeline jobs 1 s–30 min, correlation / ingestion 100 ms–5 min — a
Prometheus / OpenMetrics page on port 9464 and, when OTEL_METRICS_EXPORTER includes otlp, a periodic OTLP push
of the same instruments. The page is rendered by our own serializer (telemetry/exposition.ts), not the SDK's
Prometheus exporter, for one reason: trace exemplars. The JS SDK collects none yet; ours attaches the last
sampled measurement per bucket as # {trace_id="…",span_id="…"} <value> <ts> to a scraper that accepts OpenMetrics
(Prometheus with --enable-feature=exemplar-storage; Grafana then jumps from a slow p95 bar to the exact trace).
Pipeline histograms point at the pipeline's trace, not at the HTTP request that reported the job. The format is
pinned by exposition.spec.ts: dots → underscores, the unit as suffix, _total on counters, matelot_build_info =
1, every label in the allowed set. Names follow the OTel conventions (http.server.request.duration →
http_server_request_duration_seconds); ours carry the matelot_ prefix.
Every enumerable series exists from the first scrape, at zero (KNOWN_LABEL_SETS in metrics.ts,
seedKnownSeries; the runner's telemetry.ts likewise): job kind × terminal status, pipeline status × trigger, claim
and gate results, queue × result, the correlation / sync / webhook sets — counters through add(0), histograms as
empty buckets rendered by the serializer until observed. A series that first appears at its observed value gives
rate() and increase() nothing for that first event: the alert on the first failed job and the p95 after the first
pipeline were both NaN. Both the api and the worker publish the pipeline families (either process can be the one
that finishes a given job: the runner API, the worker's policy_eval, a timeout, a cancel), so dashboards sum over
job. What stays lazy, because its label set is not known at startup: the HTTP histogram (route template × method ×
status) and the per-pool gauges — their queries use or vector(0) / absent() where a missing series matters.
| Metric (Prometheus name) | Type | Labels | Source |
|---|---|---|---|
http_server_request_duration_seconds |
histogram | http_request_method, http_route (the route template /api/v1/projects/:projectId, never the raw path), http_response_status_code |
api (interceptor after routing; probes excluded) |
db_client_connection_count |
gauge | state (used, idle, pending) |
pool stats, every 5 s |
db_client_operation_duration_seconds |
histogram | db_namespace, error_type |
the pg instrumentation (present while tracing is on), http buckets |
matelot_queue_jobs |
gauge | queue (github-sync, github-webhooks), state (waiting, active, delayed, failed) |
worker, from BullMQ counts every 15 s |
matelot_queue_oldest_job_age_seconds |
gauge | queue |
worker |
matelot_queue_jobs_processed_total |
counter | queue, result |
worker (BullMQ events) |
matelot_pipeline_jobs_total |
counter | kind, status (succeeded, failed, cancelled, timed_out) |
api, on /complete and the sweeper |
matelot_pipeline_job_duration_seconds |
histogram | kind |
api, from started_at → finished_at |
matelot_pipeline_job_queue_wait_seconds |
histogram | kind |
api, queued_at → started_at (queued = claimable: creation for the first stage, the stage opening for later ones) |
matelot_pipeline_jobs_queued |
gauge | kind, pool |
worker, jobs table every 15 s (the DB queue: depth and age below) |
matelot_pipeline_oldest_queued_job_age_seconds |
gauge | pool |
worker |
matelot_pipelines_total |
counter | status, trigger |
api |
matelot_runners_online |
gauge | pool, managed |
worker, from heartbeats (online = heartbeat within 60 s) |
matelot_runners_busy |
gauge | pool |
worker |
matelot_runner_last_seen_timestamp_seconds |
gauge | pool |
worker: unix time of the newest heartbeat per pool, so NoRunnerOnline is time() - … > 600, an expression on the scrape |
matelot_runner_claims_total |
counter | result (job, empty, disconnected) |
api |
matelot_runner_job_duration_seconds, matelot_runner_jobs_total, matelot_runner_busy |
histogram / counter / gauge | kind, phase / kind, status / — |
runner (its own /metrics, RUNNER_METRICS_PORT; exemplars on the job histogram) |
matelot_sbom_ingest_duration_seconds, matelot_sbom_correlation_duration_seconds |
histogram | source (runner, ci_upload, manual / loop) |
api (SbomService.ingest) / worker |
matelot_findings_open |
gauge | severity, kind (dependency, code) |
worker, refreshed every 10 min with the correlation loop — per cell, never per project (cardinality) |
matelot_gate_evaluations_total |
counter | result (pass, fail, warn) |
api |
matelot_sla_breaches |
gauge | severity |
worker, every 10 min with the findings refresh: open / in-progress remediations past due_at |
matelot_integration_sync_failures_total |
counter | kind (github, gitlab, jira, registry) |
worker |
matelot_webhook_lag_seconds |
histogram | provider |
worker (delivery time → processed) |
matelot_backup_last_success_timestamp_seconds, matelot_backup_last_failure_timestamp_seconds, matelot_backup_last_duration_seconds |
gauge | — | worker, from the backup reports (POST /api/v1/internal/backup-reports, cell-local token) the backup CronJob / Compose backup service send |
matelot_license_seats_used, matelot_license_expiry_timestamp_seconds |
gauge | — | M13 (absent until licensing exists) |
matelot_build_info |
gauge (1) | version, commit, node_version, component |
each process, from /api/v1/version data |
Rules: labels are bounded enumerations (kinds, statuses, pools, severities, route templates) — never ids, names,
repository paths or user input: ALLOWED_LABELS in metrics.ts is enforced at record time (an instrument with a
label outside it throws) and on the rendered page by the spec; the runner's metrics endpoint carries only its own
job histogram, counters and build_info; anything that needs a SQL query is computed in the worker on a timer
(WorkerGauges: queues and runners every 15 s, open findings with the 10-minute correlation loop) and exported as a
gauge whose absent series vanish, never on scrape.
Postgres and Redis: postgres_exporter and redis_exporter as sidecars of the bundled StatefulSets (off with
postgres.bundled=false — a managed database brings its own); Keycloak with KC_METRICS_ENABLED=true on its
management port (/metrics, already not on the ingress); the registry's debug port (:5001/metrics).
Logs
pino stays. apps/api/src/telemetry/log-schema.ts (api + worker) and the runner's JsonLogger write one line shape —
{"time","level","component","cell","msg","context","trace_id","span_id","request_id", …fields} — trace_id /
span_id from the active span (a pino mixin), request_id from x-request-id as before; the runner's console.*
is gone. Log level per module: LOG_LEVEL=info,jobs=debug,integrations.github=trace (the first entry is the
default, then module=level, matched against Nest's logger context — JobsService is jobs). One redaction list
(REDACTED_KEYS in log-schema.ts: authorization, cookie, password, secret, token, privateKey,
clientSecret, webhookSecret, apiKey, registrationToken, runnerToken, …) applied at any depth to every
logger; log-schema.spec.ts logs an object carrying every known key, nested three levels, and fails if a value
survives. OTLP log export (OTEL_LOGS_EXPORTER=otlp) comes with drop 2 for customers whose collector prefers it to
scraping stdout; the stdout line is the primary.
Audit events and security events (gate failures, token misuse, sign-in anomalies) are also emitted as OTel log
records with a stable event.name (matelot.audit, matelot.security) and documented attributes, so a SIEM
ingests them without parsing our audit table (phase 4).
Configuration
Standard OTel variables first, ours only where OTel has none. Every variable exists in both delivery forms
(api-configmap.yaml and compose-init.sh's env/api.env — the existing rule) and in apps/api/src/config/env.ts.
| Variable | Meaning | Default |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_PROTOCOL |
where traces (and optionally metrics / logs) go; grpc or http/protobuf |
unset → nothing is pushed |
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG |
SDK sampler | parentbased_always_on |
OTEL_METRICS_EXPORTER |
prometheus (default), otlp, none, comma-separated (prometheus,otlp pushes too) |
prometheus |
OTEL_TRACES_EXPORTER |
otlp (default), console, file (JSON lines to OTEL_TRACES_FILE; tests), none |
otlp |
OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES |
override / extend the resource | set by the chart / compose per component |
MATELOT_METRICS_ENABLED, MATELOT_METRICS_PORT |
the /metrics listener |
true, 9464 (runner: RUNNER_METRICS_PORT, 0 = off) |
MATELOT_TRACING_ENABLED |
the tracer provider (auto true when an endpoint is set; false forces it off) |
derived |
DEPLOYMENT_ENVIRONMENT |
deployment.environment |
production |
LOG_LEVEL |
default level, then module=level pairs |
info |
/api/v1/health/live and /ready stay as they are. /api/v1/version carries commit and builtAt (APP_COMMIT,
APP_BUILT_AT, build arguments of the images set by the release workflow), the fields matelot_build_info carries.
Delivery
Drop 1 gave every process its metrics port and its OTLP exporter; drop 2 delivers the signals to a stack — the customer's (Helm: kube-prometheus-stack, Tempo, Loki, or anything that speaks OTLP and scrapes Prometheus) or the one the Compose profile brings. Everything below is off by default: a cell installed with the chart's defaults renders no monitoring CRD and runs on a bare kind cluster.
Helm
observability:
environment: production # deployment.environment on every signal
metrics:
enabled: true # /metrics on 9464 (api, worker, managed runners) + the scrape NetworkPolicy
port: 9464
scrapeFromNamespaces: [monitoring, observability] # the only namespaces admitted to the metrics ports
serviceMonitor:
enabled: false # one ServiceMonitor for every metrics Service below (needs the operator's CRDs)
labels: {} # e.g. release: kube-prometheus-stack — what the Prometheus's selector wants
interval: 30s
scrapeTimeout: 10s
exporters:
enabled: false # postgres_exporter / redis_exporter sidecars, Keycloak metrics, registry debug port
otlp:
endpoint: "" # e.g. http://tempo.monitoring:4318 → OTEL_* on every component
protocol: http/protobuf # or grpc
headersSecret: "" # a Secret holding OTEL_EXPORTER_OTLP_HEADERS (Grafana Cloud's token) — never values
headersSecretKey: OTEL_EXPORTER_OTLP_HEADERS
tracesSampler: parentbased_always_on
tracesSamplerArg: ""
metrics: false # also push metrics over OTLP (OTEL_METRICS_EXPORTER=prometheus,otlp)
What renders:
- Metrics Services. One headless Service per target,
<release>-<name>-metrics, port namedmetrics, labelsmatelot.cloud/metrics: "true"andmatelot.cloud/job: matelot-<name>: api, worker, runner (managed runners), and withexporters.enabledpostgres (9187) and redis (9121) when bundled, keycloak (9000, the management port) when bundled, registry (5001) when the built-in registry is on. They exist with or without the operator — any Prometheus can discover them by label. - ServiceMonitor
<release>(serviceMonitor.enabled): selects exactly those Services in the release's namespace,jobLabel: matelot.cloud/job(so the job ismatelot-api,matelot-postgres, …), one endpoint on portmetrics, and a relabel addingcell=<slug>. Rendering it withmetrics.enabled=falsefails with a message. - Exporters (
exporters.enabled):postgres_exporterv0.20.0 as a sidecar of the bundled Postgres (localhost:5432, credentials from the<release>-postgresSecret),redis_exporterv1.94.0 as a sidecar of the bundled Redis — neither withbundled: false(point your own exporter at the managed service); Keycloak getsKC_METRICS_ENABLED=true(/metricson the management port 9000, which never reaches the ingress); the registry gets itshttp.debuglistener on 5001 with Prometheus metrics. All of them run as non-root with a read-only root. - NetworkPolicy
<release>-allow-metrics-scrape: ingress to 9464 (+ 9187, 9121, 9000, 5001 with the exporters) only fromscrapeFromNamespaces. No metrics Service is referenced by the ingress (chart-testasserts it). - OTLP headers come only from a Secret:
observability.otlp.headersSecretnames it, every component getsOTEL_EXPORTER_OTLP_HEADERSthrough asecretKeyRef. The oldobservability.otlp.headersmap is refused at render time ("tokens do not belong in values"). - Backups report themselves: the backup CronJob POSTs its outcome to the API (
/api/v1/internal/backup-reports, a generated bearerBACKUP_REPORT_TOKENin<release>-api-secrets, also on S3 uploads), and the worker publishesmatelot_backup_last_success_timestamp_seconds,matelot_backup_last_failure_timestamp_secondsandmatelot_backup_last_duration_seconds— the same gauges on Compose, where no kube-state-metrics exists.
chart-test renders defaults, OTLP on, metrics off, everything on and everything on with external Postgres / Redis,
and asserts among others: no monitoring.coreos.com resource with defaults; every ServiceMonitor endpoint port
exists on a Service whose selector matches a pod exposing that port; the job list per configuration; exporter args
and env; the NetworkPolicy ports; the backup report env for PVC and S3. CI's kind step installs the
prometheus-operator CRDs only (v0.94.1, the version kube-prometheus-stack 92.11.0 ships) and runs the server-side dry
run of the everything-on render, fresh and over an existing release.
kube-prometheus-stack recipe
The values the drop was validated with are in deploy/observability/kind/ (published as examples/observability/ in
the helm-charts repository) — make kind-observability TENANT=<slug>
installs them and switches the cell on (or scripts/kind-observability.sh <slug> --print for the bare commands):
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo add grafana https://grafana.github.io/helm-charts
helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack --version 92.11.0 \
-n monitoring --create-namespace -f deploy/observability/kind/kube-prometheus-stack.values.yaml --wait
helm upgrade --install tempo grafana/tempo --version 1.24.4 -n monitoring -f deploy/observability/kind/tempo.values.yaml --wait
helm upgrade --install loki grafana/loki --version 7.3.0 -n monitoring -f deploy/observability/kind/loki.values.yaml --wait
helm upgrade --install alloy grafana/alloy --version 1.14.1 -n monitoring -f deploy/observability/kind/alloy.values.yaml --wait
and in the cell's values file (deploy/cells/<slug>.values.yaml, then cell-provision.sh apply --slug <slug>):
observability:
metrics:
serviceMonitor:
enabled: true
labels: { release: kube-prometheus-stack } # = the kube-prometheus-stack release name
exporters:
enabled: true
otlp:
endpoint: "http://tempo.monitoring:4318"
protocol: http/protobuf
Grafana gets GF_PLUGINS_PREINSTALL_DISABLED=true there and on Compose: Grafana 12 otherwise downloads plugins
(pyroscope, exploretraces, metricsdrilldown) from the internet on every start, which an air-gapped cluster cannot do
and none of our dashboards needs. What matters in the stack's values: prometheus.prometheusSpec.enableFeatures: [exemplar-storage] (the trace links
on the histograms) and serviceMonitorNamespaceSelector: {} (cells live in tenant-<slug>); Grafana's
sidecar.datasources.exemplarTraceIdDestinations (trace_id → Tempo) and the Tempo / Loki datasources with
trace → logs by trace_id; Alloy turning our JSON log lines' trace_id / span_id into Loki structured metadata.
On a real cluster keep all of that and change: Grafana's admin password from a Secret
(grafana.admin.existingSecret), persistence and retention sized for the cluster (Prometheus
storageSpec, Loki / Tempo on object storage — loki.storage.type: s3, deploymentMode: SimpleScalable, Tempo
storage.trace.backend: s3), Alertmanager on, Alloy as a DaemonSet with loki.source.file over /var/log/pods if
the API-based tailing is too much load, and Grafana's ingress behind your SSO.
Docker Compose
COMPOSE_PROFILES=observability in .env (or docker compose --profile observability up -d) — on a new cell or an
existing one: init rewrites the Caddyfile on every up and Caddy reloads it (caddy run --watch), so /grafana/
appears without recreating anything — adds:
| Service | Image | Role |
|---|---|---|
otel-collector |
otel/opentelemetry-collector-contrib 0.163.1 | OTLP in (the cell's MATELOT_OTLP_ENDPOINT defaults to http://otel-collector:4318 with the profile), matelot.cell resource attribute, traces → Tempo |
prometheus |
prom/prometheus v3.15.0 | scrapes api, worker, runner (9464), the exporters, Keycloak (9000), the registry (5001) and the stack itself; exemplar storage on; 7 days |
tempo |
grafana/tempo 2.9.0 | single binary, local storage, 7 days |
loki |
grafana/loki 3.6.12 | single binary, TSDB v13 on the filesystem, structured metadata, 7 days |
alloy |
grafana/alloy v1.21.0 | the logs of this compose project's containers (Docker socket, read-only) → Loki; trace_id / span_id as structured metadata, level / component / cell / compose_service labels |
grafana |
grafana/grafana 12.4.12 | at <domain>/grafana/ through Caddy; datasources provisioned (Prometheus exemplars → Tempo, Tempo → Loki by trace_id, Loki trace_id → Tempo) |
postgres-exporter, redis-exporter |
as on Helm | the bundled database and cache |
init renders their configuration into the config volume (the cell's slug substituted), turns on Keycloak's metrics
and the registry's debug listener, and generates Grafana's secrets like every other one. Grafana signs users in with
the cell's Keycloak (client grafana, confidential; realm-sync creates it in a realm that predates the profile);
they arrive as Viewers. The local admin account is for the operator: docker compose run --rm init show prints its
password. No service of the profile publishes a port — Prometheus, Tempo and Loki are reachable only on the compose
network and through Grafana. Alloy needs /var/run/docker.sock (read-only); that is a privileged handle on the host,
which is what container log collection costs — leave the profile off where that is not acceptable and send OTLP to
your own collector instead.
smoke.sh --observability (a second CI pass) brings the cell up with the profile and, through Caddy and Grafana's
datasource proxy only: the datasources and the Keycloak sign-in redirect; every Prometheus target up;
matelot_build_info from api / worker / runner; one series from each exporter; a checkout pipeline on a public
repository whose trace is in Tempo with matelot-pipeline and matelot-runner spans and whose log lines are in Loki
by trace_id; a backup run producing matelot_backup_last_success_timestamp_seconds.
For customers who run their own collector
Point the cell at the collector (observability.otlp.endpoint, or MATELOT_OTLP_ENDPOINT on Compose) and let the
collector scrape the metrics Services. The configuration we recommend:
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
prometheus:
config:
scrape_configs:
- job_name: matelot
scrape_interval: 30s
kubernetes_sd_configs: [{ role: endpoints }]
relabel_configs: # the cell's metrics Services only, job = matelot-<name>, cell = the namespace's slug
- { source_labels: [__meta_kubernetes_service_label_matelot_cloud_metrics], regex: "true", action: keep }
- { source_labels: [__meta_kubernetes_endpoint_port_name], regex: metrics, action: keep }
- { source_labels: [__meta_kubernetes_service_label_matelot_cloud_job], target_label: job }
- { source_labels: [__meta_kubernetes_namespace], regex: "tenant-(.+)", target_label: cell }
processors:
memory_limiter: { check_interval: 1s, limit_percentage: 80, spike_limit_percentage: 20 }
k8sattributes:
extract: { metadata: [k8s.namespace.name, k8s.pod.name, k8s.deployment.name, k8s.statefulset.name] }
batch: {}
exporters:
prometheusremotewrite: { endpoint: https://prometheus.example.com/api/v1/write }
otlp/tempo: { endpoint: tempo.monitoring:4317, tls: { insecure: true } }
otlphttp/loki: { endpoint: http://loki.monitoring:3100/otlp }
service:
pipelines:
traces: { receivers: [otlp], processors: [memory_limiter, k8sattributes, batch], exporters: [otlp/tempo] }
metrics: { receivers: [prometheus, otlp], processors: [memory_limiter, k8sattributes, batch], exporters: [prometheusremotewrite] }
The collector's namespace must be in observability.metrics.scrapeFromNamespaces. Keep exemplars: the
prometheusremotewrite exporter forwards them when the receiving Prometheus has exemplar-storage on. Logs stay on
stdout as JSON (one schema, trace_id / span_id included) for whatever ships pod logs — Alloy, Fluent Bit, the
collector's filelog receiver; promote trace_id to structured metadata (Loki) or a field (Elastic), never to an
index label. Grafana Cloud: one OTLP endpoint with basic auth — put Authorization=Basic <base64 instance:token> in a
Secret under the key OTEL_EXPORTER_OTLP_HEADERS and name it in observability.otlp.headersSecret.
Dashboards and alerts
Four dashboards and one rules file, shipped twice from one source: deploy/observability/dashboards/*.json and
deploy/observability/rules/*.yaml, copied into the chart (files/observability) and the Compose profile
(init/observability) by python3 tools/dashboards/build.py (make dashboards) — chart-test fails when a copy
differs. The dashboards are generated: edit tools/dashboards/build.py, never the JSON. tools/dashboards/test.py
(CI, chart job) regenerates and compares, parses every panel query and rule expression with promtool, and checks
that every metric a query names is one a process emits — ours from the instrument declarations in
telemetry/metrics.ts and the runner's telemetry.ts, the exporters' and Kubernetes' from its allowlist — so a
dashboard cannot plot a metric nobody publishes. The rules' unit tests (rules/matelot_test.yaml, promtool test rules) run there too.
Every query is scoped to the cell variable (label_values(matelot_build_info, cell)): on Helm the ServiceMonitor's
relabel adds cell=<slug> to every series; on Compose the scrape jobs carry it as a target label (an external label
would not reach the rules or the variable). The datasources are inputs (${DS_PROMETHEUS}, ${DS_TEMPO}) in the
source JSON, so it imports into any Grafana; the chart resolves them to the uids of
observability.grafana.dashboards.datasources (kube-prometheus-stack's prometheus, our tempo) and compose-init to
its provisioned datasources, because provisioning does not resolve inputs.
| Dashboard (uid) | Rows |
|---|---|
Cell overview (matelot-cell-overview) |
Components (api, worker, Keycloak, Postgres, Redis, registry up; runners online; version) · Traffic RED (requests/s by route, 5xx ratio, p50/p95/p99) · Queues and runners (pipeline jobs queued by kind, oldest queued job by pool, runners online/busy, BullMQ by queue and state) · Pipelines (per hour by status, success rate, jobs finished) · Database and cache (pool connections per process, query p95, Postgres transactions and size, Redis commands and memory) · Backups, storage and certificates (backup age, duration, last failure; PVC usage and certificate expiry from the cluster — empty on Compose) |
Pipelines (matelot-pipelines, variable kind: only the kinds that ran at least once; every quantile drops NaN with >= 0, so a kind without runs in the window draws no empty line) |
Jobs (duration p95/p50 by kind with exemplars, finished per hour by status, failures by kind, queue wait p95, the runners' own duration) · Runners (utilisation, claims/s by result, online by pool) · Pipelines (duration p95 by status, per hour by trigger, a 24 h table by kind). The traces: the exemplar dots open the job's trace in Tempo; a text panel gives the TraceQL for the failed pipelines |
Security posture (matelot-security-posture) |
Findings (open by severity and kind, critical/high stats, SLA breaches, total) · Gates and SLAs (evaluations per hour by result, fail ratio, breaches by severity) · SBOMs (ingested per hour by source, ingest p95, correlation run p95) |
Integrations (matelot-integrations) |
Source and issue trackers (sync failures by kind, worker queue jobs by result, webhook lag p95, failed BullMQ jobs) · Built-in registry (requests/s by handler and method = pulls and pushes, 4xx/5xx, p95) · Keycloak (active requests, JVM heap, Agroal pool) |
Two gauges that existed as instruments but were never set now are: matelot_sla_breaches{severity} (open or
in-progress remediations past their due date, with the findings refresh every 10 minutes) and
matelot_sbom_ingest_duration_seconds{source} (around SbomService.ingest; source = runner, ci_upload, manual).
"SBOM coverage" as a per-service ratio is the product's own Coverage page, not a cell metric (it would be per
service: cardinality); the dashboard shows ingests per hour by source. Where the design said "a link from a pipeline
row to its trace", the metrics carry no pipeline ids (labels are bounded sets), so the link is the exemplar on the
duration histograms plus the TraceQL panel.
Helm: observability.prometheusRules.enabled renders the PrometheusRule <release> (labels from
prometheusRules.labels, e.g. release: kube-prometheus-stack; prometheusRules.kubernetes: false leaves out the
group that needs kube-state-metrics / kubelet / cert-manager; runbookBaseUrl rewrites every runbook_url);
observability.grafana.dashboards.enabled renders one ConfigMap per dashboard with grafana_dashboard: "1" and the
grafana_folder: Matelot annotation for the sidecar (deploy/observability/kind/kube-prometheus-stack.values.yaml
sets sidecar.dashboards.folderAnnotation and ruleNamespaceSelector: {}, and runs a minimal Alertmanager whose
matelot route collects the cell's alerts — without one, the stack's own PrometheusNotConnectedToAlertmanagers fires
forever). Both need metrics.enabled and the
operator's CRDs; make kind-observability switches them on with the rest. Compose: the profile's Prometheus loads
rules/matelot.yaml (its /alerts page shows them; Grafana's alert list too) and Grafana provisions the four
dashboards into folder Matelot; smoke.sh --observability asserts the dashboards, the rules and that the inputs
resolved. Screenshots of the dashboards are not in this repository: they come from the kind acceptance run.
Alerts
deploy/observability/rules/matelot.yaml (every stack) and matelot-kubernetes.yaml (Kubernetes metrics). Each
alert links here (runbook_url, #alert-<name>); severities: critical = users or data are affected now, warning
= they will be. Prometheus evaluates them; routing to a pager is the customer's Alertmanager.
| Alert | Condition | Severity |
|---|---|---|
MatelotApiDown |
min by (cell) (up{job="matelot-api"}) == 0 for 2 m — one alert per cell, whatever the replica count |
critical |
MatelotApiAbsent |
no matelot-api target at all for 5 m |
critical |
MatelotWorkerDown |
min by (cell) (up{job="matelot-worker"}) == 0 for 5 m |
critical |
MatelotKeycloakDown |
min by (cell) (up{job="matelot-keycloak"}) == 0 for 2 m |
critical |
MatelotNoRunnerOnline |
runners online = 0 (or none ever reported) while matelot_pipeline_jobs_queued > 0, for 10 m |
warning |
MatelotQueueBacklog |
oldest queued pipeline job or BullMQ job older than 600 s, for 10 m | warning |
MatelotPipelineFailureRate |
failed / total pipelines over 1 h > 50 % with ≥ 5 pipelines, for 15 m | warning |
MatelotDbConnectionsHigh |
a process has > 8 of its 10 pool connections in use, or callers waiting, for 10 m | warning |
MatelotBackupFailed |
the last backup report is a failure newer than the last success, for 5 m | critical |
MatelotBackupTooOld |
last success older than 36 h, for 1 h | warning |
MatelotLicenseExpiring |
matelot_license_expiry_timestamp_seconds - time() < 14 d (absent until M13) |
warning |
MatelotBackupJobFailed (k8s) |
the newest *-backup-* Job of a tenant-* namespace (by kube_job_status_start_time) failed, for 5 m — an older failed Job the CronJob keeps does not count |
critical |
MatelotCertificateExpiring (k8s) |
certmanager_certificate_expiration_timestamp_seconds - time() < 14 d |
warning |
MatelotDiskAlmostFull (k8s) |
a tenant-* PVC under 10 % free (kubelet volume stats), for 15 m |
critical |
Runbooks:
Alert: MatelotApiDown
The api pod answers no scrape. kubectl -n tenant-<slug> get pods / docker compose ps: a crash loop shows in the
restart count; kubectl logs deploy/<slug>-api --previous has the reason — most often the database unreachable at
start (DATABASE_URL, Postgres down: see Postgres on the Cell overview) or a failed migration (the init container's
log). /api/v1/health/ready through the ingress says whether users are affected; if only the metrics port is
unreachable the NetworkPolicy or the ServiceMonitor changed (scrapeFromNamespaces).
Alert: MatelotApiAbsent
Prometheus has no target: the metrics Service is gone (kubectl -n tenant-<slug> get svc <slug>-api-metrics), the
ServiceMonitor is not selected (its labels must match the Prometheus's serviceMonitorSelector — the release label
of kube-prometheus-stack), or observability.metrics.enabled was turned off while this rule stayed loaded.
Alert: MatelotWorkerDown
Background work stopped: source sync, webhooks, correlation, the policy_eval stage of pipelines, the sweeper. Same
diagnosis as the api (deploy/<slug>-worker); the worker also needs Redis (REDIS_URL) — a Redis down shows as
redis_up 0 on the overview. Pipelines whose next stage is evaluate stay queued until it is back.
Alert: MatelotKeycloakDown
Nobody can sign in. kubectl -n tenant-<slug> logs deploy/<slug>-keycloak: its database (the keycloak database on
the same Postgres), the hostname settings after a domain change (KC_HOSTNAME), or memory. Existing tokens keep
working until they expire; the API itself is up.
Alert: MatelotNoRunnerOnline
Jobs wait and no runner has sent a heartbeat for a minute. Managed runners: kubectl -n tenant-<slug> get sts <slug>-runner (scaled to 0? crash loop? the registration secret rotated?). Customer runners: their host, their
network path to the API, their token. matelot runners list and matelot doctor show the same from the CLI. The
queued jobs run as soon as a runner with the required tags comes back; nothing is lost.
Alert: MatelotQueueBacklog
The oldest queued job has waited more than 10 minutes. Pipeline jobs: not enough runners for the load, or jobs whose
requiredTags / requested pool no runner serves (matelot job show <id>); the Pipelines dashboard's Queue wait and
Runners online by pool tell which. BullMQ jobs (source sync, webhooks): the worker is down or saturated — the
Integrations dashboard's Worker queue jobs and Failed BullMQ jobs.
Alert: MatelotPipelineFailureRate
More than half the pipelines of the last hour failed, at least five of them. Look at the Pipelines dashboard's
Failures per hour by kind: one kind failing everywhere is a scanner image or a repository the runners cannot
reach (the job log: matelot job logs <id>); every kind failing is the runner's environment (disk, network, a
broken image pull). A gate (policy_eval) failing every run is a policy, not an outage — the Security posture
dashboard's gate panels.
Alert: MatelotDbConnectionsHigh
A process holds more than 8 of its 10 pool connections, or requests wait for one. A slow query is the usual cause:
pg_stat_activity on the cell's Postgres (kubectl exec … psql), or the Query latency p95 panel; a sustained load
needs more api replicas (api.replicas) rather than a bigger pool.
Alert: MatelotBackupFailed
The last backup run reported a failure. The reason is in the report (backup_reports table through
GET /api/v1/internal/backup-reports is not exposed; the API's audit trail has backup.failed with the error) and
in the Job's log (kubectl -n tenant-<slug> logs job/<name>; Compose: docker compose --profile backup run --rm backup). Typical: Postgres unreachable (the run waits two minutes for it), the backups PVC full
(MatelotDiskAlmostFull), S3 credentials. Run one by hand: make backup TENANT=<slug>.
Alert: MatelotBackupTooOld
No successful backup for 36 hours. The CronJob is suspended or its schedule changed (kubectl -n tenant-<slug> get cronjob), every run fails (see above), or backups were disabled (backup.enabled) while the rules stayed loaded —
then turn the rules off or the backups on.
Alert: MatelotLicenseExpiring
The licence expires within 14 days (M13 licensing; the metric does not exist before). Renew the key in Settings → License; the cell falls back to the Free tier otherwise.
Alert: MatelotBackupJobFailed
The newest backup Job failed and no later run has started (kube-state-metrics); it clears with the next good run. Same as MatelotBackupFailed, from the cluster's side:
kubectl -n <namespace> get jobs and the failed pod's log.
Alert: MatelotCertificateExpiring
cert-manager has not renewed a cell certificate due within 14 days: kubectl -n <namespace> describe certificate
and the issuer's challenge (DNS of <slug>.<domain>, auth-<slug>.<domain>, registry-<slug>.<domain> pointing at
the ingress; rate limits). Users see browser warnings when it expires.
Alert: MatelotDiskAlmostFull
A cell volume has under 10 % free: Postgres (<slug>-postgres), the registry, or the backups PVC. Grow the PVC if the
storage class allows expansion; otherwise prune — backup retention (backup.retentionDays), registry garbage
collection, or the database's own growth (SBOM documents are the bulk). Postgres stopping on a full disk takes the
cell down.
Product-facing (phase 4)
- Settings → Observability (cell administrators, under Administration): what is enabled (metrics, tracing, log
export), the configured endpoints (headers masked), the last scrape time per component (each process records the
time of its last
/metricsrequest), a Test OTLP button (the API sends one span and reports the exporter's result), and the Grafana link whenobservability.grafana.urlis set. matelot doctorgains an observability section: metrics endpoint reachable (through the API, since 9464 is not public), OTLP configured, last trace seen, last scrape.- Audit and security events as OTel logs (schema above), documented field by field.
- Public docs: this page, rewritten for customers (recipes, dashboard gallery, alert list with runbooks, collector config).
Security
- Metrics ports are never on the ingress; the NetworkPolicy limits them to the monitoring namespace(s); the Compose
stack publishes none of them on the host — Prometheus scrapes on the compose network and Grafana is behind Caddy
- Keycloak.
- No label or span attribute carries a secret, a token, a repository URL with credentials, or user input; route
templates, not paths; the redaction list applies to logs and to span attributes (
http.urlis recorded without query string). - OTLP headers (Grafana Cloud tokens) live in a Secret the operator creates (
observability.otlp.headersSecret), referenced by every pod through asecretKeyRef; the chart refuses headers in values. - The backup report endpoint is unauthenticated for users and accepts only the generated
BACKUP_REPORT_TOKEN(compared in constant time); it is not in the OpenAPI document. - Business gauges are per cell (the customer's own cell): no cross-tenant data exists in a cell.
Plan — four drops
| Drop | Content | Acceptance |
|---|---|---|
| 1 — instrumentation ✅ | tracing.ts preload + SDK in api / worker; runner spans and traceParent in the claim protocol (pipelines.trace_parent, jobs.trace_parent, migration 0049); meter with Views + our exposition on 9464 (exemplars); log schema, per-module levels, redaction list + test; env in env.ts, api-configmap.yaml, compose-init; /version with commit; make kind-jaeger |
exposition.spec.ts (format, buckets, exemplars, labels), log-schema.spec.ts (redaction, levels, trace ids), tracing.spec.ts (no-op when off, provider when on), pipeline-trace.spec.ts (create → claim → complete: one trace, pinned ids, parents, exemplars, BullMQ propagation), runner telemetry.test.ts; verified against a real Jaeger 2.22: a security pipeline (checkout, syft, trivy, semgrep, gate) is one trace across api, runner and worker |
| 2 — delivery | observability.* values, metrics Services + one ServiceMonitor, NetworkPolicy, exporter sidecars, Keycloak metrics, registry debug port, OTLP headers from a Secret only, backup reports → backup gauges; chart-test on / off; CI kind with the operator CRDs; Compose profile with collector, Prometheus, Tempo, Loki, Grafana (Keycloak sign-in), Alloy; smoke.sh --observability; the collector config and the kube-prometheus-stack recipe above; make kind-observability |
chart-test and CI green; Compose smoke with the profile green (targets up, trace in Tempo, logs in Loki); on kind with the recipe: apply passes, every matelot target UP, matelot_build_info for api / worker / runner, the exporters' series, a demo-app pipeline trace in Tempo with its logs in Loki |
| 3 — dashboards and alerts | the four dashboards generated by tools/dashboards/build.py, the rules files with promtool unit tests, tools/dashboards/test.py in CI (regeneration, expression parsing, every metric published), PrometheusRule + dashboards ConfigMaps in the chart, Grafana provisioning + rule_files in Compose, smoke.sh --observability asserting both, the runbooks above; with it the fixes the rc.20 run asked for: series seeded at zero, the claim span inside its job, worker sweep under matelot-worker, the image tag pinned by apply --tag, the backup waiting for Postgres, polls never roots (x-matelot-poll) and GETs ratio-sampled |
kind with kube-prometheus-stack + Tempo + Loki: dashboards show the demo pipeline; NoRunnerOnline fires with runners scaled to 0; BackupFailed fires when the CronJob is made to fail |
| 4 — product | Settings → Observability, doctor section, audit / security events as OTel logs, customer docs | render tests for the page; doctor test; the SIEM schema documented and covered by a test that pins the field names |
Acceptance for the milestone: on kind with kube-prometheus-stack, Tempo and Loki installed from their charts and
observability.* on, all targets up, our dashboards in Grafana with data from the demo-app pipeline, one pipeline
trace end to end in Tempo with its logs correlated in Loki by trace_id, and the two alerts above firing on demand;
the Compose profile reaches the same on one host.
Out of scope
Our own hosted Grafana or a multi-cell fleet view (that is step 17, the control plane); profiling (pprof / Pyroscope);
synthetic checks from outside the cell (the customer's uptime tooling does that against /api/v1/health/ready);
metrics per project or per user (cardinality; the product's own dashboards cover that from the database).
Matelot