UNPKG

@dudousxd/nestjs-telescope

Version:

Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.

332 lines 15.8 kB
import type { SamplingConfig } from '../config/options.js'; import type { ContextAccessor } from '../context/context-accessor.js'; import type { TelescopeContext } from '../context/telescope-context.js'; import type { Entry, RecordInput } from '../entry/entry.js'; import type { RedactOptions } from '../redaction/redact.js'; import type { StorageProvider } from '../storage/storage-provider.js'; import type { Tagger } from '../tagging/tagger.js'; import type { TraceContextProvider } from '../trace/trace-context-provider.js'; export type DropReason = 'overflow' | 'store-failed' | 'record-error'; /** * Cheap, hot-path-safe self-observability counters describing the Recorder's * own behaviour. Every field is a plain integer/number snapshot — no per-call * timing is taken on the synchronous `record()` path. Flush timing is measured * off the host path inside `flush()`. */ export interface RecorderSelfMetrics { /** Total entries that passed sampling+filter and were buffered. */ recorded: number; /** Ring-buffer capacity. */ bufferSize: number; /** Entries currently held in the ring. */ bufferUsed: number; /** Maximum `bufferUsed` ever observed. */ bufferHighWater: number; /** Number of flushes that drained at least one entry. */ flushes: number; /** Cumulative entries drained across all flushes. */ flushedEntries: number; /** * Number of flush batches that failed their first `store()` and were retried * once. A non-zero, climbing value means the storage backend is flaky; pair * it with `storeFailedDropped` to see how many retries still ended in a drop. */ retriedFlushes: number; /** Wall-clock duration of the most recent draining flush, or null. */ lastFlushMs: number | null; /** Largest flush duration ever observed, or null. */ maxFlushMs: number | null; /** Cumulative flush duration across all draining flushes. */ totalFlushMs: number; /** Entries evicted because the ring was full. */ overflowDropped: number; /** Entries dropped because the storage provider rejected a batch. */ storeFailedDropped: number; /** Sum of all drop buckets (overflow + store-failed + record-error). */ droppedCount: number; /** * Entries whose content hit a redaction bound and was clipped (depth, string, * array, or node budget). A non-zero, climbing value means hosts are capturing * fat content (e.g. ORM entities) — a signal to project lighter content or add * per-type `sampling`. The entry is still recorded; only its payload is bounded. */ truncatedCount: number; } export interface RecorderOptions { storage: StorageProvider; context: TelescopeContext; instanceId: string; taggers: Tagger[]; redact: RedactOptions; /** * Per-type sampling. A value may be a bare keep-rate (0..1) or a * {@link SamplingRule} object that additionally keeps errors / slow entries. * Missing type ⇒ falls back to `default`, else keep. */ sampling: SamplingConfig; bufferSize: number; /** * Maximum entries handed to a single `storage.store()` call. When set and a * flush drains more than this, the drained entries are sliced into sequential * chunks of at most this size, each stored (with its own bounded retry) in * oldest→newest order. Bounds the per-store payload so a large flush can't * spike storage memory/latency. When unset (or >= the drained count) the whole * batch is stored in one call, preserving the original behaviour. */ flushBatchSize?: number; /** * Backoff (ms) before the single bounded retry of a failed `store()` batch. * Defaults to 1000ms. The retry happens inside the same in-flight flush * promise, so the `flushing` serialization still prevents pileups. */ retryDelayMs?: number; now?: () => number; random?: () => number; /** * Injectable delay seam used between the failed first store and its retry. * Defaults to an unref'd `setTimeout` so it never keeps the event loop alive. * Tests inject a resolved/controllable promise for determinism. */ delay?: (ms: number) => Promise<void>; idFactory: () => string; /** * Called (inside a try/catch) whenever entries are dropped, with the count * and reason. A faulty hook will not propagate into the Recorder. * * Reasons: * - `'overflow'` — the ring buffer was full; the oldest entry was evicted. * - `'store-failed'` — the storage provider rejected a batch; the batch is dropped. * - `'record-error'` — an unexpected error occurred inside `record()`. */ onDrop?: (count: number, reason: DropReason) => void; /** * Final allow/deny predicate applied to the enriched entry; returning false * excludes it (an intentional exclusion, not counted as a drop). */ filter?: (entry: Entry) => boolean; /** Optional ambient trace-context source; read once per recorded entry. */ traceContext?: TraceContextProvider; /** * Optional, soft-detected `@dudousxd/nestjs-context` accessor (structurally * mirrored as {@link ContextAccessor}). When present it enriches each recorded * entry as a SECONDARY correlation source, additive to the OTel * {@link traceContext}: * * - **traceId precedence**: an explicit {@link RecordInput.traceId} (when the * watcher already knows its own correlation id) wins over everything. Else * OTel wins, and the context `traceId()` is used ONLY as a FALLBACK when * {@link traceContext} did not yield one for this entry. An existing OTel * trace id is never clobbered, so cross-lib correlation with * durable/notifications (which share nestjs-context) kicks in only when OTel * is absent. * - **user/tenant tags**: when available, `user:<Type>#<id>` and * `tenant:<id>` tags are appended (before taggers run, so taggers/filters * can see them) — letting the dashboard group/filter by user and tenant. * * Read defensively once per entry; a misbehaving accessor degrades to no * enrichment and never throws into `record()`. */ contextAccessor?: ContextAccessor; /** * Best-effort hook fired with the entries a flush JUST persisted (after a * successful `store()`, before the next flush). Powers per-flush alert * evaluation (the `new-exception` rule) without coupling the Recorder to the * alerter. Called inside a try/catch — a faulty hook is swallowed and can never * break the flush or the host. NOT called for a batch that failed to store. */ onFlushStored?: (entries: Entry[]) => void | Promise<void>; /** * Best-effort hook fired with the RAW input of EVERY `record()` call, BEFORE * the pause check and sampling — so a metrics consumer sees complete counts * even when the entry is sampled out or dropped under overload. Called inside * its own try/catch; a throw is swallowed and never counted as a drop, never * breaks the synchronous record path. Powers the OTel/Prometheus metrics tap. */ onRecorded?: (input: RecordInput) => void; } /** * Buffers {@link Entry} objects in a fixed-capacity O(1) ring buffer and * periodically flushes them to a {@link StorageProvider}. * * **Overflow policy** — overflow drops the OLDEST buffered entry (so under * sustained overload a batch may be stored without its earliest entries); * recent activity is preferred. * * **Storage failures** — when `store()` rejects, the drained batch is retried * exactly ONCE after a bounded backoff (`retryDelayMs`, default 1000ms). Only a * second failure drops the batch (fail-open, never grow). The retry runs inside * the single in-flight `flushing` promise, so failed batches never pile up and * the ring keeps absorbing/evicting meanwhile — memory stays bounded. Drops are * surfaced via `onDrop` and the `storeFailedDropped` / `droppedCount` counters; * each retried batch increments the `retriedFlushes` self-metric. */ export declare class Recorder { private readonly options; private readonly ring; /** Index of the oldest entry in the ring. */ private head; /** Number of valid entries currently held. */ private count; private overflowDrops; private storeFailedDrops; private recordErrorDrops; private recordedCount; private highWaterCount; private flushCount; private flushedEntriesCount; /** Flush batches that failed their first store() and were retried once. */ private retriedFlushCount; private lastFlushDurationMs; private maxFlushDurationMs; private totalFlushDurationMs; /** Entries whose content was clipped by a redaction bound (incident guard). */ private truncatedEntryCount; private flushing; /** * When paused, `record()` becomes a no-op (the entry is dropped, counted as an * overflow drop) so a telescope under load can never amplify an incident. Set * by the overhead guard when event-loop lag crosses its threshold; cleared * when lag recovers. Flushing continues so the buffer still drains. */ private paused; private readonly now; private readonly random; private readonly delay; private readonly retryDelayMs; /** * Redaction key/path Sets compiled ONCE at boot from `options.redact`. Config * is immutable after construction, so rebuilding these per entry (in the * hottest function) was pure waste — they are precompiled here and reused on * every `enrich()` via {@link redactBoundedWith}. */ private readonly redactSpec; constructor(options: RecorderOptions); get overflowDropped(): number; get storeFailedDropped(): number; /** Sum of all drop buckets (overflow + store-failed + record-error). */ get droppedCount(): number; /** * Number of ring slots still holding an entry reference. After a flush this * MUST equal `bufferUsed` (only the live, not-yet-drained entries), never the * stale entries a previous flush left behind. * * @internal Test-only seam to assert `drain()` nulls drained slots so fat * entries don't linger in the ring after a flush. Not part of the public API. */ get retainedSlotCount(): number; /** * Snapshot of the Recorder's own behaviour. All fields are cheap integer * counters accumulated on the hot path plus off-path flush timings — no * per-record timing is taken, so reading this never taxes `record()`. */ getSelfMetrics(): RecorderSelfMetrics; /** * On-demand micro-benchmark of the synchronous capture path (sampling check + * enrich + filter) on a representative input, WITHOUT enqueuing into the ring * or touching storage. Returns the mean nanoseconds per call. Lives here so * the "cost per capture" figure is honest yet never instruments live records. */ benchmarkRecordCost(iterations: number): number; /** * Runs the same sampling+enrich+filter logic as `record()` but discards the * enriched entry instead of buffering it. Used only by the benchmark. */ private measureCaptureOnce; /** * Whether capture is currently paused by the overhead guard. While paused, * `record()` drops new entries (counted as an overflow drop) but flushing * continues so the buffer drains. */ get isPaused(): boolean; /** Pause capture: `record()` becomes a dropping no-op until {@link resume}. */ pause(): void; /** Resume capture after a {@link pause}. */ resume(): void; /** Synchronous, O(1), never throws into the caller. */ record(input: RecordInput): void; /** * Drains the buffer and persists via {@link StorageProvider.store}. * Concurrent calls share the same in-flight promise — entries added * while a flush is running are picked up on the next flush. */ flush(): Promise<void>; /** * Persists a drained batch, chunked by `flushBatchSize` when that bounds the * batch. Each chunk is stored sequentially (oldest→newest) with its own * bounded retry; per-chunk rollups + the `onFlushStored` hook fire only for * chunks that actually persisted (matching the whole-batch semantics — an * alert never fires for a dropped chunk). A chunk's failure does not abort the * remaining chunks. */ private storeDrained; /** * Stores one chunk with bounded retry, then fires per-chunk rollups + the * flush hook only when it persisted. Shared by the chunked and whole-batch * paths so the post-store side effects are identical. */ private storeChunk; /** * Persists `drained` with ONE bounded retry. On the first `store()` rejection * the Recorder waits `retryDelayMs` (default 1000ms) and retries exactly once; * a second failure drops the batch (`storeFailedDropped` + `store-failed`). * * Hard bounds preserved: this runs INSIDE the single in-flight `flushing` * promise, so no second failed batch can ever be queued concurrently, and the * ring keeps absorbing/evicting meanwhile — memory stays bounded. Returns * whether the batch was ultimately persisted. */ private storeWithRetry; /** * After a successful entry store, pre-aggregate the same batch into the * rollup layer when the storage also implements the {@link RollupStore} SPI. * The entries are already persisted, so a rollup failure must NOT be counted * as a store failure — it is swallowed independently. Never throws into the * flush chain. */ private recordRollupsAfterStore; /** * Invoke the `onFlushStored` hook with the just-persisted batch. Awaited inside * the flush chain so the hook's work (e.g. per-flush alert evaluation) settles * before `flush()` resolves, but wrapped so a rejection/throw is swallowed — the * entries are already stored and a hook bug must never break the flush. */ private notifyFlushStored; /** * Invoke the `onRecorded` tap with the raw input. Isolated try/catch: a tap * failure is swallowed and is NOT a drop — capture continues unaffected. */ private notifyRecorded; /** * Tail-sampling decision. Delegates to the shared resolver so the same logic * backs both the live path and the benchmark. The hot-path cost is shallow * field reads (type, tags, durationMs, content.statusCode/failed) — no walk. */ private passesSampling; private enrich; /** * Reads the optional, soft-detected {@link ContextAccessor} once. Returns the * context fallback `traceId` (or `null`) and any `user:`/`tenant:` tags. Every * accessor call is wrapped so a misbehaving accessor degrades to empty * enrichment and can never throw into `record()` (mirrors the OTel read). */ private readContextEnrichment; /** * O(1) ring-buffer push. On overflow the oldest entry is evicted so that * recent activity is always preserved. */ private push; /** * Drains all entries from the ring in oldest→newest order and resets it. * Returns a plain array for hand-off to storage. */ private drain; /** * Records off-path flush self-metrics. Only invoked from `flush()`, which has * already guaranteed `drainedCount >= 1`, so every call here counts a flush * that drained at least one entry. */ private recordFlushMetrics; /** Calls `onDrop` inside a try/catch so a faulty hook cannot escape. */ private notifyDrop; } //# sourceMappingURL=recorder.d.ts.map