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