/** * otelObservability — OpenTelemetry distributed-tracing adapter. * * Ships every agentfootprint event as OpenTelemetry spans + span events * via a consumer-supplied OTel API, following the OpenTelemetry **GenAI * semantic conventions** (`gen_ai.*` attribute namespace) plus * agentfootprint-specific explainability attributes (`agentfootprint.*`). * Same hierarchical mapping as the X-Ray adapter, but the destination is * whichever OTel-compat backend the consumer's SDK exports to: * * - **Honeycomb** (OTLP/HTTP) * - **Grafana Cloud / Tempo / Mimir** (OTLP) * - **AWS Distro for OTel** → AWS X-Ray (alternative to xrayObservability) * - **Datadog APM** (OTLP endpoint) * - **Splunk Observability Cloud** (OTLP) * - **New Relic** (OTLP endpoint) * - **Lightstep / ServiceNow Cloud Observability** (OTLP) * - any custom OTel collector / processor pipeline * * Subpath: `agentfootprint/observe` * Peer dep: `@opentelemetry/api` (OPTIONAL — installed only when * this adapter is used. The consumer ALSO installs the * OTel SDK + exporter of their choice — that's the BYO * contract that makes this adapter backend-agnostic.). * * **Why BYO SDK:** OTel's SDK is heavyweight and exporter-specific * (each backend has its own exporter package). Forcing a particular * exporter would defeat the "OTel is portable" guarantee. Consumers * configure the SDK + exporter once at app startup; we just speak * the typed OTel API. * * ## Event → span/attribute mapping * * agent.turn_start ↦ start root span (one trace per turn) — * `gen_ai.operation.name: 'invoke_agent'` * agent.turn_end ↦ end root span (+ turn-total `gen_ai.usage.*`) * agent.iteration_start ↦ start child span under root * agent.iteration_end ↦ end iteration span * stream.llm_start ↦ start child span (inference) — `gen_ai.*` * request attrs (`chat` operation) * stream.llm_end ↦ end llm span (+ `gen_ai.usage.*`, * `gen_ai.response.*`) * stream.tool_start ↦ start child span — `execute_tool` operation, * `gen_ai.tool.name` / `gen_ai.tool.call.id` * stream.tool_end ↦ end tool span (ERROR status + `error.type` * if errored). Correlated by toolCallId so * PARALLEL tool calls close the right span. * cost.tick ↦ setAttribute on topmost active span * error.fatal ↦ ERROR status on root + defensive unwind * context.evaluated ↦ N span events `agentfootprint.skill.routing` * — SYNTHESIZED name (one per routing entry), * not a registry-verbatim forward; all other * span events use the registry name verbatim * * ## Decisions = SPAN EVENTS, not attributes (design decision) * * Explainability signals (route decisions, skill routing, validation * rejections, permission checks, credential lifecycle) are emitted as * **span events** on the currently-active span rather than attributes: * * 1. MULTIPLICITY — an iteration span can carry several decisions * (route + N skill routings + M permission checks). Attributes are * last-write-wins and would clobber; span events accumulate. * 2. ORDERING — span events carry their own timestamps, preserving the * decision sequence inside one span. Compliance review (EU AI Act * Art. 12 record-keeping) needs the order decisions were made. * 3. ROUND-TRIP — OTLP backends (and agentThinkingUI's `fromOTLP` * ingestion) surface span events as first-class timeline entries. * * When the consumer-injected tracer's spans don't implement `addEvent` * (minimal test doubles), the adapter falls back to flattened * `${eventName}.${key}` attributes — degraded (last-write-wins) but * never silently dropped. * * ## PII discipline * * Mirrors the #9 validation contract: attribute values NEVER echo * runtime VALUES that can carry PII — * - tool args → top-level key NAMES only (`agentfootprint.tool.args.keys`) * - tool results → `typeof` only (`agentfootprint.tool.result.type`) * - validation issues → path / expected / got TYPES (bounded upstream) * - decide() evidence → rule labels, operators, thresholds (developer * constants) and the engine's redaction-aware value SUMMARIES * - userPrompt / llm content / thinking → never emitted * - error.fatal → stage + scope only (error MESSAGES can echo values) * - credential events carry no secrets by construction (registry contract) * * @example Basic — Honeycomb via OTLP * ```ts * import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; * import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; * import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; * import { otelObservability } from 'agentfootprint/observe'; * * // Set up OTel ONCE at app startup. * const provider = new NodeTracerProvider(); * provider.addSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter({ * url: 'https://api.honeycomb.io/v1/traces', * headers: { 'x-honeycomb-team': process.env.HONEYCOMB_KEY }, * }))); * provider.register(); * * const otel = otelObservability({ * serviceName: 'my-agent', * // genAiSpanNames: true, // opt-in spec span names ('chat gpt-4', …) * }); * agent.enable.observability({ strategy: otel }); * // Optional — operator-level decide()/select() evidence as span events: * // Agent.create({...}).watch(otel.decisionEvidenceRecorder()) * ``` * * @example Test injection * ```ts * otelObservability({ * serviceName: 'test', * tracer: mockTracer, // anything matching the OTel Tracer interface * }); * ``` */ import type { FlowDecisionEvent, FlowSelectedEvent } from 'footprintjs'; import type { AgentfootprintEvent } from '../../events/registry.js'; import type { ObservabilityStrategy } from '../../strategies/types.js'; export interface OtelObservabilityOptions { /** Service name on every emitted span. Surfaces in your OTel * backend's service map. Required. */ readonly serviceName: string; /** OTel Tracer to use. Defaults to `trace.getTracer(scopeName)` — where * `trace` is the lazy-imported `@opentelemetry/api` and `scopeName` is the * option below. */ readonly tracer?: OtelTracerLike; /** * The INSTRUMENTATION SCOPE name every span is recorded under — what a * backend shows as `scope.name`. Default `'agentfootprint'`. * * ── Why this is a knob and not a constant ──────────────────────────────── * Some telemetry consumers ROUTE on this name rather than read it. AWS * Bedrock AgentCore Evaluations is the case that forced the option: it will * score any agent's traces, from anywhere, but only classifies spans whose * scope name begins `opentelemetry.instrumentation.` or * `openinference.instrumentation.` — everything else is skipped in silence, * which is indistinguishable from "your agent was fine". * * The default does NOT change, because a rename moves every existing * dashboard's spans out from under it. Consumers who need a routable name * pass one; `agentCoreEvaluationSpans()` in the AgentCore adapter is that * one consumer's spelling, kept in that vendor's own file. * * Ignored when `tracer` is supplied — a tracer you built already has a name. */ readonly scopeName?: string; /** * Put the turn's PROMPT and ANSWER TEXT on the agent span (default * `false`): `gen_ai.task.input` from the user's prompt and * `gen_ai.task.output` from the final answer. * * ── Off by default, deliberately ───────────────────────────────────────── * This adapter's standing rule is that content does not ride spans — the * snapshot / audit-log channel carries it under the consumer's own * redaction policy. Turning this on EXPORTS raw prompt and answer text to * whatever backend the tracer points at, where it is typically retained and * searchable, and no redaction of ours stands between the two. * * ── Why it exists ──────────────────────────────────────────────────────── * Trace-scoring services read the answer they are grading off the span. * AgentCore Evaluations reads exactly these two attributes (after an event * body it only emits in its own runtime), so a scorer sees an empty turn * without them. Enable it when a consumer's whole purpose is to read the * content, and not to make dashboards prettier. * * ── What it is NOT ─────────────────────────────────────────────────────── * Per-inference message arrays (`gen_ai.input.messages` / * `gen_ai.output.messages`) are NOT emitted: this adapter's `llm_start` / * `llm_end` events carry model, usage and stop reason, never the messages, * so there is nothing truthful to put there. Turn-level input and output is * what we have and all this claims. */ readonly captureContent?: boolean; /** 0..1 — sample rate for turn-level spans. Default `1.0`. * Sampling decisions are normally an OTel SDK concern (via * `Sampler`); this option is a per-strategy override for cases * where the consumer wants agentfootprint to drop spans BEFORE * they reach the SDK (e.g., aggressive cost control). */ readonly sampleRate?: number; /** * Opt-in OTel GenAI semconv SPAN NAMES (default `false`): * * root → `invoke_agent {serviceName}` (was `{serviceName}`) * llm → `chat {model}` (was `llm`) * tool → `execute_tool {toolName}` (was `tool:{toolName}`) * * Off by default because existing consumers' dashboards / alerts key * on the legacy span names — renames would break them. All `gen_ai.*` * ATTRIBUTES are emitted regardless of this flag (purely additive), * so semconv-aware backends can already group by * `gen_ai.operation.name` with the flag off. */ readonly genAiSpanNames?: boolean; /** * Explainability span events (default `true`): route decisions, skill * routing provenance, validation rejections, permission decisions, * credential lifecycle. Set `false` to emit only the span tree + * `gen_ai.*` attributes (e.g., aggressive per-byte vendor billing). */ readonly explainability?: boolean; /** * Where errors go (8.11.0). * * This adapter writes to a tracer you own, so most failures surface in your * OTel pipeline rather than here — but a throwing tracer, a malformed span * or a dispatch-layer error still has to land somewhere. Without this they * reach the default sink (a rate-limited `console.error`), because telemetry * that fails invisibly is indistinguishable from telemetry that works. * * Equivalent to assigning the strategy's `_onError` property after * construction, but visible at the call site. */ readonly onError?: (error: Error, event?: AgentfootprintEvent) => void; /** * @internal Pre-resolved `@opentelemetry/api` module, for tests that need * the path where this adapter builds its OWN tracer — the only path on * which {@link OtelObservabilityOptions.scopeName} is observable. Skips the * lazy require, exactly as `_client` does on the SDK-backed adapters. */ readonly _otelApi?: OtelApiModule; } /** Attribute value union we emit. Matches OTel's `AttributeValue` * subset: primitives + homogeneous string arrays * (`gen_ai.response.finish_reasons`, issue lists, …). */ export type OtelAttributeValue = string | number | boolean | readonly string[]; /** Subset of `@opentelemetry/api`'s `Tracer` we depend on. */ export interface OtelTracerLike { startSpan(name: string, options?: OtelSpanOptions, context?: unknown): OtelSpanLike; } /** Subset of `@opentelemetry/api`'s `SpanOptions`. */ export interface OtelSpanOptions { attributes?: Record; startTime?: number; kind?: number; } /** Subset of `@opentelemetry/api`'s `Span` we depend on. */ export interface OtelSpanLike { setAttribute(key: string, value: OtelAttributeValue): unknown; setStatus(status: { code: number; message?: string; }): unknown; end(endTime?: number): void; spanContext(): { traceId: string; spanId: string; traceFlags: number; }; /** OTel `Span.addEvent` — optional in the duck-typed surface so * minimal test doubles still satisfy the interface. Explainability * signals degrade to flattened attributes when absent. */ addEvent?(name: string, attributes?: Record): unknown; } interface OtelApiModule { readonly trace?: { getTracer(name: string, version?: string): OtelTracerLike; setSpan(context: unknown, span: OtelSpanLike): unknown; }; readonly context?: { active(): unknown; with(ctx: unknown, fn: () => T): T; }; readonly SpanStatusCode?: { OK: number; ERROR: number; UNSET: number; }; } /** * footprintjs CombinedRecorder (FlowRecorder channel) that forwards * decide()/select() operator-level evidence into the paired * otelObservability strategy as span events. Attach via * `Agent.create({...}).watch(...)` or * `executor.attachCombinedRecorder(...)`. */ export interface OtelDecisionEvidenceRecorder { readonly id: string; onDecision(event: FlowDecisionEvent): void; onSelected(event: FlowSelectedEvent): void; } /** Return type of {@link otelObservability} — the base * ObservabilityStrategy plus the decide()/select() evidence bridge. */ export interface OtelObservabilityStrategy extends ObservabilityStrategy { /** * Build the decide()/select() evidence bridge for this strategy. * * Operator-level decision evidence (which rule fired, the * `key op threshold → actual` conditions) travels on footprintjs's * FlowRecorder channel (`onDecision` / `onSelected`) — it never * reaches the typed event dispatcher, so the strategy alone can't * see it. This recorder is the bridge (same pattern as the #5 * causal-evidence bridge in `memory/causal/evidenceRecorder.ts`). * * Decisions WITHOUT structured evidence are skipped — they already * arrive via the `agent.route_decided` / `composition.route_decided` * typed events, so forwarding them here would double-report. * * @remarks PII: attaching this recorder EXPORTS bounded actual scope * values to your OTel collector — each condition renders as * `key op threshold → actualSummary (bool)`, where `actualSummary` is * the engine's redaction-aware ≤80-char value summary (e.g. * `creditScore gt 700 → 750 (true)`). Keys covered by a footprintjs * `RedactionPolicy` render `[REDACTED]`; everything else leaves the * process. For compliance record-keeping that disclosure is usually * the point — but treat the collector as PII-bearing, or redact the * relevant keys upstream, before attaching. * * @remarks Attach ONCE per executor. Every instance carries the * well-known id `'otel-decision-evidence'`, so re-attaching is * idempotent-by-ID (the replacement prevents double-reported span * events); instances from the same strategy share its turn state by * design. */ decisionEvidenceRecorder(): OtelDecisionEvidenceRecorder; } export declare function otelObservability(opts: OtelObservabilityOptions): OtelObservabilityStrategy; export {}; //# sourceMappingURL=otel.d.ts.map