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
JavaScript
;
/**
* 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