@odla-ai/o11y
v2.2.2
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.
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 | Release tag (optional) |
wrangler.jsonc needs "compatibility_flags": ["nodejs_compat"] (for AsyncLocalStorage).
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).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.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}into cost + token metrics and GenAI span attributes. Unknown models return{ costUsd: null, priced: false }and emit no false$0cost point; pass an explicitpriceoverride until the shared table knows the model.calculateLlmCost(usage, opts)— perform the same pricing as a pure durable accounting step without emitting telemetry. Passprice: nullto record an explicitly unpriced call when a safe provider/cache rate is unavailable.- Keep metric
attributeslow-cardinality. Put run/app/actor ids inspanAttributes; those are stamped only on the active span and never copied onto metric series.
Private-binding traces, metrics, and logs retry network/408/429/5xx failures
once by default (exportRetries, maximum 3). 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,000-point and 1 MB encoded
limits. 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 bounded Retry-After and otherwise uses bounded exponential jitter.
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.
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
