@cat-factory/observability-otel
v0.22.5
Published
Opt-in OpenTelemetry (OTLP) trace + metrics publisher for the Agent Architecture Board. Streams LLM generations and container tool spans to any OTLP/HTTP backend — the workerd-safe fetch exporter on the Cloudflare Worker facade, the official @opentelemetr
Downloads
40,184
Readme
@cat-factory/observability-otel
Opt-in OpenTelemetry (OTLP) trace + metrics + logs publisher for the Agent Architecture Board.
It implements the runtime-neutral LlmTraceSink port from @cat-factory/kernel, so when
wired into a facade every LLM call: container-agent calls (through the LLM proxy) and
inline calls (requirements review, document planner, fragment selector, inline agent):
is exported to any OTLP/HTTP backend (Grafana Tempo/Mimir, Honeycomb, Datadog OTLP,
Jaeger, an OpenTelemetry Collector, …) as:
- a trace span per generation, plus a span per container tool call, arranged into a
per-run hierarchy (
run → agent kind → generations + tool calls); and - metrics: a
gen_ai.client.token.usagecounter (input/output) and agen_ai.client.operation.durationhistogram: following the OpenTelemetry GenAI semantic conventions.
Two further exporters ship beside it, each its own opt-in: the deployment-level platform-operator metrics, and the log export that carries the platform's own structured log lines to the same endpoint.
Span hierarchy
A run's spans form a tree, not a flat set of siblings sharing a trace id:
run (root; the whole execution, INTERNAL)
├── invoke_agent coder (one per agent KIND that ran, INTERNAL)
│ ├── chat claude-sonnet-4-5 (one per LLM call, CLIENT)
│ ├── execute_tool edit_file (one per container tool call, INTERNAL)
│ └── execute_tool run_command
└── invoke_agent ci (a gate step)
└── invoke_agent ci-fixer (a HELPER the gate escalated to, nested under it)
└── chat claude-sonnet-4-5Nothing is buffered to build it. Every parent id is a pure function of the run id
(deriveRunSpanId / deriveStepSpanId in src/mapping.ts), so a generation recorded by the
LLM proxy on one isolate and a tool span drained by the engine on another both name the same
parent without either having seen it, and a durable replay re-derives the identical tree
rather than a duplicate one. The parents themselves are emitted once, when the run settles
(LlmTraceSink.recordRunSpans, driven from the engine's single terminal hook), which is when
its boundaries are first known. Children therefore export before their parent: the ordinary
OpenTelemetry ordering, where a parent outlives what it contains, just over a longer span of
time than usual.
Two consequences worth knowing:
- The step level's grain is
(run, agent kind), not(run, step index), because the agent kind is the finest thing anLlmGenerationEventcan name — a per-step parent would be unaddressable from the very spans meant to hang under it. It is also the grain the rest of the LLM telemetry buckets by (the prompt-chain key, the(agentKind, phase)rollup). A pipeline running twocodersteps folds them into one span carryingcat_factory.step_count: 2, so a reader is told about the fold rather than seeing one long step. - A standalone inline call stays a root. Requirements review, the doc planner and the fragment selector run outside any execution, so they have no run to hang under and no parent is claimed — rather than pointing at one that will never be emitted.
- A HELPER kind gets its own span, nested under the step that dispatched it. A step often
runs work under a kind that is not its own: a gate escalating to
ci-fixer/conflict-resolver/on-call, a Tester handing off to the fixer, a two-phase coder'sfork-proposer. Every telemetry row that work produces is tagged with the HELPER's kind, so without a span of its own each of those would name a parent nobody emits and dangle inside its own run's trace. The run records what it dispatched (PipelineStep.dispatches), and the helper inherits its host step's window: a parent is required to contain its children, and the host's window is the tightest bound the run actually recorded.
Cycles: counted, not separated
The repetition these runs actually contain is cyclical rather than duplicated. A pipeline with two steps of one kind is rare (no built-in has one); a review that spawns fixes four times, a Ralph loop that iterates seven, a bounced step re-run on a judge's verdict are all ordinary.
A span cannot separate those rounds, because the events hanging under it carry no attempt ordinal
to be separated BY — threading one reaches the proxy URL contract and the harness, so it is a
deliberate non-goal here. What is NOT acceptable is letting the fold pass silently, so each step
span states both of its collapses: cat_factory.step_count (steps of this kind) and
cat_factory.attempt_count (dispatches inside them). Converging in one round and thrashing
through six are otherwise the same picture, and that picture is what an operator is looking for.
The extent is stated honestly across rounds too: a re-run step's span starts at
PipelineStep.firstStartedAt, which survives the reset that re-stamps startedAt. Without it a
bounced step's parent would begin AFTER the generations of its own earlier attempts, which still
name it.
Two transports, one behaviour
The Cloudflare Worker runtime (workerd) cannot run the official @opentelemetry/* SDK
(it relies on Node-only APIs), so this package ships two exporters behind the same
port:
| Entry | Export | Transport | Used by |
| -------------------------------------- | -------------------- | ------------------------------------------- | ----------------- |
| @cat-factory/observability-otel | createOtelSink | hand-rolled OTLP/HTTP JSON over fetch | Cloudflare Worker |
| @cat-factory/observability-otel/node | createNodeOtelSink | the official @opentelemetry/* SDK | Node / local |
Both map events through the same src/mapping.ts layer, so they emit identical span
names, attributes, trace-id grouping and metric names/units. src/conformity.test.ts
feeds the same events through both and asserts the emitted telemetry matches: the guard
that the transports never drift.
The Worker entry (.) never imports @opentelemetry/*, so the SDK is kept out of the
workerd bundle; it depends only on the fetch/crypto globals.
Behaviour
- Never throws into the caller: every method swallows its own errors (logging at most a warning). Observability must never break agent work.
- Honours the same
LLM_RECORD_PROMPTSprivacy switch as the local metric store: when prompt recording is off, spans carry usage/timing/attributes but no prompt or response bodies. - Composes alongside the Langfuse sink (
@cat-factory/observability-langfuse) via the kernelcomposeTraceSinksfan-out: a deployment can export to both at once.
Usage
// Cloudflare Worker (workerd-safe fetch exporter)
import { createOtelSink } from '@cat-factory/observability-otel'
const sink = createOtelSink({
endpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT, // e.g. http://collector:4318
headers: { 'x-api-key': env.OTEL_KEY }, // optional
serviceName: 'cat-factory', // optional; defaults to 'cat-factory'
})// Node / local (official @opentelemetry/* SDK exporter)
import { createNodeOtelSink } from '@cat-factory/observability-otel/node'
const sink = createNodeOtelSink({ endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT! })Wired into a facade via its container's buildTraceSink(config); absent config (no
OTEL_ENABLED=true + endpoint) ⇒ the sink is never built and there is no external
emission or behaviour change.
GenAI semantic-convention coverage
The OpenTelemetry GenAI semantic conventions are still experimental, so this section states
exactly what is claimed rather than leaving a reader to diff the emitted attributes against a
moving spec. src/mapping.ts's ATTR object and this table are edited together.
Emitted, per the convention:
| Attribute / signal | Where | Notes |
| ---------------------------------- | ------------------------- | --------------------------------------------------------------- |
| gen_ai.operation.name | generation / tool / step | chat / execute_tool / invoke_agent |
| gen_ai.system | generation span + metrics | the provider (anthropic, openai, workers-ai, …) |
| gen_ai.request.model | generation span + metrics | the model asked for |
| gen_ai.usage.input_tokens | generation span | FRESH input only; the cache classes are separate (see below) |
| gen_ai.usage.output_tokens | generation span | |
| gen_ai.response.finish_reasons | generation span | a one-element list; omitted when upstream reported none |
| gen_ai.agent.name | step span | the agent kind |
| gen_ai.tool.name | tool span | |
| gen_ai.tool.arguments | tool span EVENT | the call's arguments; only when body capture is permitted |
| gen_ai.tool.result | tool span EVENT | what the tool returned; same gate |
| gen_ai.client.token.usage | counter, {token}, DELTA | split by gen_ai.token.type |
| gen_ai.client.operation.duration | histogram, s, DELTA | |
| span name {operation} {model} | generation span | e.g. chat claude-sonnet-4-5; other names stay low-cardinality |
Extended beyond the convention, because the convention has no equivalent and the fact is load-bearing here:
| Attribute | Why it exists |
| ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| gen_ai.usage.cache_read_input_tokens | The convention lumps input into one count. These are priced ~0.1x and ~1.25–2x |
| gen_ai.usage.cache_creation_input_tokens | base input, so summing them hides whether a loop rides a warm cache or rewrites it. |
| gen_ai.token.type values cache_read / cache_write | The same split on the counter's dimension. |
| cat_factory.workspace_id | Tenant scope. Spans only; unbounded, so never a metric dimension. |
| cat_factory.execution_id | The run. Searchable, since the trace id is a hash of it rather than the id itself. |
| cat_factory.agent_kind | Kept beside gen_ai.agent.name because it is also a bounded METRIC dimension. |
| cat_factory.pipeline / cat_factory.step_count | The run span's pipeline, and the step span's fold size. |
| cat_factory.attempt_count | Dispatches folded into a step span: the rounds of a loop, stated since not split. |
| cat_factory.tool_call.seq | The call's ordinal in its dispatch. Start time cannot order a tool loop: several |
| | calls routinely share one millisecond. |
| cat_factory.tool_call.arguments_dropped_chars | What the harness's capture cap dropped, so a truncated body is legible AS |
| cat_factory.tool_call.result_dropped_chars | truncated rather than read as the whole of a short one. |
| Deliberately NOT emitted, each for a reason rather than an oversight: |
- COST, in any form. A span or a counter carrying money would be DERIVED data (
tokens x rates) in a store that cannot reprice it: a corrected rate table leaves the history permanently wrong with nothing marking it, and the figure would sit beside the platform's own spend ledger (SpendService/priceRollupCells) as a second, un-reconcilable answer, at a grain that deliberately dropsworkspace_id, so it could never be checked against what anyone is billed. What this exporter emits are the OBSERVED facts a downstream consumer prices FROM:gen_ai.request.modelplus the three input classes and the output count, kept apart rather than lumped, which is exactly the input a rate table needs. Money belongs where it can be recomputed. gen_ai.request.*sampling parameters (temperature,top_p,max_tokens, …). The event that reaches this package carries none of them: the proxied path records what the upstream returned, and the subscription harnesses lift metrics off a CLI's event stream, which never reports the ceiling it applied. Emitting defaults would state a request nobody made.gen_ai.response.model/gen_ai.response.id. A singlemodelfield rides the event, and it is the model the provider says SERVED the call where the transport reports one. Emitting it as both request and response would fabricate agreement between two facts we only have one of.gen_ai.conversation.id.cat_factory.execution_idalready scopes it, and the run is the unit the rest of the platform threads a conversation by.- The log-based
gen_ai.client.inference.operation.detailsevent. Prompt and completion bodies ride the older span eventsgen_ai.content.prompt/gen_ai.content.completion, which every OTLP backend in the compatibility list above renders today. They are emitted only whenLLM_RECORD_PROMPTSis on; an empty body is omitted rather than sent blank. gen_ai.operation.nameon the RUN span. A pipeline run is orchestration: it spends most of its wall clock waiting on CI and on humans. Claiming a GenAI operation for it would put that wait onto an operator's GenAI dashboards.- The pipeline name in the run span's NAME. The root span is named the bare
run, with the pipeline ascat_factory.pipeline. A span name is the one field a backend treats as a low-cardinality class (it keys the RED metrics a span-metrics connector derives), which is the trace-side counterpart of the rule that a metric dimension must be bounded. Every other name here is a closed vocabulary (an operation, a model, an agent kind, a tool); a pipeline is workspace-authored free text, so interpolating it would let a tenant mint unbounded series on an operator's backend just by renaming pipelines.
Replaying a settled run
The parents are emitted from the engine's terminal hook, which fires again for a run that has already settled (a durable re-drive, a decision resolved against a finished run). That is safe by construction rather than by a claim table, and both halves are load-bearing:
- The ids are derived, so a re-emission names the same spans rather than minting a second tree beside the first.
- The extent is folded from stamps the run recorded (
buildRunTraceSpansin orchestration), never from a clock read at emit time. Both are set-once so a replay cannot move them: adonerun is bounded by its last step'sfinishedAt, a failed one byAgentFailure.occurredAt, and a step still in flight by its heartbeat. Pairing stable span ids with a duration that changed between emissions would give a backend a contradiction to store where it can collapse a byte-identical duplicate.
Platform-operator metrics (deployment health)
The per-call sink above answers "what did THIS run do". The PlatformMetricsOtelExporter
(createPlatformMetricsOtelExporter, the . entry) answers "how is the WHOLE deployment
doing": a periodic sweep (Worker scheduled cron ⇄ Node interval, runtime-symmetric) computes
the platform-observability projection per account and this exporter pushes it to the same
OTLP endpoint as OpenTelemetry gauge metrics, so an operator watches deployment health in
their own metrics backend, the dual of the post-release-health gate that watches the
user's release.
Metrics (cat_factory.platform.*, all gauges: the OTel backend trends the series over time):
| Metric | Unit | Split dimension |
| --------------------------------------- | ------- | --------------------------- |
| cat_factory.platform.runs | {run} | cat_factory.run_status |
| cat_factory.platform.run_success_rate | 1 | — |
| cat_factory.platform.run_failures | {run} | cat_factory.failure_kind |
| cat_factory.platform.live_runs | {run} | cat_factory.run_state |
| cat_factory.platform.run_duration | s | cat_factory.duration_stat |
Every point carries cat_factory.account_id (the bounded tenant scope: safe on a metric,
unlike the unbounded workspace id excluded from the per-call metrics); the windowed gauges
also carry cat_factory.window. Null aggregates (a success rate / percentiles with no
terminal runs) are omitted rather than emitted as a misleading zero.
Unlike the per-call LLM path, the platform exporter is the fetch transport on both runtimes (there is no SDK counterpart): the push is a stateless, low-frequency snapshot POST with no need for the SDK's async instruments / periodic reader, so one workerd-safe exporter serves both facades and is tested once.
Opt-in on top of the base exporter (it adds recurring DB rollup load): off unless
OTEL_ENABLED=true + an endpoint AND OTEL_PLATFORM_METRICS=true. OTEL_PLATFORM_METRICS_WINDOW
(1h/24h/7d, default 1h) sets the trailing window; on Node
OTEL_PLATFORM_METRICS_INTERVAL_MS (default 60s) sets the sweep cadence (the Worker is
cron-driven). The runtime-neutral sweepPlatformMetrics driver + distinctAccountIds
account enumeration live in @cat-factory/orchestration.
Log export (the third signal)
The two exporters above answer "what did this run do" and "how is the deployment doing". The
OtelLogExporter (createOtelLogExporter, the . entry) carries the platform's own
structured log lines to {endpoint}/v1/logs, so an operator reads logs beside the traces
and metrics they already correlate with, in one backend, instead of tailing a Worker and
grepping a container's stdout.
It implements the kernel LogSink port, and @cat-factory/server's logging adapter
(observability/logger.ts, the one place a logging library is named) copies every emitted line
to whichever sink a facade installed. Nothing in the domain changes: packages keep logging
through the one Logger port and never learn a second destination exists.
import { createOtelLogExporter } from '@cat-factory/observability-otel'
import { setLogSink } from '@cat-factory/server'
setLogSink(createOtelLogExporter({ endpoint, headers, serviceName, maxBatchSize, logger }))What a record carries. The message is the OTLP body; the level maps onto the base
SeverityNumber of its range (debug 5, info 9, warn 13, error 17), so a backend's
severity >= WARN filter means what an operator reading LOG_LEVEL expects. The line's fields
become attributes under the names they already have (workspaceId, executionId, jobId,
scope, …) rather than being renamed into the cat_factory.* namespace the spans use: one key
works against stdout and the collector, which is worth more than symmetry with a signal whose
attribute names this package chose in the first place. child-bound fields are folded in, so a
line keeps the correlation the emitting scope gave it.
Logs and traces meet structurally. A line naming an executionId is stamped with the SAME
derived trace id that run's spans carry, so a backend links them without either side matching an
attribute, and the record marks that trace SAMPLED so a backend knows the id it is pointed at is
one it should have. A line with no run claims no trace (and no flags), rather than pointing at
one nobody will emit (the same rule the standalone inline call follows above).
That sharing is a call to the same deriveTraceId the spans go through, never a second copy
of the derivation, and the test asserts a log record's trace id against a span's. Both matter:
the two signals agreeing is the whole feature, nothing else enforces it, and a re-derivation plus
a test comparing one log record to another would let the span side drift with every test green.
An INBOUND traceparent fills in the rest. A caller already collecting a distributed trace
(one of the four SDK clients, a gateway, a sibling service) sends W3C trace context on the
request; mountRequestLogging parses it once through kernel's parseTraceparent and binds
traceId + spanId onto the request-scoped child logger, so every line the request emits is
exported into the caller's trace, pointing at the caller's own span. Malformed or absent means
ignored: a bad correlation header is not a reason to refuse real work, and the request falls
back to the correlation it always had.
The run wins when a line has both, and that precedence is load-bearing. The derived trace id
is the only thing joining a run's lines to the spans this package emits for it; re-pointing them
at the caller's trace would leave the run's spans with no logs and its logs with no spans. The
two are true statements at different scopes (the caller's trace covers the REQUEST, the derived
one covers the RUN it touched) and the run is the narrower answer. Nothing is lost either way:
the adopted ids stay queryable as attributes. Only an adopted context names a spanId: a
run-derived trace has no single span a line belongs to, so it attaches to the trace and lets the
backend place it.
Deliberately not here: emitting a traceparent of our own, and carrying tracestate. Both
are things a producer of spans owes its callees, and the request boundary produces no span: its
evidence is the log line. Adding them is the slice that adds a request span.
Draining is the facade's job, and the two runtimes differ only there:
| Runtime | When the buffer is drained |
| ----------------- | ----------------------------------------------------------------------------------------------------- |
| Node / local | An interval (OTEL_LOGS_FLUSH_INTERVAL_MS, default 5s), plus a final flush on shutdown bounded to 5s |
| Cloudflare Worker | The end of every invocation, as a waitUntil after the response |
A Worker's module state is per ISOLATE and an isolate is discarded whenever the runtime decides, so a buffered line has no later tick guaranteed to reach it: the same reasoning (and the same per-invocation cost, stated rather than assumed) as the operational-metrics flush beside it. A full batch also sends on its own, so a burst does not wait for the next tick.
What it does with what it cannot deliver, each of which was a way to make observability the new failure class:
- A failed POST is logged and dropped, never thrown:
recordonly buffers andflushresolves even when the delivery failed. - The exporter refuses to export its own failure reports. They are logged through a logger
bound with
otelLogExport: trueand records carrying that field are dropped, or a collector outage becomes an ever-growing batch of lines about a collector outage. The warning still reaches the local writer, which is where an operator whose collector is down can read it. - The buffer is bounded (
maxBatchSize× 8) and drops the OLDEST beyond it, because during an outage the newest lines are the ones being looked at. The drop count rides the next batch out as its own record: a silently short stream reads exactly like a quiet one. - An oversized field is capped at 8 KiB and says how much it cut. A collector rejects an oversized batch whole, so one field carrying captured command output would otherwise drop every line beside it.
- A field that cannot be serialised degrades to a note naming the problem, never taking the
line with it. Absent and empty stay distinct: a
null/undefinedfield is omitted rather than exported as"". - A field that cannot even be READ degrades the same way, per field.
LogFieldsisRecord<string, unknown>, so a value can throw on access (an accessor property, a Proxy trap). This runs on the DRAIN path, past the try/catch the logging adapter wrapsrecordin, so the mapping owes its own guards: an unreadable value is reported under its own key (the key's presence is what says the emitter had something there) and its neighbours are unaffected. - The send chain is terminated, so
flushcannot reject however the drain path fails. Sends are serialised behind one promise tail, and an escaped rejection there is not one lost batch: every later send would inherit it, the exporter would go permanently silent, and on Node the flush interval's un-awaited call would become an unhandled rejection that the process failure guards answer by exiting. Observability may not take the deployment down.
Opt-in on top of the base exporter (it adds an egress POST per batch of lines): off unless
OTEL_ENABLED=true + an endpoint AND OTEL_LOGS=true. OTEL_LOGS_MAX_BATCH_SIZE (default 128)
sets the lines per POST and, with the multiplier above, the buffer bound; on Node
OTEL_LOGS_FLUSH_INTERVAL_MS (default 5s) sets the cadence (the Worker flushes per invocation).
LOG_LEVEL governs both destinations. The export sits behind the same threshold as the
local writer, so an operator has one dial rather than two that can disagree; there is
deliberately no separate export level.
Two things are NOT exported, and both are stated here rather than left to be discovered:
- Lines emitted before config is resolved (the
LOG_LEVELread, the process guards, config validation, migrations) reach the local writer only. The endpoint IS config, and buffering against one that may never arrive would be a leak on exactly the boots that fail. - Nothing is scrubbed here. A field carrying command output, a URL or model text goes
through
redactSecretsat its emit site (backend/docs/logging.md); this exporter carries what the local writer already got, and adding a second scrub would state a guarantee the stdout stream does not have.
