/** * 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()`. A hard kill therefore loses at most the buffer — 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 `.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 `.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; /** Rotate: `` → `.1`, replacing any previous `.1`. */ rename(from: string, to: string): Promise; } /** * 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