agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
150 lines • 7.83 kB
TypeScript
/**
* fileObservability — the typed event stream, one JSON line per event, in a
* local file.
*
* The sink for the shop that has no collector. Every other adapter in this
* folder ships somewhere: CloudWatch, X-Ray, an OTLP endpoint. A great many
* on-premises deployments have none of those — they have a directory, a log
* shipper (Filebeat, Fluent Bit, Vector, `promtail`, `journald`, or a person
* with `grep`), and a rule that nothing leaves the network. NDJSON on disk is
* the format all of those already read, so the sink is the file.
*
* Zero dependencies: `node:fs`, lazily required at construction so merely
* importing `agentfootprint/observe` stays browser-safe (this factory is
* Node-only; calling it in a browser throws by name).
*
* ## The line
*
* One `JSON.stringify(event)` per line, newline-terminated, appended in
* dispatch order — the SAME envelope `cloudwatchObservability` puts in a log
* event, so a query written against one reads the other:
*
* ```jsonl
* {"type":"agentfootprint.agent.turn_start","payload":{…},"meta":{"runId":"…","sessionId":"…"}}
* {"type":"agentfootprint.stream.tool_end","payload":{…},"meta":{…}}
* ```
*
* Nothing is summarized, bounded or redacted on the way out. **A payload that
* must not be on that disk must not reach this strategy** — narrow it with
* `eventTypes` / `tier` / `sampleRate`, or apply a footprintjs
* `RedactionPolicy` upstream, exactly as with every other sink. (For a bounded
* record by construction, `auditExport({ payloadMode: 'bounded' })` is the
* adapter that does that job.)
*
* ## Buffered, not synchronous
*
* `exportEvent` is sync and never touches the disk: it serializes, buffers, and
* returns. Batches are appended asynchronously on a size trigger
* (`maxBufferEvents` / `maxBufferBytes`), on a timer (`flushIntervalMs`), and on
* `flush()` — all through ONE queue (`outbox.ts`), so one append is in flight
* at a time: this sink's appends never interleave, and lines land in dispatch
* order (#223). That is not atomicity — a disk that fails partway through an
* append (ENOSPC) can still leave part of a line behind, and that batch is
* reported dropped. A hard kill loses the buffer and can interrupt the append
* in flight — the price of not making telemetry a term in agent-loop latency.
* Call `flush()` (or `agent.shutdown()`, which does) at process end; see the
* 8.12.0 lifecycle laws on {@link BaseStrategy.flush}.
*
* ## Rotation is ONE generation, and that is deliberate
*
* With `maxBytes` set, a batch that would push the file past the ceiling first
* renames it to `<path>.1` — **replacing any previous `.1`** — and starts a
* fresh file. That is the whole policy. There is no `.2`, no compression, no
* time-based schedule, no cross-process coordination (two processes writing one
* file each keep their own byte count and will both rotate it). It exists so an
* unattended agent cannot fill a disk, and for nothing else: **retention is a
* log-management daemon's job**, and `logrotate` with `copytruncate`, Fluent Bit,
* or a systemd timer will do it properly. Omit `maxBytes` — the default — and
* this adapter never renames anything, which is the right choice when a real
* rotator already owns the file.
*
* @example
* ```ts
* import { fileObservability } from 'agentfootprint/observe';
*
* const telemetry = agent.enable.observability({
* strategy: fileObservability({
* path: '/var/log/agentfootprint/events.ndjson',
* maxBytes: 64 * 1024 * 1024, // safety ceiling; logrotate owns retention
* }),
* });
*
* // … run …
* await agent.shutdown(); // flushes + stops everything enabled
* ```
*/
import type { AgentfootprintEvent, AgentfootprintEventType } from '../../events/registry.js';
import type { ObservabilityStrategy } from '../../strategies/types.js';
export interface FileObservabilityOptions {
/** Absolute or relative path to the NDJSON file. **Required.** Its parent
* directories are created (`recursive`) and the file itself is claimed at
* construction, so an unwritable path fails where you wrote it rather than
* at the first event — the only moment a caller is still watching. */
readonly path: string;
/** Rotation ceiling in bytes. Omitted → **never rotates** (the right choice
* when `logrotate` or a shipper already owns the file). Set → a batch that
* would cross the ceiling first renames the file to `<path>.1`, replacing
* any previous `.1`, and starts fresh. ONE generation, no compression, no
* cross-process coordination — see the note in this module's docstring. */
readonly maxBytes?: number;
/** Max events buffered before a forced append. Default 100. */
readonly maxBufferEvents?: number;
/** Max buffered payload bytes (UTF-8) before a forced append. Default 65536
* (64 KB) — a local write is cheap, so this is far larger than the network
* adapters' 10 KB. */
readonly maxBufferBytes?: number;
/** Forced-append interval when traffic is sparse, in ms. Default 1000.
* `0` disables the timer — only size triggers and `flush()` write. */
readonly flushIntervalMs?: number;
/** Narrow what lands on the disk. Becomes the strategy's
* {@link ObservabilityStrategy.relevantEventTypes}, so the dispatcher does
* not even forward the rest — the filter costs nothing at the hot path.
* Omitted → every event the `tier` lets through is written. */
readonly eventTypes?: readonly AgentfootprintEventType[];
/**
* Where delivery failures go — a full disk, a revoked permission, a path
* whose directory was removed under a long-running process.
*
* Same law as the network adapters (8.11.0): **telemetry that fails
* invisibly is indistinguishable from telemetry that works.** Unhandled,
* failures reach a rate-limited `console.error`. The batch that failed is
* dropped, never requeued, so a disk that has been full for an hour cannot
* grow the buffer without bound.
*/
readonly onError?: (error: Error, event?: AgentfootprintEvent) => void;
/** Test seam — inject a filesystem. Bypasses `node:fs` entirely, which is
* also what lets the rotation policy be asserted without a real disk. */
readonly _fs?: FileSinkFs;
}
/**
* The slice of the filesystem this adapter touches — five calls, named by what
* the adapter uses them FOR rather than by their `node:fs` signatures.
*
* Sync members run once, at construction; the append path is async so the agent
* loop never waits on a disk.
*/
export interface FileSinkFs {
/** Create the log file's parent directory chain. */
mkdirSync(dir: string, options: {
readonly recursive: boolean;
}): void;
/** Claim the file at construction. This is the writability refusal: an
* unwritable path throws HERE, with the caller still on the stack. */
appendFileSync(file: string, data: string): void;
/** Current size, so the rotation counter starts from what is already there
* rather than from zero on every restart. */
statSync(file: string): {
readonly size: number;
};
/** Append one batch of NDJSON lines. */
appendFile(file: string, data: string): Promise<void>;
/** Rotate: `<path>` → `<path>.1`, replacing any previous `.1`. */
rename(from: string, to: string): Promise<void>;
}
/**
* NDJSON-to-a-local-file observability strategy. See
* {@link FileObservabilityOptions} for the per-option contract, and this
* module's docstring for the rotation policy and what is NOT bounded.
*/
export declare function fileObservability(opts: FileObservabilityOptions): ObservabilityStrategy;
//# sourceMappingURL=file.d.ts.map