Headroom OTel-SDK derived image — design
Bead: hl-v9xz.11 (epic hl-v9xz — Headroom central LLM compression)
Date: 2026-06-24
Status: Design — pending design-review gate
Problem
Section titled “Problem”Both Headroom deployments (headroom-apps, headroom-agents) are fully wired
for native OTLP/HTTP metrics push to ClickStack — HEADROOM_OTEL_METRICS_ENABLED=1
plus the complete HEADROOM_OTEL_METRICS_* env (exporter otlp_http, endpoint
http://cs-otel-collector.clickstack.svc.cluster.local:4318/v1/metrics, headers,
service name). Yet no Headroom metrics reach ClickStack: the export is a
silent no-op.
This blocks hl-bdok (central-compression validation needs Headroom metrics in
ClickStack to measure real-traffic compression effectiveness).
Root cause (verified 2026-06-24)
Section titled “Root cause (verified 2026-06-24)”The official ghcr.io/chopratejas/headroom images ship the OpenTelemetry API
but not the SDK. They are built as headroom-ai[proxy], not
headroom-ai[proxy,otel]. In-pod evidence:
python3 -c 'import opentelemetry.sdk'→ModuleNotFoundError.get_otel_metrics_status()→{configured: False, enabled: False, exporter: None}despite the full env.
Headroom’s metrics setup finds no SDK and silently does nothing — no error logged. Two non-causes were ruled out and are red herrings:
HEADROOM_TELEMETRYcontrols only the anonymous upstream usage beacon; it is independent of OTLP export.offis correct.- The CiliumNetworkPolicy already permits egress to
clickstack:4318.
No official image carries the OTel SDK
Section titled “No official image carries the OTel SDK”A read-only GHCR probe (manifest HEAD, the reliable check — tags/list
paginates at 100 and reports false-missing, confirmed when 0.27.0-nonroot was
absent from the listing yet returned HTTP 200) established that the published
variant matrix is {code, slim, nonroot} only — there is no otel axis.
Every candidate (otel, 0.27.0-otel, 0.27.0-nonroot-otel,
0.27.0-code-nonroot-otel, 0.6.7-otel, …) returned HTTP 404.
Therefore the only way to get the SDK into the running container is to build a derived image. That reverses ADR hl-8ycr (use official images; do not build cluster-owned), so this design includes a scoped amendment to that ADR.
Decision
Section titled “Decision”Build one thin derived image — ghcr.io/fzymgc-house/headroom-otel — that adds
the OpenTelemetry SDK and OTLP/HTTP exporter on top of the official
code-nonroot base, and repoint both deployments at it.
Why code-nonroot for both instances
Section titled “Why code-nonroot for both instances”The two instances run different official bases today: headroom-apps on
0.27.0-nonroot, headroom-agents on 0.27.0-code-nonroot. Per ADR hl-8ycr’s
own rationale, code-nonroot is a superset of nonroot — the same
debian-slim base (so it keeps the shell headroom-apps uses for /stats
tuning) plus tree-sitter. A single derived image from code-nonroot
therefore serves both, collapsing what would otherwise be a per-base build
matrix into one image.
Trade-off accepted: the apps pod carries tree-sitter it does not use (a few MB, no functional or runtime cost). This is the deliberate price of one image instead of two.
Approaches considered
Section titled “Approaches considered”- A — Thin derived image (chosen).
FROMofficial + install the two OTel SDK packages. Matches the existingimages/openclaw-customandimages/k8s-utilsprecedent; immutable digest; build runs on CI, not the cluster. Cost: reverses ADR hl-8ycr → scoped amendment. - B — Runtime SDK injection via initContainer. Install the SDK into a shared
PYTHONPATHvolume at pod start. Avoids building an image (no ADR reversal) but requires PyPI egress at startup, a CiliumNetworkPolicy change (onlyclickstack:4318egress is open today), and makes pod startup network-fragile. Rejected. - C — Native Prometheus scrape. Headroom exposes a native
/metricsendpoint; scrape it like the agentgateway metrics lane. Fully honors hl-8ycr with zero build, but abandons the user-directed OTLP/OTel path. Rejected.
Design
Section titled “Design”Component 1 — images/headroom-otel/Dockerfile
Section titled “Component 1 — images/headroom-otel/Dockerfile”A single Dockerfile following the repo’s thin-derived-image convention:
# SPDX-License-Identifier: MIT# Thin derived Headroom image: adds the OpenTelemetry SDK + OTLP/HTTP exporter# that the official ghcr.io/chopratejas/headroom images omit (built [proxy], not# [proxy,otel]), so native HEADROOM_OTEL_METRICS_* export to ClickStack works.# Base is code-nonroot (superset of nonroot) so one image serves both the apps# and agents deployments. See docs/adr/hl-8ycr (amended) and bead hl-v9xz.11.FROM ghcr.io/chopratejas/headroom:0.27.0-code-nonroot@sha256:<pin-at-build>
USER root# Install ONLY what headroom-ai[otel] adds, pinned to the opentelemetry-api# version already bundled by [proxy] — guarantees api/sdk/exporter lockstep and# avoids reinstalling headroom-ai itself (no version drift).RUN API_VER="$(python -c 'import importlib.metadata as m; print(m.version("opentelemetry-api"))')" && \ pip install --no-cache-dir \ "opentelemetry-sdk==${API_VER}" \ "opentelemetry-exporter-otlp-proto-http==${API_VER}"USER 65532Grounding facts (deepwiki + context7) that make this correct:
- Headroom is installed into system site-packages
(
/usr/local/lib/python3.11/site-packages, onPYTHONPATH); base ispython:3.11-slim;pipis present. So packages installed here are importable by Headroom — no virtualenv mismatch. - The
[otel]extra is exactlyopentelemetry-sdk>=1.24.0+opentelemetry-exporter-otlp-proto-http>=1.24.0;opentelemetry-api>=1.24.0is already a core dep. The OTLP HTTP metrics exporter (OTLPMetricExporter) lives inopentelemetry.exporter.otlp.proto.http.
Component 2 — .github/workflows/build-headroom-otel.yml
Section titled “Component 2 — .github/workflows/build-headroom-otel.yml”A near-verbatim copy of build-openclaw-custom.yml (the established multi-arch
lane): a build-amd64 job and a build-arm64 job on the namespace.so
namespace-profile-linux-{amd64,arm64}-2x4 runners, then a create-manifest
job stitching a multi-arch manifest. Image name
ghcr.io/fzymgc-house/headroom-otel; tag derived from the base version
(0.27.0). Triggers on push to main touching images/headroom-otel/** or the
workflow file, plus workflow_dispatch.
Multi-arch is retained (not arm64-only) to reuse the proven workflow pattern unchanged, even though the cluster is currently all-arm64.
Component 3 — deployment repoint (the only manifest change)
Section titled “Component 3 — deployment repoint (the only manifest change)”Both deployments already carry the complete OTLP env. The sole change is the image reference:
argocd/app-configs/headroom-apps/deployment.yamlargocd/app-configs/headroom-agents/deployment.yaml
each set to:
image: ghcr.io/fzymgc-house/headroom-otel:0.27.0@sha256:<derived-digest>--mode token (apps) and --mode cache (agents) and all other args/env stay
exactly as-is.
Component 4 — image pinning and Renovate
Section titled “Component 4 — image pinning and Renovate”- The deployment pins
tag@sha256:<digest>to preserve the digest immutability ADR hl-8ycr values. - The Dockerfile
FROMis digest-pinned; Renovate’sdockerfilemanager bumps it on upstream Headroom releases. - Flow is two-hop, both automergeable: a
dockerfile-manager PR bumpsFROM→ the build workflow publishes a new derived digest → anargocd-manager PR digest-bumps the two deployments. Documented indocs/operations/headroom.md.
ADR impact
Section titled “ADR impact”ADR hl-8ycr (“use official images; do not build cluster-owned”) is reversed in a
narrow, capability-driven way. Because hl-8ycr is bd-rendered
(adr-render: source=bd:hl-8ycr), the amendment is captured via the beads ADR
mechanism (/adr / evolve-adr) during the plan phase — not a hand-edit of the
rendered file. The amendment scopes the rule to:
Prefer official images. Build a thin derived image only when the official image matrix lacks a required capability extra that cannot be supplied via configuration — here, the OpenTelemetry SDK, absent from every published
[proxy]build.
The derived image stays minimal (no Headroom fork; only well-known OTel packages added) to preserve most of hl-8ycr’s supply-chain rationale. hl-8ycr gains a “partially amended by ⟨new ADR⟩” status.
Verification (acceptance criteria)
Section titled “Verification (acceptance criteria)”- Build / pre-publish: an in-image import check passes —
python -c "import opentelemetry.sdk, opentelemetry.exporter.otlp.proto.http.metric_exporter"exits 0. - Post-deploy (in-pod), both instances:
get_otel_metrics_status()returnsconfigured == Trueandenabled == True. - Post-deploy (ClickStack):
headroom-appsandheadroom-agentsappear indefault.otel_metrics_sum(ClickHouse query). - Unblocks:
hl-bdokcan then measure real-traffic compression in ClickStack.
Rollback
Section titled “Rollback”Revert the two deployment image: references to the official
0.27.0-{nonroot,code-nonroot} tags. Metrics export returns to its current
inert state; Headroom proxying is otherwise unaffected.
Risks and consequences
Section titled “Risks and consequences”- A build pipeline now exists for Headroom — the thing hl-8ycr avoided.
Mitigated: it mirrors the existing
openclaw-custom/k8s-utilslanes; the Dockerfile is ~6 lines; no Headroom source is forked. - Two-hop Renovate flow adds one extra automerged PR per upstream bump. Documented in the ops runbook.
- Supply chain: the image now injects
opentelemetry-sdkandopentelemetry-exporter-otlp-proto-httpat build. Pinning to the bundledopentelemetry-apiversion avoids api/sdk skew; both are first-party OTel packages. - Re-verify at each upstream bump (the standing hl-8ycr risk): confirm Headroom still installs to system site-packages and that the api/sdk pin still resolves.
Out of scope (YAGNI)
Section titled “Out of scope (YAGNI)”- arm64-only build (multi-arch retained to reuse the existing workflow).
- The
[ml]/ Kompress extra and any non-OTel Headroom extras. - Changes to the OTLP env, headers secret, NetworkPolicy, or PVCs — all already correct.