UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

514 lines • 23.3 kB
"use strict"; /** * xrayObservability — AWS X-Ray distributed-tracing adapter. * * Maps agentfootprint's event taxonomy onto AWS X-Ray segment trees: * * agent.turn_start ↦ root segment (one trace per turn) * agent.turn_end ↦ close root segment + flush * agent.iteration_start ↦ push subsegment under root * agent.iteration_end ↦ close iteration subsegment * stream.llm_start ↦ push leaf subsegment (model call) * stream.llm_end ↦ close llm subsegment * stream.tool_start ↦ push leaf subsegment (tool call) * stream.tool_end ↦ close tool subsegment (correlated * by toolCallId — parallel-safe) * a bracket carrying ↦ nothing: the call never executed * `notDispatched` (9.113.0), so no subsegment opens or * closes for it. Moot today: a trace * opens on `agent.turn_start` only, a * resumed leg emits none and carries a * new `meta.runId`, so none of it is * traced — and a settled bracket only * ever rides a resumed leg * error.fatal ↦ fault on root + close the whole * tree (turn_end never arrives) * * Events are anchored on `meta.runId` (the dispatcher envelope), * with a `payload.runId` fallback for hand-built events. * * The result in the X-Ray Trace Map: a hierarchical timeline of every * agent run — turn → iteration → llm-call/tool-call — queryable in * X-Ray Insights, joinable with the rest of your AWS distributed * trace via `AWSTraceHeader` propagation (consumer's responsibility * to wire upstream/downstream IDs). * * Subpath: `agentfootprint/observe` * Peer dep: `@aws-sdk/client-xray` (OPTIONAL — installed only when * this adapter is used). * * Sampling: * By default every turn produces one trace. Pass `sampleRate: 0.1` * to sample 10% of turns — sampling decisions are made at * `turn_start` and persist for the whole turn (so partial traces * never reach X-Ray). * * @example * ```ts * import { xrayObservability } from 'agentfootprint/observe'; * import { microtaskBatchDriver } from 'footprintjs/detach'; * * agent.enable.observability({ * strategy: xrayObservability({ * region: 'us-east-1', * serviceName: 'my-agent', * sampleRate: 0.1, // 10% sampling * }), * detach: { driver: microtaskBatchDriver, mode: 'forget' }, * }); * ``` * * @example Test injection * ```ts * xrayObservability({ * serviceName: 'test', * _client: { * putTraceSegments: async (input) => { capturedDocs.push(input); }, * }, * }); * ``` */ Object.defineProperty(exports, "__esModule", { value: true }); exports.xrayObservability = void 0; const lazyRequire_js_1 = require("../../lib/lazyRequire.js"); const wireJson_js_1 = require("../../lib/wireJson.js"); const deliveryErrors_js_1 = require("./deliveryErrors.js"); const outbox_js_1 = require("./outbox.js"); // ─── Strategy factory ──────────────────────────────────────────────── function xrayObservability(opts) { if (!opts.serviceName) { throw new TypeError(`[xrayObservability] \`serviceName\` is required. ` + `Pass an identifier visible in your X-Ray service map, e.g. 'my-agent-prod'.`); } const sampleRate = opts.sampleRate ?? 1; const maxBatchSegments = opts.maxBatchSegments ?? 25; const flushIntervalMs = opts.flushIntervalMs ?? 1000; // Per-turn state. agentfootprint events arrive interleaved across // multiple in-flight turns; we key the active stack by the run // anchor (`meta.runId` — see anchorRunId). const activeTurns = new Map(); // Set by `stop()`: no more events accepted. NOT a guard on delivery (8.11.1): // segments already closed and queued are still owed to X-Ray. let stopped = false; // The fallback when the consumer wires nothing. Rate-limited; a // consumer-supplied sink is not. const consoleSink = (0, deliveryErrors_js_1.rateLimitedConsoleSink)('xray'); // Lazy SDK client. let client = opts._client; function ensureClient() { if (client) return client; client = createXRayClient(opts.region, opts._sdk); return client; } /** Ship one batch of closed segments. Throws when it could not — the * outbox drops the batch and reports it once. */ async function shipSegments(batch) { await ensureClient().putTraceSegments({ TraceSegmentDocuments: batch.map((s) => (0, wireJson_js_1.toWireJson)(s)), }); } // Closed segments ready for PutTraceSegments, and the one queue every put // goes through — size trigger, timer, `flush()`, shutdown (outbox.ts; // #223). A put takes at most `maxBatchSegments` (its whole part); what the // cap leaves behind ships right after it. const outbox = (0, outbox_js_1.createOutbox)({ name: 'xray', // The trigger compares the value as given, as before the outbox. maxItems: maxBatchSegments, maxItemsPerDelivery: segmentsPerPut(maxBatchSegments), flushIntervalMs, timerHoldsProcess: true, deliver: shipSegments, dropped: (batch, cause) => new Error(`${batch.length} segment(s) dropped shipping to X-Ray: ` + (cause instanceof Error ? cause.message : String(cause))), // Routed through the strategy's CURRENT `_onError` (not a hook captured // at construction), so a consumer who assigns it — or passes `onError` // — actually receives delivery failures (8.11.0). report: (err) => strategy._onError?.(err), }); function pushSegment(turnState, name) { const parent = turnState.stack[turnState.stack.length - 1]; const seg = { name, id: hexId(16), trace_id: turnState.traceId, ...(parent && { parent_id: parent.id }), start_time: nowSeconds(), in_progress: true, }; turnState.stack.push(seg); return seg; } function popSegment(turnState, expectedName) { // Defensive: pop the topmost segment whose name matches (if // provided). Out-of-order events would otherwise leave dangling // segments. If no match, pop the topmost. let idx = turnState.stack.length - 1; if (expectedName) { // idx >= 0 guard above guarantees stack[idx] exists. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion while (idx >= 0 && turnState.stack[idx].name !== expectedName) idx--; } if (idx < 0) return undefined; // splice(idx, 1) returns a 1-element array; idx < 0 guarded above. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const seg = turnState.stack.splice(idx, 1)[0]; seg.end_time = nowSeconds(); delete seg.in_progress; return seg; } function closeSegment(turnState, expectedName, extra) { const seg = popSegment(turnState, expectedName); if (!seg) return; finishSegment(turnState, seg, extra); } /** Seal an already-popped segment and graduate the turn to the * outbox once its stack is empty. */ function finishSegment(turnState, seg, extra) { // Idempotent seal — popSegment stamps end_time; segments removed // from the stack by identity (toolCallId correlation) arrive raw. if (seg.end_time === undefined) { seg.end_time = nowSeconds(); delete seg.in_progress; } if (extra?.error) seg.error = true; if (extra?.fault) seg.fault = true; if (extra?.annotations) seg.annotations = { ...seg.annotations, ...extra.annotations }; if (extra?.metadata) seg.metadata = { default: { ...(seg.metadata?.default ?? {}), ...extra.metadata } }; if (turnState.sampled) { turnState.closed.push(seg); // Once the root closes, the whole turn graduates to the outbox. if (turnState.stack.length === 0) { for (const closed of turnState.closed) outbox.add(closed); } } } // ─── Event-to-segment dispatch ───────────────────────────────────── /** * Resolve the run anchor for an event. * * Real runtime events are dispatcher envelopes — the run id lives on * `event.meta.runId` (built by `bridge/eventMeta.ts`). The legacy * `payload.runId` read is kept as a fallback for consumers feeding * hand-built events (the pre-fix shape this adapter's own tests * used). Without the meta read, NO segment ever opened on a real * agent run — the bug the fabricated test shapes masked. */ function anchorRunId(event) { const meta = event.meta; return meta?.runId ?? event.payload?.runId; } function handleEvent(event) { if (stopped) return; const runId = anchorRunId(event); if (!runId) return; // Events without a turn anchor — skip. switch (event.type) { case 'agentfootprint.agent.turn_start': { const sampled = sampleRate >= 1 || Math.random() < sampleRate; const turnState = { traceId: makeTraceId(), stack: [], closed: [], sampled, toolSegments: new Map(), }; activeTurns.set(runId, turnState); if (sampled) pushSegment(turnState, opts.serviceName); break; } case 'agentfootprint.agent.turn_end': { const t = activeTurns.get(runId); if (!t) break; // Close everything still on the stack — defensive against // missing `_end` events (e.g., pause/resume mid-turn). while (t.stack.length > 0) closeSegment(t, undefined); activeTurns.delete(runId); break; } case 'agentfootprint.agent.iteration_start': { const t = activeTurns.get(runId); if (t?.sampled) { // Runtime shape: `iterIndex` (AgentIterationStartPayload). // Legacy fallback `iteration` keeps hand-fed events working. const iteration = event.payload.iterIndex ?? event.payload.iteration; pushSegment(t, `iteration:${iteration ?? '?'}`); } break; } case 'agentfootprint.agent.iteration_end': { const t = activeTurns.get(runId); if (t?.sampled) closeSegment(t, undefined); break; } case 'agentfootprint.stream.llm_start': { const t = activeTurns.get(runId); if (!t?.sampled) break; const seg = pushSegment(t, 'llm'); const model = event.payload.model; if (model) seg.annotations = { model }; break; } case 'agentfootprint.stream.llm_end': { const t = activeTurns.get(runId); if (!t?.sampled) break; closeSegment(t, 'llm', { metadata: { event: event.payload }, }); break; } case 'agentfootprint.stream.tool_start': { const t = activeTurns.get(runId); if (!t?.sampled) break; const p = event.payload; // A call the batch settlement answered (9.113.0) never executed: no // subsegment, which would claim a unit of work that never happened. // X-Ray has no span-event channel to carry the fact; the event stream, // the history and the audit chain do (`notDispatched`). Moot on the // legs traced today — see the header's mapping. if (p.notDispatched !== undefined) break; const toolName = p.toolName ?? 'tool'; const seg = pushSegment(t, `tool:${toolName}`); seg.annotations = { toolName }; if (p.toolCallId !== undefined) t.toolSegments.set(p.toolCallId, seg); break; } case 'agentfootprint.stream.tool_end': { const t = activeTurns.get(runId); if (!t?.sampled) break; const p = event.payload; // Its tool_start opened nothing; the name-less fallback below would // close whatever segment is on top — an iteration, even. if (p.notDispatched !== undefined) break; const errored = p.error !== undefined && p.error !== false; // Correlate by toolCallId — the only identity ToolEndPayload // carries at runtime (it has NO toolName), and parallel tool // calls end out of LIFO order. Fallback chain keeps legacy // hand-fed events (toolName) working. const byId = p.toolCallId === undefined ? undefined : t.toolSegments.get(p.toolCallId); if (byId !== undefined && p.toolCallId !== undefined) { t.toolSegments.delete(p.toolCallId); // Remove from the stack by identity so the LIFO unwind stays clean. const idx = t.stack.indexOf(byId); if (idx >= 0) t.stack.splice(idx, 1); finishSegment(t, byId, { error: errored }); } else { closeSegment(t, p.toolName !== undefined ? `tool:${p.toolName}` : undefined, { error: errored, }); } break; } // A fatal run error: the turn will never see turn_end, so close // the segment tree here (fault on the root — X-Ray's marker for // unhandled exceptions) instead of leaking it in activeTurns, // where the closed segments would never graduate to the outbox. case 'agentfootprint.error.fatal': { const t = activeTurns.get(runId); if (!t) break; while (t.stack.length > 1) closeSegment(t, undefined); const p = event.payload; // Stage + scope only — error MESSAGES can echo PII. const annotations = { ...(p.stage !== undefined && { errorStage: p.stage }), ...(p.scope !== undefined && { errorScope: p.scope }), }; closeSegment(t, undefined, { fault: true, ...(Object.keys(annotations).length > 0 && { annotations }), }); activeTurns.delete(runId); break; } // Other events become annotations on the topmost active segment // (cheaper than spawning a subsegment per event). default: { const t = activeTurns.get(runId); const top = t?.stack[t.stack.length - 1]; if (!t?.sampled || !top) break; // Annotate cost ticks specially so they're queryable in // X-Ray Insights. Runtime shape: `cumulative.estimatedUsd` // (CostTickPayload); legacy fallback `cumulativeCostUsd` // keeps hand-fed events working. if (event.type === 'agentfootprint.cost.tick') { const p = event.payload; const usd = p.cumulative?.estimatedUsd ?? p.cumulativeCostUsd; if (typeof usd === 'number') { top.annotations = { ...top.annotations, cumulativeCostUsd: usd }; } } break; } } } const strategy = { name: 'xray', capabilities: { events: true, traces: true }, exportEvent: handleEvent, /** * Force-close in-flight segments and drain, so a turn cut short by a * shutdown still reaches X-Ray as a partial trace. * * Called for you on shutdown since 8.12.0 — by the handle * `enable.observability()` returns, by `agent.shutdown()`, and by a * `standingAgent` closing: * * process.on('SIGTERM', async () => { await agent.shutdown(); }); * * Calling it yourself is still fine. Safe in any order relative to * `stop()` since 8.11.1: a flush after a stop still ships the segments * already closed. */ async flush() { // Force-close any in-flight turn segments so partial traces // make it into X-Ray on shutdown. for (const [, t] of activeTurns) { if (!t.sampled) continue; while (t.stack.length > 0) closeSegment(t, undefined); } // Segments ship in the order they closed, one put at a time, behind the // put in flight — one the timer started included. Resolves once every // segment closed before this call is shipped (or dropped and reported), // after one more sweep; a put that never settles holds it. await outbox.drain(); }, /** Stop the flush timer and stop accepting events. **The framework does * not call this for you** — an un-stopped strategy keeps a `setTimeout` * alive. * * Terminal: events exported after this are ignored. What it does NOT do * is discard the outbox — `flush()` after `stop()` still ships it * (8.11.1). */ stop() { stopped = true; outbox.stop(); }, /** * Where errors go. Two callers: the dispatch layer (when `exportEvent` * throws) and this adapter's own delivery path. Overriding it works — * assign `_onError`, or pass `onError` in the factory options — because * delivery failures read this method at call time rather than a hook * captured at construction. */ _onError(err, event) { (opts.onError ?? consoleSink)(err, event); }, }; return strategy; } exports.xrayObservability = xrayObservability; // ─── Batch size ────────────────────────────────────────────────────── /** * How many segments one put takes — exactly what `outbox.splice(0, * maxBatchSegments)` took before the shared queue: the whole part of the * value, and every segment for `Infinity` (returned as no cap). * * A value whose whole part is below 1 — `0`, a fraction under 1, a negative * number, `NaN` — took NO segments, so every put was empty and the queue * never emptied: puts in a loop, forever. That never worked, so it is refused * here, by the option's name; every value that did work keeps its meaning. */ function segmentsPerPut(maxBatchSegments) { const whole = Math.trunc(maxBatchSegments); if (whole === Infinity) return undefined; if (whole >= 1) return whole; throw new TypeError(`[xrayObservability] \`maxBatchSegments\` must be at least 1 (got ${String(maxBatchSegments)}): ` + `it is how many segments one put takes, and a put of none never empties the queue.`); } // ─── ID + time helpers ─────────────────────────────────────────────── /** * Generate an X-Ray trace ID. Format: * `1-{8-hex-of-unix-timestamp}-{24-hex-random}` * (Note X-Ray's docs say "12 hex" for the random part; the actual * spec is 24 hex / 96-bit. AWS examples use 24.) */ function makeTraceId() { const seconds = Math.floor(Date.now() / 1000); return `1-${seconds.toString(16).padStart(8, '0')}-${hexId(24)}`; } /** Generate a hex string of `len` chars, cryptographically-strong * where available, falling back to Math.random for environments * without `crypto.getRandomValues` (older runtimes). */ function hexId(len) { const bytes = Math.ceil(len / 2); // Try the Web Crypto / Node Crypto API first. // eslint-disable-next-line @typescript-eslint/no-explicit-any const cryptoApi = globalThis.crypto; if (cryptoApi?.getRandomValues) { const buf = new Uint8Array(bytes); cryptoApi.getRandomValues(buf); return Array.from(buf, (b) => b.toString(16).padStart(2, '0')) .join('') .slice(0, len); } // Fallback (deterministic-quality, NOT for security-critical IDs — // X-Ray IDs aren't security boundaries, just trace correlation). let s = ''; while (s.length < len) s += Math.random().toString(16).slice(2); return s.slice(0, len); } /** X-Ray timestamps are unix seconds with fractional precision. */ function nowSeconds() { return Date.now() / 1000; } // ─── SDK construction (lazy) ───────────────────────────────────────── function createXRayClient(region, injected) { let mod; if (injected) { mod = injected; } else { try { mod = (0, lazyRequire_js_1.lazyRequire)('@aws-sdk/client-xray'); } catch { throw new Error('xrayObservability requires the `@aws-sdk/client-xray` peer dependency.\n' + ' Install: npm install @aws-sdk/client-xray\n' + ' Or pass `_client` for test injection.'); } } if (!mod.XRayClient || !mod.PutTraceSegmentsCommand) { throw new Error('xrayObservability: `@aws-sdk/client-xray` is installed but `XRayClient` / ' + '`PutTraceSegmentsCommand` was not found. Update the SDK.'); } const sdkClient = new mod.XRayClient({ ...(region && { region }) }); return { async putTraceSegments(input) { const cmd = new mod.PutTraceSegmentsCommand(input); await sdkClient.send(cmd); }, }; } //# sourceMappingURL=xray.js.map