@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
332 lines • 15.8 kB
TypeScript
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