@manyoars/oars 0.2.0 → 0.4.0

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 (320) hide show
  1. package/README.md +147 -28
  2. package/dist/api.js +1 -1
  3. package/dist/bin.js +6 -29
  4. package/dist/cli.d.ts +10 -4
  5. package/dist/cli.js +440 -91
  6. package/dist/commands/keys.d.ts +31 -0
  7. package/dist/commands/keys.js +65 -0
  8. package/dist/commands/login.d.ts +1 -1
  9. package/dist/commands/login.js +3 -3
  10. package/dist/errors.d.ts +13 -13
  11. package/dist/errors.js +28 -11
  12. package/dist/gate/checks.d.ts +68 -0
  13. package/dist/gate/checks.js +584 -0
  14. package/dist/gate/command.d.ts +14 -0
  15. package/dist/gate/command.js +61 -0
  16. package/dist/gate/hook-fill-main.d.ts +1 -0
  17. package/dist/gate/hook-fill-main.js +16 -0
  18. package/dist/gate/hook-fill.d.ts +38 -0
  19. package/dist/gate/hook-fill.js +94 -0
  20. package/dist/gate/hook-main.d.ts +1 -0
  21. package/dist/gate/hook-main.js +47 -0
  22. package/dist/gate/hook-process.d.ts +24 -0
  23. package/dist/gate/hook-process.js +112 -0
  24. package/dist/gate/hook-record.d.ts +3 -0
  25. package/dist/gate/hook-record.js +8 -0
  26. package/dist/gate/hook.d.ts +48 -0
  27. package/dist/gate/hook.js +259 -0
  28. package/dist/gate/install.d.ts +25 -0
  29. package/dist/gate/install.js +126 -0
  30. package/dist/gate/local-bin.d.ts +8 -0
  31. package/dist/gate/local-bin.js +36 -0
  32. package/dist/gate/notes.d.ts +19 -0
  33. package/dist/gate/notes.js +27 -0
  34. package/dist/gate/novelty.d.ts +35 -0
  35. package/dist/gate/novelty.js +99 -0
  36. package/dist/gate/policy.d.ts +25 -0
  37. package/dist/gate/policy.js +73 -0
  38. package/dist/gate/record.d.ts +120 -0
  39. package/dist/gate/record.js +125 -0
  40. package/dist/gate/registry-state.d.ts +10 -0
  41. package/dist/gate/registry-state.js +30 -0
  42. package/dist/gate/registry.d.ts +56 -0
  43. package/dist/gate/registry.js +94 -0
  44. package/dist/gate/replay.d.ts +20 -0
  45. package/dist/gate/replay.js +254 -0
  46. package/dist/gate/report.d.ts +50 -0
  47. package/dist/gate/report.js +89 -0
  48. package/dist/gate/scope.d.ts +7 -0
  49. package/dist/gate/scope.js +27 -0
  50. package/dist/gate/transcripts.d.ts +68 -0
  51. package/dist/gate/transcripts.js +232 -0
  52. package/dist/hook-bin.d.ts +1 -0
  53. package/dist/hook-bin.js +50 -0
  54. package/dist/issuer.d.ts +10 -6
  55. package/dist/issuer.js +19 -24
  56. package/dist/main.d.ts +1 -0
  57. package/dist/main.js +67 -0
  58. package/dist/oidc.js +1 -1
  59. package/dist/sessions/actions.d.ts +35 -0
  60. package/dist/sessions/actions.js +429 -0
  61. package/dist/sessions/allow.d.ts +23 -0
  62. package/dist/sessions/allow.js +71 -0
  63. package/dist/sessions/archive.d.ts +11 -0
  64. package/dist/sessions/archive.js +38 -0
  65. package/dist/sessions/audit.d.ts +42 -0
  66. package/dist/sessions/audit.js +104 -0
  67. package/dist/sessions/baseline.d.ts +62 -0
  68. package/dist/sessions/baseline.js +190 -0
  69. package/dist/sessions/call-shape.d.ts +19 -0
  70. package/dist/sessions/call-shape.js +85 -0
  71. package/dist/sessions/capture-runtime.d.ts +10 -0
  72. package/dist/sessions/capture-runtime.js +12 -0
  73. package/dist/sessions/cell-increments.d.ts +47 -0
  74. package/dist/sessions/cell-increments.js +243 -0
  75. package/dist/sessions/cells.d.ts +109 -0
  76. package/dist/sessions/cells.js +261 -0
  77. package/dist/sessions/codex.d.ts +53 -0
  78. package/dist/sessions/codex.js +745 -0
  79. package/dist/sessions/command.d.ts +127 -0
  80. package/dist/sessions/command.js +419 -0
  81. package/dist/sessions/copilot.d.ts +40 -0
  82. package/dist/sessions/copilot.js +432 -0
  83. package/dist/sessions/coverage.d.ts +19 -0
  84. package/dist/sessions/coverage.js +56 -0
  85. package/dist/sessions/cursor.d.ts +24 -0
  86. package/dist/sessions/cursor.js +324 -0
  87. package/dist/sessions/derive.d.ts +57 -0
  88. package/dist/sessions/derive.js +386 -0
  89. package/dist/sessions/exclusion.d.ts +87 -0
  90. package/dist/sessions/exclusion.js +361 -0
  91. package/dist/sessions/extract.d.ts +181 -0
  92. package/dist/sessions/extract.js +1806 -0
  93. package/dist/sessions/fold.d.ts +17 -0
  94. package/dist/sessions/fold.js +71 -0
  95. package/dist/sessions/frame.d.ts +24 -0
  96. package/dist/sessions/frame.js +95 -0
  97. package/dist/sessions/gemini.d.ts +37 -0
  98. package/dist/sessions/gemini.js +613 -0
  99. package/dist/sessions/gold-arms.d.ts +67 -0
  100. package/dist/sessions/gold-arms.js +172 -0
  101. package/dist/sessions/gold.d.ts +197 -0
  102. package/dist/sessions/gold.js +463 -0
  103. package/dist/sessions/graph.d.ts +12 -0
  104. package/dist/sessions/graph.js +34 -0
  105. package/dist/sessions/grok.d.ts +36 -0
  106. package/dist/sessions/grok.js +421 -0
  107. package/dist/sessions/hook-calls.d.ts +3 -0
  108. package/dist/sessions/hook-calls.js +47 -0
  109. package/dist/sessions/hook-index.d.ts +66 -0
  110. package/dist/sessions/hook-index.js +164 -0
  111. package/dist/sessions/hooks.d.ts +17 -0
  112. package/dist/sessions/hooks.js +281 -0
  113. package/dist/sessions/identity.d.ts +27 -0
  114. package/dist/sessions/identity.js +131 -0
  115. package/dist/sessions/install.d.ts +70 -0
  116. package/dist/sessions/install.js +246 -0
  117. package/dist/sessions/lineage.d.ts +19 -0
  118. package/dist/sessions/lineage.js +118 -0
  119. package/dist/sessions/mcp.d.ts +7 -0
  120. package/dist/sessions/mcp.js +157 -0
  121. package/dist/sessions/merkle.d.ts +7 -0
  122. package/dist/sessions/merkle.js +28 -0
  123. package/dist/sessions/motif.d.ts +44 -0
  124. package/dist/sessions/motif.js +59 -0
  125. package/dist/sessions/opencode.d.ts +33 -0
  126. package/dist/sessions/opencode.js +319 -0
  127. package/dist/sessions/otel.d.ts +60 -0
  128. package/dist/sessions/otel.js +494 -0
  129. package/dist/sessions/package-books.d.ts +48 -0
  130. package/dist/sessions/package-books.js +120 -0
  131. package/dist/sessions/package-resolve.d.ts +32 -0
  132. package/dist/sessions/package-resolve.js +185 -0
  133. package/dist/sessions/pending.d.ts +34 -0
  134. package/dist/sessions/pending.js +142 -0
  135. package/dist/sessions/pi.d.ts +36 -0
  136. package/dist/sessions/pi.js +353 -0
  137. package/dist/sessions/pool-books.d.ts +21 -0
  138. package/dist/sessions/pool-books.js +75 -0
  139. package/dist/sessions/pool-choices.d.ts +5 -0
  140. package/dist/sessions/pool-choices.js +11 -0
  141. package/dist/sessions/pool-command.d.ts +115 -0
  142. package/dist/sessions/pool-command.js +669 -0
  143. package/dist/sessions/pool-delta.d.ts +54 -0
  144. package/dist/sessions/pool-delta.js +190 -0
  145. package/dist/sessions/pool-nightly-deps.d.ts +3 -0
  146. package/dist/sessions/pool-nightly-deps.js +11 -0
  147. package/dist/sessions/pool-nightly.d.ts +10 -0
  148. package/dist/sessions/pool-nightly.js +85 -0
  149. package/dist/sessions/pool-outbox.d.ts +76 -0
  150. package/dist/sessions/pool-outbox.js +313 -0
  151. package/dist/sessions/pool-print.d.ts +26 -0
  152. package/dist/sessions/pool-print.js +68 -0
  153. package/dist/sessions/pool-raw.d.ts +27 -0
  154. package/dist/sessions/pool-raw.js +68 -0
  155. package/dist/sessions/pool-replica.d.ts +35 -0
  156. package/dist/sessions/pool-replica.js +91 -0
  157. package/dist/sessions/pool-send-lock.d.ts +20 -0
  158. package/dist/sessions/pool-send-lock.js +87 -0
  159. package/dist/sessions/pool-sent.d.ts +4 -0
  160. package/dist/sessions/pool-sent.js +26 -0
  161. package/dist/sessions/pool-totals.d.ts +10 -0
  162. package/dist/sessions/pool-totals.js +21 -0
  163. package/dist/sessions/pool-wire.d.ts +435 -0
  164. package/dist/sessions/pool-wire.js +1100 -0
  165. package/dist/sessions/pool.d.ts +32 -0
  166. package/dist/sessions/pool.js +164 -0
  167. package/dist/sessions/projection.d.ts +75 -0
  168. package/dist/sessions/projection.js +165 -0
  169. package/dist/sessions/public-vocabulary.d.ts +72 -0
  170. package/dist/sessions/public-vocabulary.js +295 -0
  171. package/dist/sessions/query.d.ts +123 -0
  172. package/dist/sessions/query.js +268 -0
  173. package/dist/sessions/read-adoption.d.ts +2 -0
  174. package/dist/sessions/read-adoption.js +27 -0
  175. package/dist/sessions/read-call-shape.d.ts +2 -0
  176. package/dist/sessions/read-call-shape.js +25 -0
  177. package/dist/sessions/read-change.d.ts +2 -0
  178. package/dist/sessions/read-change.js +30 -0
  179. package/dist/sessions/read-cost.d.ts +4 -0
  180. package/dist/sessions/read-cost.js +48 -0
  181. package/dist/sessions/read-day-move.d.ts +3 -0
  182. package/dist/sessions/read-day-move.js +65 -0
  183. package/dist/sessions/read-fit.d.ts +7 -0
  184. package/dist/sessions/read-fit.js +41 -0
  185. package/dist/sessions/read-interaction.d.ts +5 -0
  186. package/dist/sessions/read-interaction.js +62 -0
  187. package/dist/sessions/read-lead-lag.d.ts +15 -0
  188. package/dist/sessions/read-lead-lag.js +81 -0
  189. package/dist/sessions/read-live.d.ts +6 -0
  190. package/dist/sessions/read-live.js +38 -0
  191. package/dist/sessions/read-method.d.ts +2 -0
  192. package/dist/sessions/read-method.js +29 -0
  193. package/dist/sessions/read-next-step.d.ts +2 -0
  194. package/dist/sessions/read-next-step.js +35 -0
  195. package/dist/sessions/read-order.d.ts +54 -0
  196. package/dist/sessions/read-order.js +62 -0
  197. package/dist/sessions/read-readable.d.ts +8 -0
  198. package/dist/sessions/read-readable.js +24 -0
  199. package/dist/sessions/read-recovery-edge.d.ts +2 -0
  200. package/dist/sessions/read-recovery-edge.js +26 -0
  201. package/dist/sessions/read-recovery.d.ts +20 -0
  202. package/dist/sessions/read-recovery.js +97 -0
  203. package/dist/sessions/read-rows.d.ts +13 -0
  204. package/dist/sessions/read-rows.js +47 -0
  205. package/dist/sessions/read-substitution.d.ts +5 -0
  206. package/dist/sessions/read-substitution.js +51 -0
  207. package/dist/sessions/read-support.d.ts +48 -0
  208. package/dist/sessions/read-support.js +73 -0
  209. package/dist/sessions/read-tool-day.d.ts +2 -0
  210. package/dist/sessions/read-tool-day.js +26 -0
  211. package/dist/sessions/read-version-boundary.d.ts +31 -0
  212. package/dist/sessions/read-version-boundary.js +95 -0
  213. package/dist/sessions/read.d.ts +86 -0
  214. package/dist/sessions/read.js +87 -0
  215. package/dist/sessions/recall-bundle.d.ts +12 -0
  216. package/dist/sessions/recall-bundle.js +33 -0
  217. package/dist/sessions/recall-consequence.d.ts +45 -0
  218. package/dist/sessions/recall-consequence.js +179 -0
  219. package/dist/sessions/recall-frames.d.ts +18 -0
  220. package/dist/sessions/recall-frames.js +44 -0
  221. package/dist/sessions/recall-next.d.ts +145 -0
  222. package/dist/sessions/recall-next.js +241 -0
  223. package/dist/sessions/recall-object.d.ts +139 -0
  224. package/dist/sessions/recall-object.js +78 -0
  225. package/dist/sessions/recall-pool.d.ts +19 -0
  226. package/dist/sessions/recall-pool.js +96 -0
  227. package/dist/sessions/recall-push.d.ts +26 -0
  228. package/dist/sessions/recall-push.js +47 -0
  229. package/dist/sessions/recall-reads.d.ts +124 -0
  230. package/dist/sessions/recall-reads.js +417 -0
  231. package/dist/sessions/recall-render.d.ts +19 -0
  232. package/dist/sessions/recall-render.js +168 -0
  233. package/dist/sessions/recovery-calls.d.ts +21 -0
  234. package/dist/sessions/recovery-calls.js +25 -0
  235. package/dist/sessions/redact.d.ts +17 -0
  236. package/dist/sessions/redact.js +113 -0
  237. package/dist/sessions/repo-root.d.ts +10 -0
  238. package/dist/sessions/repo-root.js +101 -0
  239. package/dist/sessions/schema.d.ts +694 -0
  240. package/dist/sessions/schema.js +167 -0
  241. package/dist/sessions/session-shape.d.ts +76 -0
  242. package/dist/sessions/session-shape.js +355 -0
  243. package/dist/sessions/sources.d.ts +50 -0
  244. package/dist/sessions/sources.js +233 -0
  245. package/dist/sessions/steps.d.ts +105 -0
  246. package/dist/sessions/steps.js +250 -0
  247. package/dist/sessions/store.d.ts +309 -0
  248. package/dist/sessions/store.js +1498 -0
  249. package/dist/sessions/strata.d.ts +26 -0
  250. package/dist/sessions/strata.js +77 -0
  251. package/dist/sessions/survival.d.ts +27 -0
  252. package/dist/sessions/survival.js +230 -0
  253. package/dist/sessions/transcript-tail.d.ts +14 -0
  254. package/dist/sessions/transcript-tail.js +64 -0
  255. package/dist/sessions/verify.d.ts +53 -0
  256. package/dist/sessions/verify.js +286 -0
  257. package/dist/sessions/vocab-load.d.ts +26 -0
  258. package/dist/sessions/vocab-load.js +61 -0
  259. package/dist/sessions/vscode-copilot.d.ts +38 -0
  260. package/dist/sessions/vscode-copilot.js +494 -0
  261. package/dist/telemetry/command.d.ts +8 -0
  262. package/dist/telemetry/command.js +85 -0
  263. package/dist/telemetry/consent.d.ts +17 -0
  264. package/dist/telemetry/consent.js +63 -0
  265. package/dist/telemetry/crash.d.ts +8 -0
  266. package/dist/telemetry/crash.js +52 -0
  267. package/dist/telemetry/emit.d.ts +31 -0
  268. package/dist/telemetry/emit.js +42 -0
  269. package/dist/telemetry/fields.d.ts +16 -0
  270. package/dist/telemetry/fields.js +26 -0
  271. package/dist/telemetry/health.d.ts +21 -0
  272. package/dist/telemetry/health.js +108 -0
  273. package/dist/telemetry/hook-emit.d.ts +4 -0
  274. package/dist/telemetry/hook-emit.js +18 -0
  275. package/dist/telemetry/measures.d.ts +32 -0
  276. package/dist/telemetry/measures.js +128 -0
  277. package/dist/telemetry/observe.d.ts +13 -0
  278. package/dist/telemetry/observe.js +51 -0
  279. package/dist/telemetry/read-events.d.ts +9 -0
  280. package/dist/telemetry/read-events.js +17 -0
  281. package/dist/telemetry/record.d.ts +17 -0
  282. package/dist/telemetry/record.js +26 -0
  283. package/dist/telemetry/report.d.ts +77 -0
  284. package/dist/telemetry/report.js +188 -0
  285. package/dist/telemetry/schema.d.ts +40 -0
  286. package/dist/telemetry/schema.js +187 -0
  287. package/dist/telemetry/send-main.d.ts +1 -0
  288. package/dist/telemetry/send-main.js +11 -0
  289. package/dist/telemetry/send.d.ts +36 -0
  290. package/dist/telemetry/send.js +172 -0
  291. package/dist/telemetry/spool.d.ts +17 -0
  292. package/dist/telemetry/spool.js +70 -0
  293. package/dist/telemetry/wire.d.ts +25 -0
  294. package/dist/telemetry/wire.js +76 -0
  295. package/package.json +4 -5
  296. package/vocab/crates.json +1 -0
  297. package/vocab/go.json +1 -0
  298. package/vocab/harness.json +1 -0
  299. package/vocab/homebrew.json +1 -0
  300. package/vocab/manifest.json +1 -0
  301. package/vocab/maven.json +1 -0
  302. package/vocab/models.json +1 -0
  303. package/vocab/npm.json +1 -0
  304. package/vocab/programs.json +1 -0
  305. package/vocab/pypi.json +1 -0
  306. package/vocab/rubygems.json +1 -0
  307. package/dist/commands/deploy-input.d.ts +0 -38
  308. package/dist/commands/deploy-input.js +0 -150
  309. package/dist/commands/deploy.d.ts +0 -22
  310. package/dist/commands/deploy.js +0 -124
  311. package/dist/commands/status.d.ts +0 -7
  312. package/dist/commands/status.js +0 -18
  313. package/dist/commands/templates.d.ts +0 -11
  314. package/dist/commands/templates.js +0 -32
  315. package/dist/commands/test-charge.d.ts +0 -11
  316. package/dist/commands/test-charge.js +0 -34
  317. package/dist/journal.d.ts +0 -26
  318. package/dist/journal.js +0 -58
  319. package/parity/deploy-plans/paid-api.json +0 -34
  320. package/parity/deploy-plans/per-task-agent.json +0 -34
