@senpi/runtime 3.0.125 → 3.0.126
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/action-events.d.ts +7 -21
- package/dist/actions/action-events.d.ts.map +1 -1
- package/dist/actions/action-events.js +26 -54
- package/dist/actions/action-events.js.map +1 -1
- package/dist/actions/action-runtime-module.d.ts.map +1 -1
- package/dist/actions/action-runtime-module.js +7 -38
- package/dist/actions/action-runtime-module.js.map +1 -1
- package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
- package/dist/actions/close-position/close-position.action.js +15 -122
- package/dist/actions/close-position/close-position.action.js.map +1 -1
- package/dist/actions/decision-engine.d.ts.map +1 -1
- package/dist/actions/decision-engine.js +2 -15
- package/dist/actions/decision-engine.js.map +1 -1
- package/dist/actions/event-derivation.d.ts +2 -71
- package/dist/actions/event-derivation.d.ts.map +1 -1
- package/dist/actions/event-derivation.js +8 -251
- package/dist/actions/event-derivation.js.map +1 -1
- package/dist/actions/open-position/open-position.action.d.ts +2 -17
- package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
- package/dist/actions/open-position/open-position.action.js +47 -162
- package/dist/actions/open-position/open-position.action.js.map +1 -1
- package/dist/actions/position-tracker/position-tracker.action.d.ts +1 -2
- package/dist/actions/position-tracker/position-tracker.action.d.ts.map +1 -1
- package/dist/actions/position-tracker/position-tracker.action.js +17 -62
- package/dist/actions/position-tracker/position-tracker.action.js.map +1 -1
- package/dist/actions/store/types.d.ts +2 -9
- package/dist/actions/store/types.d.ts.map +1 -1
- package/dist/actions/store/types.js.map +1 -1
- package/dist/actions/types.d.ts +0 -13
- package/dist/actions/types.d.ts.map +1 -1
- package/dist/actions/types.js +0 -13
- package/dist/actions/types.js.map +1 -1
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +0 -3
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/dsl/engine-client/event-mapping.d.ts.map +1 -1
- package/dist/dsl/engine-client/event-mapping.js +0 -14
- package/dist/dsl/engine-client/event-mapping.js.map +1 -1
- package/dist/dsl/events/build-closed-event.d.ts +1 -16
- package/dist/dsl/events/build-closed-event.d.ts.map +1 -1
- package/dist/dsl/events/build-closed-event.js +0 -6
- package/dist/dsl/events/build-closed-event.js.map +1 -1
- package/dist/dsl/events/handlers.d.ts +0 -7
- package/dist/dsl/events/handlers.d.ts.map +1 -1
- package/dist/dsl/events/handlers.js +7 -37
- package/dist/dsl/events/handlers.js.map +1 -1
- package/dist/dsl/monitor/create-monitor.d.ts +2 -6
- package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
- package/dist/dsl/monitor/create-monitor.js +9 -15
- package/dist/dsl/monitor/create-monitor.js.map +1 -1
- package/dist/dsl/monitor/dsl-failure-events.d.ts +0 -2
- package/dist/dsl/monitor/dsl-failure-events.d.ts.map +1 -1
- package/dist/dsl/monitor/dsl-failure-events.js +1 -2
- package/dist/dsl/monitor/dsl-failure-events.js.map +1 -1
- package/dist/dsl/monitor/process-one-position.d.ts +0 -1
- package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
- package/dist/dsl/monitor/process-one-position.js +25 -59
- package/dist/dsl/monitor/process-one-position.js.map +1 -1
- package/dist/dsl/plugin/index.d.ts +2 -8
- package/dist/dsl/plugin/index.d.ts.map +1 -1
- package/dist/dsl/plugin/index.js +6 -8
- package/dist/dsl/plugin/index.js.map +1 -1
- package/dist/health/types.d.ts +0 -13
- package/dist/health/types.d.ts.map +1 -1
- package/dist/health/types.js +0 -12
- package/dist/health/types.js.map +1 -1
- package/dist/index.d.ts +4 -27
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +42 -254
- package/dist/index.js.map +1 -1
- package/dist/runtime/action-registry.d.ts.map +1 -1
- package/dist/runtime/action-registry.js +3 -4
- package/dist/runtime/action-registry.js.map +1 -1
- package/dist/runtime/domain-events.d.ts.map +1 -1
- package/dist/runtime/domain-events.js +0 -7
- package/dist/runtime/domain-events.js.map +1 -1
- package/dist/runtime/lifecycle-events.d.ts +1 -9
- package/dist/runtime/lifecycle-events.d.ts.map +1 -1
- package/dist/runtime/lifecycle-events.js +2 -11
- package/dist/runtime/lifecycle-events.js.map +1 -1
- package/dist/runtime/reconcile-run.d.ts +3 -13
- package/dist/runtime/reconcile-run.d.ts.map +1 -1
- package/dist/runtime/reconcile-run.js +3 -4
- package/dist/runtime/reconcile-run.js.map +1 -1
- package/dist/runtime/reconcile-state.d.ts +0 -8
- package/dist/runtime/reconcile-state.d.ts.map +1 -1
- package/dist/runtime/reconcile-state.js +1 -4
- package/dist/runtime/reconcile-state.js.map +1 -1
- package/dist/runtime/run.d.ts +0 -1
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/runtime-schema.js +3 -3
- package/dist/runtime/runtime-schema.js.map +1 -1
- package/dist/runtime/runtime.d.ts +0 -1
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +3 -27
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.d.ts +0 -13
- package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.js +0 -1
- package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
- package/dist/runtime/validate-dsl-position-tracking.d.ts.map +1 -1
- package/dist/runtime/validate-dsl-position-tracking.js +1 -2
- package/dist/runtime/validate-dsl-position-tracking.js.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.js +0 -1
- package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
- package/dist/runtime-api/handlers/types.d.ts +0 -5
- package/dist/runtime-api/handlers/types.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-events.d.ts +1 -1
- package/dist/runtime-api/intake/intake-events.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-events.js +3 -9
- package/dist/runtime-api/intake/intake-events.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +19 -4
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/runtime-api/intake/process-error-post.d.ts +1 -6
- package/dist/runtime-api/intake/process-error-post.d.ts.map +1 -1
- package/dist/runtime-api/intake/process-error-post.js +1 -5
- package/dist/runtime-api/intake/process-error-post.js.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.d.ts +1 -7
- package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.js +0 -5
- package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
- package/dist/runtime-api/intake/scaffold-error-router.js.map +1 -1
- package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
- package/dist/runtime-api/intake/scanner-launcher.js +0 -15
- package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
- package/dist/runtime-api/intake/types.d.ts +0 -55
- package/dist/runtime-api/intake/types.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.d.ts +0 -2
- package/dist/runtime-api/routes/signals.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.js +1 -5
- package/dist/runtime-api/routes/signals.js.map +1 -1
- package/dist/runtime-api/routes/signals.schema.d.ts +0 -43
- package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.schema.js +0 -43
- package/dist/runtime-api/routes/signals.schema.js.map +1 -1
- package/dist/runtime-api/server.d.ts +1 -7
- package/dist/runtime-api/server.d.ts.map +1 -1
- package/dist/runtime-api/server.js +0 -1
- package/dist/runtime-api/server.js.map +1 -1
- package/dist/scanners/engine/engine.d.ts +1 -22
- package/dist/scanners/engine/engine.d.ts.map +1 -1
- package/dist/scanners/engine/engine.js +11 -78
- package/dist/scanners/engine/engine.js.map +1 -1
- package/dist/scanners/engine/lifecycle.d.ts +1 -7
- package/dist/scanners/engine/lifecycle.d.ts.map +1 -1
- package/dist/scanners/events.d.ts +0 -4
- package/dist/scanners/events.d.ts.map +1 -1
- package/dist/scanners/events.js +0 -2
- package/dist/scanners/events.js.map +1 -1
- package/dist/scanners/external/scaffold/delivery.py +25 -194
- package/dist/scanners/external/scaffold/scaffold.py +31 -172
- package/dist/scanners/external/scaffold/senpi_mcp.py +8 -150
- package/dist/scanners/external-scanner-receiver.d.ts +0 -17
- package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
- package/dist/scanners/external-scanner-receiver.js +0 -8
- package/dist/scanners/external-scanner-receiver.js.map +1 -1
- package/dist/scanners/runtime-module.d.ts +0 -1
- package/dist/scanners/runtime-module.d.ts.map +1 -1
- package/dist/scanners/runtime-module.js +9 -66
- package/dist/scanners/runtime-module.js.map +1 -1
- package/dist/telemetry/attributes.d.ts +0 -20
- package/dist/telemetry/attributes.d.ts.map +1 -1
- package/dist/telemetry/attributes.js +0 -24
- package/dist/telemetry/attributes.js.map +1 -1
- package/dist/telemetry/event-builders.d.ts +6 -81
- package/dist/telemetry/event-builders.d.ts.map +1 -1
- package/dist/telemetry/event-builders.js +17 -141
- package/dist/telemetry/event-builders.js.map +1 -1
- package/dist/telemetry/event-store.d.ts +0 -6
- package/dist/telemetry/event-store.d.ts.map +1 -1
- package/dist/telemetry/event-store.js +8 -12
- package/dist/telemetry/event-store.js.map +1 -1
- package/dist/telemetry/metrics.d.ts +2 -14
- package/dist/telemetry/metrics.d.ts.map +1 -1
- package/dist/telemetry/metrics.js +4 -19
- package/dist/telemetry/metrics.js.map +1 -1
- package/dist/telemetry/spans/redact-pii.d.ts.map +1 -1
- package/dist/telemetry/spans/redact-pii.js +2 -18
- package/dist/telemetry/spans/redact-pii.js.map +1 -1
- package/dist/telemetry/tracing.d.ts +0 -9
- package/dist/telemetry/tracing.d.ts.map +1 -1
- package/dist/telemetry/tracing.js +0 -36
- package/dist/telemetry/tracing.js.map +1 -1
- package/dist/types/dsl/index.d.ts +0 -11
- package/dist/types/dsl/index.d.ts.map +1 -1
- package/dist/types/scanner.d.ts +0 -15
- package/dist/types/scanner.d.ts.map +1 -1
- package/dist/utils/event-catalog.d.ts +0 -166
- package/dist/utils/event-catalog.d.ts.map +1 -1
- package/dist/utils/event-catalog.js +0 -18
- package/dist/utils/event-catalog.js.map +1 -1
- package/dist/utils/logger.d.ts +1 -4
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js.map +1 -1
- package/dist/validate/recipe-checks.d.ts.map +1 -1
- package/dist/validate/recipe-checks.js +2 -2
- package/dist/validate/recipe-checks.js.map +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/dist/actions/skipped-signal-results.d.ts +0 -25
- package/dist/actions/skipped-signal-results.d.ts.map +0 -1
- package/dist/actions/skipped-signal-results.js +0 -71
- package/dist/actions/skipped-signal-results.js.map +0 -1
- package/dist/runtime-api/intake/ingest-request.d.ts +0 -17
- package/dist/runtime-api/intake/ingest-request.d.ts.map +0 -1
- package/dist/runtime-api/intake/ingest-request.js +0 -34
- package/dist/runtime-api/intake/ingest-request.js.map +0 -1
- package/dist/runtime-api/intake/scaffold-tick-router.d.ts +0 -39
- package/dist/runtime-api/intake/scaffold-tick-router.d.ts.map +0 -1
- package/dist/runtime-api/intake/scaffold-tick-router.js +0 -122
- package/dist/runtime-api/intake/scaffold-tick-router.js.map +0 -1
- package/dist/scanners/external/scaffold/tick_facts.py +0 -130
- package/dist/scanners/external/scaffold/ulid.py +0 -58
- package/dist/telemetry/live-telemetry.d.ts +0 -238
- package/dist/telemetry/live-telemetry.d.ts.map +0 -1
- package/dist/telemetry/live-telemetry.js +0 -853
- package/dist/telemetry/live-telemetry.js.map +0 -1
- package/dist/telemetry/scanner-stats.d.ts +0 -143
- package/dist/telemetry/scanner-stats.d.ts.map +0 -1
- package/dist/telemetry/scanner-stats.js +0 -426
- package/dist/telemetry/scanner-stats.js.map +0 -1
- package/dist/telemetry/scanner-tick-feed.d.ts +0 -87
- package/dist/telemetry/scanner-tick-feed.d.ts.map +0 -1
- package/dist/telemetry/scanner-tick-feed.js +0 -48
- package/dist/telemetry/scanner-tick-feed.js.map +0 -1
- package/dist/telemetry/strategy-topology.d.ts +0 -99
- package/dist/telemetry/strategy-topology.d.ts.map +0 -1
- package/dist/telemetry/strategy-topology.js +0 -93
- package/dist/telemetry/strategy-topology.js.map +0 -1
- package/dist/telemetry/telemetry-publisher.d.ts +0 -63
- package/dist/telemetry/telemetry-publisher.d.ts.map +0 -1
- package/dist/telemetry/telemetry-publisher.js +0 -229
- package/dist/telemetry/telemetry-publisher.js.map +0 -1
- package/dist/telemetry/tick-frame.d.ts +0 -212
- package/dist/telemetry/tick-frame.d.ts.map +0 -1
- package/dist/telemetry/tick-frame.js +0 -421
- package/dist/telemetry/tick-frame.js.map +0 -1
- package/dist/telemetry/timeline-buffer.d.ts +0 -95
- package/dist/telemetry/timeline-buffer.d.ts.map +0 -1
- package/dist/telemetry/timeline-buffer.js +0 -237
- package/dist/telemetry/timeline-buffer.js.map +0 -1
- package/dist/utils/telemetry-required-log.d.ts +0 -60
- package/dist/utils/telemetry-required-log.d.ts.map +0 -1
- package/dist/utils/telemetry-required-log.js +0 -105
- package/dist/utils/telemetry-required-log.js.map +0 -1
- package/dist/utils/ulid.d.ts +0 -9
- package/dist/utils/ulid.d.ts.map +0 -1
- package/dist/utils/ulid.js +0 -43
- package/dist/utils/ulid.js.map +0 -1
|
@@ -1,853 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The publisher: the piece that turns the buffer and the three builders into frames on the gateway
|
|
3
|
-
* websocket, plus the snapshot a client reads when it connects.
|
|
4
|
-
*
|
|
5
|
-
* Two things about the transport shape the whole module:
|
|
6
|
-
* - The only route to the websocket is a captured `context.broadcast`, and `context` exists only
|
|
7
|
-
* inside a gateway RPC handler — the plugin api object has no push surface at all. So the handle is
|
|
8
|
-
* captured from the FIRST request of ANY kind, not only from the subscribe method: a box whose
|
|
9
|
-
* client never calls subscribe would otherwise hold nothing to publish through, silently.
|
|
10
|
-
* - The gateway drops any event name that does not start with `plugin.`, and closes the socket with
|
|
11
|
-
* 1008 for a slow client unless the call passes `dropIfSlow`. Both are enforced here rather than at
|
|
12
|
-
* each call site, because either mistake is invisible until a client is missing frames.
|
|
13
|
-
*
|
|
14
|
-
* Three cadences, because the three payloads cost different things to build:
|
|
15
|
-
* - timeline, on the flush tick — a drain plus a filter, cheap, and the one a live screen feels.
|
|
16
|
-
* - stats, on the sample tick — a scan of the whole held window and an async read of every runtime's
|
|
17
|
-
* health and system state, so it runs an order of magnitude less often.
|
|
18
|
-
* - topology, on the sample tick but published only when it changes — it rides the same gather as
|
|
19
|
-
* stats, and nothing in the plugin signals a topology change, so "on change" means polled and
|
|
20
|
-
* diffed rather than pushed.
|
|
21
|
-
*
|
|
22
|
-
* The snapshot is what stops a client that joins mid-life from seeing an empty screen: it is read
|
|
23
|
-
* from the buffer's held window, which the flush drain never evicts.
|
|
24
|
-
*
|
|
25
|
-
* A fourth broadcast sits outside all of that. The tick frame is a LEVEL — the latest finished run of
|
|
26
|
-
* each scanner, most of which produce nothing and so leave no record anywhere — and it is published
|
|
27
|
-
* on a window of its own, driven by the runs themselves rather than by a timer that is always
|
|
28
|
-
* running. It carries no seq, is never buffered, is not in the snapshot and is not replayed, because
|
|
29
|
-
* a client that misses one is made whole by the next one. Keeping it off the buffer is the point: the
|
|
30
|
-
* buffer is where decision records live, and per-tick facts would evict them.
|
|
31
|
-
*
|
|
32
|
-
* Everything that leaves the box is scrubbed here. `logger.event` redacts its `redact` slot and not
|
|
33
|
-
* its `attributes`, which is sound for the disk ring — that never leaves — but not for a stream a
|
|
34
|
-
* browser reads. There are four ways out — the timeline frame, the stats frame, the tick frame and
|
|
35
|
-
* the snapshot that carries one of the first three — and two kinds of free text between them, so the
|
|
36
|
-
* scrub sits in two places:
|
|
37
|
-
* - {@link toWireRecord} for a timeline record's attributes. It is what makes the flush tick
|
|
38
|
-
* asynchronous and gives it an in-flight guard.
|
|
39
|
-
* - {@link scrubScannerText} for text a scanner process authored: the scanner state's three
|
|
40
|
-
* free-text fields, via {@link scrubStatsPayload}, which reach both the stats frame and the
|
|
41
|
-
* snapshot; and a tick's `outcome`. Their content is a stringified throwable or status from a
|
|
42
|
-
* scanner process, so they are the strings on the wire with no bound of their own and they are
|
|
43
|
-
* capped as well as scrubbed.
|
|
44
|
-
*
|
|
45
|
-
* The topology payload carries no captured text: its `name` and `scannerId` are operator-authored
|
|
46
|
-
* config keys the client joins on, and the scanner `config` that could hold a resolved secret is
|
|
47
|
-
* never copied onto it.
|
|
48
|
-
*/
|
|
49
|
-
import { createLogger } from "../utils/logger.js";
|
|
50
|
-
import { withSpan } from "./tracing.js";
|
|
51
|
-
import { getDefaultPiiRedactor } from "./spans/redact-pii.js";
|
|
52
|
-
import { ADMITTED_EVENT_NAMES, readAndResetTapCounters } from "./telemetry-publisher.js";
|
|
53
|
-
import { getTimelineBuffer, teardownTimelineBuffer, } from "./timeline-buffer.js";
|
|
54
|
-
import { capValue } from "./attributes.js";
|
|
55
|
-
import { setScannerTickSink } from "./scanner-tick-feed.js";
|
|
56
|
-
import { createTickFrameBuilder, resolveTickTelemetry, } from "./tick-frame.js";
|
|
57
|
-
import { normalizeStrategyAddress } from "./event-store.js";
|
|
58
|
-
import { buildScannerStats, } from "./scanner-stats.js";
|
|
59
|
-
import { buildStrategyTopology, } from "./strategy-topology.js";
|
|
60
|
-
const logger = createLogger("[senpi-core][live-telemetry]");
|
|
61
|
-
/** The gateway drops any broadcast whose name does not carry this prefix, with no error. */
|
|
62
|
-
export const EVENT_TIMELINE = "plugin.senpi.timeline";
|
|
63
|
-
export const EVENT_STATS = "plugin.senpi.scanner.stats";
|
|
64
|
-
export const EVENT_TOPOLOGY = "plugin.senpi.strategies";
|
|
65
|
-
export const EVENT_TICK = "plugin.senpi.tick";
|
|
66
|
-
/**
|
|
67
|
-
* Byte ceiling for one frame. A frame published here travels southbound — gateway to bridge — and
|
|
68
|
-
* meets the bridge's southbound read limit, 1 MiB unless a deployment sets its own; a frame past it
|
|
69
|
-
* takes the whole connection down rather than costing the one frame. The budget is set far inside
|
|
70
|
-
* that: the payload envelope the gateway wraps around ours, and the growth of a single record's
|
|
71
|
-
* attribute bag, both have to fit in the margin, and neither is measured here.
|
|
72
|
-
*/
|
|
73
|
-
export const MAX_FRAME_BYTES = 128 * 1024;
|
|
74
|
-
/**
|
|
75
|
-
* Character ceiling for one of the scanner state's free-text fields on the wire — `lastRunReason`,
|
|
76
|
-
* `lastRunStatus` and `degradedReason`.
|
|
77
|
-
*
|
|
78
|
-
* These carry a stringified throwable from a scanner process, which nothing upstream bounds: an HTTP
|
|
79
|
-
* client's error message alone runs to a few hundred characters and a stack-bearing one to
|
|
80
|
-
* kilobytes, once per scanner per sample. 1024 holds the sentence that says what failed — the error
|
|
81
|
-
* type, the operation and the endpoint. It is also what keeps the payload bounded at all: this frame
|
|
82
|
-
* is published whole, with no splitting of the kind the timeline does, so two hundred scanners each
|
|
83
|
-
* reporting a 4 KiB error in every one of the three fields serialise to about 1.7 MB — past the read
|
|
84
|
-
* limit that ends the connection — and at this cap the same box lands under half of it.
|
|
85
|
-
*/
|
|
86
|
-
export const MAX_SCANNER_TEXT_CHARS = 1_024;
|
|
87
|
-
/**
|
|
88
|
-
* What one of those fields reads as when its text could not be scrubbed — no redactor registered, or
|
|
89
|
-
* a redactor that threw.
|
|
90
|
-
*
|
|
91
|
-
* Not `null` and not an absent key: the producer reporting no reason and this box withholding one it
|
|
92
|
-
* cannot vouch for are different facts, and a summary screen is read as if they were the same.
|
|
93
|
-
*/
|
|
94
|
-
export const SCANNER_TEXT_WITHHELD = "[withheld: not scrubbed]";
|
|
95
|
-
/** Flush cadence for the timeline. A live screen feels this number. */
|
|
96
|
-
export const DEFAULT_FLUSH_INTERVAL_MS = 1_000;
|
|
97
|
-
/**
|
|
98
|
-
* Sample cadence for stats and topology. Both are built from one async read of every runtime's health
|
|
99
|
-
* and system state, so they share a tick. Five seconds is roughly a third of the default bucket width
|
|
100
|
-
* — fast enough that a counter visibly moves and a hung scan is seen while it is still running, slow
|
|
101
|
-
* enough that the per-scanner store read behind the system state is not a poll loop.
|
|
102
|
-
*/
|
|
103
|
-
export const DEFAULT_SAMPLE_INTERVAL_MS = 5_000;
|
|
104
|
-
/**
|
|
105
|
-
* How long the first tick of a frame waits for the ticks that land beside it.
|
|
106
|
-
*
|
|
107
|
-
* Scanners on a box share cadences — a recipe gives several of them the same interval, and every
|
|
108
|
-
* runtime starts them within the same boot — so their terminal callbacks arrive in clumps. A frame per
|
|
109
|
-
* callback would put one websocket write per scanner per cadence on the wire for a payload of seven
|
|
110
|
-
* scalars; one window collapses a clump into a single write. It is short enough that the level a
|
|
111
|
-
* dashboard shows is still the current one, and it is not the timeline's flush tick because a tick
|
|
112
|
-
* frame must not wait behind a scrub of hundreds of records.
|
|
113
|
-
*/
|
|
114
|
-
export const TICK_COALESCE_MS = 250;
|
|
115
|
-
/** The comma JSON puts between two array entries. Counted, so the budget is the frame's real size. */
|
|
116
|
-
const SEPARATOR_BYTES = 1;
|
|
117
|
-
/**
|
|
118
|
-
* The PUBLICATION list: what leaves on the timeline event. A name being on this list AND on the
|
|
119
|
-
* admission list is the normal case, not a duplication — `signal.outcome` is the decision row the feed
|
|
120
|
-
* is built around and the source of `opens`/`closes`/`skips`, and the two `scaffold.tick_*` names are
|
|
121
|
-
* the source of `errors` and the rows behind the Errors filter. A failing external scanner has to
|
|
122
|
-
* leave a visible line, not only a number that silently goes up.
|
|
123
|
-
*
|
|
124
|
-
* The two lists are separate so admission can be widened for a counter the feed does not display,
|
|
125
|
-
* which is the direction that has to stay open: the display filter belongs here at publish time,
|
|
126
|
-
* where it is still recoverable, never at append time, where it is not. Today nothing is admitted for
|
|
127
|
-
* counting alone, so the sets are equal — the seam is what matters, not the difference.
|
|
128
|
-
*/
|
|
129
|
-
export const PUBLISHED_EVENT_NAMES = new Set(ADMITTED_EVENT_NAMES);
|
|
130
|
-
/**
|
|
131
|
-
* The share of its own interval a tick may take before it is warned about. A fraction rather than a
|
|
132
|
-
* constant, so a deployment that changes a cadence gets a proportional threshold: 250ms on the
|
|
133
|
-
* one-second timeline flush, 1.25s on the five-second sample.
|
|
134
|
-
*
|
|
135
|
-
* A quarter, not a whole interval. Both ticks share an event loop with the serial trading path, so a
|
|
136
|
-
* tick holding it for a quarter of its own period is already adding latency to trading; and a tick
|
|
137
|
-
* that fills its interval has already started stacking on the next one, which is too late to be told.
|
|
138
|
-
*/
|
|
139
|
-
export const SLOW_TICK_FRACTION = 0.25;
|
|
140
|
-
/**
|
|
141
|
-
* How often the timing summary is logged, and the floor between two slow-tick warns of the same kind.
|
|
142
|
-
* The flush tick runs every second, so a line per tick would be tens of thousands a day for numbers
|
|
143
|
-
* that only mean anything aggregated — and a repeating tick that is slow is slow every time.
|
|
144
|
-
*/
|
|
145
|
-
export const TIMING_REPORT_INTERVAL_MS = 60_000;
|
|
146
|
-
/** What the builders see when the facts could not be read: nothing, rather than a stale guess. */
|
|
147
|
-
const NO_FACTS = {
|
|
148
|
-
strategies: [],
|
|
149
|
-
runningRuntimeIds: { has: () => false },
|
|
150
|
-
unwiredRuntimeIds: { has: () => false },
|
|
151
|
-
scannerState: [],
|
|
152
|
-
};
|
|
153
|
-
/**
|
|
154
|
-
* Every string value in `attrs`, scrubbed. Numbers and booleans cannot carry PII and are copied as
|
|
155
|
-
* they are, so a record whose attributes are all scalars allocates nothing and awaits nothing.
|
|
156
|
-
*
|
|
157
|
-
* A value the redactor could not produce is DROPPED, never passed through — `redactStringsDeep`
|
|
158
|
-
* keeps the original on a throw because a span attribute is still inside the box, and this is not.
|
|
159
|
-
* With no redactor at all every string value is dropped for the same reason.
|
|
160
|
-
*/
|
|
161
|
-
async function scrubAttrs(attrs, redactor, onScrubFailure) {
|
|
162
|
-
let out;
|
|
163
|
-
for (const [key, value] of Object.entries(attrs)) {
|
|
164
|
-
// An empty string carries nothing, and the redactor charges for it like any other.
|
|
165
|
-
if (typeof value !== "string" || value === "")
|
|
166
|
-
continue;
|
|
167
|
-
out ??= { ...attrs };
|
|
168
|
-
if (redactor === null) {
|
|
169
|
-
delete out[key];
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
try {
|
|
173
|
-
out[key] = await redactor.redact(value);
|
|
174
|
-
}
|
|
175
|
-
catch (err) {
|
|
176
|
-
delete out[key];
|
|
177
|
-
onScrubFailure(err);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return out ?? attrs;
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* The single point where a buffered record becomes something that goes outside, and so the only
|
|
184
|
-
* place the scrub has to be.
|
|
185
|
-
*
|
|
186
|
-
* `body` is not scrubbed: it is built from the catalogued template and bounded by it, which is the
|
|
187
|
-
* same reason `logger.event` never redacts it. Neither are the record's own string fields — `name`
|
|
188
|
-
* is a closed-catalog entry, `address` is a normalised wallet address, `scannerId` is the engine's
|
|
189
|
-
* id, `level` is a severity. They are machine identifiers the client groups and joins on, not free
|
|
190
|
-
* text, and scrubbing one would break the join for no gain.
|
|
191
|
-
*/
|
|
192
|
-
async function toWireRecord(record, redactor, onScrubFailure) {
|
|
193
|
-
return {
|
|
194
|
-
seq: record.seq,
|
|
195
|
-
ts: record.ts,
|
|
196
|
-
name: record.name,
|
|
197
|
-
level: record.level,
|
|
198
|
-
address: record.address,
|
|
199
|
-
...(record.scannerId !== undefined ? { scannerId: record.scannerId } : {}),
|
|
200
|
-
body: record.body,
|
|
201
|
-
attrs: await scrubAttrs(record.attrs, redactor, onScrubFailure),
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* One of the scanner state's free-text fields as it goes outside: scrubbed, then capped to
|
|
206
|
-
* {@link MAX_SCANNER_TEXT_CHARS} with the marker `capValue` puts on every other capped value, so the
|
|
207
|
-
* cut is visible to whoever reads it.
|
|
208
|
-
*
|
|
209
|
-
* The cap is taken AFTER the scrub and never before. A redaction can be longer than the text it
|
|
210
|
-
* replaces — a placeholder carries a kind and a counter where the original carried a number — so a
|
|
211
|
-
* cut taken first bounds the input and not what leaves.
|
|
212
|
-
*
|
|
213
|
-
* `null` passes through: a field the provider never filled is a different fact from one this box
|
|
214
|
-
* withheld, and {@link SCANNER_TEXT_WITHHELD} is what says which of the two happened.
|
|
215
|
-
*/
|
|
216
|
-
async function scrubScannerText(value, redactor, onScrubFailure) {
|
|
217
|
-
if (value === null || value === "")
|
|
218
|
-
return value;
|
|
219
|
-
if (redactor === null)
|
|
220
|
-
return SCANNER_TEXT_WITHHELD;
|
|
221
|
-
try {
|
|
222
|
-
return capValue(await redactor.redact(value), MAX_SCANNER_TEXT_CHARS);
|
|
223
|
-
}
|
|
224
|
-
catch (err) {
|
|
225
|
-
onScrubFailure(err);
|
|
226
|
-
return SCANNER_TEXT_WITHHELD;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
/**
|
|
230
|
-
* The stats payload as it goes outside, and so the only place the stats scrub has to be: both the
|
|
231
|
-
* sample tick and the snapshot publish what this returns.
|
|
232
|
-
*
|
|
233
|
-
* Three fields per entry are touched. Everything else on one is a wallet address, a scanner id, a
|
|
234
|
-
* health token, a timestamp or a counter — machine values the client joins and sums on, none of
|
|
235
|
-
* which can carry what a scanner's error string carries. An entry holding none of the three is
|
|
236
|
-
* passed through as it is, so a box of healthy scanners awaits nothing.
|
|
237
|
-
*/
|
|
238
|
-
async function scrubStatsPayload(payload, redactor, onScrubFailure) {
|
|
239
|
-
let scrubbedAny = false;
|
|
240
|
-
const scanners = [];
|
|
241
|
-
for (const entry of payload.scanners) {
|
|
242
|
-
if (entry.degradedReason === null &&
|
|
243
|
-
entry.lastRunStatus === null &&
|
|
244
|
-
entry.lastRunReason === null) {
|
|
245
|
-
scanners.push(entry);
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
scrubbedAny = true;
|
|
249
|
-
scanners.push({
|
|
250
|
-
...entry,
|
|
251
|
-
degradedReason: await scrubScannerText(entry.degradedReason, redactor, onScrubFailure),
|
|
252
|
-
lastRunStatus: await scrubScannerText(entry.lastRunStatus, redactor, onScrubFailure),
|
|
253
|
-
lastRunReason: await scrubScannerText(entry.lastRunReason, redactor, onScrubFailure),
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
return scrubbedAny ? { ...payload, scanners } : payload;
|
|
257
|
-
}
|
|
258
|
-
/** A cursor is a resume point, so anything that is not a whole non-negative number means "from the start". */
|
|
259
|
-
function normalizeAfterSeq(raw) {
|
|
260
|
-
if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0)
|
|
261
|
-
return 0;
|
|
262
|
-
return Math.floor(raw);
|
|
263
|
-
}
|
|
264
|
-
/** Everything but `generatedAt`, which changes on every build and would make every topology "changed". */
|
|
265
|
-
function topologySignature(payload) {
|
|
266
|
-
return JSON.stringify({ v: payload.v, strategies: payload.strategies });
|
|
267
|
-
}
|
|
268
|
-
/** Microsecond precision. A sub-millisecond tick is the interesting case; its 17th digit is not. */
|
|
269
|
-
function roundMs(ms) {
|
|
270
|
-
return Math.round(ms * 1_000) / 1_000;
|
|
271
|
-
}
|
|
272
|
-
export function createLiveTelemetry(options) {
|
|
273
|
-
const ownsProcessBuffer = options.buffer === undefined;
|
|
274
|
-
// Resolved at every use, never captured. The event sink resolves it the same way on every
|
|
275
|
-
// append, and a plugin reload drops the process singleton — capturing it here once would leave
|
|
276
|
-
// the publisher draining a buffer nothing writes to any more, with no symptom but an empty feed.
|
|
277
|
-
const timelineBuffer = () => options.buffer ?? getTimelineBuffer();
|
|
278
|
-
const now = options.now ?? Date.now;
|
|
279
|
-
const monotonicMs = options.monotonicMs ?? (() => performance.now());
|
|
280
|
-
const flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
281
|
-
const sampleIntervalMs = options.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS;
|
|
282
|
-
// When this epoch began watching: `since` falls back to it while the buffer holds nothing, so an
|
|
283
|
-
// empty window reads as "watching since X, nothing happened" rather than "no data".
|
|
284
|
-
const bufferStartedAt = now();
|
|
285
|
-
let broadcast;
|
|
286
|
-
let flushTimer;
|
|
287
|
-
let sampleTimer;
|
|
288
|
-
let sampleInFlight = false;
|
|
289
|
-
let flushInFlight = false;
|
|
290
|
-
let batchSeq = 0;
|
|
291
|
-
let lastTopologySignature;
|
|
292
|
-
// These guards sit on per-second paths, so each warns once until the condition clears.
|
|
293
|
-
let warnedNoBroadcast = false;
|
|
294
|
-
let warnedBroadcastFailure = false;
|
|
295
|
-
let warnedOversizedRecord = false;
|
|
296
|
-
let warnedNoRedactor = false;
|
|
297
|
-
let warnedScrubFailure = false;
|
|
298
|
-
let warnedFlushFailure = false;
|
|
299
|
-
let warnedTickFrameFull = false;
|
|
300
|
-
let warnedTickFlushFailure = false;
|
|
301
|
-
// The two detail dials, read once. `invalid` is warned about here rather than thrown, and once
|
|
302
|
-
// rather than per frame: the value is read at register time and does not change under us.
|
|
303
|
-
const { settings: tickSettings, invalid: invalidTickTelemetry } = resolveTickTelemetry(options.tickTelemetry);
|
|
304
|
-
if (invalidTickTelemetry !== undefined) {
|
|
305
|
-
logger.warn("live telemetry ignored an invalid tick telemetry setting and kept the default", {
|
|
306
|
-
"senpi.telemetry.tick_telemetry_invalid": invalidTickTelemetry,
|
|
307
|
-
"senpi.telemetry.tick_telemetry_mcp": tickSettings.mcp,
|
|
308
|
-
"senpi.telemetry.tick_telemetry_state": tickSettings.state,
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
// What a tick frame costs before it holds a single tick, used to admit into the OPEN window. The
|
|
312
|
-
// frame that goes out is measured again, on the scrubbed frame, by the builder — a redaction can
|
|
313
|
-
// be longer than the text it replaced, so this figure bounds memory and the builder bounds bytes.
|
|
314
|
-
const tickEnvelopeBytes = Buffer.byteLength(JSON.stringify({ v: 1, e: timelineBuffer().epoch, t0: 0, d: { a: [], s: [], m: [], o: [], x: [] }, k: [] }));
|
|
315
|
-
// The open coalescing window: the runs it holds, roughly what they would cost, and the timer that
|
|
316
|
-
// closes it. Nothing accumulates here on a box no client has ever reached.
|
|
317
|
-
let pendingTicks = [];
|
|
318
|
-
let pendingTickBytes = tickEnvelopeBytes;
|
|
319
|
-
let tickTimer;
|
|
320
|
-
// What the ticks cost over one report window. Bucket width and buffer retention are deliberately
|
|
321
|
-
// unfixed by the design, and these are the numbers they get sized from.
|
|
322
|
-
const timelineWindow = { ticks: 0, msMax: 0, records: 0, payloadBytes: 0 };
|
|
323
|
-
const statsWindow = { ticks: 0, msMax: 0, recordsScanned: 0, payloadBytesMax: 0 };
|
|
324
|
-
const tapWindow = { calls: 0, nsTotal: 0, nsMax: 0 };
|
|
325
|
-
let lastReportAt = bufferStartedAt;
|
|
326
|
-
const lastSlowWarnAt = { timeline: 0, stats: 0 };
|
|
327
|
-
/** True when the frame reached the gateway, so a caller counts bytes only for what went out. */
|
|
328
|
-
function publish(event, payload) {
|
|
329
|
-
if (broadcast === undefined) {
|
|
330
|
-
if (!warnedNoBroadcast) {
|
|
331
|
-
warnedNoBroadcast = true;
|
|
332
|
-
logger.warn("live telemetry has no gateway broadcast handle yet; nothing is published until a client calls any senpi gateway method", { "senpi.telemetry.event": event });
|
|
333
|
-
}
|
|
334
|
-
return false;
|
|
335
|
-
}
|
|
336
|
-
try {
|
|
337
|
-
// `dropIfSlow` is not optional: without it the gateway closes the socket with 1008 for a slow
|
|
338
|
-
// client instead of skipping the one frame it could not fit.
|
|
339
|
-
broadcast(event, payload, { dropIfSlow: true });
|
|
340
|
-
warnedBroadcastFailure = false;
|
|
341
|
-
return true;
|
|
342
|
-
}
|
|
343
|
-
catch (err) {
|
|
344
|
-
if (!warnedBroadcastFailure) {
|
|
345
|
-
warnedBroadcastFailure = true;
|
|
346
|
-
logger.warn("live telemetry broadcast failed; frames are being lost", {
|
|
347
|
-
"senpi.telemetry.event": event,
|
|
348
|
-
err: err instanceof Error ? err.message : String(err),
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
return false;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
/** Record what a tick cost, and warn when it is eating its own interval. */
|
|
355
|
-
function noteTick(kind, elapsedMs, intervalMs) {
|
|
356
|
-
const window = kind === "timeline" ? timelineWindow : statsWindow;
|
|
357
|
-
window.ticks += 1;
|
|
358
|
-
if (elapsedMs > window.msMax)
|
|
359
|
-
window.msMax = elapsedMs;
|
|
360
|
-
const thresholdMs = intervalMs * SLOW_TICK_FRACTION;
|
|
361
|
-
if (elapsedMs <= thresholdMs)
|
|
362
|
-
return;
|
|
363
|
-
const at = now();
|
|
364
|
-
// A tick that is slow is slow every time, and these repeat on the order of seconds.
|
|
365
|
-
if (at - lastSlowWarnAt[kind] < TIMING_REPORT_INTERVAL_MS)
|
|
366
|
-
return;
|
|
367
|
-
lastSlowWarnAt[kind] = at;
|
|
368
|
-
logger.warn("live telemetry tick is slow relative to its own interval", {
|
|
369
|
-
"senpi.telemetry.tick": kind,
|
|
370
|
-
"senpi.telemetry.tick_ms": roundMs(elapsedMs),
|
|
371
|
-
"senpi.telemetry.tick_threshold_ms": thresholdMs,
|
|
372
|
-
"senpi.telemetry.tick_interval_ms": intervalMs,
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
/**
|
|
376
|
-
* One aggregated line per window, off the flush tick. Logged and not only traced: the OTel SDK
|
|
377
|
-
* stands up only when an OTLP endpoint is configured, so on a box with no collector there are no
|
|
378
|
-
* spans and nothing that says there are none.
|
|
379
|
-
*/
|
|
380
|
-
function reportTiming() {
|
|
381
|
-
const at = now();
|
|
382
|
-
const windowMs = at - lastReportAt;
|
|
383
|
-
if (windowMs < TIMING_REPORT_INTERVAL_MS)
|
|
384
|
-
return;
|
|
385
|
-
lastReportAt = at;
|
|
386
|
-
logger.info("live telemetry tick timing", {
|
|
387
|
-
"senpi.telemetry.window_ms": windowMs,
|
|
388
|
-
"senpi.telemetry.timeline_ticks": timelineWindow.ticks,
|
|
389
|
-
"senpi.telemetry.timeline_ms_max": roundMs(timelineWindow.msMax),
|
|
390
|
-
"senpi.telemetry.records_flushed": timelineWindow.records,
|
|
391
|
-
"senpi.telemetry.timeline_payload_bytes": timelineWindow.payloadBytes,
|
|
392
|
-
"senpi.telemetry.stats_ticks": statsWindow.ticks,
|
|
393
|
-
"senpi.telemetry.stats_ms_max": roundMs(statsWindow.msMax),
|
|
394
|
-
"senpi.telemetry.records_scanned": statsWindow.recordsScanned,
|
|
395
|
-
"senpi.telemetry.stats_payload_bytes_max": statsWindow.payloadBytesMax,
|
|
396
|
-
"senpi.telemetry.tap_calls": tapWindow.calls,
|
|
397
|
-
"senpi.telemetry.tap_ns_total": tapWindow.nsTotal,
|
|
398
|
-
// No tap call is an idle box, not a division: the average of nothing is nothing.
|
|
399
|
-
"senpi.telemetry.tap_ns_avg": tapWindow.calls === 0 ? 0 : Math.round(tapWindow.nsTotal / tapWindow.calls),
|
|
400
|
-
"senpi.telemetry.tap_ns_p_max": tapWindow.nsMax,
|
|
401
|
-
});
|
|
402
|
-
timelineWindow.ticks = 0;
|
|
403
|
-
timelineWindow.msMax = 0;
|
|
404
|
-
timelineWindow.records = 0;
|
|
405
|
-
timelineWindow.payloadBytes = 0;
|
|
406
|
-
statsWindow.ticks = 0;
|
|
407
|
-
statsWindow.msMax = 0;
|
|
408
|
-
statsWindow.recordsScanned = 0;
|
|
409
|
-
statsWindow.payloadBytesMax = 0;
|
|
410
|
-
tapWindow.calls = 0;
|
|
411
|
-
tapWindow.nsTotal = 0;
|
|
412
|
-
tapWindow.nsMax = 0;
|
|
413
|
-
}
|
|
414
|
-
function noteOversizedRecord(record, bytes) {
|
|
415
|
-
if (warnedOversizedRecord)
|
|
416
|
-
return;
|
|
417
|
-
warnedOversizedRecord = true;
|
|
418
|
-
logger.warn("live telemetry dropped a record too large for one frame", {
|
|
419
|
-
"senpi.event.name": record.name,
|
|
420
|
-
"senpi.telemetry.record_bytes": bytes,
|
|
421
|
-
"senpi.telemetry.frame_limit_bytes": MAX_FRAME_BYTES,
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
function noteScrubFailure(err) {
|
|
425
|
-
if (warnedScrubFailure)
|
|
426
|
-
return;
|
|
427
|
-
warnedScrubFailure = true;
|
|
428
|
-
logger.warn("live telemetry redactor threw; the attribute it could not scrub was dropped", {
|
|
429
|
-
err: err instanceof Error ? err.message : String(err),
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* The process redactor, or `null` when none is registered yet — the boot window before
|
|
434
|
-
* `initDefaultPiiRedactor` resolves, an openredaction install that failed, or a test.
|
|
435
|
-
*
|
|
436
|
-
* Read once per outbound batch rather than per record, so one batch is scrubbed one way. The warn
|
|
437
|
-
* clears once a redactor appears, so a later regression is reported again rather than swallowed by
|
|
438
|
-
* a one-shot that already fired during boot.
|
|
439
|
-
*/
|
|
440
|
-
function currentRedactor() {
|
|
441
|
-
const redactor = getDefaultPiiRedactor();
|
|
442
|
-
if (redactor !== null) {
|
|
443
|
-
warnedNoRedactor = false;
|
|
444
|
-
return redactor;
|
|
445
|
-
}
|
|
446
|
-
if (!warnedNoRedactor) {
|
|
447
|
-
warnedNoRedactor = true;
|
|
448
|
-
logger.warn("live telemetry has no PII redactor registered; string event attributes are dropped from the wire until one is");
|
|
449
|
-
}
|
|
450
|
-
return null;
|
|
451
|
-
}
|
|
452
|
-
/**
|
|
453
|
-
* Split records into frames that each serialise under the byte budget, scrubbing each one on the
|
|
454
|
-
* way. A record that cannot fit a frame by itself is dropped rather than sent: an oversized frame
|
|
455
|
-
* does not lose one line, it closes the client's socket with 1009 and loses the stream. The size is
|
|
456
|
-
* measured on the scrubbed record, because that is the one that goes out.
|
|
457
|
-
*
|
|
458
|
-
* `redactMs` is the scrub's share of the work, reported on the publish span: redaction is regex
|
|
459
|
-
* over every string attribute of every record, and a batch is hundreds of records at a one-second
|
|
460
|
-
* tick, so it is the number that says whether this fits its own interval.
|
|
461
|
-
*/
|
|
462
|
-
async function toFrames(records) {
|
|
463
|
-
const redactor = currentRedactor();
|
|
464
|
-
const frames = [];
|
|
465
|
-
let current = [];
|
|
466
|
-
let bytes = 0;
|
|
467
|
-
let redactMs = 0;
|
|
468
|
-
for (const record of records) {
|
|
469
|
-
const scrubStartedAt = monotonicMs();
|
|
470
|
-
const wire = await toWireRecord(record, redactor, noteScrubFailure);
|
|
471
|
-
redactMs += monotonicMs() - scrubStartedAt;
|
|
472
|
-
// Bytes, not string length: the read limit is counted on the wire, and one non-ASCII
|
|
473
|
-
// character costs up to three of them.
|
|
474
|
-
const size = Buffer.byteLength(JSON.stringify(wire));
|
|
475
|
-
if (size > MAX_FRAME_BYTES) {
|
|
476
|
-
noteOversizedRecord(wire, size);
|
|
477
|
-
continue;
|
|
478
|
-
}
|
|
479
|
-
if (current.length > 0 && bytes + size > MAX_FRAME_BYTES) {
|
|
480
|
-
frames.push(current);
|
|
481
|
-
current = [];
|
|
482
|
-
bytes = 0;
|
|
483
|
-
}
|
|
484
|
-
current.push(wire);
|
|
485
|
-
bytes += size;
|
|
486
|
-
}
|
|
487
|
-
if (current.length > 0)
|
|
488
|
-
frames.push(current);
|
|
489
|
-
return { frames, redactMs };
|
|
490
|
-
}
|
|
491
|
-
function published(records) {
|
|
492
|
-
return records.filter((record) => PUBLISHED_EVENT_NAMES.has(record.name));
|
|
493
|
-
}
|
|
494
|
-
/** `null` when the facts could not be read — the caller decides whether to publish without them. */
|
|
495
|
-
async function readFacts() {
|
|
496
|
-
try {
|
|
497
|
-
return await options.collectFacts();
|
|
498
|
-
}
|
|
499
|
-
catch (err) {
|
|
500
|
-
// Health and state come from live runtime handles, which can be mid-teardown. A tick that
|
|
501
|
-
// cannot read them reports nothing rather than taking the stream down.
|
|
502
|
-
logger.warn("live telemetry could not read runtime facts", {
|
|
503
|
-
err: err instanceof Error ? err.message : String(err),
|
|
504
|
-
});
|
|
505
|
-
return null;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
function buildStats(facts, sampledAt,
|
|
509
|
-
// The whole held window, not the drained batch: buckets are computed on demand from the same
|
|
510
|
-
// records the snapshot replays.
|
|
511
|
-
records) {
|
|
512
|
-
return buildScannerStats({
|
|
513
|
-
epoch: timelineBuffer().epoch,
|
|
514
|
-
records,
|
|
515
|
-
scannerState: facts.scannerState,
|
|
516
|
-
bufferStartedAt,
|
|
517
|
-
sampledAt,
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
|
-
function buildTopology(facts) {
|
|
521
|
-
return buildStrategyTopology({
|
|
522
|
-
epoch: timelineBuffer().epoch,
|
|
523
|
-
strategies: facts.strategies,
|
|
524
|
-
runningRuntimeIds: facts.runningRuntimeIds,
|
|
525
|
-
unwiredRuntimeIds: facts.unwiredRuntimeIds,
|
|
526
|
-
readHealth: facts.readHealth,
|
|
527
|
-
now,
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
async function flushTimeline() {
|
|
531
|
-
// The scrub made this tick asynchronous, so two of them can be inside one interval. Same guard as
|
|
532
|
-
// `sample`: a tick still scrubbing keeps the drain, the batchSeq run and the frame order to
|
|
533
|
-
// itself, and the skipped tick's records come back on the next drain rather than being lost.
|
|
534
|
-
if (flushInFlight)
|
|
535
|
-
return;
|
|
536
|
-
flushInFlight = true;
|
|
537
|
-
const startedAt = monotonicMs();
|
|
538
|
-
try {
|
|
539
|
-
// The drain advances the publish cursor whether or not a handle exists, so a box nobody is
|
|
540
|
-
// watching does not build a backlog that lands as one flood on the first connect. The snapshot
|
|
541
|
-
// covers that client instead — `drain` evicts nothing.
|
|
542
|
-
const batch = published(timelineBuffer().drain());
|
|
543
|
-
if (batch.length > 0) {
|
|
544
|
-
await withSpan("telemetry.publish", {}, async (span) => {
|
|
545
|
-
const { frames, redactMs } = await toFrames(batch);
|
|
546
|
-
const sendStartedAt = monotonicMs();
|
|
547
|
-
let payloadBytes = 0;
|
|
548
|
-
let recordsFlushed = 0;
|
|
549
|
-
for (const records of frames) {
|
|
550
|
-
const payload = {
|
|
551
|
-
v: 1,
|
|
552
|
-
epoch: timelineBuffer().epoch,
|
|
553
|
-
batchSeq: ++batchSeq,
|
|
554
|
-
records,
|
|
555
|
-
};
|
|
556
|
-
// Measured on the frame handed to broadcast. A sum of record sizes is an intermediate
|
|
557
|
-
// that omits the envelope and the separators, and those are real bytes against the read
|
|
558
|
-
// limit.
|
|
559
|
-
const bytes = Buffer.byteLength(JSON.stringify(payload));
|
|
560
|
-
if (!publish(EVENT_TIMELINE, payload))
|
|
561
|
-
continue;
|
|
562
|
-
payloadBytes += bytes;
|
|
563
|
-
recordsFlushed += records.length;
|
|
564
|
-
}
|
|
565
|
-
span.setAttributes({
|
|
566
|
-
records_flushed: recordsFlushed,
|
|
567
|
-
payload_bytes: payloadBytes,
|
|
568
|
-
broadcast_ms: monotonicMs() - sendStartedAt,
|
|
569
|
-
redact_ms: redactMs,
|
|
570
|
-
});
|
|
571
|
-
timelineWindow.records += recordsFlushed;
|
|
572
|
-
timelineWindow.payloadBytes += payloadBytes;
|
|
573
|
-
});
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
catch (err) {
|
|
577
|
-
// A `setInterval` callback has nowhere to put a rejection, and a tick that dies takes the whole
|
|
578
|
-
// feed with it. Every step inside already guards itself, so reaching here is a bug worth a line.
|
|
579
|
-
if (!warnedFlushFailure) {
|
|
580
|
-
warnedFlushFailure = true;
|
|
581
|
-
logger.warn("live telemetry flush tick failed; this batch was lost", {
|
|
582
|
-
err: err instanceof Error ? err.message : String(err),
|
|
583
|
-
});
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
finally {
|
|
587
|
-
flushInFlight = false;
|
|
588
|
-
noteTick("timeline", monotonicMs() - startedAt, flushIntervalMs);
|
|
589
|
-
reportTiming();
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
function noteTickFrameFull() {
|
|
593
|
-
if (warnedTickFrameFull)
|
|
594
|
-
return;
|
|
595
|
-
warnedTickFrameFull = true;
|
|
596
|
-
logger.warn("live telemetry dropped a scanner tick because the tick frame was full", {
|
|
597
|
-
"senpi.telemetry.event": EVENT_TICK,
|
|
598
|
-
"senpi.telemetry.frame_limit_bytes": MAX_FRAME_BYTES,
|
|
599
|
-
});
|
|
600
|
-
}
|
|
601
|
-
/**
|
|
602
|
-
* Take one finished run into the open window.
|
|
603
|
-
*
|
|
604
|
-
* The first line is what makes this free on an unwatched box: with no broadcast handle there is
|
|
605
|
-
* nothing to publish through, so nothing is built, nothing is held and no timer is armed. A tick
|
|
606
|
-
* that lands then is gone — it is a level, and the next one replaces it anyway.
|
|
607
|
-
*
|
|
608
|
-
* The window is capped by the frame budget rather than by a count, so the cap is the same number
|
|
609
|
-
* the timeline splits on and there is no second budget to keep in step. A tick that does not fit is
|
|
610
|
-
* dropped and the window stays publishable: an oversized frame does not cost one tick, it closes the
|
|
611
|
-
* client's socket and costs the stream.
|
|
612
|
-
*/
|
|
613
|
-
function recordTick(run, startedAt, finishedAt) {
|
|
614
|
-
try {
|
|
615
|
-
if (broadcast === undefined || !tickSettings.enabled)
|
|
616
|
-
return;
|
|
617
|
-
const scannedCount = run.scannedCount;
|
|
618
|
-
const extras = run.tick ?? {};
|
|
619
|
-
const tick = {
|
|
620
|
-
// Normalised here for the same reason the buffer and the stats builder normalise: a client
|
|
621
|
-
// joins a tick to a stats row on `(address, scannerId)`, and a runtime's configured wallet
|
|
622
|
-
// may be checksummed where the record's is not.
|
|
623
|
-
address: normalizeStrategyAddress(run.address),
|
|
624
|
-
scannerId: run.scannerId,
|
|
625
|
-
finishedAt,
|
|
626
|
-
outcome: run.status,
|
|
627
|
-
...(typeof scannedCount === "number" && Number.isFinite(scannedCount)
|
|
628
|
-
? { scannedCount }
|
|
629
|
-
: {}),
|
|
630
|
-
signalCount: run.signalCount ?? run.signals?.length ?? 0,
|
|
631
|
-
...(startedAt === undefined ? {} : { durationMs: Math.max(0, finishedAt - startedAt) }),
|
|
632
|
-
...extras,
|
|
633
|
-
};
|
|
634
|
-
// Bytes, not entry count: one non-ASCII character in a scanner-authored outcome costs up to
|
|
635
|
-
// three of them, and the read limit at the far end counts bytes. The separator is counted with
|
|
636
|
-
// the tick it precedes, so the running total is the window's own size and not a sum under it.
|
|
637
|
-
const size = Buffer.byteLength(JSON.stringify(tick)) + (pendingTicks.length > 0 ? SEPARATOR_BYTES : 0);
|
|
638
|
-
if (pendingTickBytes + size > MAX_FRAME_BYTES) {
|
|
639
|
-
noteTickFrameFull();
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
pendingTicks.push(tick);
|
|
643
|
-
pendingTickBytes += size;
|
|
644
|
-
if (tickTimer !== undefined)
|
|
645
|
-
return;
|
|
646
|
-
tickTimer = setTimeout(() => {
|
|
647
|
-
tickTimer = undefined;
|
|
648
|
-
void flushTicks();
|
|
649
|
-
}, TICK_COALESCE_MS);
|
|
650
|
-
// Telemetry must never be the reason a process stays alive.
|
|
651
|
-
tickTimer.unref?.();
|
|
652
|
-
}
|
|
653
|
-
catch {
|
|
654
|
-
// This runs inside the scanner's terminal callback. Nothing here may reach it.
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
/**
|
|
658
|
-
* Close the window and publish it as one compact frame.
|
|
659
|
-
*
|
|
660
|
-
* Every scanner-authored string in the window — the run outcome, the author's own state values,
|
|
661
|
-
* the arguments a tool call carried — goes through the same scrub the stats payload's free text
|
|
662
|
-
* takes. The budget is met HERE and not when the run was admitted, because a redaction can be
|
|
663
|
-
* longer than the text it replaced and it is the scrubbed frame that goes out.
|
|
664
|
-
*
|
|
665
|
-
* `t0` is the first run's finish time, so every `t` in the frame is a small offset rather than a
|
|
666
|
-
* repeated 13-digit stamp.
|
|
667
|
-
*/
|
|
668
|
-
async function flushTicks() {
|
|
669
|
-
const batch = pendingTicks;
|
|
670
|
-
pendingTicks = [];
|
|
671
|
-
pendingTickBytes = tickEnvelopeBytes;
|
|
672
|
-
if (batch.length === 0 || broadcast === undefined)
|
|
673
|
-
return;
|
|
674
|
-
try {
|
|
675
|
-
const redactor = currentRedactor();
|
|
676
|
-
const scrub = (text) => scrubScannerText(text, redactor, noteScrubFailure);
|
|
677
|
-
const builder = createTickFrameBuilder({
|
|
678
|
-
epoch: timelineBuffer().epoch,
|
|
679
|
-
t0: batch[0].finishedAt,
|
|
680
|
-
settings: tickSettings,
|
|
681
|
-
maxBytes: MAX_FRAME_BYTES,
|
|
682
|
-
});
|
|
683
|
-
for (const tick of batch) {
|
|
684
|
-
// A run that does not fit is dropped on its own; the window stays publishable, because an
|
|
685
|
-
// oversized frame does not cost one tick — it closes the client's socket.
|
|
686
|
-
if (!(await builder.add(tick, scrub)))
|
|
687
|
-
noteTickFrameFull();
|
|
688
|
-
}
|
|
689
|
-
const frame = builder.build();
|
|
690
|
-
if (frame === null)
|
|
691
|
-
return;
|
|
692
|
-
publish(EVENT_TICK, frame);
|
|
693
|
-
}
|
|
694
|
-
catch (err) {
|
|
695
|
-
// A `setTimeout` callback has nowhere to put a rejection. Every step inside guards itself, so
|
|
696
|
-
// reaching here is a bug worth a line rather than an unhandled rejection.
|
|
697
|
-
if (!warnedTickFlushFailure) {
|
|
698
|
-
warnedTickFlushFailure = true;
|
|
699
|
-
logger.warn("live telemetry tick flush failed; this frame was lost", {
|
|
700
|
-
err: err instanceof Error ? err.message : String(err),
|
|
701
|
-
});
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
async function sample() {
|
|
706
|
-
// A gather slower than the tick would otherwise stack ticks on top of each other.
|
|
707
|
-
if (sampleInFlight)
|
|
708
|
-
return;
|
|
709
|
-
sampleInFlight = true;
|
|
710
|
-
const startedAt = monotonicMs();
|
|
711
|
-
try {
|
|
712
|
-
const facts = await readFacts();
|
|
713
|
-
if (facts === null)
|
|
714
|
-
return;
|
|
715
|
-
const stats = await withSpan("telemetry.stats.compute", {}, async (span) => {
|
|
716
|
-
// The tap counters are read here because this is the span that carries them: reading them on
|
|
717
|
-
// the faster tick would put a one-second window on a five-second measurement.
|
|
718
|
-
const tap = readAndResetTapCounters();
|
|
719
|
-
const records = timelineBuffer().read();
|
|
720
|
-
const built = buildStats(facts, now(), records);
|
|
721
|
-
const scrubStartedAt = monotonicMs();
|
|
722
|
-
const payload = await scrubStatsPayload(built, currentRedactor(), noteScrubFailure);
|
|
723
|
-
const redactMs = monotonicMs() - scrubStartedAt;
|
|
724
|
-
// Measured on the scrubbed payload: capping the free text is what decides the size, and the
|
|
725
|
-
// pre-scrub number is not the one that meets the read limit.
|
|
726
|
-
const payloadBytes = Buffer.byteLength(JSON.stringify(payload));
|
|
727
|
-
const bucketsEmitted = payload.scanners.reduce((n, entry) => n + entry.buckets.length, 0);
|
|
728
|
-
span.setAttributes({
|
|
729
|
-
records_scanned: records.length,
|
|
730
|
-
scanners: payload.scanners.length,
|
|
731
|
-
buckets_emitted: bucketsEmitted,
|
|
732
|
-
payload_bytes: payloadBytes,
|
|
733
|
-
tap_ns_total: tap.nsTotal,
|
|
734
|
-
tap_calls: tap.calls,
|
|
735
|
-
tap_ns_p_max: tap.nsMax,
|
|
736
|
-
redact_ms: redactMs,
|
|
737
|
-
});
|
|
738
|
-
statsWindow.recordsScanned += records.length;
|
|
739
|
-
if (payloadBytes > statsWindow.payloadBytesMax)
|
|
740
|
-
statsWindow.payloadBytesMax = payloadBytes;
|
|
741
|
-
tapWindow.calls += tap.calls;
|
|
742
|
-
tapWindow.nsTotal += tap.nsTotal;
|
|
743
|
-
if (tap.nsMax > tapWindow.nsMax)
|
|
744
|
-
tapWindow.nsMax = tap.nsMax;
|
|
745
|
-
return payload;
|
|
746
|
-
});
|
|
747
|
-
publish(EVENT_STATS, stats);
|
|
748
|
-
const topology = buildTopology(facts);
|
|
749
|
-
const signature = topologySignature(topology);
|
|
750
|
-
if (signature === lastTopologySignature)
|
|
751
|
-
return;
|
|
752
|
-
// Remembered only once the frame actually left. The ticks start before any client connects, so
|
|
753
|
-
// the first topology is built with no handle to send it through — recording that as delivered
|
|
754
|
-
// would suppress every later publish of a topology that then never changes again.
|
|
755
|
-
if (publish(EVENT_TOPOLOGY, topology))
|
|
756
|
-
lastTopologySignature = signature;
|
|
757
|
-
}
|
|
758
|
-
finally {
|
|
759
|
-
sampleInFlight = false;
|
|
760
|
-
// The whole tick, including the runtime read: that is what has to fit the interval, and it is
|
|
761
|
-
// the half the compute span cannot see.
|
|
762
|
-
noteTick("stats", monotonicMs() - startedAt, sampleIntervalMs);
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
return {
|
|
766
|
-
// A getter for the same reason the drain resolves the buffer per append: a reload replaces
|
|
767
|
-
// the singleton, and a value frozen at construction would name an epoch whose seq run ended.
|
|
768
|
-
get epoch() {
|
|
769
|
-
return timelineBuffer().epoch;
|
|
770
|
-
},
|
|
771
|
-
captureBroadcast(ctx, methodName) {
|
|
772
|
-
if (broadcast !== undefined)
|
|
773
|
-
return;
|
|
774
|
-
const candidate = ctx?.context
|
|
775
|
-
?.broadcast;
|
|
776
|
-
if (typeof candidate !== "function")
|
|
777
|
-
return;
|
|
778
|
-
broadcast = candidate;
|
|
779
|
-
warnedNoBroadcast = false;
|
|
780
|
-
logger.info("live telemetry captured the gateway broadcast handle", {
|
|
781
|
-
"senpi.gateway.method": methodName,
|
|
782
|
-
});
|
|
783
|
-
},
|
|
784
|
-
async snapshot(params) {
|
|
785
|
-
// A seq only means something inside its epoch. A cursor minted by a previous run names records
|
|
786
|
-
// this one never had — most sharply after a restart, where the client's seq sits far above a
|
|
787
|
-
// counter that began again at 1 and "nothing after it" would hide the whole window. So unless
|
|
788
|
-
// the cursor names this epoch it is dropped entirely and everything held is served.
|
|
789
|
-
const afterSeq = params?.afterEpoch === timelineBuffer().epoch ? normalizeAfterSeq(params?.afterSeq) : 0;
|
|
790
|
-
const held = timelineBuffer().read();
|
|
791
|
-
// A cursor below the eviction frontier lost a record. The oldest record still held cannot
|
|
792
|
-
// answer this: the rings are per address, so one strategy's untouched low seqs sit under
|
|
793
|
-
// another's evictions. `afterSeq: 0` is a fresh client asking for whatever is held, not a
|
|
794
|
-
// resume, so it is never a gap.
|
|
795
|
-
let truncated = afterSeq > 0 && timelineBuffer().highestDroppedSeq() > afterSeq;
|
|
796
|
-
const owed = published(held.filter((record) => record.seq > afterSeq));
|
|
797
|
-
const { frames } = await toFrames(owed);
|
|
798
|
-
// A response is one frame, so an over-budget window sends the newest slice and says so.
|
|
799
|
-
const records = frames.length === 0 ? [] : frames[frames.length - 1];
|
|
800
|
-
if (frames.length > 1)
|
|
801
|
-
truncated = true;
|
|
802
|
-
// A snapshot still answers when the facts are unreadable: the recent window is the part a
|
|
803
|
-
// joining client cannot reconstruct, and it comes from the buffer alone.
|
|
804
|
-
const facts = (await readFacts()) ?? NO_FACTS;
|
|
805
|
-
const sampledAt = now();
|
|
806
|
-
return {
|
|
807
|
-
epoch: timelineBuffer().epoch,
|
|
808
|
-
topology: buildTopology(facts),
|
|
809
|
-
stats: await scrubStatsPayload(buildStats(facts, sampledAt, held), currentRedactor(), noteScrubFailure),
|
|
810
|
-
recent: { v: 1, epoch: timelineBuffer().epoch, batchSeq: ++batchSeq, records },
|
|
811
|
-
truncated,
|
|
812
|
-
};
|
|
813
|
-
},
|
|
814
|
-
flushTimeline,
|
|
815
|
-
sample,
|
|
816
|
-
recordTick,
|
|
817
|
-
flushTicks,
|
|
818
|
-
start() {
|
|
819
|
-
// Armed here rather than at construction: the sink is process-global, so a publisher that was
|
|
820
|
-
// built but never started must not be the one the scanners feed.
|
|
821
|
-
setScannerTickSink(recordTick);
|
|
822
|
-
if (flushTimer !== undefined)
|
|
823
|
-
return;
|
|
824
|
-
flushTimer = setInterval(() => void flushTimeline(), flushIntervalMs);
|
|
825
|
-
sampleTimer = setInterval(() => void sample(), sampleIntervalMs);
|
|
826
|
-
// Telemetry must never be the reason a process stays alive.
|
|
827
|
-
flushTimer.unref?.();
|
|
828
|
-
sampleTimer.unref?.();
|
|
829
|
-
},
|
|
830
|
-
stop() {
|
|
831
|
-
setScannerTickSink(null);
|
|
832
|
-
if (flushTimer !== undefined)
|
|
833
|
-
clearInterval(flushTimer);
|
|
834
|
-
if (sampleTimer !== undefined)
|
|
835
|
-
clearInterval(sampleTimer);
|
|
836
|
-
if (tickTimer !== undefined)
|
|
837
|
-
clearTimeout(tickTimer);
|
|
838
|
-
flushTimer = undefined;
|
|
839
|
-
sampleTimer = undefined;
|
|
840
|
-
tickTimer = undefined;
|
|
841
|
-
pendingTicks = [];
|
|
842
|
-
pendingTickBytes = tickEnvelopeBytes;
|
|
843
|
-
broadcast = undefined;
|
|
844
|
-
lastTopologySignature = undefined;
|
|
845
|
-
timelineBuffer().teardown();
|
|
846
|
-
// The next reader mints a fresh epoch, so a restarted seq run cannot collide with the one a
|
|
847
|
-
// client already holds.
|
|
848
|
-
if (ownsProcessBuffer)
|
|
849
|
-
teardownTimelineBuffer();
|
|
850
|
-
},
|
|
851
|
-
};
|
|
852
|
-
}
|
|
853
|
-
//# sourceMappingURL=live-telemetry.js.map
|