@dev.fast/whiteboard 0.0.0-stage → 0.2.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 (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,53 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { jsonObject, jsonString, parseJsonText } from "@dev.fast/json";
6
+ import { findPackageRoot } from "@dev.fast/trace-core";
7
+
8
+ export function findReviewPackageRoot(
9
+ moduleUrl: string = import.meta.url,
10
+ ): string {
11
+ return findPackageRoot(moduleUrl);
12
+ }
13
+
14
+ export function readReviewPackageVersion(
15
+ moduleUrl: string = import.meta.url,
16
+ ): string {
17
+ try {
18
+ const packageJson = jsonObject(
19
+ parseJsonText(
20
+ readFileSync(
21
+ path.join(findReviewPackageRoot(moduleUrl), "package.json"),
22
+ "utf8",
23
+ ),
24
+ ),
25
+ );
26
+
27
+ return jsonString(packageJson?.version) ?? "unknown";
28
+ } catch {
29
+ return "unknown";
30
+ }
31
+ }
32
+
33
+ /** `build-info.json` in a built package's `dist`, when present. */
34
+ export function readBuildInfo(distDirectory: string) {
35
+ try {
36
+ return jsonObject(
37
+ parseJsonText(
38
+ readFileSync(path.join(distDirectory, "build-info.json"), "utf8"),
39
+ ),
40
+ );
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ /** The build's commit; null from source, where an old `dist` may linger. */
47
+ export function readBuildCommit(moduleUrl: string): string | null {
48
+ const dist = path.join(findReviewPackageRoot(moduleUrl), "dist");
49
+
50
+ if (!fileURLToPath(moduleUrl).startsWith(`${dist}${path.sep}`)) return null;
51
+
52
+ return jsonString(readBuildInfo(dist)?.commit) ?? null;
53
+ }
@@ -0,0 +1,610 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFile, readdir, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { type JsonValue, jsonValueSchema } from "@dev.fast/json";
6
+ import { parseJsonText } from "@dev.fast/review-protocol";
7
+ import { withFileLock, writeFileAtomic } from "@dev.fast/trace-core";
8
+ import { z } from "zod";
9
+
10
+ import { EMBEDDED_PROGRESSIVE_REVIEW_POSTHOG_KEY } from "./embedded-posthog-key";
11
+ import { DEV_REVIEW_HOME_ENV, devReviewHome } from "./review-home-paths";
12
+
13
+ export type PostHogCaptureProperties = Record<string, JsonValue | undefined>;
14
+
15
+ export interface PostHogCaptureInput {
16
+ event: string;
17
+ distinctId: string;
18
+ properties?: PostHogCaptureProperties;
19
+ /** When the event happened, in epoch ms; defaults to the capture. */
20
+ timestamp?: number;
21
+ }
22
+
23
+ export interface PostHogCaptureClientOptions {
24
+ apiKey?: string;
25
+ host?: string;
26
+ timeoutMs?: number;
27
+ fetch?: typeof fetch;
28
+ timeoutSignal?: (timeoutMs: number) => AbortSignal;
29
+ queueDir?: string;
30
+ now?: () => number;
31
+ idFactory?: () => string;
32
+ }
33
+
34
+ export const PROGRESSIVE_REVIEW_POSTHOG_KEY_ENV =
35
+ "PROGRESSIVE_REVIEW_POSTHOG_KEY";
36
+
37
+ export const PROGRESSIVE_REVIEW_POSTHOG_HOST_ENV =
38
+ "PROGRESSIVE_REVIEW_POSTHOG_HOST";
39
+
40
+ const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
41
+
42
+ const DEFAULT_CAPTURE_TIMEOUT_MS = 1_000;
43
+
44
+ const QUEUE_LIMIT = 1_000;
45
+
46
+ const BATCH_LIMIT = 50;
47
+
48
+ const QUEUE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
49
+
50
+ const FLUSH_DELAY_MS = 5_000;
51
+
52
+ const MAX_BACKOFF_MS = 60 * 60 * 1_000;
53
+
54
+ const DROPPED_FILE = "dropped.json";
55
+
56
+ const DROP_REASONS = [
57
+ "queue_full",
58
+ "expired",
59
+ "corrupt",
60
+ "permanent_rejection",
61
+ "storage_failure",
62
+ ] as const;
63
+
64
+ type DropReason = (typeof DROP_REASONS)[number];
65
+
66
+ /** The dropped-event tally as this module wrote it to disk. */
67
+ const droppedCountsSchema = z.partialRecord(z.enum(DROP_REASONS), z.number());
68
+
69
+ interface QueuedPostHogEvent extends PostHogCaptureInput {
70
+ /**
71
+ * Sent as the PostHog event uuid, which PostHog dedupes on: a batch resent
72
+ * after a lost response or a crash before the queue was cleared lands once.
73
+ * Absent only on events queued before uuids were stored.
74
+ */
75
+ uuid?: string;
76
+ createdAt: number;
77
+ attempts: number;
78
+ nextAttemptAt: number;
79
+ }
80
+
81
+ type SendResult = "success" | "transient" | "permanent";
82
+
83
+ type FlushBatchResult =
84
+ | { state: "done"; nextRetryAt?: number }
85
+ | { state: "more" }
86
+ | { state: "retry"; nextRetryAt: number };
87
+
88
+ export class PostHogCaptureClient {
89
+ private readonly apiKey: string | undefined;
90
+ private readonly batchUrl: string;
91
+ private readonly fetchImpl: typeof fetch | undefined;
92
+ private readonly timeoutMs: number;
93
+ private readonly timeoutSignal: (timeoutMs: number) => AbortSignal;
94
+ private readonly queueDir: string | undefined;
95
+ private readonly now: () => number;
96
+ private readonly idFactory: () => string;
97
+ private memoryDrops: Partial<Record<DropReason, number>> = {};
98
+ private flushTimer: ReturnType<typeof setTimeout> | undefined;
99
+ private queuedSinceFlush = 0;
100
+ private defaultProperties: PostHogCaptureProperties = {};
101
+
102
+ constructor(options: PostHogCaptureClientOptions = {}) {
103
+ this.apiKey = options.apiKey?.trim() || undefined;
104
+ this.batchUrl = `${normalizeHost(options.host)}/batch/`;
105
+ this.fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
106
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_CAPTURE_TIMEOUT_MS;
107
+ this.timeoutSignal =
108
+ options.timeoutSignal ?? ((timeoutMs) => AbortSignal.timeout(timeoutMs));
109
+ this.queueDir = options.queueDir;
110
+ this.now = options.now ?? Date.now;
111
+ this.idFactory = options.idFactory ?? randomUUID;
112
+ }
113
+
114
+ static fromEnv(
115
+ env: NodeJS.ProcessEnv = process.env,
116
+ options: Omit<
117
+ PostHogCaptureClientOptions,
118
+ "apiKey" | "host" | "queueDir"
119
+ > = {},
120
+ ): PostHogCaptureClient {
121
+ const reviewHome = devReviewHome(env);
122
+
123
+ return new PostHogCaptureClient({
124
+ ...options,
125
+ apiKey:
126
+ nonEmpty(env[PROGRESSIVE_REVIEW_POSTHOG_KEY_ENV]) ??
127
+ nonEmpty(env.DEV_FAST_POSTHOG_KEY) ??
128
+ nonEmpty(env.POSTHOG_KEY) ??
129
+ EMBEDDED_PROGRESSIVE_REVIEW_POSTHOG_KEY,
130
+ host:
131
+ nonEmpty(env[PROGRESSIVE_REVIEW_POSTHOG_HOST_ENV]) ??
132
+ nonEmpty(env.DEV_FAST_POSTHOG_HOST) ??
133
+ nonEmpty(env.POSTHOG_HOST),
134
+ queueDir: path.join(reviewHome, "telemetry", "events"),
135
+ });
136
+ }
137
+
138
+ get enabled(): boolean {
139
+ return Boolean(this.apiKey && this.fetchImpl);
140
+ }
141
+
142
+ /** Properties for events the client emits itself, such as drop diagnostics. */
143
+ setDefaultProperties(properties: PostHogCaptureProperties): void {
144
+ this.defaultProperties = { ...properties };
145
+ }
146
+
147
+ async capture(input: PostHogCaptureInput): Promise<void> {
148
+ if (!this.enabled) return;
149
+
150
+ const uuid = this.idFactory();
151
+
152
+ const queued: QueuedPostHogEvent = {
153
+ uuid,
154
+ event: input.event,
155
+ distinctId: input.distinctId,
156
+ properties: compactProperties(input.properties ?? {}),
157
+ createdAt: input.timestamp ?? this.now(),
158
+ attempts: 0,
159
+ nextAttemptAt: 0,
160
+ };
161
+
162
+ if (!this.queueDir) {
163
+ await this.sendBatch([queued]);
164
+
165
+ return;
166
+ }
167
+
168
+ try {
169
+ writeFileAtomic(
170
+ path.join(this.queueDir, `${queued.createdAt}-${uuid}.json`),
171
+ `${JSON.stringify(queued)}\n`,
172
+ "utf8",
173
+ );
174
+ this.queuedSinceFlush += 1;
175
+ this.scheduleFlush(
176
+ this.queuedSinceFlush >= BATCH_LIMIT ? 0 : FLUSH_DELAY_MS,
177
+ );
178
+ } catch {
179
+ this.incrementMemoryDrop("storage_failure");
180
+ throw new Error("Could not store a telemetry event");
181
+ }
182
+ }
183
+
184
+ async discard(): Promise<void> {
185
+ this.clearFlushTimer();
186
+
187
+ if (!this.queueDir) return;
188
+ await withFileLock(
189
+ path.join(this.queueDir, ".flush.lock"),
190
+ {
191
+ retryMs: 10,
192
+ staleMs: 30_000,
193
+ timeoutMs: 1_000,
194
+ unownedGraceMs: 1_000,
195
+ heartbeatMs: 5_000,
196
+ },
197
+ async () => {
198
+ const files = await readdir(this.queueDir!).catch(() => []);
199
+ await Promise.all(
200
+ files
201
+ .filter((file) => file.endsWith(".json"))
202
+ .map((file) =>
203
+ rm(path.join(this.queueDir!, file), { force: true }),
204
+ ),
205
+ );
206
+ this.queuedSinceFlush = 0;
207
+ this.memoryDrops = {};
208
+ },
209
+ );
210
+ }
211
+
212
+ async flush(deadlineMs = DEFAULT_CAPTURE_TIMEOUT_MS): Promise<void> {
213
+ if (!this.enabled || !this.queueDir) return;
214
+ this.clearFlushTimer();
215
+ const startedAt = this.now();
216
+
217
+ const lock = await withFileLock(
218
+ path.join(this.queueDir, ".flush.lock"),
219
+ {
220
+ retryMs: 10,
221
+ staleMs: 30_000,
222
+ timeoutMs: Math.min(100, Math.max(0, deadlineMs)),
223
+ unownedGraceMs: 1_000,
224
+ heartbeatMs: 5_000,
225
+ },
226
+ async () => {
227
+ let result: FlushBatchResult;
228
+
229
+ do {
230
+ result = await this.flushBatchLocked(deadlineMs, startedAt);
231
+ } while (
232
+ result.state === "more" &&
233
+ this.now() - startedAt < deadlineMs
234
+ );
235
+
236
+ if (result.state === "more") this.scheduleFlush(0);
237
+
238
+ if ("nextRetryAt" in result && result.nextRetryAt !== undefined) {
239
+ this.scheduleFlush(Math.max(0, result.nextRetryAt - this.now()));
240
+ }
241
+ },
242
+ ).catch(() => ({ acquired: false as const }));
243
+
244
+ if (!lock.acquired) this.scheduleFlush(FLUSH_DELAY_MS);
245
+ }
246
+
247
+ async shutdown(deadlineMs = DEFAULT_CAPTURE_TIMEOUT_MS): Promise<void> {
248
+ this.clearFlushTimer();
249
+ await this.flush(deadlineMs);
250
+ }
251
+
252
+ private async flushBatchLocked(
253
+ deadlineMs: number,
254
+ startedAt: number,
255
+ ): Promise<FlushBatchResult> {
256
+ const queueDir = this.queueDir!;
257
+ const now = this.now();
258
+
259
+ const fileNames = (await readdir(queueDir).catch(() => []))
260
+ .filter((name) => name.endsWith(".json") && name !== DROPPED_FILE)
261
+ .sort();
262
+
263
+ const drops = mergeDropCounts(
264
+ await this.readDroppedCounts(),
265
+ this.memoryDrops,
266
+ );
267
+
268
+ this.memoryDrops = {};
269
+
270
+ const overflow = Math.max(0, fileNames.length - QUEUE_LIMIT);
271
+ const retainedNames = fileNames.slice(overflow);
272
+
273
+ for (const fileName of fileNames.slice(0, overflow)) {
274
+ await rm(path.join(queueDir, fileName), { force: true });
275
+ }
276
+
277
+ addDrop(drops, "queue_full", overflow);
278
+
279
+ const eligible: Array<{ fileName: string; event: QueuedPostHogEvent }> = [];
280
+ let hasMoreEligible = false;
281
+ let nextRetryAt: number | undefined;
282
+
283
+ for (const fileName of retainedNames) {
284
+ const filePath = path.join(queueDir, fileName);
285
+ const event = await readQueuedEvent(filePath);
286
+
287
+ if (!event) {
288
+ await rm(filePath, { force: true });
289
+ addDrop(drops, "corrupt", 1);
290
+ continue;
291
+ }
292
+
293
+ if (now - event.createdAt > QUEUE_MAX_AGE_MS) {
294
+ await rm(filePath, { force: true });
295
+ addDrop(drops, "expired", 1);
296
+ continue;
297
+ }
298
+
299
+ if (event.nextAttemptAt <= now) {
300
+ if (eligible.length < BATCH_LIMIT) {
301
+ eligible.push({ fileName, event });
302
+ } else {
303
+ hasMoreEligible = true;
304
+ }
305
+ } else {
306
+ nextRetryAt = Math.min(nextRetryAt ?? Infinity, event.nextAttemptAt);
307
+ }
308
+ }
309
+
310
+ await this.writeDroppedCounts(drops);
311
+
312
+ if (eligible.length === 0) {
313
+ this.queuedSinceFlush = 0;
314
+
315
+ return doneResult(nextRetryAt);
316
+ }
317
+
318
+ const diagnostics = await this.queueDropDiagnostics(
319
+ drops,
320
+ eligible[0]!.event.distinctId,
321
+ now,
322
+ );
323
+
324
+ const sentEligible = [...diagnostics, ...eligible].slice(0, BATCH_LIMIT);
325
+
326
+ hasMoreEligible ||=
327
+ sentEligible.length < diagnostics.length + eligible.length;
328
+
329
+ const batch = sentEligible.map(({ event }) => event);
330
+
331
+ const remainingMs = Math.max(1, deadlineMs - (this.now() - startedAt));
332
+ const result = await this.sendBatch(batch, remainingMs);
333
+
334
+ if (result === "success") {
335
+ await Promise.all(
336
+ sentEligible.map(({ fileName }) =>
337
+ rm(path.join(queueDir, fileName), { force: true }),
338
+ ),
339
+ );
340
+
341
+ this.queuedSinceFlush = 0;
342
+
343
+ return hasMoreEligible ? { state: "more" } : doneResult(nextRetryAt);
344
+ }
345
+
346
+ if (result === "permanent") {
347
+ await Promise.all(
348
+ sentEligible.map(({ fileName }) =>
349
+ rm(path.join(queueDir, fileName), { force: true }),
350
+ ),
351
+ );
352
+ // Earlier counts are queued as diagnostics by now; only this is new.
353
+ await this.writeDroppedCounts({
354
+ permanent_rejection: sentEligible.length,
355
+ });
356
+ this.queuedSinceFlush = 0;
357
+
358
+ return hasMoreEligible ? { state: "more" } : doneResult(nextRetryAt);
359
+ }
360
+
361
+ let retryAt = Infinity;
362
+
363
+ for (const { fileName, event } of sentEligible) {
364
+ const attempts = event.attempts + 1;
365
+ const eventRetryAt = now + retryDelay(attempts);
366
+ retryAt = Math.min(retryAt, eventRetryAt);
367
+ writeFileAtomic(
368
+ path.join(queueDir, fileName),
369
+ `${JSON.stringify({
370
+ ...event,
371
+ attempts,
372
+ nextAttemptAt: eventRetryAt,
373
+ })}\n`,
374
+ "utf8",
375
+ );
376
+ }
377
+
378
+ return { state: "retry", nextRetryAt: retryAt };
379
+ }
380
+
381
+ private async sendBatch(
382
+ events: readonly QueuedPostHogEvent[],
383
+ timeoutMs = this.timeoutMs,
384
+ ): Promise<SendResult> {
385
+ if (!this.apiKey || !this.fetchImpl || events.length === 0) {
386
+ return "success";
387
+ }
388
+
389
+ try {
390
+ const response = await this.fetchImpl(this.batchUrl, {
391
+ method: "POST",
392
+ headers: { "content-type": "application/json" },
393
+ body: JSON.stringify({
394
+ api_key: this.apiKey,
395
+ batch: events.map((event) => ({
396
+ uuid: event.uuid,
397
+ event: event.event,
398
+ properties: {
399
+ // The installation ID is random, so an event is personless
400
+ // unless its envelope says the install is aliased to an account.
401
+ $process_person_profile: false,
402
+ ...compactProperties(event.properties ?? {}),
403
+ distinct_id: event.distinctId,
404
+ },
405
+ timestamp: new Date(event.createdAt).toISOString(),
406
+ })),
407
+ }),
408
+ signal: this.timeoutSignal(Math.min(this.timeoutMs, timeoutMs)),
409
+ });
410
+
411
+ if (response.ok) return "success";
412
+
413
+ if (
414
+ response.status === 408 ||
415
+ response.status === 429 ||
416
+ response.status >= 500
417
+ ) {
418
+ return "transient";
419
+ }
420
+
421
+ return "permanent";
422
+ } catch {
423
+ return "transient";
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Turns the pending drop tally into queued events, each with its own uuid,
429
+ * and clears the tally. A diagnostic then retries like any event, so a
430
+ * batch resent after a lost response cannot count a drop twice.
431
+ */
432
+ private async queueDropDiagnostics(
433
+ drops: Partial<Record<DropReason, number>>,
434
+ distinctId: string,
435
+ now: number,
436
+ ): Promise<Array<{ fileName: string; event: QueuedPostHogEvent }>> {
437
+ const queueDir = this.queueDir!;
438
+
439
+ const diagnostics = DROP_REASONS.flatMap((reason) => {
440
+ const count = drops[reason];
441
+
442
+ if (count === undefined || count <= 0) return [];
443
+ const uuid = this.idFactory();
444
+
445
+ const event: QueuedPostHogEvent = {
446
+ uuid,
447
+ event: "review_telemetry_dropped",
448
+ distinctId,
449
+ properties: compactProperties({
450
+ ...this.defaultProperties,
451
+ reason,
452
+ count,
453
+ }),
454
+ createdAt: now,
455
+ attempts: 0,
456
+ nextAttemptAt: 0,
457
+ };
458
+
459
+ return [{ fileName: `${now}-${uuid}.json`, event }];
460
+ });
461
+
462
+ if (diagnostics.length === 0) return [];
463
+
464
+ for (const { fileName, event } of diagnostics) {
465
+ writeFileAtomic(
466
+ path.join(queueDir, fileName),
467
+ `${JSON.stringify(event)}\n`,
468
+ "utf8",
469
+ );
470
+ }
471
+
472
+ await rm(path.join(queueDir, DROPPED_FILE), { force: true });
473
+
474
+ return diagnostics;
475
+ }
476
+
477
+ private scheduleFlush(delayMs: number): void {
478
+ if (!this.queueDir || this.flushTimer) return;
479
+ this.flushTimer = setTimeout(() => {
480
+ this.flushTimer = undefined;
481
+ void this.flush();
482
+ }, delayMs);
483
+ this.flushTimer.unref?.();
484
+ }
485
+
486
+ private clearFlushTimer(): void {
487
+ if (this.flushTimer) clearTimeout(this.flushTimer);
488
+ this.flushTimer = undefined;
489
+ }
490
+
491
+ private incrementMemoryDrop(reason: DropReason): void {
492
+ this.memoryDrops[reason] = (this.memoryDrops[reason] ?? 0) + 1;
493
+ }
494
+
495
+ private async readDroppedCounts(): Promise<
496
+ Partial<Record<DropReason, number>>
497
+ > {
498
+ if (!this.queueDir) return {};
499
+
500
+ return readFile(path.join(this.queueDir, DROPPED_FILE), "utf8")
501
+ .then((value) => {
502
+ const parsed = droppedCountsSchema.safeParse(parseJsonText(value));
503
+
504
+ return parsed.success ? parsed.data : {};
505
+ })
506
+ .catch(() => ({}));
507
+ }
508
+
509
+ private async writeDroppedCounts(
510
+ drops: Partial<Record<DropReason, number>>,
511
+ ): Promise<void> {
512
+ if (!this.queueDir) return;
513
+
514
+ const compact = Object.fromEntries(
515
+ Object.entries(drops).filter(([, count]) => Number(count) > 0),
516
+ );
517
+
518
+ if (Object.keys(compact).length === 0) return;
519
+ writeFileAtomic(
520
+ path.join(this.queueDir, DROPPED_FILE),
521
+ `${JSON.stringify(compact)}\n`,
522
+ "utf8",
523
+ );
524
+ }
525
+ }
526
+
527
+ function doneResult(nextRetryAt: number | undefined): FlushBatchResult {
528
+ const result: FlushBatchResult = { state: "done" };
529
+
530
+ if (nextRetryAt) result.nextRetryAt = nextRetryAt;
531
+
532
+ return result;
533
+ }
534
+
535
+ /** A queued event as this module wrote it to disk. */
536
+ const QueuedPostHogEventSchema = z.object({
537
+ uuid: z.string().min(1).optional(),
538
+ event: z.string(),
539
+ distinctId: z.string(),
540
+ properties: z.record(z.string(), jsonValueSchema).optional(),
541
+ createdAt: z.number(),
542
+ attempts: z.number(),
543
+ nextAttemptAt: z.number(),
544
+ });
545
+
546
+ async function readQueuedEvent(
547
+ filePath: string,
548
+ ): Promise<QueuedPostHogEvent | undefined> {
549
+ try {
550
+ const parsed = QueuedPostHogEventSchema.safeParse(
551
+ parseJsonText(await readFile(filePath, "utf8")),
552
+ );
553
+
554
+ return parsed.success ? parsed.data : undefined;
555
+ } catch {
556
+ return undefined;
557
+ }
558
+ }
559
+
560
+ function mergeDropCounts(
561
+ left: Partial<Record<DropReason, number>>,
562
+ right: Partial<Record<DropReason, number>>,
563
+ ): Partial<Record<DropReason, number>> {
564
+ const merged = { ...left };
565
+
566
+ for (const reason of DROP_REASONS) {
567
+ const count = right[reason];
568
+
569
+ if (count !== undefined) addDrop(merged, reason, count);
570
+ }
571
+
572
+ return merged;
573
+ }
574
+
575
+ function addDrop(
576
+ drops: Partial<Record<DropReason, number>>,
577
+ reason: DropReason,
578
+ count: number,
579
+ ): void {
580
+ if (count > 0) drops[reason] = (drops[reason] ?? 0) + count;
581
+ }
582
+
583
+ function retryDelay(attempts: number): number {
584
+ const base = Math.min(
585
+ MAX_BACKOFF_MS,
586
+ 1_000 * 2 ** Math.min(attempts - 1, 12),
587
+ );
588
+
589
+ return Math.round(base * (0.75 + Math.random() * 0.5));
590
+ }
591
+
592
+ function normalizeHost(host: string | undefined): string {
593
+ return (host?.trim() || DEFAULT_POSTHOG_HOST).replace(/\/+$/, "");
594
+ }
595
+
596
+ function nonEmpty(value: string | undefined): string | undefined {
597
+ const trimmed = value?.trim();
598
+
599
+ return trimmed ? trimmed : undefined;
600
+ }
601
+
602
+ function compactProperties(
603
+ properties: PostHogCaptureProperties,
604
+ ): Record<string, JsonValue> {
605
+ return Object.fromEntries(
606
+ Object.entries(properties).filter(
607
+ (entry): entry is [string, JsonValue] => entry[1] !== undefined,
608
+ ),
609
+ );
610
+ }