/** * The `plugin.senpi.scanner.stats` payload: per-scanner activity counters plus the per-scanner state * a summary screen needs, built as one pure function of its inputs. * * Two halves, from two sources: * - The **counters** are computed on demand by scanning the recent-window buffer — no second data * structure, no pre-aggregation on the write path, no retention setting of their own. Records are * grouped by `(address, scannerId)` and bucketed by timestamp, because `scannerId` alone collides * across strategies (the engine keys on the same pair). * - The **state fields** are not derivable from records at all — health, schedule and liveness live in * the health/system-state providers — so they arrive as {@link ScannerStateSnapshot} inputs and are * joined on the same key. A scanner known to only one side still gets an entry: counters without * state read `unknown`, state without counters carries an empty bucket list. * * The box never picks the window. It emits fixed-width buckets and the frontend sums however many it * wants, so one payload serves every client and every window at once. `since` is what makes that sum * honest: it is the stamp of the oldest record still held, and when nothing is held it is the moment * this epoch began watching — "I have been watching since X and nothing happened" is a different * statement from "no data", and only one of them is true. */ import type { ComponentHealth } from "../health/types.js"; import type { TimelineRecord } from "./timeline-buffer.js"; /** * Bucket width in ms. It sets two things at once: the payload cost per active scanner, and the finest * window a frontend can ask for (it can sum buckets, never split one). * * 15s divides the windows a summary screen actually shows — 1m is 4 buckets, 5m is 20, 15m is 60 — so * no rendered window ever needs a partial bucket. It is also about two ticks of the fastest scanner * the runtime will schedule (built-in cadence is floored at 7s in `runtime-schema.ts`), which keeps a * bucket from hiding a run while still letting "it went quiet 30 seconds ago" show up. Buckets are * emitted sparsely, so an idle scanner costs nothing regardless of this value. * * This is the number to change once real payload sizes are measured — see also the `bucketMs` input, * which overrides it per call. */ export declare const DEFAULT_BUCKET_MS = 15000; /** * Ceiling on the buckets one payload may carry, across every scanner and every address. * * Buckets are the only part of this payload that grows without a bound of its own. A scanner * producing one counted record per run fills one bucket per run, so a retention window of N records * is N buckets on a single scanner, and every address the box runs adds its own series — the state * rows do not, because there is one per registered scanner. One bucket serialises to about sixty * bytes, so this budgets them at roughly 60 KiB and leaves the rest of the frame to the rows. * * Nothing downstream survives a breach: this payload travels southbound to the bridge, whose * southbound read limit is 1 MiB unless a deployment sets its own, and a frame past it takes the * connection down rather than losing the one sample. * * The trim cuts at a bucket boundary, so a payload may carry up to one extra bucket per scanner — * splitting a timestamp across scanners would misalign the series a client sums. */ export declare const MAX_STATS_BUCKETS = 1000; /** * The four counters, which sum to the records counted in the bucket — each counted record * increments exactly one of them, and one position transition is counted once however many records * describe it (see {@link classify}). `closes` is its own field because an accepted close is none of * the other three: it opened nothing, it was not declined, and nothing failed — folding it into any * of them replaces one wrong number with another. */ export interface ScannerStatsBucket { /** Bucket start, epoch ms, aligned to a `bucketMs` grid so buckets line up across scanners. */ readonly t: number; readonly opens: number; readonly closes: number; readonly skips: number; readonly errors: number; } /** * One scanner's state as the health/system-state providers report it. These fields have no * representation in the buffer, so the builder takes them rather than reaching into the runtime. * * `inFlightSince` is a timestamp, not a boolean: a scan running for 200ms is normal and one running * for four minutes on a 15s interval is hung, and a boolean cannot tell those apart. */ export interface ScannerStateSnapshot { readonly address: string; readonly scannerId: string; readonly health: ComponentHealth; readonly degradedReason?: string | null; readonly enabled: boolean; readonly inFlightSince: number | null; readonly lastRunStartedAt: number | null; readonly lastRunFinishedAt: number | null; readonly lastRunStatus: string | null; readonly lastRunReason: string | null; readonly nextRunAt: number | null; readonly lastAliveAt: number | null; /** Null when no supervision row covers this scanner — a known zero is a different statement. */ readonly restartCount: number | null; } /** * One scanner on the wire. `scannerId` is absent — not null — when the records could not be * attributed to a scanner; the client buckets those separately rather than seeing them folded into a * named scanner's numbers. Every state field is `null` when no snapshot covers this scanner, which is * a different statement from a known `false` or a known `0`. * * `degradedReason`, `lastRunStatus` and `lastRunReason` are copied from the snapshot as they were * given. They are free text captured from a scanner process, so the publisher scrubs and caps them * on the way out — this builder stays pure and holds no redactor. */ export interface ScannerStatsEntry { readonly address: string; readonly scannerId?: string; readonly health: ComponentHealth; readonly degradedReason: string | null; readonly enabled: boolean | null; readonly inFlightSince: number | null; readonly lastRunStartedAt: number | null; readonly lastRunFinishedAt: number | null; readonly lastRunStatus: string | null; readonly lastRunReason: string | null; readonly nextRunAt: number | null; readonly lastAliveAt: number | null; readonly restartCount: number | null; /** Only buckets with activity, ascending by `t`. A gap means zero, not missing. */ readonly buckets: ScannerStatsBucket[]; } export interface ScannerStatsPayload { readonly v: 1; readonly epoch: string; readonly sampledAt: number; /** Oldest record still held, or the buffer's start time when nothing is held. Never absent. */ readonly since: number; readonly bucketMs: number; readonly scanners: ScannerStatsEntry[]; } export interface BuildScannerStatsInput { /** The buffer's epoch ULID. A change means the box restarted. */ readonly epoch: string; /** Every record the counters are derived from — the buffer's full held window. */ readonly records: readonly TimelineRecord[]; /** Per-scanner state from the health/system-state providers. */ readonly scannerState: readonly ScannerStateSnapshot[]; /** When this epoch began watching. Used as `since` only while the buffer holds nothing. */ readonly bufferStartedAt: number; readonly sampledAt: number; /** Overrides {@link DEFAULT_BUCKET_MS}. Ignored unless it is a finite width of at least 1ms. */ readonly bucketMs?: number; } /** Build one `plugin.senpi.scanner.stats` payload. Pure: no timers, no clocks, no runtime reads. */ export declare function buildScannerStats(input: BuildScannerStatsInput): ScannerStatsPayload; //# sourceMappingURL=scanner-stats.d.ts.map