/** * Chain-health sampling — measures what the chain was doing underneath a deploy. * * Motivation: deploy latency drifted ~17% on paseo-next-v2 over the second half * of July 2026 while the Bulletin storage phase stayed flat, pointing at Asset * Hub extrinsic inclusion. That diagnosis had to be reconstructed by subtracting * child-span durations after the fact, because we emit nothing about the chain * itself. This module closes that gap. * * Design note — why interval spans rather than numeric attributes: * `@sentry/node` user-defined attributes come back string-typed in EAP no matter * how they're set, so `avg()`/`p95()` refuse to run on them (see the numeric * attribute caveat in CLAUDE.md). `span.duration` is a built-in numeric column * and does aggregate. All three signals here are genuinely elapsed wall-clock * intervals, so encoding each as a span whose duration IS the measurement is * honest — nothing synthetic — and it is the only shape that gives us * `p50(span.duration)` charts. */ /** Which chain a sampler is watching. Kept to the two that bound deploy time. */ type ChainHealthChainId = "bulletin" | "asset-hub"; /** * Minimal slice of papi's PolkadotClient that the sampler needs. Declared * structurally rather than importing PolkadotClient so tests can drive the * sampler with a scripted block stream and no chain. */ interface ChainHealthClient { blocks$: { subscribe(observer: { next: (block: { hash: string; number: number; }) => void; error?: (e: unknown) => void; }): { unsubscribe(): void; }; }; finalizedBlock$: { subscribe(observer: { next: (block: { hash: string; number: number; }) => void; error?: (e: unknown) => void; }): { unsubscribe(): void; }; }; getBlockHeader(hash: string): Promise; } /** * Blocks per measurement window. 10 smooths per-block jitter without delaying * the first sample past a median-length deploy. */ declare const DEFAULT_WINDOW_BLOCKS = 10; /** * Hard cap on spans emitted per sampler, per deploy. A pathological multi-hour * deploy must not emit thousands. Hitting it is reported, never silent. */ declare const MAX_SPANS_PER_SAMPLER = 60; /** * Strip a wss:// endpoint down to its host. The endpoints in environments.json * carry no credentials today; host-only keeps it that way by construction and * keeps attribute cardinality at a handful of values. */ declare function endpointHost(endpoint: string): string; /** * Watches one chain and emits `chain.health.*` interval spans. * * Lifecycle is attach/detach rather than a one-shot start/stop because the * Bulletin client is destroyed and recreated mid-deploy during WS-halt * recovery (deploy.ts) and the Asset Hub client is recreated by * `recreateReviveClient` (dotns.ts, the #1131 retry path). Each recreation * re-attaches; the in-flight window is discarded rather than emitted, because * a window spanning a reconnect measures our outage, not the chain's block * rate, and would silently poison the metric this exists to provide. */ declare class ChainHealthSampler { private readonly chainId; private readonly windowBlocks; private host; private blocksSub; private finalizedSub; /** Wall-clock of the block that opened the current window. */ private windowStartMs; private windowStartBlock; /** blockNumber -> first-seen wall clock, FIFO-evicted at MAX_TRACKED_BLOCKS. */ private firstSeen; private spansEmitted; private capReported; /** Set once a window closes, so exactly one ping is issued per window. */ private pingInFlight; /** * True while `blocks$.subscribe()` is replaying history. * * papi documents that on subscribe it synchronously emits the latest * finalized block and every known descendant. Those arrive microseconds * apart, so letting them close a window would report a near-zero elapsed * time and mark the chain as impossibly fast — corrupting the exact metric * this module exists to produce. Replayed blocks still record first-seen * times (finality lag needs them) and still move the window anchor forward, * so timing starts from the moment we caught up rather than in stale history. */ private inReplay; constructor(chainId: ChainHealthChainId, windowBlocks?: number); /** * Subscribe to a (re)created client. Safe to call repeatedly — an existing * subscription is torn down first. Never throws: a sampler failure must not * be able to fail a deploy. */ attach(client: ChainHealthClient, endpoint: string): void; /** Unsubscribe and drop the partial window. Idempotent. */ detach(): void; /** * Detach and reset per-deploy state. Call from the deploy's teardown. * * Resetting the span budget here (rather than in attach()) is deliberate. * attach() also runs on mid-deploy reconnects, where clearing the counter * would defeat the cap; stop() runs exactly once per deploy. deploy.ts holds * its Bulletin sampler at module scope, so without this a second deploy() in * the same process — playground-cli consumes deploy() as a library — would * inherit the first deploy's exhausted budget and emit nothing. */ stop(): void; /** True while subscribed — used by tests and by re-attach guards. */ get attached(): boolean; private resetWindow; private atCap; private onBlock; private onFinalized; /** * Time one `getBlockHeader` round-trip. Separates "the chain is producing * blocks slowly" from "our link to this endpoint is slow" — the endpoints are * load-balanced, so those are genuinely different failures. */ private ping; } export { type ChainHealthChainId, type ChainHealthClient, ChainHealthSampler, DEFAULT_WINDOW_BLOCKS, MAX_SPANS_PER_SAMPLER, endpointHost };