@senpi/runtime 3.0.123 → 3.0.124

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.
Files changed (252) hide show
  1. package/dist/actions/action-events.d.ts +7 -21
  2. package/dist/actions/action-events.d.ts.map +1 -1
  3. package/dist/actions/action-events.js +26 -54
  4. package/dist/actions/action-events.js.map +1 -1
  5. package/dist/actions/action-runtime-module.d.ts.map +1 -1
  6. package/dist/actions/action-runtime-module.js +7 -38
  7. package/dist/actions/action-runtime-module.js.map +1 -1
  8. package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
  9. package/dist/actions/close-position/close-position.action.js +15 -122
  10. package/dist/actions/close-position/close-position.action.js.map +1 -1
  11. package/dist/actions/decision-engine.d.ts.map +1 -1
  12. package/dist/actions/decision-engine.js +2 -15
  13. package/dist/actions/decision-engine.js.map +1 -1
  14. package/dist/actions/event-derivation.d.ts +2 -71
  15. package/dist/actions/event-derivation.d.ts.map +1 -1
  16. package/dist/actions/event-derivation.js +8 -251
  17. package/dist/actions/event-derivation.js.map +1 -1
  18. package/dist/actions/open-position/open-position.action.d.ts +2 -17
  19. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  20. package/dist/actions/open-position/open-position.action.js +47 -162
  21. package/dist/actions/open-position/open-position.action.js.map +1 -1
  22. package/dist/actions/position-tracker/position-tracker.action.d.ts +1 -2
  23. package/dist/actions/position-tracker/position-tracker.action.d.ts.map +1 -1
  24. package/dist/actions/position-tracker/position-tracker.action.js +17 -62
  25. package/dist/actions/position-tracker/position-tracker.action.js.map +1 -1
  26. package/dist/actions/store/types.d.ts +2 -9
  27. package/dist/actions/store/types.d.ts.map +1 -1
  28. package/dist/actions/store/types.js.map +1 -1
  29. package/dist/actions/types.d.ts +0 -13
  30. package/dist/actions/types.d.ts.map +1 -1
  31. package/dist/actions/types.js +0 -13
  32. package/dist/actions/types.js.map +1 -1
  33. package/dist/cli/guide-commands.d.ts.map +1 -1
  34. package/dist/cli/guide-commands.js +0 -3
  35. package/dist/cli/guide-commands.js.map +1 -1
  36. package/dist/dsl/engine-client/event-mapping.d.ts.map +1 -1
  37. package/dist/dsl/engine-client/event-mapping.js +0 -14
  38. package/dist/dsl/engine-client/event-mapping.js.map +1 -1
  39. package/dist/dsl/events/build-closed-event.d.ts +1 -16
  40. package/dist/dsl/events/build-closed-event.d.ts.map +1 -1
  41. package/dist/dsl/events/build-closed-event.js +0 -6
  42. package/dist/dsl/events/build-closed-event.js.map +1 -1
  43. package/dist/dsl/events/handlers.d.ts +0 -7
  44. package/dist/dsl/events/handlers.d.ts.map +1 -1
  45. package/dist/dsl/events/handlers.js +7 -37
  46. package/dist/dsl/events/handlers.js.map +1 -1
  47. package/dist/dsl/monitor/create-monitor.d.ts +2 -6
  48. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  49. package/dist/dsl/monitor/create-monitor.js +9 -15
  50. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  51. package/dist/dsl/monitor/dsl-failure-events.d.ts +0 -2
  52. package/dist/dsl/monitor/dsl-failure-events.d.ts.map +1 -1
  53. package/dist/dsl/monitor/dsl-failure-events.js +1 -2
  54. package/dist/dsl/monitor/dsl-failure-events.js.map +1 -1
  55. package/dist/dsl/monitor/process-one-position.d.ts +0 -1
  56. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  57. package/dist/dsl/monitor/process-one-position.js +25 -59
  58. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  59. package/dist/dsl/plugin/index.d.ts +2 -8
  60. package/dist/dsl/plugin/index.d.ts.map +1 -1
  61. package/dist/dsl/plugin/index.js +6 -8
  62. package/dist/dsl/plugin/index.js.map +1 -1
  63. package/dist/health/types.d.ts +0 -13
  64. package/dist/health/types.d.ts.map +1 -1
  65. package/dist/health/types.js +0 -12
  66. package/dist/health/types.js.map +1 -1
  67. package/dist/index.d.ts +4 -27
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +42 -254
  70. package/dist/index.js.map +1 -1
  71. package/dist/runtime/action-registry.d.ts.map +1 -1
  72. package/dist/runtime/action-registry.js +3 -4
  73. package/dist/runtime/action-registry.js.map +1 -1
  74. package/dist/runtime/domain-events.d.ts.map +1 -1
  75. package/dist/runtime/domain-events.js +0 -7
  76. package/dist/runtime/domain-events.js.map +1 -1
  77. package/dist/runtime/lifecycle-events.d.ts +1 -9
  78. package/dist/runtime/lifecycle-events.d.ts.map +1 -1
  79. package/dist/runtime/lifecycle-events.js +2 -11
  80. package/dist/runtime/lifecycle-events.js.map +1 -1
  81. package/dist/runtime/reconcile-run.d.ts +3 -13
  82. package/dist/runtime/reconcile-run.d.ts.map +1 -1
  83. package/dist/runtime/reconcile-run.js +3 -4
  84. package/dist/runtime/reconcile-run.js.map +1 -1
  85. package/dist/runtime/reconcile-state.d.ts +0 -8
  86. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  87. package/dist/runtime/reconcile-state.js +1 -4
  88. package/dist/runtime/reconcile-state.js.map +1 -1
  89. package/dist/runtime/run.d.ts +0 -1
  90. package/dist/runtime/run.d.ts.map +1 -1
  91. package/dist/runtime/run.js.map +1 -1
  92. package/dist/runtime/runtime-schema.js +3 -3
  93. package/dist/runtime/runtime-schema.js.map +1 -1
  94. package/dist/runtime/runtime.d.ts +0 -1
  95. package/dist/runtime/runtime.d.ts.map +1 -1
  96. package/dist/runtime/runtime.js +3 -27
  97. package/dist/runtime/runtime.js.map +1 -1
  98. package/dist/runtime/supervisor/long-running-supervisor.d.ts +0 -13
  99. package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
  100. package/dist/runtime/supervisor/long-running-supervisor.js +0 -1
  101. package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
  102. package/dist/runtime/validate-dsl-position-tracking.d.ts.map +1 -1
  103. package/dist/runtime/validate-dsl-position-tracking.js +1 -2
  104. package/dist/runtime/validate-dsl-position-tracking.js.map +1 -1
  105. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  106. package/dist/runtime-api/handlers/ingest-signals.js +0 -1
  107. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  108. package/dist/runtime-api/handlers/types.d.ts +0 -5
  109. package/dist/runtime-api/handlers/types.d.ts.map +1 -1
  110. package/dist/runtime-api/intake/intake-events.d.ts +1 -1
  111. package/dist/runtime-api/intake/intake-events.d.ts.map +1 -1
  112. package/dist/runtime-api/intake/intake-events.js +3 -9
  113. package/dist/runtime-api/intake/intake-events.js.map +1 -1
  114. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  115. package/dist/runtime-api/intake/intake-mount.js +19 -4
  116. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  117. package/dist/runtime-api/intake/process-error-post.d.ts +1 -6
  118. package/dist/runtime-api/intake/process-error-post.d.ts.map +1 -1
  119. package/dist/runtime-api/intake/process-error-post.js +1 -5
  120. package/dist/runtime-api/intake/process-error-post.js.map +1 -1
  121. package/dist/runtime-api/intake/process-signal-post.d.ts +1 -7
  122. package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
  123. package/dist/runtime-api/intake/process-signal-post.js +0 -5
  124. package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
  125. package/dist/runtime-api/intake/scaffold-error-router.js.map +1 -1
  126. package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
  127. package/dist/runtime-api/intake/scanner-launcher.js +0 -15
  128. package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
  129. package/dist/runtime-api/intake/types.d.ts +0 -55
  130. package/dist/runtime-api/intake/types.d.ts.map +1 -1
  131. package/dist/runtime-api/routes/signals.d.ts +0 -2
  132. package/dist/runtime-api/routes/signals.d.ts.map +1 -1
  133. package/dist/runtime-api/routes/signals.js +1 -5
  134. package/dist/runtime-api/routes/signals.js.map +1 -1
  135. package/dist/runtime-api/routes/signals.schema.d.ts +0 -43
  136. package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
  137. package/dist/runtime-api/routes/signals.schema.js +0 -43
  138. package/dist/runtime-api/routes/signals.schema.js.map +1 -1
  139. package/dist/runtime-api/server.d.ts +1 -7
  140. package/dist/runtime-api/server.d.ts.map +1 -1
  141. package/dist/runtime-api/server.js +0 -1
  142. package/dist/runtime-api/server.js.map +1 -1
  143. package/dist/scanners/engine/engine.d.ts +1 -22
  144. package/dist/scanners/engine/engine.d.ts.map +1 -1
  145. package/dist/scanners/engine/engine.js +11 -78
  146. package/dist/scanners/engine/engine.js.map +1 -1
  147. package/dist/scanners/engine/lifecycle.d.ts +1 -7
  148. package/dist/scanners/engine/lifecycle.d.ts.map +1 -1
  149. package/dist/scanners/events.d.ts +0 -4
  150. package/dist/scanners/events.d.ts.map +1 -1
  151. package/dist/scanners/events.js +0 -2
  152. package/dist/scanners/events.js.map +1 -1
  153. package/dist/scanners/external/scaffold/delivery.py +25 -194
  154. package/dist/scanners/external/scaffold/scaffold.py +31 -172
  155. package/dist/scanners/external/scaffold/senpi_mcp.py +8 -150
  156. package/dist/scanners/external-scanner-receiver.d.ts +0 -17
  157. package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
  158. package/dist/scanners/external-scanner-receiver.js +0 -8
  159. package/dist/scanners/external-scanner-receiver.js.map +1 -1
  160. package/dist/scanners/runtime-module.d.ts +0 -1
  161. package/dist/scanners/runtime-module.d.ts.map +1 -1
  162. package/dist/scanners/runtime-module.js +9 -66
  163. package/dist/scanners/runtime-module.js.map +1 -1
  164. package/dist/telemetry/attributes.d.ts +0 -20
  165. package/dist/telemetry/attributes.d.ts.map +1 -1
  166. package/dist/telemetry/attributes.js +0 -24
  167. package/dist/telemetry/attributes.js.map +1 -1
  168. package/dist/telemetry/event-builders.d.ts +6 -81
  169. package/dist/telemetry/event-builders.d.ts.map +1 -1
  170. package/dist/telemetry/event-builders.js +17 -141
  171. package/dist/telemetry/event-builders.js.map +1 -1
  172. package/dist/telemetry/event-store.d.ts +0 -6
  173. package/dist/telemetry/event-store.d.ts.map +1 -1
  174. package/dist/telemetry/event-store.js +8 -12
  175. package/dist/telemetry/event-store.js.map +1 -1
  176. package/dist/telemetry/metrics.d.ts +2 -14
  177. package/dist/telemetry/metrics.d.ts.map +1 -1
  178. package/dist/telemetry/metrics.js +4 -19
  179. package/dist/telemetry/metrics.js.map +1 -1
  180. package/dist/telemetry/spans/redact-pii.d.ts.map +1 -1
  181. package/dist/telemetry/spans/redact-pii.js +2 -18
  182. package/dist/telemetry/spans/redact-pii.js.map +1 -1
  183. package/dist/telemetry/tracing.d.ts +0 -9
  184. package/dist/telemetry/tracing.d.ts.map +1 -1
  185. package/dist/telemetry/tracing.js +0 -36
  186. package/dist/telemetry/tracing.js.map +1 -1
  187. package/dist/types/dsl/index.d.ts +0 -11
  188. package/dist/types/dsl/index.d.ts.map +1 -1
  189. package/dist/types/scanner.d.ts +0 -15
  190. package/dist/types/scanner.d.ts.map +1 -1
  191. package/dist/utils/event-catalog.d.ts +0 -166
  192. package/dist/utils/event-catalog.d.ts.map +1 -1
  193. package/dist/utils/event-catalog.js +0 -18
  194. package/dist/utils/event-catalog.js.map +1 -1
  195. package/dist/utils/logger.d.ts +1 -4
  196. package/dist/utils/logger.d.ts.map +1 -1
  197. package/dist/utils/logger.js.map +1 -1
  198. package/dist/validate/recipe-checks.d.ts.map +1 -1
  199. package/dist/validate/recipe-checks.js +2 -2
  200. package/dist/validate/recipe-checks.js.map +1 -1
  201. package/openclaw.plugin.json +1 -1
  202. package/package.json +1 -1
  203. package/dist/actions/skipped-signal-results.d.ts +0 -25
  204. package/dist/actions/skipped-signal-results.d.ts.map +0 -1
  205. package/dist/actions/skipped-signal-results.js +0 -71
  206. package/dist/actions/skipped-signal-results.js.map +0 -1
  207. package/dist/runtime-api/intake/ingest-request.d.ts +0 -17
  208. package/dist/runtime-api/intake/ingest-request.d.ts.map +0 -1
  209. package/dist/runtime-api/intake/ingest-request.js +0 -34
  210. package/dist/runtime-api/intake/ingest-request.js.map +0 -1
  211. package/dist/runtime-api/intake/scaffold-tick-router.d.ts +0 -39
  212. package/dist/runtime-api/intake/scaffold-tick-router.d.ts.map +0 -1
  213. package/dist/runtime-api/intake/scaffold-tick-router.js +0 -122
  214. package/dist/runtime-api/intake/scaffold-tick-router.js.map +0 -1
  215. package/dist/scanners/external/scaffold/tick_facts.py +0 -130
  216. package/dist/scanners/external/scaffold/ulid.py +0 -58
  217. package/dist/telemetry/live-telemetry.d.ts +0 -238
  218. package/dist/telemetry/live-telemetry.d.ts.map +0 -1
  219. package/dist/telemetry/live-telemetry.js +0 -853
  220. package/dist/telemetry/live-telemetry.js.map +0 -1
  221. package/dist/telemetry/scanner-stats.d.ts +0 -143
  222. package/dist/telemetry/scanner-stats.d.ts.map +0 -1
  223. package/dist/telemetry/scanner-stats.js +0 -426
  224. package/dist/telemetry/scanner-stats.js.map +0 -1
  225. package/dist/telemetry/scanner-tick-feed.d.ts +0 -87
  226. package/dist/telemetry/scanner-tick-feed.d.ts.map +0 -1
  227. package/dist/telemetry/scanner-tick-feed.js +0 -48
  228. package/dist/telemetry/scanner-tick-feed.js.map +0 -1
  229. package/dist/telemetry/strategy-topology.d.ts +0 -99
  230. package/dist/telemetry/strategy-topology.d.ts.map +0 -1
  231. package/dist/telemetry/strategy-topology.js +0 -93
  232. package/dist/telemetry/strategy-topology.js.map +0 -1
  233. package/dist/telemetry/telemetry-publisher.d.ts +0 -63
  234. package/dist/telemetry/telemetry-publisher.d.ts.map +0 -1
  235. package/dist/telemetry/telemetry-publisher.js +0 -229
  236. package/dist/telemetry/telemetry-publisher.js.map +0 -1
  237. package/dist/telemetry/tick-frame.d.ts +0 -212
  238. package/dist/telemetry/tick-frame.d.ts.map +0 -1
  239. package/dist/telemetry/tick-frame.js +0 -421
  240. package/dist/telemetry/tick-frame.js.map +0 -1
  241. package/dist/telemetry/timeline-buffer.d.ts +0 -95
  242. package/dist/telemetry/timeline-buffer.d.ts.map +0 -1
  243. package/dist/telemetry/timeline-buffer.js +0 -237
  244. package/dist/telemetry/timeline-buffer.js.map +0 -1
  245. package/dist/utils/telemetry-required-log.d.ts +0 -60
  246. package/dist/utils/telemetry-required-log.d.ts.map +0 -1
  247. package/dist/utils/telemetry-required-log.js +0 -105
  248. package/dist/utils/telemetry-required-log.js.map +0 -1
  249. package/dist/utils/ulid.d.ts +0 -9
  250. package/dist/utils/ulid.d.ts.map +0 -1
  251. package/dist/utils/ulid.js +0 -43
  252. 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