@odla-ai/o11y
v2.5.0
Published
Official observability client for odla Cloudflare Workers — OpenTelemetry traces, metrics, structured errors, and LLM cost, exported over OTLP to the odla-o11y collector.
Maintainers
Readme
@odla-ai/o11y
⚠️ Early access — pre-1.0. Agents work from bounded runbooks; humans approve credentials, production changes, releases, and merges. APIs and exact package availability can change. Review the documented guarantees and limitations; this software is MIT-licensed and provided without warranty.
Official observability client for odla Cloudflare Workers. Wrap your Worker once and get OpenTelemetry traces, metrics, structured errors, and LLM cost — exported over OTLP to the odla-o11y collector.
- Traces via the maintained
@pydantic/otel-cf-workersadapter (auto-instruments incoming/outgoing fetch, Durable Objects, and bindings). Version 2 floors its OpenTelemetry stack at the baggage-allocation fix. - Metrics + logs via lightweight OTLP/JSON emitters (the traces-only otel library doesn't cover them, and the Node metrics SDK doesn't fit the isolate model).
- Targets the Cloudflare Workers runtime; requires the
nodejs_compatflag. - Distributed context is deliberately W3C
traceparent/tracestateonly.tracestateis capped at 512 bytes and baggage is neither parsed nor emitted.
Ask the runbooks first. odla's operational procedures live in a database, not in this file:
npx @odla-ai/cli runbook ask "<question>"returns the current steps, and unlike anything written here it cannot be out of date. Use it before searching the web or working from memory. This README and the JSDoc in the shipped.d.tsare the version-matched API reference; a runbook is the procedure. Most tasks need an answer from both.
Install
npm i @odla-ai/o11yFor an odla app, add o11y to services in odla.config.mjs, make the source
change below, then let the CLI own enablement and credentials:
npx @odla-ai/cli provision --write-dev-vars --push-secretsThe CLI issues or reuses the per-environment ingest token, persists it locally,
writes .dev.vars, and transfers ODLA_O11Y_TOKEN to each planned Worker over
Wrangler stdin. It never prints the value. Rotate only when necessary with
npx @odla-ai/cli provision --rotate-o11y-token --push-secrets (--yes when
production is in the plan). The manual token control in Studio is a recovery
path and does not update the repository's cached credential.
Migrating from 1.x
- Replace
metrics().histogram(...)withmetrics().observe(...). The deprecated alias now emits a non-monotonic gauge observation; usecount(...)only for true delta counters. recordLlmUsage(...)now returns{ costUsd: number | null, priced: boolean }. Branch onpriced(or checkcostUsd !== null) before doing cost arithmetic; an unknown model is unpriced, not a false$0call.- Self-hosted collectors should apply
0005_ingest_idempotencybefore upgrading emitters. The collector remains compatible with 1.x clients, while 2.x adds byte/point chunking and idempotent batch headers.
Use
import { withObservability, span, count, recordError, recordLlmUsage } from "@odla-ai/o11y";
const handler = {
async fetch(req: Request, env: Env): Promise<Response> {
count("http.requests", 1, { "http.route": new URL(req.url).pathname });
return span("handle", async () => new Response("ok"), { kind: "server" });
},
} satisfies ExportedHandler<Env>;
export default withObservability(handler);Wrap a Durable Object class with instrumentDurableObject(MyDO).
Cloudflare Workflows and other class entrypoints do not pass through a
Worker's fetch/scheduled handler. Give them the real invocation context so
their spans, metrics, errors, and LLM usage use the same sink and private
collector binding:
import { runWithObservability } from "@odla-ai/o11y";
export class ReviewWorkflow extends WorkflowEntrypoint<Env, Params> {
run(event: WorkflowEvent<Params>, step: WorkflowStep) {
return runWithObservability(this.env, this.ctx, "workflow.review", () =>
runReview(event, step));
}
}Configuration
For public ingest, ODLA_O11Y_TOKEN enables export. The SDK then defaults the
endpoint to https://o11y.odla.ai and the service label to
ODLA_O11Y_SERVICE ?? ODLA_APP_ID. These variables remain available for
self-hosting and explicit overrides (or override per call via the second
withObservability argument):
| Var | Meaning |
| --- | --- |
| ODLA_O11Y_ENDPOINT | Collector base URL (defaults to https://o11y.odla.ai when a token exists) |
| ODLA_O11Y_SERVICE | This service's name (falls back to ODLA_APP_ID) |
| ODLA_O11Y_TOKEN | Per-service bearer token (secret) |
| ODLA_O11Y_VERSION | Explicit release override (optional) |
| ODLA_O11Y_SAMPLE_RATIO | Head-sampling ratio 0..1 for traces (optional; clamped) |
| ODLA_O11Y_MAX_EXPORT_DELAY_MS | How long binding-safe spans may wait to be batched (optional; default 5,000, maximum 60,000) |
The last two exist so a service emitting too much telemetry can be turned
down without a code change and a redeploy — the alternative was removing
ODLA_O11Y_TOKEN, which turns o11y off instead. An explicit option still wins,
and an unparseable value is ignored rather than read as zero.
wrangler.jsonc needs "compatibility_flags": ["nodejs_compat"] (for
AsyncLocalStorage). Add Cloudflare's built-in Version Metadata binding so the
SDK can attribute telemetry to the immutable Worker version without another API
credential:
{
"version_metadata": { "binding": "CF_VERSION_METADATA" }
}Explicit opts.version or ODLA_O11Y_VERSION still wins. Otherwise the SDK
uses CF_VERSION_METADATA.id, then falls back to "0.0.0" for local runtimes
without the binding.
Private ingest via a service binding
By default all signals export over fetch to ODLA_O11Y_ENDPOINT. If the Worker
is bound to the collector (a service binding named ODLA_O11Y_COLLECTOR, or one
passed as opts.fetcher), the SDK routes traces, metrics, and logs through
that binding instead — private worker-to-worker, no public hop, and the endpoint
host becomes irrelevant. This is the transport odla's first-party hosting uses;
the binding and its credential are injected at deploy time, so instrumented code
stays export default withObservability(handler) with nothing else to set.
On public ingest, keep only ODLA_O11Y_TOKEN secret. The CLI supplies the full
local values; for deployed odla apps, the SDK defaults above mean the token is
the only required o11y Worker setting. Without a token, public export is
disabled; with no token, explicit endpoint, or collector binding, every signal
exporter is a network no-op and the app itself is unaffected.
The boundary is intentional: the CLI owns deterministic platform work from
odla.config.mjs (enablement, token issuance/storage, .dev.vars, and Wrangler
secret transfer). Your coding agent or developer owns source semantics:
installing this package, applying withObservability, and selecting useful
spans, metrics, errors, and LLM-usage records. Humans approve device codes,
production changes, and destructive rotations.
API
withObservability(handler, opts?)— wrap a Worker handler (fetch/scheduled).withBindingSafeObservability(handler, opts?, predicate?)— install request spans plus the metrics/logs sink without instrumenting or replacingenvbindings. Use it when a service-binding call is part of the request hot path; automatic nested fetch/binding spans are intentionally omitted.runWithObservability(env, ctx, name, operation, opts?)— establish the traced invocation plus metrics/log sink for Workflows or another class entrypoint that does not pass through the exported Worker handler. Signal flushing is attached to the suppliedExecutionContext.waitUntil.instrumentDurableObject(cls, opts?)— wrap a Durable Object class.recordOperationalSpan(env, lifecycle, input, opts?)— emit one binding-safe, best-effort span from a Durable Object or another runtime where automatic binding instrumentation is unsafe. Supply a stable route template and operation name; delivery attaches tolifecycle.waitUntil.recordOperationalMetrics(env, lifecycle, record, opts?)— emit one content-free metric batch from a Durable Object or another entrypoint without an ambient signal sink. The synchronous recorder receives the regularMetricsAPI and the best-effort flush attaches tolifecycle.waitUntil.span(name, fn, opts?)— runfninside an active span.count(name, by?, attrs?)/metrics()— counters, gauges, andobserve(name, value, attrs?, unit?).histogram()remains a deprecated compatibility alias forobserve; observations are gauge points, not an OTLP histogram or monotonic sum.recordError(err, report?)— structured error; message/stack go only to the collector's R2 artifact bundle, never to metrics. Caller artifacts are cycle/BigInt/accessor-safe and capped at 64 KB so reporting cannot replace the application error with a serialization failure.recordLlmUsage(usage, opts)— turn{calls, inputTokens, outputTokens, cacheReadTokens?, cacheCreationTokens?}into cost + token metrics and GenAI span attributes. The four token classes are disjoint and each is priced at its own rate; report the cache counts on a caching workload or the cheap majority of the prompt goes unbilled. Unknown models return{ costUsd: null, priced: false }and emit no false$0cost point.calculateLlmCost(usage, opts)— perform the same pricing as a pure durable accounting step without emitting telemetry. Passprice: nullto record an explicitly unpriced call.setModelPricing(resolver)— wire the authoritative price catalog once at boot, so every emitter is priced without passingpriceper call:import { modelPricing } from "@odla-ai/ai/pricing"; setModelPricing(modelPricing);The built-in table covers only a handful of Claude models and is a fallback, not the source of truth — unwired, every OpenAI and Gemini call reports as unpriced. A resolver returning
undefinedfalls through to that table, so wiring one can only add coverage. Passnullto unwire.workerVersion(env)/workerVersionHeaders(env)— project the built-inCF_VERSION_METADATAbinding into a normalized release object and content-free health headers.WORKER_VERSION_HEADERSexports the exact names.Keep metric
attributeslow-cardinality. Put run/app/actor ids inspanAttributes; those are stamped only on the active span and never copied onto metric series.
Spans from withRequestSpan and recordOperationalSpan are batched: spans
sharing a resource coalesce into one collector request, up to the 125-span chunk
limit, bounding the export rate at roughly one request per window per service
rather than one request per span. The first span after an idle window still
exports immediately, so one-off telemetry is not delayed; the window
(maxExportDelayMs) only applies while spans keep arriving. The queue is
in-memory and each flush is attached to the waitUntil of the invocation that
recorded the span, so isolate eviction can still drop what has not been posted.
In a hibernatable Durable Object that pending waitUntil keeps the object
awake, so a scheduled flush delays hibernation by up to the window past the last
message. Where hibernating promptly matters more than batching, set
maxExportDelayMs (or ODLA_O11Y_MAX_EXPORT_DELAY_MS) to 0: spans then
export on the next tick, as they did before batching existed.
Private-binding traces, metrics, and logs retry network/408/429/5xx failures
once by default (exportRetries, maximum 3). A 429 whose Retry-After names a
backoff longer than the one-second retry ceiling is not retried: the
collector answers an exhausted daily ingest quota that way, and retrying it
cannot succeed while it does add load to a collector already refusing work. Failed metric/log records stay in
a bounded per-invocation buffer (maxPendingRecords, default 1,000, hard maximum
10,000). The cap is
enforced as records are added, not only during export. Concurrent flushes are
serialized, and records appended
during an export stay queued for the next pass. This is invocation-local memory,
not a durable queue: isolate eviction can still lose a failed export. A non-2xx
response is never acknowledged as a successful export. Collector attributes use
an exact semantic allowlist for spans and metrics; register custom keys explicitly
rather than relying on broad prefixes. Denied keys always remain denied.
The SDK splits every signal under the collector's 1 MB encoded limit and the
storage path it actually uses. Metric chunks contain at most 250 points,
matching Cloudflare Analytics Engine's
250-write-per-invocation limit.
Trace chunks contain at most 125 spans because each span always writes an index
row and may also write an LLM-cost row; that worst case still fits 250 writes.
Logs do not use Analytics Engine and retain the collector's 1,000-point cap.
Each chunk carries a stable x-odla-batch-id across bounded retries; the
collector atomically deduplicates it per service across UTC midnight before consuming quota,
so an accepted response lost in transit is not stored or charged twice. Generic
OTLP clients that omit the header remain backward-compatible but at-least-once.
One record that cannot fit by itself is dropped deterministically, counted on the
internal sink/exporter drop counter, and reported through a safe console.warn
that never includes record content or emits recursive telemetry. Retry timing
honors a Retry-After inside the ceiling and otherwise uses bounded exponential
jitter; a Retry-After beyond the ceiling ends the attempt instead of being
clamped down to it.
Trace export itself crosses an explicit instrumentation boundary: public export
uses the adapter's original unwrapped fetch, while a configured collector
binding is captured before handler bindings are proxied. Export traffic therefore
cannot create spans about its own retries or recurse when the collector is down.
Head-sampled spans carry their inverse sampling weight. The odla collector
combines that with Analytics Engine's _sample_interval, so request/message
counts and error rates estimate source traffic instead of shrinking with the
SDK sampling ratio. Latency quantiles use the same combined weight.
Incoming trace propagation accepts only the fixed-size W3C traceparent format
and at most 512 bytes of tracestate. The SDK intentionally ignores baggage:
odla does not use it, so parsing arbitrary baggage would add an unnecessary
attacker-controlled allocation surface. If application data must cross a
service boundary, pass it through a validated application header or payload.
Console & debugging
What you emit shows up in the Studio o11y console (per app, in
odla.ai): RED charts, LLM cost, a dimension explorer
(slice p95/errors by route/status/method/env), error groups that drill into the
message + stack + trace waterfall, and alert triggers that fire into your
app's odla-db, email, or a webhook. To triage from a coding agent, point it at
the locally installed odla-o11y-debug skill from
npx @odla-ai/cli setup. The rendered SDK reference is at
https://odla.ai/docs/packages/o11y.
License
MIT
