/** * The publisher: the piece that turns the buffer and the three builders into frames on the gateway * websocket, plus the snapshot a client reads when it connects. * * Two things about the transport shape the whole module: * - The only route to the websocket is a captured `context.broadcast`, and `context` exists only * inside a gateway RPC handler — the plugin api object has no push surface at all. So the handle is * captured from the FIRST request of ANY kind, not only from the subscribe method: a box whose * client never calls subscribe would otherwise hold nothing to publish through, silently. * - The gateway drops any event name that does not start with `plugin.`, and closes the socket with * 1008 for a slow client unless the call passes `dropIfSlow`. Both are enforced here rather than at * each call site, because either mistake is invisible until a client is missing frames. * * Three cadences, because the three payloads cost different things to build: * - timeline, on the flush tick — a drain plus a filter, cheap, and the one a live screen feels. * - stats, on the sample tick — a scan of the whole held window and an async read of every runtime's * health and system state, so it runs an order of magnitude less often. * - topology, on the sample tick but published only when it changes — it rides the same gather as * stats, and nothing in the plugin signals a topology change, so "on change" means polled and * diffed rather than pushed. * * The snapshot is what stops a client that joins mid-life from seeing an empty screen: it is read * from the buffer's held window, which the flush drain never evicts. * * A fourth broadcast sits outside all of that. The tick frame is a LEVEL — the latest finished run of * each scanner, most of which produce nothing and so leave no record anywhere — and it is published * on a window of its own, driven by the runs themselves rather than by a timer that is always * running. It carries no seq, is never buffered, is not in the snapshot and is not replayed, because * a client that misses one is made whole by the next one. Keeping it off the buffer is the point: the * buffer is where decision records live, and per-tick facts would evict them. * * Everything that leaves the box is scrubbed here. `logger.event` redacts its `redact` slot and not * its `attributes`, which is sound for the disk ring — that never leaves — but not for a stream a * browser reads. There are four ways out — the timeline frame, the stats frame, the tick frame and * the snapshot that carries one of the first three — and two kinds of free text between them, so the * scrub sits in two places: * - {@link toWireRecord} for a timeline record's attributes. It is what makes the flush tick * asynchronous and gives it an in-flight guard. * - {@link scrubScannerText} for text a scanner process authored: the scanner state's three * free-text fields, via {@link scrubStatsPayload}, which reach both the stats frame and the * snapshot; and a tick's `outcome`. Their content is a stringified throwable or status from a * scanner process, so they are the strings on the wire with no bound of their own and they are * capped as well as scrubbed. * * The topology payload carries no captured text: its `name` and `scannerId` are operator-authored * config keys the client joins on, and the scanner `config` that could hold a resolved secret is * never copied onto it. */ import type { EventName } from "../utils/event-catalog.js"; import { type TimelineBuffer } from "./timeline-buffer.js"; import { type ScannerRunFacts } from "./scanner-tick-feed.js"; import { type TickFramePayload, type TickTelemetryConfig } from "./tick-frame.js"; import { type ScannerStateSnapshot, type ScannerStatsPayload } from "./scanner-stats.js"; import { type IdMembership, type RuntimeHealthLike, type StrategyTopologyPayload, type TopologyStrategySource } from "./strategy-topology.js"; /** The gateway drops any broadcast whose name does not carry this prefix, with no error. */ export declare const EVENT_TIMELINE = "plugin.senpi.timeline"; export declare const EVENT_STATS = "plugin.senpi.scanner.stats"; export declare const EVENT_TOPOLOGY = "plugin.senpi.strategies"; export declare const EVENT_TICK = "plugin.senpi.tick"; /** * Byte ceiling for one frame. A frame published here travels southbound — gateway to bridge — and * meets the bridge's southbound read limit, 1 MiB unless a deployment sets its own; a frame past it * takes the whole connection down rather than costing the one frame. The budget is set far inside * that: the payload envelope the gateway wraps around ours, and the growth of a single record's * attribute bag, both have to fit in the margin, and neither is measured here. */ export declare const MAX_FRAME_BYTES: number; /** * Character ceiling for one of the scanner state's free-text fields on the wire — `lastRunReason`, * `lastRunStatus` and `degradedReason`. * * These carry a stringified throwable from a scanner process, which nothing upstream bounds: an HTTP * client's error message alone runs to a few hundred characters and a stack-bearing one to * kilobytes, once per scanner per sample. 1024 holds the sentence that says what failed — the error * type, the operation and the endpoint. It is also what keeps the payload bounded at all: this frame * is published whole, with no splitting of the kind the timeline does, so two hundred scanners each * reporting a 4 KiB error in every one of the three fields serialise to about 1.7 MB — past the read * limit that ends the connection — and at this cap the same box lands under half of it. */ export declare const MAX_SCANNER_TEXT_CHARS = 1024; /** * What one of those fields reads as when its text could not be scrubbed — no redactor registered, or * a redactor that threw. * * Not `null` and not an absent key: the producer reporting no reason and this box withholding one it * cannot vouch for are different facts, and a summary screen is read as if they were the same. */ export declare const SCANNER_TEXT_WITHHELD = "[withheld: not scrubbed]"; /** Flush cadence for the timeline. A live screen feels this number. */ export declare const DEFAULT_FLUSH_INTERVAL_MS = 1000; /** * Sample cadence for stats and topology. Both are built from one async read of every runtime's health * and system state, so they share a tick. Five seconds is roughly a third of the default bucket width * — fast enough that a counter visibly moves and a hung scan is seen while it is still running, slow * enough that the per-scanner store read behind the system state is not a poll loop. */ export declare const DEFAULT_SAMPLE_INTERVAL_MS = 5000; /** * How long the first tick of a frame waits for the ticks that land beside it. * * Scanners on a box share cadences — a recipe gives several of them the same interval, and every * runtime starts them within the same boot — so their terminal callbacks arrive in clumps. A frame per * callback would put one websocket write per scanner per cadence on the wire for a payload of seven * scalars; one window collapses a clump into a single write. It is short enough that the level a * dashboard shows is still the current one, and it is not the timeline's flush tick because a tick * frame must not wait behind a scrub of hundreds of records. */ export declare const TICK_COALESCE_MS = 250; /** * The PUBLICATION list: what leaves on the timeline event. A name being on this list AND on the * admission list is the normal case, not a duplication — `signal.outcome` is the decision row the feed * is built around and the source of `opens`/`closes`/`skips`, and the two `scaffold.tick_*` names are * the source of `errors` and the rows behind the Errors filter. A failing external scanner has to * leave a visible line, not only a number that silently goes up. * * The two lists are separate so admission can be widened for a counter the feed does not display, * which is the direction that has to stay open: the display filter belongs here at publish time, * where it is still recoverable, never at append time, where it is not. Today nothing is admitted for * counting alone, so the sets are equal — the seam is what matters, not the difference. */ export declare const PUBLISHED_EVENT_NAMES: ReadonlySet; /** * The share of its own interval a tick may take before it is warned about. A fraction rather than a * constant, so a deployment that changes a cadence gets a proportional threshold: 250ms on the * one-second timeline flush, 1.25s on the five-second sample. * * A quarter, not a whole interval. Both ticks share an event loop with the serial trading path, so a * tick holding it for a quarter of its own period is already adding latency to trading; and a tick * that fills its interval has already started stacking on the next one, which is too late to be told. */ export declare const SLOW_TICK_FRACTION = 0.25; /** * How often the timing summary is logged, and the floor between two slow-tick warns of the same kind. * The flush tick runs every second, so a line per tick would be tens of thousands a day for numbers * that only mean anything aggregated — and a repeating tick that is slow is slow every time. */ export declare const TIMING_REPORT_INTERVAL_MS = 60000; export interface GatewayBroadcastOpts { dropIfSlow?: boolean; stateVersion?: number; } export type GatewayBroadcastFn = (event: string, payload: unknown, opts?: GatewayBroadcastOpts) => void; /** One record as it goes on the wire. */ export interface TimelineWireRecord { seq: number; ts: number; name: string; level: string; address: string; scannerId?: string; body: string; attrs: Record; } export interface TimelinePayload { v: 1; epoch: string; batchSeq: number; records: TimelineWireRecord[]; } /** * A tick frame, in the compact wire shape {@link ./tick-frame.js} builds: a per-frame string * dictionary, index references into it, times as offsets from one `t0`, and short keys. * * It carries no `batchSeq` and its entries carry no `seq`, because there is nothing to resume: this * is the current state of a set of scanners, and a client that misses a frame is made whole by the * next one rather than by a replay. */ export type ScannerTickPayload = TickFramePayload; export interface TelemetrySnapshot { epoch: string; topology: StrategyTopologyPayload; stats: ScannerStatsPayload; recent: TimelinePayload; /** * The client asked to resume from a cursor inside THIS epoch whose successor is no longer held, or * the window it is owed did not fit one frame. Either way there is a gap this response cannot fill, * and only the box knows it: `recent.records` alone looks like a normal short window. * * A cursor from another epoch is not truncation. It buys nothing, so the whole retained window is * served — which is the most this box could ever serve, and so nothing was withheld. */ truncated: boolean; } /** Everything the two builders need that the buffer cannot supply, gathered once per tick. */ export interface TelemetryFacts { strategies: TopologyStrategySource[]; runningRuntimeIds: IdMembership; unwiredRuntimeIds: IdMembership; readHealth?: (runtimeId: string) => RuntimeHealthLike | undefined; scannerState: ScannerStateSnapshot[]; } export interface LiveTelemetryOptions { /** Defaults to the process buffer — one per process, shared by every address. */ buffer?: TimelineBuffer; collectFacts: () => Promise; flushIntervalMs?: number; sampleIntervalMs?: number; now?: () => number; /** * Monotonic millisecond reader for tick durations. Defaults to `performance.now`; `now` is wall * clock and can step backwards under NTP, which would report a negative tick. */ monotonicMs?: () => number; /** * How much detail a tick frame carries, as the plugin config states it. Plugin config and not an * env var on purpose: `register()` re-reads plugin config on a config reload, while a service * variable change restarts the container. An unusable value warns and falls back rather than * throwing — a configuration mistake must not be why a box goes dark. */ tickTelemetry?: TickTelemetryConfig; } export interface LiveTelemetry { readonly epoch: string; /** Take the broadcast handle off a gateway request context if we do not hold one yet. */ captureBroadcast(ctx: unknown, methodName: string): void; /** `afterSeq` counts only when `afterEpoch` is the epoch this box is running. */ snapshot(params?: { afterEpoch?: unknown; afterSeq?: unknown; }): Promise; /** One flush tick: drain, filter to the publication list, scrub, frame, broadcast. */ flushTimeline(): Promise; /** One sample tick: stats always, topology when it changed. */ sample(): Promise; /** * Take one finished scanner run for the next tick frame. Synchronous, never throws, and returns * without building anything when no client has ever connected. `start` registers this as the * process tick sink; it is on the interface so a caller holding the publisher can drive it directly. */ recordTick(run: ScannerRunFacts, startedAt: number | undefined, finishedAt: number): void; /** Publish whatever the current coalescing window holds, now. */ flushTicks(): Promise; start(): void; /** Clear every timer, release the tick sink, drop the captured handle, and empty the timelineBuffer(). */ stop(): void; } export declare function createLiveTelemetry(options: LiveTelemetryOptions): LiveTelemetry; //# sourceMappingURL=live-telemetry.d.ts.map