@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,1066 @@
1
+ import { createHmac, randomUUID } from "node:crypto";
2
+ import { rmSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+
6
+ import {
7
+ jsonObject,
8
+ jsonString,
9
+ parseJsonText,
10
+ } from "@dev.fast/review-protocol";
11
+ import {
12
+ processIsAlive,
13
+ withFileLock,
14
+ writeFileAtomic,
15
+ } from "@dev.fast/trace-core";
16
+ import { valid as validSemver } from "semver";
17
+
18
+ import { resolveAuthoringSessionRef } from "./agent-session-ref";
19
+ import { EMBEDDED_PROGRESSIVE_REVIEW_POSTHOG_KEY } from "./embedded-posthog-key";
20
+ import {
21
+ type ChunkIds,
22
+ exceptionProperties,
23
+ readChunkIds,
24
+ } from "./exception-telemetry";
25
+ import { readReviewPackageVersion as readReviewPackageVersionSync } from "./package-paths";
26
+ import {
27
+ PROGRESSIVE_REVIEW_POSTHOG_HOST_ENV,
28
+ PROGRESSIVE_REVIEW_POSTHOG_KEY_ENV,
29
+ PostHogCaptureClient,
30
+ type PostHogCaptureInput,
31
+ type PostHogCaptureProperties,
32
+ } from "./posthog-capture-client";
33
+ import {
34
+ type OpenSessionMarker,
35
+ clearOpenSession,
36
+ launchEnvelope,
37
+ openSessionMarkersPath,
38
+ recordOpenSession,
39
+ takeOpenSessions,
40
+ } from "./session-markers";
41
+ import {
42
+ type ReviewTelemetryInstallConfig,
43
+ type ReviewTelemetrySurface,
44
+ createTelemetryInstallConfig,
45
+ installAgeDays,
46
+ isInternalTelemetry,
47
+ isTelemetryOptedOut,
48
+ isUuidV7,
49
+ legacyAppTelemetryConfigPath,
50
+ normalizeTelemetryInstallConfig,
51
+ reviewTelemetryChannel,
52
+ reviewTelemetryConfigPath,
53
+ reviewTelemetryEnvironment,
54
+ telemetryInstallConfigNeedsWrite,
55
+ } from "./telemetry-config";
56
+ import { createTelemetryDebugSink } from "./telemetry-debug-sink";
57
+ import {
58
+ type ReviewSessionAgent,
59
+ type ReviewSessionOutcome,
60
+ } from "./ui-telemetry-events";
61
+
62
+ export const REVIEW_APP_VERSION_ENV = "DEV_FAST_REVIEW_APP_VERSION";
63
+
64
+ export const REVIEW_APP_SESSION_ID_ENV = "DEV_FAST_REVIEW_APP_SESSION_ID";
65
+
66
+ const REVIEW_SERVER_ENTRY_ENV = "DEV_FAST_REVIEW_SERVER_ENTRY";
67
+
68
+ /** Install config fields announceOnce guards. */
69
+ type AnnouncedField =
70
+ | "installationCreatedSent"
71
+ | "firstReviewPresentedSent"
72
+ | "accountAlias";
73
+
74
+ /**
75
+ * A fixed namespace, not the installation id: a key per install would give
76
+ * every install a different alias for one account and defeat the linking.
77
+ * The hash is one-way; the account id itself is never sent.
78
+ */
79
+ const ACCOUNT_ALIAS_KEY = "dev.fast.review.telemetry.account.v1";
80
+
81
+ /** `gh_` + the first 16 bytes of HMAC-SHA256(namespace, account id). */
82
+ export function accountAlias(accountId: string): string {
83
+ const digest = createHmac("sha256", ACCOUNT_ALIAS_KEY)
84
+ .update(accountId)
85
+ .digest()
86
+ .subarray(0, 16)
87
+ .toString("base64url");
88
+
89
+ return `gh_${digest}`;
90
+ }
91
+
92
+ /** A tool name is program-owned, but only an identifier is ever sent. */
93
+ const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]{0,39}$/;
94
+
95
+ export interface ReviewToolCall {
96
+ tool: string;
97
+ via: "api" | "mcp";
98
+ ok: boolean;
99
+ durationMs: number;
100
+ /** Set on a failed call: why it failed, from the closed CLI vocabulary. */
101
+ errorName?: ReviewTelemetryErrorName;
102
+ errorCategory?: ReviewTelemetryErrorCategory;
103
+ /** The calling agent when the caller knows better than this process's environment. */
104
+ agentKind?: ReviewSessionAgent;
105
+ }
106
+
107
+ export type ReviewCliCommand = "review" | "map" | "status";
108
+
109
+ export type ReviewCliCommandPath =
110
+ | "help"
111
+ | "version"
112
+ | "app.launch"
113
+ | "app.pick"
114
+ | "info"
115
+ | "connect"
116
+ | "instances"
117
+ | "instances.use"
118
+ | "instances.clear"
119
+ | "migrate.apply"
120
+ | "login"
121
+ | "logout"
122
+ | "whoami"
123
+ | "trace.store.create"
124
+ | "trace.store.delete"
125
+ | "trace.store.info"
126
+ | "trace.install"
127
+ | "trace.allow"
128
+ | "trace.deny"
129
+ | "trace.storage.use"
130
+ | "trace.config.migrate"
131
+ | "api"
132
+ | "mcp"
133
+ | "server.start"
134
+ | "invalid";
135
+
136
+ export type ReviewTelemetryErrorName =
137
+ | "usage_error"
138
+ | "review_not_found"
139
+ | "review_state_error"
140
+ | "repository_error"
141
+ | "desktop_connection_error"
142
+ | "network_error"
143
+ | "storage_error"
144
+ | "index_error"
145
+ | "process_error"
146
+ | "unexpected_error";
147
+
148
+ export type ReviewTelemetryErrorCategory =
149
+ | "user_input"
150
+ | "local_state"
151
+ | "dependency"
152
+ | "transport"
153
+ | "internal";
154
+
155
+ export type { ReviewSessionAgent, ReviewSessionOutcome };
156
+
157
+ export type ReviewTelemetryTab =
158
+ | "review"
159
+ | "commits"
160
+ | "map"
161
+ | "files"
162
+ | "trace";
163
+
164
+ export type ReviewTabTelemetryReason =
165
+ | "tab_change"
166
+ | "visibility_hidden"
167
+ | "pagehide"
168
+ | "unmount";
169
+
170
+ export interface ReviewTabTelemetryEvent {
171
+ tab: ReviewTelemetryTab;
172
+ durationMs: number;
173
+ reason: ReviewTabTelemetryReason;
174
+ appSessionId: string;
175
+ }
176
+
177
+ export interface ReviewCommandTelemetryInput {
178
+ command: ReviewCliCommandPath;
179
+ commandRunId: string;
180
+ exitCode: number;
181
+ durationMs?: number;
182
+ properties?: PostHogCaptureProperties;
183
+ errorName?: ReviewTelemetryErrorName;
184
+ errorCategory?: ReviewTelemetryErrorCategory;
185
+ }
186
+
187
+ export interface ReviewCommandStartedInput {
188
+ command: ReviewCliCommandPath;
189
+ commandRunId: string;
190
+ }
191
+
192
+ export interface ReviewTelemetryContext {
193
+ reviewUuid?: string;
194
+ presentationSessionId?: string;
195
+ }
196
+
197
+ export interface ReviewTelemetryCaptureClient {
198
+ readonly enabled: boolean;
199
+ /**
200
+ * True for a client that prints events instead of sending them. The opt-out
201
+ * stops sending, so it does not apply to such a client.
202
+ */
203
+ readonly ignoresOptOut?: boolean;
204
+ capture(input: PostHogCaptureInput): Promise<void>;
205
+ setDefaultProperties?(properties: PostHogCaptureProperties): void;
206
+ flush?(deadlineMs?: number): Promise<void>;
207
+ shutdown?(deadlineMs?: number): Promise<void>;
208
+ discard?(): Promise<void>;
209
+ }
210
+
211
+ export interface ReviewTelemetryOptions {
212
+ captureClient?: ReviewTelemetryCaptureClient;
213
+ env?: NodeJS.ProcessEnv;
214
+ installConfigPath?: string;
215
+ legacyInstallConfigPath?: string;
216
+ idFactory?: () => string;
217
+ randomUUID?: () => string;
218
+ now?: () => Date;
219
+ fetch?: typeof fetch;
220
+ timeoutMs?: number;
221
+ openSessionMarkersPath?: string;
222
+ /** The process recorded as owning open sessions; defaults to the parent. */
223
+ openSessionOwnerPid?: number;
224
+ /** Which process family sends this instance's events. */
225
+ surface?: ReviewTelemetrySurface;
226
+ }
227
+
228
+ /** A single structured value a log line may carry beside its message. */
229
+ export type LoggerAttributeValue = string | number | boolean | null | undefined;
230
+
231
+ export type LoggerAttributes = Record<string, LoggerAttributeValue>;
232
+
233
+ export interface Logger {
234
+ trace(message: string, attributes?: LoggerAttributes): void;
235
+ debug(message: string, attributes?: LoggerAttributes): void;
236
+ info(message: string, attributes?: LoggerAttributes): void;
237
+ warn(message: string, attributes?: LoggerAttributes): void;
238
+ error(message: string, attributes?: LoggerAttributes): void;
239
+ }
240
+
241
+ const noop = () => undefined;
242
+
243
+ const noopLogger: Logger = {
244
+ trace: noop,
245
+ debug: noop,
246
+ info: noop,
247
+ warn: noop,
248
+ error: noop,
249
+ };
250
+
251
+ const sharedInstallConfigs = new Map<string, ReviewTelemetryInstallConfig>();
252
+
253
+ /**
254
+ * Announcements the debug sink printed without persisting, keyed by install
255
+ * config path and field, so each prints once per process.
256
+ */
257
+ const printedAnnouncements = new Set<string>();
258
+
259
+ export function createLogger(_scope: string): Logger {
260
+ return noopLogger;
261
+ }
262
+
263
+ /** The telemetry surface a CLI command run drives; tests fake this contract. */
264
+ export type ReviewCommandTelemetry = Pick<
265
+ ReviewTelemetry,
266
+ | "createCommandRunId"
267
+ | "setSurface"
268
+ | "setDesktopVersion"
269
+ | "captureInstallationCreated"
270
+ | "captureCommandStarted"
271
+ | "captureCommandSucceeded"
272
+ | "captureCommandFailed"
273
+ | "captureUiEvent"
274
+ | "captureToolCalled"
275
+ | "captureAccountAlias"
276
+ | "shutdown"
277
+ >;
278
+
279
+ export class ReviewTelemetry {
280
+ private readonly captureClient: ReviewTelemetryCaptureClient;
281
+ private readonly env: NodeJS.ProcessEnv;
282
+ private readonly installConfigPath: string;
283
+ private readonly legacyInstallConfigPath: string;
284
+ private readonly openSessionMarkersPath: string;
285
+ private readonly openSessionOwnerPid: number;
286
+ private readonly idFactory: () => string;
287
+ private readonly commandRunIdFactory: () => string;
288
+ private readonly now: () => Date;
289
+ private surface: ReviewTelemetrySurface;
290
+ private readonly packageVersion: string;
291
+ private desktopVersion: string | undefined;
292
+ private installConfig: ReviewTelemetryInstallConfig | undefined;
293
+ private chunkIds: ChunkIds | undefined;
294
+
295
+ constructor(options: ReviewTelemetryOptions = {}) {
296
+ this.env = options.env ?? process.env;
297
+ this.captureClient =
298
+ options.captureClient ??
299
+ createTelemetryDebugSink(this.env) ??
300
+ (options.fetch
301
+ ? directCaptureClient(this.env, options.fetch, options.timeoutMs)
302
+ : PostHogCaptureClient.fromEnv(this.env));
303
+ this.installConfigPath =
304
+ options.installConfigPath ?? reviewTelemetryConfigPath(this.env);
305
+ this.legacyInstallConfigPath =
306
+ options.legacyInstallConfigPath ?? legacyAppTelemetryConfigPath(this.env);
307
+ this.openSessionMarkersPath =
308
+ options.openSessionMarkersPath ?? openSessionMarkersPath(this.env);
309
+ this.openSessionOwnerPid = options.openSessionOwnerPid ?? process.ppid;
310
+ this.commandRunIdFactory = options.randomUUID ?? randomUUID;
311
+ this.idFactory = options.idFactory ?? this.commandRunIdFactory;
312
+ this.now = options.now ?? (() => new Date());
313
+ this.surface = options.surface ?? "cli";
314
+ this.packageVersion = readReviewPackageVersionSync(import.meta.url);
315
+ }
316
+
317
+ static fromEnv(
318
+ env: NodeJS.ProcessEnv = process.env,
319
+ options: Omit<ReviewTelemetryOptions, "env"> = {},
320
+ ): ReviewTelemetry {
321
+ return new ReviewTelemetry({ ...options, env });
322
+ }
323
+
324
+ async getInstallationId(): Promise<string> {
325
+ return (await this.loadInstallConfig()).installationId;
326
+ }
327
+
328
+ createCommandRunId(): string {
329
+ return this.commandRunIdFactory();
330
+ }
331
+
332
+ /** Sets the surface for every later event, envelope included. */
333
+ setSurface(surface: ReviewTelemetrySurface): void {
334
+ this.surface = surface;
335
+ }
336
+
337
+ /**
338
+ * Records the release of the Desktop this process reached, for `app_version`
339
+ * on every later event. A process Desktop launched already has it in its env.
340
+ */
341
+ setDesktopVersion(version: string | undefined): void {
342
+ const value = nonEmpty(version);
343
+
344
+ this.desktopVersion = value && validSemver(value) ? value : undefined;
345
+ }
346
+
347
+ async setEnabled(enabled: boolean): Promise<void> {
348
+ await this.withConfigLock(async () => {
349
+ const config = await this.readOrCreateInstallConfig();
350
+ config.enabled = enabled;
351
+ this.writeInstallConfig(config);
352
+ this.installConfig = config;
353
+ sharedInstallConfigs.set(this.installConfigPath, config);
354
+ }, 5_000);
355
+
356
+ if (!enabled) {
357
+ await this.captureClient.discard?.().catch(() => undefined);
358
+ // Sessions opened before the opt-out must not end as abnormal when
359
+ // telemetry comes back weeks later.
360
+ await this.lockOpenSessions(() =>
361
+ rmSync(this.openSessionMarkersPath, { force: true }),
362
+ );
363
+ }
364
+ }
365
+
366
+ async setInternal(internal: boolean): Promise<void> {
367
+ await this.withConfigLock(async () => {
368
+ const config = await this.readOrCreateInstallConfig();
369
+ config.internal = internal;
370
+ this.writeInstallConfig(config);
371
+ this.installConfig = config;
372
+ sharedInstallConfigs.set(this.installConfigPath, config);
373
+ }, 5_000);
374
+ }
375
+
376
+ /**
377
+ * Whether events leave this machine right now: a real client, no opt-out.
378
+ * The debug sink prints instead of sending, so it answers false.
379
+ */
380
+ async sendsEvents(): Promise<boolean> {
381
+ if (this.captureClient.ignoresOptOut) return false;
382
+
383
+ return this.isEnabled();
384
+ }
385
+
386
+ /** The common properties every event carries; bug reports embed them. */
387
+ async envelope(): Promise<PostHogCaptureProperties> {
388
+ return this.commonProperties(await this.loadInstallConfig());
389
+ }
390
+
391
+ async captureInstallationCreated(): Promise<void> {
392
+ await this.announceOnce("installationCreatedSent", true, async (config) => {
393
+ await this.captureClient.capture({
394
+ event: "review_installation_created",
395
+ distinctId: config.installationId,
396
+ properties: await this.commonProperties(config),
397
+ });
398
+ });
399
+ }
400
+
401
+ /**
402
+ * Link this installation to a signed-in account by a one-way hash, so
403
+ * several installs by one person count as one; the raw account id never
404
+ * leaves this process. Once per installation: the first account wins. A
405
+ * later login to another account sends nothing, because a second alias
406
+ * would merge two accounts, and every later install of either, into one
407
+ * PostHog person. The account is looked up only when an alias would be
408
+ * sent, so a login with telemetry off makes no network call.
409
+ */
410
+ async captureAccountAlias(
411
+ lookupAccountId: () => Promise<string>,
412
+ ): Promise<void> {
413
+ if (!(await this.isEnabled()) || this.installConfig?.accountAlias) return;
414
+
415
+ const alias = accountAlias(await lookupAccountId());
416
+
417
+ await this.announceOnce("accountAlias", alias, async (config) => {
418
+ await this.captureClient.capture({
419
+ event: "$create_alias",
420
+ distinctId: config.installationId,
421
+ // PostHog merges identities only while processing persons, so the
422
+ // alias itself must turn processing on.
423
+ properties: {
424
+ ...(await this.commonProperties(config)),
425
+ alias,
426
+ $process_person_profile: true,
427
+ },
428
+ });
429
+ });
430
+ }
431
+
432
+ /**
433
+ * Sends an event exactly once per installation, guarded by a persisted
434
+ * install config field that `value` fills. The field is persisted before
435
+ * the send completes: under-counting
436
+ * is recoverable, announcing twice is not. A printed event is not a sent
437
+ * event, so the debug sink leaves the field alone (it still always sends,
438
+ * ignoring opt-out as today) and only remembers the announcement for the
439
+ * life of the process.
440
+ */
441
+ private async announceOnce<Field extends AnnouncedField>(
442
+ field: Field,
443
+ value: NonNullable<ReviewTelemetryInstallConfig[Field]>,
444
+ send: (config: ReviewTelemetryInstallConfig) => Promise<void>,
445
+ ): Promise<void> {
446
+ if (!this.captureClient.enabled || this.optedOut()) return;
447
+ await this.withConfigLock(async () => {
448
+ const config = await this.readOrCreateInstallConfig();
449
+ this.installConfig = config;
450
+ sharedInstallConfigs.set(this.installConfigPath, config);
451
+
452
+ if (this.optedOut(config) || config[field]) return;
453
+
454
+ if (this.captureClient.ignoresOptOut) {
455
+ const printed = `${this.installConfigPath}\0${field}`;
456
+
457
+ if (printedAnnouncements.has(printed)) return;
458
+
459
+ printedAnnouncements.add(printed);
460
+ } else {
461
+ config[field] = value;
462
+ this.writeInstallConfig(config);
463
+ }
464
+
465
+ await send(config);
466
+ });
467
+ }
468
+
469
+ async captureCommandSucceeded(
470
+ input: ReviewCommandTelemetryInput,
471
+ ): Promise<void> {
472
+ await this.captureCommandEvent("review_command_succeeded", input);
473
+ }
474
+
475
+ async captureCommandFailed(
476
+ input: ReviewCommandTelemetryInput,
477
+ ): Promise<void> {
478
+ await this.captureCommandEvent("review_command_failed", input);
479
+ }
480
+
481
+ async captureCommandStarted(input: ReviewCommandStartedInput): Promise<void> {
482
+ const properties: PostHogCaptureProperties = {
483
+ command_path: input.command,
484
+ command_run_id: input.commandRunId,
485
+ agent_kind: this.sessionAgent(),
486
+ };
487
+
488
+ await this.captureEvent("review_command_started", properties);
489
+ }
490
+
491
+ async captureToolCalled(call: ReviewToolCall): Promise<void> {
492
+ const properties: PostHogCaptureProperties = {
493
+ tool: TOOL_NAME_PATTERN.test(call.tool) ? call.tool : "other",
494
+ via: call.via,
495
+ ok: call.ok,
496
+ duration_ms: Math.max(0, Math.round(call.durationMs)),
497
+ agent_kind: call.agentKind ?? this.sessionAgent(),
498
+ };
499
+
500
+ if (!call.ok && call.errorName) properties.error_name = call.errorName;
501
+
502
+ if (!call.ok && call.errorCategory)
503
+ properties.error_category = call.errorCategory;
504
+
505
+ await this.captureEvent("review_mcp_tool_called", properties);
506
+ }
507
+
508
+ /**
509
+ * A tab dwell period ended. Time on the files tab is also the diff dwell,
510
+ * so it doubles as `review_diff_viewed` without a second client beacon.
511
+ */
512
+ async captureTabViewed(
513
+ event: ReviewTabTelemetryEvent,
514
+ context?: ReviewTelemetryContext,
515
+ ): Promise<void> {
516
+ await this.captureEvent(
517
+ "review_tab_viewed",
518
+ {
519
+ tab: event.tab,
520
+ duration_ms: event.durationMs,
521
+ reason: event.reason,
522
+ source: "review_app",
523
+ app_session_id: event.appSessionId,
524
+ },
525
+ context,
526
+ );
527
+
528
+ if (event.tab !== "files") return;
529
+
530
+ await this.captureEvent(
531
+ "review_diff_viewed",
532
+ {
533
+ duration_ms: event.durationMs,
534
+ source: "review_app",
535
+ app_session_id: event.appSessionId,
536
+ },
537
+ context,
538
+ );
539
+ }
540
+
541
+ /**
542
+ * A session start leaves a marker until its end arrives, so a session the
543
+ * process never closed can be reported as abnormal on the next launch. The
544
+ * marker is cleared before the end is sent: a lost marker for a sent end
545
+ * is harmless, a stale one would report the session ended twice.
546
+ */
547
+ async captureUiEvent(
548
+ event: string,
549
+ properties: Record<string, string | number | boolean>,
550
+ context?: ReviewTelemetryContext,
551
+ occurredAt = this.now().getTime(),
552
+ ): Promise<void> {
553
+ const reviewUuid = context?.reviewUuid;
554
+ const presentationSessionId = context?.presentationSessionId;
555
+ const inSession = reviewUuid && presentationSessionId;
556
+
557
+ if (inSession && event === "review_session_started") {
558
+ const marker: OpenSessionMarker = {
559
+ presentationSessionId,
560
+ reviewUuid,
561
+ startedAt: this.now().getTime(),
562
+ ownerPid: this.openSessionOwnerPid,
563
+ };
564
+
565
+ const appSessionId = nonEmpty(properties.app_session_id?.toString());
566
+
567
+ if (appSessionId) marker.appSessionId = appSessionId;
568
+
569
+ if (await this.isEnabled()) {
570
+ // Read before the lock: the envelope may wait on the config lock, and
571
+ // every Desktop on this home shares the markers lock.
572
+ const envelope = launchEnvelope(
573
+ await this.envelope().catch(() => ({})),
574
+ );
575
+
576
+ if (envelope) marker.envelope = envelope;
577
+ await this.lockOpenSessions(() =>
578
+ recordOpenSession(this.openSessionMarkersPath, marker),
579
+ );
580
+ }
581
+ } else if (inSession && event === "review_session_ended") {
582
+ await this.updateOpenSessions(() =>
583
+ clearOpenSession(this.openSessionMarkersPath, presentationSessionId),
584
+ );
585
+ }
586
+
587
+ await this.captureEvent(
588
+ event,
589
+ {
590
+ source: "review_app",
591
+ ...properties,
592
+ },
593
+ context,
594
+ occurredAt,
595
+ );
596
+
597
+ if (inSession && event === "review_review_presented") {
598
+ await this.captureFirstReviewPresented(context).catch(() => undefined);
599
+ }
600
+
601
+ // PostHog error tracking groups on $exception; the custom event stays for
602
+ // one release so the existing error insights keep working.
603
+ if (event !== "review_client_error") return;
604
+
605
+ const exception = exceptionProperties(
606
+ properties,
607
+ (this.chunkIds ??= readChunkIds(this.env[REVIEW_SERVER_ENTRY_ENV])),
608
+ );
609
+
610
+ if (!exception) return;
611
+ await this.captureEvent(
612
+ "$exception",
613
+ { source: "review_app", ...properties, ...exception },
614
+ context,
615
+ );
616
+ }
617
+
618
+ /**
619
+ * Reports sessions an earlier app launch never closed as abnormal ends.
620
+ * Sessions of the current launch survive a server restart untouched. Call
621
+ * once at startup.
622
+ */
623
+ async reconcileOpenSessions(): Promise<void> {
624
+ const currentAppSessionId = nonEmpty(this.env[REVIEW_APP_SESSION_ID_ENV]);
625
+ const ended: OpenSessionMarker[] = [];
626
+
627
+ await this.updateOpenSessions(() => {
628
+ for (const marker of takeOpenSessions(this.openSessionMarkersPath)) {
629
+ const stillOpen =
630
+ (currentAppSessionId !== undefined &&
631
+ marker.appSessionId === currentAppSessionId) ||
632
+ (marker.ownerPid !== undefined && processIsAlive(marker.ownerPid));
633
+
634
+ if (stillOpen) {
635
+ recordOpenSession(this.openSessionMarkersPath, marker);
636
+ } else {
637
+ ended.push(marker);
638
+ }
639
+ }
640
+ });
641
+
642
+ for (const marker of ended) {
643
+ const outcome: ReviewSessionOutcome = "abnormal";
644
+
645
+ // Overrides the envelope with the launch the session belonged to. An
646
+ // unknown app session is dropped rather than misattributed; a legacy
647
+ // marker without a stored envelope keeps the current one.
648
+ const launch = marker.envelope && {
649
+ app_version: undefined,
650
+ ...marker.envelope,
651
+ };
652
+
653
+ const properties: PostHogCaptureProperties = {
654
+ ...launch,
655
+ source: "review_app",
656
+ outcome,
657
+ app_session_id: marker.appSessionId,
658
+ };
659
+
660
+ await this.captureEvent("review_session_ended", properties, {
661
+ reviewUuid: marker.reviewUuid,
662
+ presentationSessionId: marker.presentationSessionId,
663
+ });
664
+ }
665
+ }
666
+
667
+ /**
668
+ * `occurredAt` defaults to the call, before any await: config and marker
669
+ * locks must not reorder events that happened in order.
670
+ */
671
+ async captureEvent(
672
+ event: string,
673
+ properties: PostHogCaptureProperties = {},
674
+ context?: ReviewTelemetryContext,
675
+ occurredAt = this.now().getTime(),
676
+ ): Promise<void> {
677
+ await this.withTelemetry(async (config) => {
678
+ const common = await this.commonProperties(config);
679
+ this.captureClient.setDefaultProperties?.(common);
680
+ await this.captureClient.capture({
681
+ event,
682
+ distinctId: config.installationId,
683
+ properties: withSessionId({
684
+ ...common,
685
+ ...properties,
686
+ ...correlationProperties(config.installationId, context),
687
+ }),
688
+ timestamp: occurredAt,
689
+ });
690
+ });
691
+ }
692
+
693
+ private async captureFirstReviewPresented(
694
+ context: ReviewTelemetryContext,
695
+ ): Promise<void> {
696
+ await this.announceOnce("firstReviewPresentedSent", true, async () => {
697
+ await this.captureEvent(
698
+ "review_first_review_presented",
699
+ { source: "review_app" },
700
+ context,
701
+ );
702
+ });
703
+ }
704
+
705
+ /**
706
+ * Marker I/O is best effort, skipped entirely when telemetry is off, and
707
+ * locked because concurrent Desktops share the file.
708
+ */
709
+ private async updateOpenSessions(
710
+ update: () => void | Promise<void>,
711
+ ): Promise<void> {
712
+ if (!(await this.isEnabled())) return;
713
+ await this.lockOpenSessions(update);
714
+ }
715
+
716
+ private async lockOpenSessions(
717
+ update: () => void | Promise<void>,
718
+ ): Promise<void> {
719
+ try {
720
+ await withFileLock(
721
+ `${this.openSessionMarkersPath}.lock`,
722
+ {
723
+ retryMs: 10,
724
+ staleMs: 30_000,
725
+ timeoutMs: 250,
726
+ unownedGraceMs: 1_000,
727
+ heartbeatMs: 5_000,
728
+ },
729
+ async () => update(),
730
+ );
731
+ } catch {
732
+ // A full disk must not break the review.
733
+ }
734
+ }
735
+
736
+ async flush(deadlineMs = 1_000): Promise<void> {
737
+ if (!(await this.isEnabled())) return;
738
+ await this.captureClient.flush?.(deadlineMs).catch(() => undefined);
739
+ }
740
+
741
+ async shutdown(deadlineMs = 1_000): Promise<void> {
742
+ if (!(await this.isEnabled())) return;
743
+ await this.captureClient.shutdown?.(deadlineMs).catch(() => undefined);
744
+ }
745
+
746
+ private async captureCommandEvent(
747
+ event: "review_command_succeeded" | "review_command_failed",
748
+ input: ReviewCommandTelemetryInput,
749
+ ): Promise<void> {
750
+ const properties: PostHogCaptureProperties = {
751
+ command_path: input.command,
752
+ exit_code: input.exitCode,
753
+ };
754
+
755
+ if (input.durationMs !== undefined)
756
+ properties.duration_ms = input.durationMs;
757
+ Object.assign(properties, input.properties);
758
+ properties.command_run_id = input.commandRunId;
759
+
760
+ if (input.errorName) properties.error_name = input.errorName;
761
+
762
+ if (input.errorCategory) properties.error_category = input.errorCategory;
763
+
764
+ await this.captureEvent(event, properties);
765
+ }
766
+
767
+ private async withTelemetry(
768
+ fn: (config: ReviewTelemetryInstallConfig) => Promise<void>,
769
+ ): Promise<void> {
770
+ if (!this.captureClient.enabled || this.optedOut()) return;
771
+
772
+ try {
773
+ const config = await this.loadInstallConfig();
774
+
775
+ if (this.optedOut(config)) return;
776
+ await fn(config);
777
+ } catch {
778
+ // Telemetry is best effort and must never affect Review behavior.
779
+ }
780
+ }
781
+
782
+ private async loadInstallConfig(): Promise<ReviewTelemetryInstallConfig> {
783
+ const shared = sharedInstallConfigs.get(this.installConfigPath);
784
+
785
+ if (shared) {
786
+ this.installConfig = shared;
787
+
788
+ return shared;
789
+ }
790
+
791
+ if (this.installConfig) return this.installConfig;
792
+ await this.withConfigLock(async () => {
793
+ this.installConfig = await this.readOrCreateInstallConfig();
794
+ sharedInstallConfigs.set(this.installConfigPath, this.installConfig);
795
+ });
796
+
797
+ return (
798
+ this.installConfig ??
799
+ createTelemetryInstallConfig(this.idFactory(), this.now)
800
+ );
801
+ }
802
+
803
+ private optedOut(config?: ReviewTelemetryInstallConfig): boolean {
804
+ if (this.captureClient.ignoresOptOut) return false;
805
+
806
+ return isTelemetryOptedOut(this.env, config);
807
+ }
808
+
809
+ private async isEnabled(): Promise<boolean> {
810
+ if (!this.captureClient.enabled || this.optedOut()) {
811
+ return false;
812
+ }
813
+
814
+ try {
815
+ const parsed = parseJsonText(
816
+ await readFile(this.installConfigPath, "utf8"),
817
+ );
818
+
819
+ const config = normalizeTelemetryInstallConfig(parsed, this.now);
820
+
821
+ if (!config) return true;
822
+
823
+ // A config that still needs its one-time write is left for
824
+ // readOrCreateInstallConfig, which persists what it backfills.
825
+ if (!telemetryInstallConfigNeedsWrite(parsed, config)) {
826
+ this.installConfig = config;
827
+ sharedInstallConfigs.set(this.installConfigPath, config);
828
+ }
829
+
830
+ return !this.optedOut(config);
831
+ } catch {
832
+ return true;
833
+ }
834
+ }
835
+
836
+ private async readOrCreateInstallConfig(): Promise<ReviewTelemetryInstallConfig> {
837
+ try {
838
+ const parsed = parseJsonText(
839
+ await readFile(this.installConfigPath, "utf8"),
840
+ );
841
+
842
+ const config = normalizeTelemetryInstallConfig(parsed, this.now);
843
+
844
+ if (config) {
845
+ if (telemetryInstallConfigNeedsWrite(parsed, config)) {
846
+ try {
847
+ this.writeInstallConfig(config);
848
+ } catch {
849
+ // Keep the existing identity when a best-effort migration fails.
850
+ }
851
+ }
852
+
853
+ return config;
854
+ }
855
+ } catch {
856
+ // Missing or invalid config gets replaced below.
857
+ }
858
+
859
+ // The legacy file holds the stable identity; preview counts separately.
860
+ const legacyInstallId =
861
+ reviewTelemetryChannel(this.env) === "preview"
862
+ ? undefined
863
+ : await this.readLegacyInstallId();
864
+
865
+ const config = createTelemetryInstallConfig(
866
+ legacyInstallId ?? this.idFactory(),
867
+ this.now,
868
+ );
869
+
870
+ // A legacy id is by definition an existing installation: never announce
871
+ // it as newly created.
872
+ if (legacyInstallId) config.installationCreatedSent = true;
873
+
874
+ this.writeInstallConfig(config);
875
+
876
+ return config;
877
+ }
878
+
879
+ private async readLegacyInstallId(): Promise<string | undefined> {
880
+ try {
881
+ const installId = jsonString(
882
+ jsonObject(
883
+ parseJsonText(await readFile(this.legacyInstallConfigPath, "utf8")),
884
+ )?.installId,
885
+ );
886
+
887
+ return installId ? installId : undefined;
888
+ } catch {
889
+ return undefined;
890
+ }
891
+ }
892
+
893
+ private writeInstallConfig(config: ReviewTelemetryInstallConfig): void {
894
+ writeFileAtomic(
895
+ this.installConfigPath,
896
+ `${JSON.stringify(config, null, 2)}\n`,
897
+ "utf8",
898
+ );
899
+ }
900
+
901
+ private async withConfigLock(
902
+ operation: () => Promise<void>,
903
+ timeoutMs = 250,
904
+ ): Promise<void> {
905
+ const outcome = await withFileLock(
906
+ `${this.installConfigPath}.lock`,
907
+ {
908
+ retryMs: 10,
909
+ staleMs: 30_000,
910
+ timeoutMs,
911
+ unownedGraceMs: 1_000,
912
+ heartbeatMs: 5_000,
913
+ },
914
+ operation,
915
+ );
916
+
917
+ if (!outcome.acquired) {
918
+ throw new Error("Timed out while updating the telemetry configuration");
919
+ }
920
+ }
921
+
922
+ private async commonProperties(
923
+ config: Pick<
924
+ ReviewTelemetryInstallConfig,
925
+ "internal" | "accountAlias" | "createdAt"
926
+ >,
927
+ ): Promise<PostHogCaptureProperties> {
928
+ const appVersion = reviewAppVersion(this.env) ?? this.desktopVersion;
929
+ const appSessionId = nonEmpty(this.env[REVIEW_APP_SESSION_ID_ENV]);
930
+
931
+ const properties: PostHogCaptureProperties = {
932
+ cli_version: this.packageVersion,
933
+ // Kept for one release while the DAU/WAU insights still read it.
934
+ version: this.packageVersion,
935
+ channel: reviewTelemetryChannel(this.env),
936
+ environment: reviewTelemetryEnvironment(this.env, config),
937
+ surface: this.surface,
938
+ node_major: Number(process.versions.node.split(".", 1)[0]),
939
+ platform: process.platform,
940
+ arch: process.arch,
941
+ os_version: os.release(),
942
+ ci: Boolean(this.env.CI),
943
+ internal: isInternalTelemetry(this.env, config),
944
+ install_age_days: installAgeDays(config, this.now()),
945
+ // Anonymous until an account is aliased; then PostHog keeps a person.
946
+ $process_person_profile: config.accountAlias !== undefined,
947
+ };
948
+
949
+ if (appVersion) properties.app_version = appVersion;
950
+
951
+ if (appSessionId) properties.app_session_id = appSessionId;
952
+
953
+ return withSessionId(properties);
954
+ }
955
+
956
+ private sessionAgent(): ReviewSessionAgent {
957
+ return reviewSessionAgent(this.env);
958
+ }
959
+ }
960
+
961
+ /** The agent harness this process runs under, from its session environment. */
962
+ export function reviewSessionAgent(env: NodeJS.ProcessEnv): ReviewSessionAgent {
963
+ const harness = resolveAuthoringSessionRef(env)?.harness;
964
+
965
+ if (harness === "codex") return "codex";
966
+
967
+ if (harness === "claude-code") return "claude";
968
+
969
+ if (harness === "pi") return "pi";
970
+
971
+ return "other";
972
+ }
973
+
974
+ export { isTelemetryOptedOut } from "./telemetry-config";
975
+
976
+ /**
977
+ * `$session_id` always mirrors the event's final `app_session_id`, which an
978
+ * event may override (a recovered end carries the launch that died). An id
979
+ * that is not a UUIDv7, such as a canvas's own fallback id or one from a
980
+ * launch before v7 ids, leaves `$session_id` out.
981
+ */
982
+ function withSessionId(
983
+ properties: PostHogCaptureProperties,
984
+ ): PostHogCaptureProperties {
985
+ const { $session_id: _stale, ...rest } = properties;
986
+
987
+ return isUuidV7(rest.app_session_id)
988
+ ? { ...rest, $session_id: rest.app_session_id }
989
+ : rest;
990
+ }
991
+
992
+ function correlationProperties(
993
+ installationId: string,
994
+ context: ReviewTelemetryContext | undefined,
995
+ ): PostHogCaptureProperties {
996
+ const properties: PostHogCaptureProperties = {};
997
+
998
+ if (!context) return properties;
999
+
1000
+ if (context.reviewUuid) {
1001
+ properties.review_id = opaqueCorrelationId(
1002
+ "rv_",
1003
+ installationId,
1004
+ "review",
1005
+ context.reviewUuid,
1006
+ );
1007
+ }
1008
+
1009
+ if (context.presentationSessionId) {
1010
+ properties.presentation_id = opaqueCorrelationId(
1011
+ "pr_",
1012
+ installationId,
1013
+ "presentation",
1014
+ context.presentationSessionId,
1015
+ );
1016
+ }
1017
+
1018
+ return properties;
1019
+ }
1020
+
1021
+ function opaqueCorrelationId(
1022
+ prefix: "rv_" | "pr_",
1023
+ installationId: string,
1024
+ namespace: "review" | "presentation",
1025
+ value: string,
1026
+ ): string {
1027
+ const digest = createHmac("sha256", installationId)
1028
+ .update(`dev.fast.review.telemetry.v1\0${namespace}\0${value}`)
1029
+ .digest()
1030
+ .subarray(0, 16)
1031
+ .toString("base64url");
1032
+
1033
+ return `${prefix}${digest}`;
1034
+ }
1035
+
1036
+ function directCaptureClient(
1037
+ env: NodeJS.ProcessEnv,
1038
+ fetchImpl: typeof fetch,
1039
+ timeoutMs: number | undefined,
1040
+ ): PostHogCaptureClient {
1041
+ return new PostHogCaptureClient({
1042
+ apiKey:
1043
+ nonEmpty(env[PROGRESSIVE_REVIEW_POSTHOG_KEY_ENV]) ??
1044
+ nonEmpty(env.DEV_FAST_POSTHOG_KEY) ??
1045
+ nonEmpty(env.POSTHOG_KEY) ??
1046
+ EMBEDDED_PROGRESSIVE_REVIEW_POSTHOG_KEY,
1047
+ host:
1048
+ nonEmpty(env[PROGRESSIVE_REVIEW_POSTHOG_HOST_ENV]) ??
1049
+ nonEmpty(env.DEV_FAST_POSTHOG_HOST) ??
1050
+ nonEmpty(env.POSTHOG_HOST),
1051
+ fetch: fetchImpl,
1052
+ timeoutMs,
1053
+ });
1054
+ }
1055
+
1056
+ function nonEmpty(value: string | undefined): string | undefined {
1057
+ const trimmed = value?.trim();
1058
+
1059
+ return trimmed ? trimmed : undefined;
1060
+ }
1061
+
1062
+ function reviewAppVersion(env: NodeJS.ProcessEnv): string | undefined {
1063
+ const value = nonEmpty(env[REVIEW_APP_VERSION_ENV]);
1064
+
1065
+ return value && validSemver(value) ? value : undefined;
1066
+ }