@@ -0,0 +1,63 @@
1
+ // Whether telemetry records and may send, and the one-time notice. The off switches are `oars telemetry off` (a saved setting), `OARS_TELEMETRY=0`, and
2
+ // `DO_NOT_TRACK=1`; the environment wins over a saved `on`. `telemetrySendAllowed` is the check the sender must call before any byte leaves.
3
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
4
+ import { spoolPaths } from './spool.js';
5
+ /** Printed once on stderr, on the first run that is not a hook; also what `oars telemetry on` prints. */
6
+ export const NOTICE = [
7
+ 'oars records how it is used, on this machine: one event per command with its path, flag names (never their values), how long it took, the exit code, and the class of any error; one per hook fire, recall, deposit, and fetch, as counts and closed words (which hook, how long, hit or miss, how many bytes, how many sessions a deposit counted and how many each gate kept out, never which); each with a random install id, the oars version, the OS, and the time. Once a night it also records counts and sizes of the session store and what the run cost. A crash is recorded as the error\'s class and a hash of its stack (never its message). Each pool deposit records, per batch, which fields it held and how many bytes each took, and each send (a wire-send event) its HTTP status, the pool\'s refusal code, and its size.',
8
+ 'It never records session content, prompts, file paths, URLs, arguments, or anything you typed. oars sends the events in batches to the pool server in OARS_POOL_URL, else to the Many Oars pool (pool.manyoars.com, not deployed yet: until it is, a send fails and the events stay on this machine), and nowhere else.',
9
+ 'See exactly what is kept: oars telemetry show. Turn it off: oars telemetry off, or set OARS_TELEMETRY=0 or DO_NOT_TRACK=1. More: docs/telemetry.md.',
10
+ ].join('\n');
11
+ const readSettings = (dir) => {
12
+ try {
13
+ const parsed = JSON.parse(readFileSync(spoolPaths(dir).settings, 'utf8'));
14
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
15
+ return {};
16
+ const enabled = Reflect.get(parsed, 'enabled');
17
+ const shown = Reflect.get(parsed, 'noticeShownAt');
18
+ return { ...(typeof enabled === 'boolean' ? { enabled } : {}), ...(typeof shown === 'string' ? { noticeShownAt: shown } : {}) };
19
+ }
20
+ catch {
21
+ return {};
22
+ }
23
+ };
24
+ /** Write to a sibling and rename over, so a crash leaves the old settings or the new, never half. */
25
+ const writeSettings = (dir, settings) => {
26
+ const { settings: path } = spoolPaths(dir);
27
+ mkdirSync(dir, { recursive: true });
28
+ const scratch = `${path}.tmp`;
29
+ writeFileSync(scratch, `${JSON.stringify(settings)}\n`);
30
+ renameSync(scratch, path);
31
+ };
32
+ const OFF_WORDS = new Set(['0', 'false', 'off', 'no']);
33
+ const DO_NOT_TRACK_WORDS = new Set(['1', 'true']);
34
+ export const consentOf = (env, dir) => {
35
+ const settings = readSettings(dir);
36
+ const noticeShown = settings.noticeShownAt !== undefined;
37
+ if (OFF_WORDS.has((env.OARS_TELEMETRY ?? '').toLowerCase()))
38
+ return { enabled: false, reason: 'OARS_TELEMETRY', noticeShown };
39
+ if (DO_NOT_TRACK_WORDS.has((env.DO_NOT_TRACK ?? '').toLowerCase()))
40
+ return { enabled: false, reason: 'DO_NOT_TRACK', noticeShown };
41
+ if (settings.enabled !== undefined)
42
+ return { enabled: settings.enabled, reason: 'setting', noticeShown };
43
+ return { enabled: true, reason: 'default', noticeShown };
44
+ };
45
+ /** Saves `on` or `off`. Throws when the folder cannot be written: the command that asked reports it. */
46
+ export const setEnabled = (dir, enabled) => {
47
+ writeSettings(dir, { ...readSettings(dir), enabled });
48
+ };
49
+ /** Records that the notice was shown; `false` when it could not be recorded (nothing is recorded or sent from a folder that cannot be written). */
50
+ export const markNoticeShown = (dir, now) => {
51
+ try {
52
+ writeSettings(dir, { ...readSettings(dir), noticeShownAt: new Date(now()).toISOString() });
53
+ return true;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ };
59
+ /** Whether the sender (t875) may send: telemetry on, and the notice shown. Off, or notice not yet shown, means nothing sends. */
60
+ export const telemetrySendAllowed = (env, dir) => {
61
+ const consent = consentOf(env, dir);
62
+ return consent.enabled && consent.noticeShown;
63
+ };
@@ -0,0 +1,8 @@
1
+ import { type Emitter } from './emit.js';
2
+ import { type CrashClass } from './schema.js';
3
+ /** The class of a thrown value: a built-in error class by name, else `other`. A class a program or library defines, and a name set to text, are `other`. */
4
+ export declare const crashClassOf: (error: unknown) => CrashClass;
5
+ /** 16 hex characters from the frames of the stack, the message's lines left out (a message can run over many lines, and none begins with `at`). */
6
+ export declare const stackHashOf: (error: unknown) => string;
7
+ /** Records a crash. It reads the error for its class and its frames only; the message is never touched. */
8
+ export declare const recordCrash: (emitter: Emitter, error: unknown) => void;
@@ -0,0 +1,52 @@
1
+ // A crash report is the error's class and a hash of its stack: never its message, which can hold a path. The stack's own text is read only to be
2
+ // hashed, and every file path in it is reduced to its place inside a package (or hashed whole when it sits nowhere known) before that, so the same
3
+ // code on two machines is the same crash and no home folder reaches the hash's input in clear.
4
+ import { createHash } from 'node:crypto';
5
+ import { emitEvent } from './emit.js';
6
+ import { CRASH_CLASSES } from './schema.js';
7
+ /** The class of a thrown value: a built-in error class by name, else `other`. A class a program or library defines, and a name set to text, are `other`. */
8
+ export const crashClassOf = (error) => {
9
+ if (!(error instanceof Error))
10
+ return 'other';
11
+ // A subclass of a built-in is that built-in; walk up until one is named.
12
+ for (let proto = Object.getPrototypeOf(error); typeof proto === 'object' && proto !== null; proto = Object.getPrototypeOf(proto)) {
13
+ const named = Reflect.get(proto, 'constructor');
14
+ const name = typeof named === 'function' ? Reflect.get(named, 'name') : '';
15
+ const found = CRASH_CLASSES.find((known) => known === name && known !== 'other');
16
+ if (found !== undefined)
17
+ return found;
18
+ }
19
+ return 'other';
20
+ };
21
+ const short = (text) => createHash('sha256').update(text).digest('hex').slice(0, 8);
22
+ /** `file:///C:/a/b.js`, `C:\a\b.js`, and `/a/b.js` as one slash-separated path, so two machines' separators agree. */
23
+ const plain = (location) => location.replace(/^file:\/\/\/?/, '').replaceAll('\\', '/');
24
+ /** Where a package's own files start in a path: after the last `dist/`, `src/`, or `node_modules/`. What lies before it is the machine's. */
25
+ const WITHIN = /^.*\/(?:dist|src|node_modules)\/(.*)$/;
26
+ const frameOf = (line) => {
27
+ const found = /^\s*at (?:(.*?) \()?(.*?)\)?$/.exec(line);
28
+ if (found === null)
29
+ return undefined;
30
+ const name = found[1] ?? '';
31
+ const place = /^(.*):(\d+):(\d+)$/.exec(found[2] ?? '');
32
+ // A frame with no line and column is an engine frame (`node:internal/...`, `<anonymous>`): nothing of the machine's.
33
+ if (place === null)
34
+ return `${name}@${found[2] ?? ''}`;
35
+ const path = plain(place[1] ?? '');
36
+ const within = WITHIN.exec(path)?.[1];
37
+ // Hashed either way: a package-relative path names no one, and one that is not inside a package is the machine's and is hashed whole.
38
+ return `${name}@${short(within ?? path)}:${place[2] ?? ''}:${place[3] ?? ''}`;
39
+ };
40
+ /** 16 hex characters from the frames of the stack, the message's lines left out (a message can run over many lines, and none begins with `at`). */
41
+ export const stackHashOf = (error) => {
42
+ const stack = error instanceof Error && typeof error.stack === 'string' ? error.stack : '';
43
+ const frames = stack
44
+ .split('\n')
45
+ .filter((line) => /^\s+at /.test(line))
46
+ .flatMap((line) => frameOf(line) ?? []);
47
+ return createHash('sha256').update(frames.join('\n')).digest('hex').slice(0, 16);
48
+ };
49
+ /** Records a crash. It reads the error for its class and its frames only; the message is never touched. */
50
+ export const recordCrash = (emitter, error) => {
51
+ emitEvent(emitter, 'crash', { errorClass: crashClassOf(error), stackHash: stackHashOf(error) });
52
+ };
@@ -0,0 +1,31 @@
1
+ import { type TelemetryRuntime } from './record.js';
2
+ /** What recording needs from a run: where the folder is, the environment's switches, the version, and the clock. */
3
+ export interface Emitter {
4
+ env: NodeJS.ProcessEnv;
5
+ telemetry?: TelemetryRuntime | undefined;
6
+ version: string;
7
+ now: () => number;
8
+ }
9
+ /** Whether this run records at all: a folder is set and consent is on. A caller that must do work to build an event asks first, so an opt-out pays nothing. */
10
+ export declare const recordsEvents: (emitter: Emitter) => boolean;
11
+ export declare const emitEvent: (emitter: Emitter, type: string, fields: object) => void;
12
+ /** Records one event of a known type; a no-op when telemetry is off, has no folder, or fails. */
13
+ export type Emit = (type: string, fields: object) => void;
14
+ export interface EmitContext {
15
+ env: NodeJS.ProcessEnv;
16
+ /** Absent: nothing is recorded. */
17
+ telemetry: TelemetryRuntime | undefined;
18
+ /** Read only when an event is about to be written, so a run with telemetry off never reads it. */
19
+ version: () => string;
20
+ now: () => number;
21
+ }
22
+ export declare const emitFor: ({ env, telemetry, version, now }: EmitContext) => Emit;
23
+ /** An emitter that records nothing, for a caller built without telemetry. */
24
+ export declare const noEmit: Emit;
25
+ /** The emitter for a CLI run: the run's environment, telemetry folder, version, and clock. */
26
+ export declare const emitOf: (deps: {
27
+ env: NodeJS.ProcessEnv;
28
+ telemetry?: TelemetryRuntime | undefined;
29
+ version: string;
30
+ now: () => number;
31
+ }) => Emit;
@@ -0,0 +1,42 @@
1
+ // The one way a part of oars records an event: it checks the switches, passes the closed schema through `recordEvent`, and never throws,
2
+ // so no command's exit code or output can depend on it. A working path holds one `Emit`: one call, consent checked, so the line it carries is the whole cost to its caller.
3
+ import { consentOf } from './consent.js';
4
+ import { recordEvent } from './record.js';
5
+ /** Whether this run records at all: a folder is set and consent is on. A caller that must do work to build an event asks first, so an opt-out pays nothing. */
6
+ export const recordsEvents = (emitter) => {
7
+ try {
8
+ return emitter.telemetry !== undefined && consentOf(emitter.env, emitter.telemetry.dir).enabled;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ };
14
+ export const emitEvent = (emitter, type, fields) => {
15
+ try {
16
+ const runtime = emitter.telemetry;
17
+ if (runtime === undefined || !consentOf(emitter.env, runtime.dir).enabled)
18
+ return;
19
+ recordEvent({ ...runtime, version: emitter.version, now: emitter.now }, type, fields);
20
+ }
21
+ catch {
22
+ // Telemetry is never allowed to change what a command or hook does.
23
+ }
24
+ };
25
+ export const emitFor = ({ env, telemetry, version, now }) => {
26
+ const emitter = {
27
+ env,
28
+ telemetry,
29
+ // A getter, so `emitEvent` reads the version only after consent says an event will be written.
30
+ get version() {
31
+ return version();
32
+ },
33
+ now,
34
+ };
35
+ return (type, fields) => {
36
+ emitEvent(emitter, type, fields);
37
+ };
38
+ };
39
+ /** An emitter that records nothing, for a caller built without telemetry. */
40
+ export const noEmit = () => undefined;
41
+ /** The emitter for a CLI run: the run's environment, telemetry folder, version, and clock. */
42
+ export const emitOf = (deps) => emitFor({ env: deps.env, telemetry: deps.telemetry, version: () => deps.version, now: deps.now });
@@ -0,0 +1,16 @@
1
+ import type { Recall } from '../sessions/recall-object.js';
2
+ /** The harness every hook oars installs runs under (Claude Code); one spelling for the session hooks and the gate hook, so the two emitters cannot drift. */
3
+ export declare const HOOK_HARNESS = "claude-code";
4
+ /** The harness a hook ran under, as the closed list names it, else `other`. */
5
+ export declare const harnessWord: (harness: string) => string;
6
+ /**
7
+ * The harness, harness version, and model a hook saw, as the public vocabulary knows them or `other`; a version or model the hook did not see is absent. A hook fire names its harness (the
8
+ * recall's frame when a push made one); the nightly task runs under none, so its caller passes `undefined` and the event carries no harness (it reads as `unrecorded`).
9
+ */
10
+ export declare const frameWords: (harness: string | undefined, harnessVersion: string | null | undefined, model: string | null | undefined) => {
11
+ harness?: string;
12
+ harnessVersion?: string;
13
+ model?: string;
14
+ };
15
+ /** A recall as counts: how it was asked, which families it served, how many reads and evidence rows, the size of the answer, and its arm. Never the query text, a name, or the rendered answer. */
16
+ export declare const recallFields: (recall: Recall, families: readonly string[], legacy?: boolean) => object;
@@ -0,0 +1,26 @@
1
+ import { HARNESSES, OTHER } from '../sessions/pool-wire.js';
2
+ import { modelWord, versionWord } from './schema.js';
3
+ /** The harness every hook oars installs runs under (Claude Code); one spelling for the session hooks and the gate hook, so the two emitters cannot drift. */
4
+ export const HOOK_HARNESS = 'claude-code';
5
+ /** The harness a hook ran under, as the closed list names it, else `other`. */
6
+ export const harnessWord = (harness) => (HARNESSES.some((known) => known === harness) ? harness : OTHER);
7
+ /**
8
+ * The harness, harness version, and model a hook saw, as the public vocabulary knows them or `other`; a version or model the hook did not see is absent. A hook fire names its harness (the
9
+ * recall's frame when a push made one); the nightly task runs under none, so its caller passes `undefined` and the event carries no harness (it reads as `unrecorded`).
10
+ */
11
+ export const frameWords = (harness, harnessVersion, model) => ({
12
+ ...(harness === undefined ? {} : { harness: harnessWord(harness) }),
13
+ ...(harnessVersion === undefined || harnessVersion === null ? {} : { harnessVersion: versionWord(harnessVersion) }),
14
+ ...(model === undefined || model === null ? {} : { model: modelWord(model) }),
15
+ });
16
+ /** A recall as counts: how it was asked, which families it served, how many reads and evidence rows, the size of the answer, and its arm. Never the query text, a name, or the rendered answer. */
17
+ export const recallFields = (recall, families, legacy = false) => ({
18
+ form: legacy ? 'legacy' : recall.query.form,
19
+ mode: recall.mode,
20
+ families: [...new Set(families)],
21
+ kind: recall.answer.kind,
22
+ reads: recall.answer.reads.length,
23
+ evidence: recall.evidence.length,
24
+ tokens: recall.answer.tokens,
25
+ arm: recall.arm,
26
+ });
@@ -0,0 +1,21 @@
1
+ import { type SessionsRuntime } from '../sessions/store.js';
2
+ import { type Emitter } from './emit.js';
3
+ /** What the nightly run needs to record: the emitter, and a reader for the process's peak memory (a test sets it). */
4
+ export interface NightlyTelemetry extends Emitter {
5
+ peakRssMb?: () => number;
6
+ }
7
+ /**
8
+ * The distinct values seen, each kept when the pool wire's own public vocabulary admits it and written as `other` when it does not, sorted with `other`
9
+ * last. More than the schema's list length folds the tail into `other`.
10
+ */
11
+ export declare const closedList: (values: readonly string[], kind: "version" | "model") => string[];
12
+ /** The distinct whole-number versions among the stamps, ascending; a stamp that is not a whole number names no version and is left out. */
13
+ export declare const versionsHeld: (stamps: readonly string[]) => number[];
14
+ /** The store-health event of one nightly run. A store that cannot be read is recorded as that and nothing else; it never stops the run. */
15
+ export declare const recordStoreHealth: (telemetry: NightlyTelemetry, runtime: SessionsRuntime) => Promise<void>;
16
+ /** What a nightly run cost: the whole run, the deposit's parse, the process's peak memory, and whether it failed. */
17
+ export declare const recordRunCost: (telemetry: NightlyTelemetry, cost: {
18
+ totalMs: number;
19
+ parseMs: number;
20
+ failed: boolean;
21
+ }) => void;
@@ -0,0 +1,108 @@
1
+ // What the nightly run tells telemetry about the health of an install: a snapshot of the session store (counts, sizes, verify results, the versions it
2
+ // holds) and what the run cost. Counts, bytes, and closed words only; the schema checks them again before anything reaches the spool.
3
+ import { readdir, stat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { CAPTURE_VERSION, OLDEST_READABLE_CAPTURE } from '../sessions/schema.js';
6
+ import { readSteps } from '../sessions/steps.js';
7
+ import { frozenSessions, loadStore, paths, readSkeletons } from '../sessions/store.js';
8
+ import { LITERAL, publicModel, publicVersion } from '../sessions/public-vocabulary.js';
9
+ import { verifyAll, verifyCaptured } from '../sessions/verify.js';
10
+ import { emitEvent, recordsEvents } from './emit.js';
11
+ import { spoolEvents } from './read-events.js';
12
+ const MAX_LIST = 32;
13
+ const OTHER = 'other';
14
+ const WEEK_MS = 7 * 86_400_000;
15
+ /**
16
+ * The distinct values seen, each kept when the pool wire's own public vocabulary admits it and written as `other` when it does not, sorted with `other`
17
+ * last. More than the schema's list length folds the tail into `other`.
18
+ */
19
+ export const closedList = (values, kind) => {
20
+ const visible = kind === 'version' ? publicVersion : publicModel;
21
+ const kept = new Set();
22
+ let other = false;
23
+ for (const value of values) {
24
+ const shown = visible(value);
25
+ if (shown === null || shown === LITERAL || shown !== value)
26
+ other = true;
27
+ else
28
+ kept.add(value);
29
+ }
30
+ const sorted = [...kept].sort();
31
+ const room = MAX_LIST - 1;
32
+ if (sorted.length > room)
33
+ return [...sorted.slice(0, room), OTHER];
34
+ return other ? [...sorted, OTHER] : sorted;
35
+ };
36
+ /** The distinct whole-number versions among the stamps, ascending; a stamp that is not a whole number names no version and is left out. */
37
+ export const versionsHeld = (stamps) => [...new Set(stamps.map(Number).filter((stamp) => Number.isSafeInteger(stamp) && stamp >= 0))].sort((a, b) => a - b).slice(-MAX_LIST);
38
+ /** Every byte under a folder; a folder that is not there holds none. */
39
+ const bytesUnder = async (dir) => {
40
+ let entries;
41
+ try {
42
+ entries = await readdir(dir, { withFileTypes: true });
43
+ }
44
+ catch (error) {
45
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
46
+ return 0;
47
+ throw error;
48
+ }
49
+ let total = 0;
50
+ for (const entry of entries)
51
+ total += entry.isDirectory() ? await bytesUnder(join(dir, entry.name)) : (await stat(join(dir, entry.name))).size;
52
+ return total;
53
+ };
54
+ /** The bytes the last seven days of sends put on the wire, read from telemetry's own `wire-send` events: nothing is asked of the pool. */
55
+ const egressBytes = (dir, now) => spoolEvents(dir, 'wire-send').reduce((total, { at, data }) => (data.sent === 1 && typeof data.bytes === 'number' && Date.parse(at) >= now - WEEK_MS ? total + data.bytes : total), 0);
56
+ /** The extractor version each pipeline step last ran at: the log is append-only, so older rows name versions the store no longer holds. A tie on `at` goes to the later row. */
57
+ const lastExtractorVersions = (steps) => {
58
+ const latest = new Map();
59
+ for (const step of steps) {
60
+ const held = latest.get(step.step);
61
+ if (held === undefined || Date.parse(step.at) >= Date.parse(held.at))
62
+ latest.set(step.step, step);
63
+ }
64
+ return [...latest.values()].map((step) => step.ev);
65
+ };
66
+ const storeFields = async (runtime) => {
67
+ const { storeDir } = runtime;
68
+ const { skeletons, unreadable } = await readSkeletons(storeDir, undefined, true);
69
+ const store = await loadStore(storeDir);
70
+ const steps = await readSteps(storeDir);
71
+ const captured = [...store.sessions, ...store.subagents];
72
+ const frozen = await frozenSessions(runtime, captured.filter((session) => Number(session.cv) >= OLDEST_READABLE_CAPTURE && Number(session.cv) < Number(CAPTURE_VERSION)));
73
+ const results = [...verifyAll(skeletons, skeletons), ...verifyCaptured(captured, store.tools, frozen)];
74
+ return {
75
+ sessions: store.sessions.length,
76
+ subagents: store.subagents.length,
77
+ skeletons: skeletons.length,
78
+ unparseable: unreadable + captured.reduce((sum, session) => sum + session.badLines, 0),
79
+ rawBytes: await bytesUnder(paths(storeDir).raw),
80
+ verifyChecked: results.length,
81
+ verifyFailed: results.filter((result) => result.violations > 0).length,
82
+ // What the store holds: the `cv` of each captured session and the `ev` each pipeline step last ran at. A store with a session behind this build lists two.
83
+ captureVersions: versionsHeld(captured.map((session) => session.cv)),
84
+ extractorVersions: versionsHeld(lastExtractorVersions(steps)),
85
+ harnessVersions: closedList(captured.flatMap((session) => session.versions), 'version'),
86
+ models: closedList(captured.flatMap((session) => Object.keys(session.models)), 'model'),
87
+ };
88
+ };
89
+ /** The store-health event of one nightly run. A store that cannot be read is recorded as that and nothing else; it never stops the run. */
90
+ export const recordStoreHealth = async (telemetry, runtime) => {
91
+ // The pass reads every skeleton and stats every raw file, so an install with telemetry off must not start it.
92
+ if (!recordsEvents(telemetry))
93
+ return;
94
+ try {
95
+ const fields = await storeFields(runtime);
96
+ // The outbox is an extra: a folder that cannot be read leaves the two figures out and the snapshot stands.
97
+ const extras = await bytesUnder(join(runtime.storeDir, 'pool-outbox')).then((outboxBytes) => ({ outboxBytes, egressBytes: egressBytes(telemetry.telemetry?.dir ?? '', telemetry.now()) }), () => ({}));
98
+ emitEvent(telemetry, 'store-health', { ...fields, ...extras });
99
+ }
100
+ catch {
101
+ emitEvent(telemetry, 'store-health', { readFailed: 1 });
102
+ }
103
+ };
104
+ /** What a nightly run cost: the whole run, the deposit's parse, the process's peak memory, and whether it failed. */
105
+ export const recordRunCost = (telemetry, cost) => {
106
+ const peak = telemetry.peakRssMb ?? (() => Math.round(process.resourceUsage().maxRSS / 1024));
107
+ emitEvent(telemetry, 'run-cost', { totalMs: Math.max(0, cost.totalMs), parseMs: Math.max(0, cost.parseMs), peakRssMb: peak(), failed: cost.failed ? 1 : 0 });
108
+ };
@@ -0,0 +1,4 @@
1
+ import { type Emit } from './emit.js';
2
+ /** The version in the package.json at `manifest`; `0.0.0` when it cannot be read (the schema writes a version it cannot read as `unknown`). */
3
+ export declare const versionAt: (manifest: URL) => string;
4
+ export declare const hookEmitter: (env: NodeJS.ProcessEnv, platform: NodeJS.Platform, home: string, manifest: URL, now: () => number) => Emit;
@@ -0,0 +1,18 @@
1
+ // The emitter a hook process builds for itself. A hook never loads the CLI (`main.ts` reads the version and the folders), so this reads the same two things for itself:
2
+ // the config folder the CLI uses, and the version in this package's `package.json`, read only when an event is about to be written.
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { defaultConfigDir } from '../credentials.js';
6
+ import { emitFor } from './emit.js';
7
+ /** The version in the package.json at `manifest`; `0.0.0` when it cannot be read (the schema writes a version it cannot read as `unknown`). */
8
+ export const versionAt = (manifest) => {
9
+ try {
10
+ const parsed = JSON.parse(readFileSync(manifest, 'utf8'));
11
+ const version = typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'version') : undefined;
12
+ return typeof version === 'string' ? version : '0.0.0';
13
+ }
14
+ catch {
15
+ return '0.0.0';
16
+ }
17
+ };
18
+ export const hookEmitter = (env, platform, home, manifest, now) => emitFor({ env, telemetry: { dir: join(defaultConfigDir(env, platform, home), 'oars', 'telemetry'), platform }, version: () => versionAt(manifest), now });
@@ -0,0 +1,32 @@
1
+ /** A latency in a cell, or a yes (1) or no (0) that a rate is made from. */
2
+ export type Kind = 'latency' | 'rate';
3
+ export interface Measure {
4
+ metric: string;
5
+ /** What the value groups by: the harness and harness version a hook ran under (`claude-code/2.1.5`), or the oars version for a command. */
6
+ dimension: 'harnessVersion' | 'cli';
7
+ value: string;
8
+ kind: Kind;
9
+ /** Milliseconds for a latency; 1 or 0 for a rate. */
10
+ x: number;
11
+ }
12
+ /** The windows the report shows and the pool serves, in days. */
13
+ export declare const WINDOWS: readonly number[];
14
+ /** The hooks that exist; any other name in a batch is not measured. */
15
+ export declare const HOOK_NAMES: readonly string[];
16
+ export declare const OTHER = "other";
17
+ export declare const UNRECORDED = "unrecorded";
18
+ /** What one event measures; an event of a type nothing measures, or that is not an object, measures nothing. */
19
+ export declare const measuresOf: (event: unknown) => Measure[];
20
+ /** Every cell is named by these three together. */
21
+ export declare const cellKey: (measure: Pick<Measure, "metric" | "dimension" | "value">) => string;
22
+ /**
23
+ * The bucket edges a latency histogram counts in, each 1.5 times the one before (1, 2, 3, 4, 6, 9, ...) up to a day. The pool keeps a histogram, not the latencies, so its percentile is the
24
+ * upper edge of the bucket that holds it: within a factor of 1.5 of the true one.
25
+ */
26
+ export declare const LATENCY_EDGES: readonly number[];
27
+ /** The index of the first edge at or above the latency. */
28
+ export declare const bucketOf: (milliseconds: number) => number;
29
+ /** The p-th percentile (0 to 1) of a histogram given as [bucket index, count] pairs, as that bucket's upper edge. */
30
+ export declare const histogramPercentile: (buckets: readonly (readonly [number, number])[], p: number) => number;
31
+ /** The nearest-rank p-th percentile (0 to 1) of numbers; 0 for none. */
32
+ export declare const percentile: (values: readonly number[], p: number) => number;
@@ -0,0 +1,128 @@
1
+ // What telemetry measures, in one place: the CLI's own report and the pool's rollup both turn an event into the same `Measure`s, so a user's number and the fleet's are the same number.
2
+ // A measure is one observation in a cell: a latency in milliseconds, or a yes/no that rates are made from. The cell is (metric, dimension, value): `hook.pre-tool.latency` at harness
3
+ // `claude-code`, version `2.1.5`. The reader is tolerant on purpose: the pool reads batches kept whole from any client, so a value that does not fit is skipped, never trusted.
4
+ import { isHarness } from '../sessions/pool-wire.js';
5
+ /** The windows the report shows and the pool serves, in days. */
6
+ export const WINDOWS = [7, 30];
7
+ const DAY_MS = 86_400_000;
8
+ /** The hooks that exist; any other name in a batch is not measured. */
9
+ export const HOOK_NAMES = ['session-start', 'prompt', 'pre-tool', 'post-tool', 'stop', 'nightly', 'gate'];
10
+ /** A group value as the program writes it: a version or a word, short, with no space, slash, or quote. Anything else is `other`. */
11
+ const VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
12
+ const COMMAND = /^[a-z][A-Za-z0-9-]{0,31}$/;
13
+ export const OTHER = 'other';
14
+ export const UNRECORDED = 'unrecorded';
15
+ const valueOf = (raw) => {
16
+ if (raw === undefined)
17
+ return UNRECORDED;
18
+ return typeof raw === 'string' && VALUE.test(raw) ? raw : OTHER;
19
+ };
20
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
21
+ const fieldOf = (value, key) => (isObject(value) ? Reflect.get(value, key) : undefined);
22
+ /** A whole number of milliseconds a day long at most, or undefined. */
23
+ const millis = (value) => (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= DAY_MS ? value : undefined);
24
+ const latency = (metric, dimension, value, x) => ({ metric, dimension, value, kind: 'latency', x });
25
+ const rate = (metric, dimension, value, yes) => ({ metric, dimension, value, kind: 'rate', x: yes ? 1 : 0 });
26
+ /**
27
+ * The value of a hook cell: the harness and its version, `claude-code/2.1.5`, so one version number under two harnesses is two cells. A harness off the closed list is `other`; an event written
28
+ * before the field existed has none and reads `unrecorded`, never dropped.
29
+ */
30
+ const hookCellOf = (harness, version) => {
31
+ const named = harness === undefined ? UNRECORDED : typeof harness === 'string' && isHarness(harness) ? harness : OTHER;
32
+ return `${named}/${valueOf(version)}`;
33
+ };
34
+ /** The measures of a `hook` event: its latency, and whether it found something. A hook that failed is neither a hit nor a miss. */
35
+ const hookMeasures = (data) => {
36
+ const name = fieldOf(data, 'name');
37
+ if (typeof name !== 'string' || !HOOK_NAMES.includes(name))
38
+ return [];
39
+ const version = hookCellOf(fieldOf(data, 'harness'), fieldOf(data, 'harnessVersion'));
40
+ const found = [];
41
+ const took = millis(fieldOf(data, 'latencyMs'));
42
+ if (took !== undefined)
43
+ found.push(latency(`hook.${name}.latency`, 'harnessVersion', version, took));
44
+ const outcome = fieldOf(data, 'outcome');
45
+ // `held` found something and held it back for the unexposed arm, so it counts as a find.
46
+ if (outcome === 'hit' || outcome === 'held')
47
+ found.push(rate(`hook.${name}.hit`, 'harnessVersion', version, true));
48
+ else if (outcome === 'miss')
49
+ found.push(rate(`hook.${name}.hit`, 'harnessVersion', version, false));
50
+ return found;
51
+ };
52
+ /** The measures of a `command` event: how long it took, and whether it failed, under the first name of its path. */
53
+ const commandMeasures = (event, data) => {
54
+ const path = fieldOf(data, 'path');
55
+ const first = Array.isArray(path) && typeof path[0] === 'string' && COMMAND.test(path[0]) ? path[0] : 'none';
56
+ const version = valueOf(fieldOf(event, 'cli'));
57
+ const found = [];
58
+ const took = millis(fieldOf(data, 'durationMs'));
59
+ if (took !== undefined)
60
+ found.push(latency(`command.${first}.latency`, 'cli', version, took));
61
+ const exit = fieldOf(data, 'exitCode');
62
+ if (typeof exit === 'number' && Number.isSafeInteger(exit) && exit >= 0 && exit <= 255)
63
+ found.push(rate(`command.${first}.failure`, 'cli', version, exit !== 0));
64
+ return found;
65
+ };
66
+ /** The measures of a `store-health` event: whether the nightly could read the store, and whether it found a session that does not verify. */
67
+ const storeMeasures = (event, data) => {
68
+ const version = valueOf(fieldOf(event, 'cli'));
69
+ const unread = fieldOf(data, 'readFailed') === 1;
70
+ const found = [rate('store.readFailure', 'cli', version, unread)];
71
+ const failed = fieldOf(data, 'verifyFailed');
72
+ if (!unread && typeof failed === 'number' && Number.isSafeInteger(failed) && failed >= 0)
73
+ found.push(rate('store.verifyFailure', 'cli', version, failed > 0));
74
+ return found;
75
+ };
76
+ /** What one event measures; an event of a type nothing measures, or that is not an object, measures nothing. */
77
+ export const measuresOf = (event) => {
78
+ const type = fieldOf(event, 'type');
79
+ const data = fieldOf(event, 'data');
80
+ switch (type) {
81
+ case 'hook':
82
+ return hookMeasures(data);
83
+ case 'command':
84
+ return commandMeasures(event, data);
85
+ case 'store-health':
86
+ return storeMeasures(event, data);
87
+ default:
88
+ return [];
89
+ }
90
+ };
91
+ /** Every cell is named by these three together. */
92
+ export const cellKey = (measure) => `${measure.metric}\u0000${measure.dimension}\u0000${measure.value}`;
93
+ /**
94
+ * The bucket edges a latency histogram counts in, each 1.5 times the one before (1, 2, 3, 4, 6, 9, ...) up to a day. The pool keeps a histogram, not the latencies, so its percentile is the
95
+ * upper edge of the bucket that holds it: within a factor of 1.5 of the true one.
96
+ */
97
+ export const LATENCY_EDGES = (() => {
98
+ const edges = [];
99
+ for (let step = 0;; step += 1) {
100
+ const edge = Math.ceil(1.5 ** step);
101
+ if (edges.at(-1) !== edge)
102
+ edges.push(edge);
103
+ if (edge >= DAY_MS)
104
+ return edges;
105
+ }
106
+ })();
107
+ /** The index of the first edge at or above the latency. */
108
+ export const bucketOf = (milliseconds) => LATENCY_EDGES.findIndex((edge) => milliseconds <= edge);
109
+ /** The p-th percentile (0 to 1) of a histogram given as [bucket index, count] pairs, as that bucket's upper edge. */
110
+ export const histogramPercentile = (buckets, p) => {
111
+ const sorted = [...buckets].sort((a, b) => a[0] - b[0]);
112
+ const total = sorted.reduce((sum, [, count]) => sum + count, 0);
113
+ const target = Math.max(1, Math.ceil(p * total));
114
+ let seen = 0;
115
+ for (const [index, count] of sorted) {
116
+ seen += count;
117
+ if (seen >= target)
118
+ return LATENCY_EDGES[index] ?? DAY_MS;
119
+ }
120
+ return 0;
121
+ };
122
+ /** The nearest-rank p-th percentile (0 to 1) of numbers; 0 for none. */
123
+ export const percentile = (values, p) => {
124
+ if (values.length === 0)
125
+ return 0;
126
+ const sorted = [...values].sort((a, b) => a - b);
127
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1))] ?? 0;
128
+ };
@@ -0,0 +1,13 @@
1
+ import type { Command } from 'commander';
2
+ import type { RunDeps } from '../cli.js';
3
+ import type { ExitCode } from '../errors.js';
4
+ export interface Observed {
5
+ /** The command path, such as `['pool', 'fetch']`; empty when no command ran (`--help`, `--version`, a usage error). */
6
+ path: string[];
7
+ /** The long names (as commander keys them) of the options given on the command line. */
8
+ flags: string[];
9
+ }
10
+ /** Called by commander just before an action runs. */
11
+ export declare const observeCommand: (observed: Observed, action: Command) => void;
12
+ /** Prints the notice on the first run that is not a hook, then records the event. Never throws and never changes what the command did. */
13
+ export declare const finishTelemetry: (deps: RunDeps, argv: string[], observed: Observed, startedAt: number, exit: ExitCode) => void;
@@ -0,0 +1,51 @@
1
+ import { consentOf, markNoticeShown, NOTICE } from './consent.js';
2
+ import { recordEvent } from './record.js';
3
+ import { startDailySend } from './send.js';
4
+ import { errorClassOf } from './schema.js';
5
+ const pathOf = (command) => {
6
+ const names = [];
7
+ let step = command;
8
+ // The root program has no parent and is not part of the path.
9
+ while (step.parent !== null) {
10
+ names.unshift(step.name());
11
+ step = step.parent;
12
+ }
13
+ return names;
14
+ };
15
+ /** Called by commander just before an action runs. */
16
+ export const observeCommand = (observed, action) => {
17
+ observed.path = pathOf(action);
18
+ const given = action.optsWithGlobals();
19
+ observed.flags = Object.keys(given).filter((key) => action.getOptionValueSourceWithGlobals(key) === 'cli');
20
+ };
21
+ const isHook = (argv, observed) => {
22
+ const gateHook = (words) => words[0] === 'gate' && words[1] === 'hook';
23
+ return gateHook(observed.path) || gateHook(argv);
24
+ };
25
+ /** Prints the notice on the first run that is not a hook, then records the event. Never throws and never changes what the command did. */
26
+ export const finishTelemetry = (deps, argv, observed, startedAt, exit) => {
27
+ try {
28
+ const runtime = deps.telemetry;
29
+ if (runtime === undefined)
30
+ return;
31
+ const consent = consentOf(deps.env, runtime.dir);
32
+ if (!consent.enabled)
33
+ return;
34
+ if (!consent.noticeShown && !isHook(argv, observed) && markNoticeShown(runtime.dir, deps.now))
35
+ deps.io.stderr(`${NOTICE}\n`);
36
+ const errorClass = errorClassOf(exit);
37
+ recordEvent({ ...runtime, version: deps.version, now: deps.now }, 'command', {
38
+ path: observed.path,
39
+ flags: observed.flags,
40
+ durationMs: Math.max(0, deps.now() - startedAt),
41
+ exitCode: exit,
42
+ ...(errorClass === undefined ? {} : { errorClass }),
43
+ });
44
+ // Never from a hook: a hook has a time budget, and the send is for the nightly task and the commands a person runs.
45
+ if (!isHook(argv, observed))
46
+ startDailySend(runtime, deps.env, deps.now());
47
+ }
48
+ catch {
49
+ // Telemetry is never allowed to change what a command does.
50
+ }
51
+ };
@@ -0,0 +1,9 @@
1
+ export interface SpoolEvent {
2
+ type: string;
3
+ at: string;
4
+ data: {
5
+ [field: string]: unknown;
6
+ };
7
+ }
8
+ /** Every spooled event, oldest first, or only those of `type`. */
9
+ export declare const spoolEvents: (dir: string, type?: string) => SpoolEvent[];