UNPKG

agentfootprint

Version:

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

337 lines • 16.5 kB
/** * cloudwatchObservability — Generic AWS CloudWatch Logs adapter. * * Ships every `AgentfootprintEvent` to a CloudWatch Logs stream. Use * when you want agent telemetry alongside the rest of your AWS * observability stack — CloudWatch Insights queries, alarms, * cross-service correlation. Same SDK as `agentcoreObservability` * but **without** the AgentCore-specific defaults (log-stream * convention, format opinions). Use this when: * * 1. You're shipping to CloudWatch but NOT running inside Bedrock * AgentCore (most common case). * 2. You want full control over log group / stream / format and * don't need AgentCore's hosted-agent telemetry conventions. * * Subpath: `agentfootprint/observe` * Peer dep: `@aws-sdk/client-cloudwatch-logs` (OPTIONAL — installed * only when this adapter is used; declared via * `peerDependenciesMeta.{name}.optional = true`). * * This module also exports the underlying base function used by * `agentcoreObservability` — keeps the per-event hot path in one * place so improvements (batching, retry, backpressure) flow to * every CloudWatch-shaped adapter automatically. * * @example * ```ts * import { cloudwatchObservability } from 'agentfootprint/observe'; * import { microtaskBatchDriver } from 'footprintjs/detach'; * * agent.enable.observability({ * strategy: cloudwatchObservability({ * region: 'us-east-1', * logGroupName: '/myapp/agent-prod', * logStreamName: `${process.env.HOSTNAME}/${Date.now()}`, * }), * detach: { driver: microtaskBatchDriver, mode: 'forget' }, * }); * ``` */ import { lazyRequire } from '../../lib/lazyRequire.js'; import { toWireJson } from '../../lib/wireJson.js'; import { rateLimitedConsoleSink } from './deliveryErrors.js'; import { createOutbox } from './outbox.js'; /** * What ONE `PutLogEvents` request may carry — CloudWatch Logs' documented * per-request limits (PutLogEvents API reference): "The maximum number of log * events in a batch is 10,000", and "The maximum batch size is 1,048,576 * bytes. This size is calculated as the sum of all event messages in UTF-8, * plus 26 bytes for each log event." * * These split a delivery into legal requests; they are NOT triggers. * `maxBatchEvents` / `maxBatchBytes` still decide when a delivery starts, * exactly as before. The split matters because puts now go one at a time * (#223): a slow put lets a backlog build behind it, and without the split * the next delivery sent the whole backlog as one request CloudWatch refuses. */ const PUT_LOG_EVENTS_MAX_EVENTS = 10_000; const PUT_LOG_EVENTS_MAX_BYTES = 1_048_576; const PUT_LOG_EVENTS_BYTES_PER_EVENT = 26; // ─── Generic base — also used by agentcoreObservability ────────────── /** * Internal: shared CloudWatch Logs base used by every adapter that * ships to CWL. `cloudwatchObservability` is the public generic * factory; `agentcoreObservability` calls this with AgentCore-flavored * defaults. * * Exported for adapter authors only — consumers should call * `cloudwatchObservability` or `agentcoreObservability` directly. * * @internal */ export function _buildCloudWatchObservability(opts, strategyName) { if (!opts.logGroupName) { throw new TypeError(`[${strategyName}Observability] \`logGroupName\` is required. ` + `Pass an existing CloudWatch log group, e.g. '/myapp/agent-prod'.`); } const logStreamName = opts.logStreamName ?? 'agentfootprint'; const maxBatchEvents = opts.maxBatchEvents ?? 100; const maxBatchBytes = opts.maxBatchBytes ?? 10_240; const flushIntervalMs = opts.flushIntervalMs ?? 1000; // Set by `stop()`: no more events accepted. NOT a guard on delivery (8.11.1): // stopping does not authorise throwing away what was already accepted. let stopped = false; // What we know about the log stream (8.11.0). This is the per-(group,stream) // create latch — group and stream are fixed for the life of a strategy, so // one tri-state IS that latch: // 'unknown' — never delivered yet, or the stream vanished under us // 'created' — we created it, or a put has succeeded against it // 'unavailable' — a create attempt failed for a reason retrying won't fix // (no permission, missing GROUP). Never attempt again. // 'created' is deliberately NOT terminal: a retention policy can delete a // stream mid-process, and the next ResourceNotFoundException should heal it. // Only 'unavailable' is terminal, which is what stops a create/put loop. let streamState = 'unknown'; // The fallback when the consumer wires nothing. Rate-limited; a // consumer-supplied sink is not. const consoleSink = rateLimitedConsoleSink(strategyName); // Lazy-resolved on first flush so consumers who never trigger a // flush (because nothing was emitted) don't even hit the SDK. let client = opts._client; function ensureClient() { if (client) return client; client = createCloudWatchClient(opts.region, strategyName, opts._sdk); return client; } /** Route a delivery failure through whatever `_onError` IS RIGHT NOW. * Reading it at call time (rather than capturing a hook at construction) * is the whole fix: a consumer who assigns `_onError` after building the * strategy gets delivery failures, and one who doesn't still gets the * default console sink. */ function reportFailure(err) { strategy._onError?.(err); } async function putBatch(client, batch) { await client.putLogEvents({ logGroupName: opts.logGroupName, logStreamName, logEvents: batch, }); } /** Ship one batch. Throws when it could not — the outbox drops the batch * and reports it once. Runs inside the outbox's queue, so two puts are * never in flight at once and CloudWatch's per-stream order holds. */ async function shipBatch(batch) { const client = ensureClient(); try { await putBatch(client, batch); streamState = 'created'; // a successful put proves the stream is there } catch (err) { // The one recoverable shape: CloudWatch rejects a put into a stream // that doesn't exist. Before 8.11.0 this adapter asserted that // precondition and never established it, so any stream name that // didn't already exist dropped every event, forever, in silence — // and the documented `${HOSTNAME}/${Date.now()}` convention can never // pre-exist, so following the docs guaranteed the bug. if (!isMissingStreamError(err) || streamState === 'unavailable') throw err; await createStream(client); // Exactly ONE re-put of the same batch. Not a retry loop: if this // fails the events are gone, and saying so is better than growing a // buffer during an outage. await putBatch(client, batch); } } /** Create the missing stream, or latch it off for good when retrying cannot * help. Losing a create race to another process is success. */ async function createStream(client) { if (!client.createLogStream) { throw new Error(`log stream '${logStreamName}' does not exist in log group ` + `'${opts.logGroupName}', and the injected \`_client\` has no ` + `\`createLogStream\` to create it.`); } try { await client.createLogStream({ logGroupName: opts.logGroupName, logStreamName }); } catch (createErr) { // Two processes racing to create the same stream is normal and // expected — whoever lost the race still has a usable stream. if (!isAlreadyExistsError(createErr)) { // Anything else (no `logs:CreateLogStream`, or the log GROUP itself is // missing) will not fix itself. Latch it off so we never loop, and say // which of the two it probably is. streamState = 'unavailable'; throw new Error(`could not create log stream '${logStreamName}' in log group ` + `'${opts.logGroupName}': ${errorMessage(createErr)}. Check that the log ` + `GROUP exists (this adapter never creates one) and that the role allows ` + `\`logs:CreateLogStream\`.`); } } streamState = 'created'; } // Buffered log events, and the one queue every put goes through — size // trigger, timer, `flush()`, shutdown (outbox.ts; #223). const outbox = createOutbox({ name: strategyName, // When a delivery starts — compared as given, as before. maxItems: maxBatchEvents, maxBytes: maxBatchBytes, // What one put may carry: the oldest events that fit; the rest ship in // the next put, right after, in order. The byte measure is the one `add` // records (UTF-8 message bytes) plus CloudWatch's 26 per event. An event // too large for any request is still sent — alone — as it was before; // CloudWatch refuses it and that one event is reported dropped, without // taking its neighbours with it. maxItemsPerDelivery: PUT_LOG_EVENTS_MAX_EVENTS, maxBytesPerDelivery: PUT_LOG_EVENTS_MAX_BYTES, overheadBytesPerItem: PUT_LOG_EVENTS_BYTES_PER_EVENT, flushIntervalMs, // An un-stopped strategy lingers up to `flushIntervalMs` so the timer can // still ship the tail (see `stop()`). timerHoldsProcess: true, deliver: shipBatch, dropped: (batch, cause) => new Error(`${batch.length} event(s) dropped shipping to log group '${opts.logGroupName}' ` + `stream '${logStreamName}': ${errorMessage(cause)}`), report: reportFailure, }); function enqueue(event) { if (stopped) return; // The wire rule (`lib/wireJson.ts`) — an Error's custom properties never ship. const message = toWireJson(event); outbox.add({ timestamp: Date.now(), message }, Buffer.byteLength(message, 'utf8')); } const strategy = { name: strategyName, capabilities: { events: true, logs: true }, exportEvent: enqueue, /** * Drain the buffer. * * Called for you on shutdown since 8.12.0 — by the handle * `enable.observability()` returns, by `agent.shutdown()`, and by a * `standingAgent` closing: * * const telemetry = agent.enable.observability({ strategy }); * process.on('SIGTERM', async () => { await agent.shutdown(); }); * * Calling it yourself is still fine and still the right thing at any point * you want the buffer on the wire. Safe in any order relative to `stop()` * since 8.11.1: a flush after a stop still ships what was already * accepted. */ flush() { // Resolves once every event accepted before the call is shipped (or // dropped and reported) — one a timed put already had in flight // included — after one more sweep of what arrived while it waited; it // does not chase a producer that keeps emitting. A put that never // settles holds it. Since 8.11.1 delivery ignores `stop()`, so a flush // after a stop still ships and never spins. return 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 (one-shot, re-armed per event, so the process lingers up to * `flushIntervalMs` past the last event). * * Terminal: events exported after this are dropped and there is no * restart. What it does NOT do is discard the buffer — `flush()` after * `stop()` still ships it (8.11.1). */ stop() { stopped = true; outbox.stop(); }, /** * Where errors go. Two callers: the dispatch layer (when `exportEvent` * itself throws) and this adapter's own delivery path. * * Before 8.11.0 this method lazily installed the console fallback INSIDE * itself, so a delivery failure — which reads the hook rather than calling * this method — found `undefined` and vanished. The fallback now exists * from construction, and delivery failures route through this method, * which means overriding it works: assign `_onError`, or pass `onError` * in the factory options, and you receive delivery failures too. */ _onError(err, event) { (opts.onError ?? consoleSink)(err, event); }, }; return strategy; } // ─── Error shape helpers ───────────────────────────────────────────── function errorMessage(err) { return err instanceof Error ? err.message : String(err); } /** * Did this put fail because the log stream isn't there? * * Matched on `name` first (how the AWS SDK v3 surfaces it) with a message * fallback, because a transport shim or an injected test double may carry the * code only in the text. */ function isMissingStreamError(err) { if (err?.name === 'ResourceNotFoundException') return true; return /ResourceNotFoundException|log stream does not exist/i.test(errorMessage(err)); } /** Lost a create race with another process. Not an error — the stream exists. */ function isAlreadyExistsError(err) { if (err?.name === 'ResourceAlreadyExistsException') return true; return /ResourceAlreadyExistsException|already exists/i.test(errorMessage(err)); } // ─── Public factory: cloudwatchObservability ───────────────────────── /** * Generic CloudWatch Logs observability adapter. See * `CloudwatchObservabilityOptions` for the per-option contract. * * For AgentCore-specific conventions, use `agentcoreObservability` * which thin-wraps this with AgentCore-flavored defaults. */ export function cloudwatchObservability(opts) { return _buildCloudWatchObservability(opts, 'cloudwatch'); } // ─── SDK client construction (lazy) ────────────────────────────────── function createCloudWatchClient(region, strategyName, injected) { let mod; if (injected) { mod = injected; } else { try { mod = lazyRequire('@aws-sdk/client-cloudwatch-logs'); } catch { throw new Error(`[${strategyName}Observability] requires the \`@aws-sdk/client-cloudwatch-logs\` peer dependency.\n` + ` Install: npm install @aws-sdk/client-cloudwatch-logs\n` + ` Or pass \`_client\` for test injection.`); } } if (!mod.CloudWatchLogsClient || !mod.PutLogEventsCommand) { throw new Error(`[${strategyName}Observability]: \`@aws-sdk/client-cloudwatch-logs\` is installed but ` + `\`CloudWatchLogsClient\` / \`PutLogEventsCommand\` was not found. Update the SDK.`); } const sdkClient = new mod.CloudWatchLogsClient({ ...(region && { region }) }); const createLogStreamCommand = mod.CreateLogStreamCommand; return { async putLogEvents(input) { // Cast the SDK constructor to the call shape — same trick as // the memory adapter to stay forward-compat with SDK shape drift. const cmd = new mod.PutLogEventsCommand(input); await sdkClient.send(cmd); }, // Only offered when the installed SDK has the command. An older SDK // without it degrades to a reported failure rather than a crash — the // adapter checks for this method before reaching for it. ...(createLogStreamCommand && { async createLogStream(input) { const cmd = new createLogStreamCommand(input); await sdkClient.send(cmd); }, }), }; } //# sourceMappingURL=cloudwatch.js.map