@dev.fast/whiteboard 0.0.0-stage → 0.2.1-preview.20261005.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/THIRD_PARTY_NOTICES.md +93 -0
- package/dist/account-alias-DOHN1RSH.js +973 -0
- package/dist/agent-cli-ChOq0ZuO.js +117 -0
- package/dist/agent-cli-DzWfZr6G.js +2 -0
- package/dist/agent-client-CPQo7iTI.js +305 -0
- package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
- package/dist/build-info.json +1 -0
- package/dist/cli-hVgHcdsv.js +47 -0
- package/dist/cli-runner-D8y2_luU.js +5095 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +128 -0
- package/dist/client-DFoh50W3.js +950 -0
- package/dist/desktop-discovery-DBO-PM2V.js +164 -0
- package/dist/error-message-OtiDonty.js +6 -0
- package/dist/fs-utils-BMPLt0cr.js +22 -0
- package/dist/fuzzy-match-BoAmcyak.js +30 -0
- package/dist/headless-host-BJxf4dUU.js +94 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/local-data-CxLk75rx.js +4491 -0
- package/dist/mcp-QeF8vkdf.js +140 -0
- package/dist/package-paths-B6-zxvIO.js +47 -0
- package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
- package/dist/profile-4ry2f3AM.js +76 -0
- package/dist/profile-Bh5FIrmf.js +2 -0
- package/dist/request-origin-D_4QIdoN.js +811 -0
- package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
- package/dist/review-home-paths-6zZH-1c9.js +2560 -0
- package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
- package/dist/runtime-CKiWhGrf.js +52 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.js +2 -0
- package/dist/s3-SzLpeCG6.js +345 -0
- package/dist/s3-config-BgxkSoOy.js +2 -0
- package/dist/s3-config-CAxhO9u_.js +676 -0
- package/dist/server/desktop-host.d.ts +4 -0
- package/dist/server/desktop-host.js +1702 -0
- package/dist/server-discovery-Dbzn3w6Z.js +66 -0
- package/dist/sharing/index.d.ts +1977 -0
- package/dist/sharing/index.js +2 -0
- package/dist/src-CGP5ytbV.js +283 -0
- package/dist/src-CmdiBL20.js +3676 -0
- package/dist/src-DnwdaQ2r.js +865 -0
- package/dist/src-X9phtB2j.js +68 -0
- package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
- package/dist/tool-failure-C8zB73HV.js +98 -0
- package/dist/tutorial-trace-DrBvkaeL.js +43 -0
- package/instructions/authoring.md +36 -0
- package/instructions/file-lenses.md +13 -0
- package/instructions/scratchpad.md +23 -0
- package/instructions/trace-archaeology.md +113 -0
- package/onboarding.md +8 -0
- package/package.json +106 -3
- package/src/agent-selection.ts +100 -0
- package/src/agent-session-ref.ts +80 -0
- package/src/ask/agents.ts +351 -0
- package/src/ask/checkout-files.ts +47 -0
- package/src/ask/file-refs.ts +111 -0
- package/src/ask/pi-mcp.ts +154 -0
- package/src/ask/protocol.ts +244 -0
- package/src/ask/thread-state.ts +350 -0
- package/src/ask/thread.ts +1400 -0
- package/src/ask/threads.ts +219 -0
- package/src/ask/watch.ts +79 -0
- package/src/cli-install.ts +1041 -0
- package/src/cli-runner.ts +1461 -0
- package/src/cli-runtime-info.ts +35 -0
- package/src/cli.ts +222 -0
- package/src/connect-prompts.ts +248 -0
- package/src/cursor-deeplink.ts +11 -0
- package/src/desktop-discovery.ts +355 -0
- package/src/diff-selection-migration.ts +63 -0
- package/src/embedded-posthog-key.ts +6 -0
- package/src/error-telemetry.ts +247 -0
- package/src/evidence.ts +14 -0
- package/src/exception-telemetry.ts +126 -0
- package/src/fixtures/blocks/call_stack_diff.json +27 -0
- package/src/fixtures/blocks/callout.json +11 -0
- package/src/fixtures/blocks/code.json +9 -0
- package/src/fixtures/blocks/code_peek.json +8 -0
- package/src/fixtures/blocks/database_lens.json +51 -0
- package/src/fixtures/blocks/divider.json +1 -0
- package/src/fixtures/blocks/fixtures.ts +33 -0
- package/src/fixtures/blocks/flow_diagram.json +36 -0
- package/src/fixtures/blocks/ids.ts +8 -0
- package/src/fixtures/blocks/image.json +9 -0
- package/src/fixtures/blocks/markdown.json +7 -0
- package/src/fixtures/blocks/section.json +11 -0
- package/src/fixtures/blocks/sequence.json +31 -0
- package/src/fixtures/blocks/software_map.json +7 -0
- package/src/fixtures/blocks/trace_quote.json +9 -0
- package/src/fixtures/blocks/tutorial.json +51 -0
- package/src/fs-utils.ts +32 -0
- package/src/fuzzy-match.ts +94 -0
- package/src/install.ts +29 -0
- package/src/legacy-skills.ts +133 -0
- package/src/lens-selection.ts +230 -0
- package/src/markdown-latex-math.ts +230 -0
- package/src/markdown.ts +81 -0
- package/src/package-paths.ts +53 -0
- package/src/posthog-capture-client.ts +610 -0
- package/src/review-api/README.md +295 -0
- package/src/review-api/activity.ts +340 -0
- package/src/review-api/agent-cli.ts +219 -0
- package/src/review-api/agent-client.ts +188 -0
- package/src/review-api/anchor-quotes.ts +88 -0
- package/src/review-api/ask-history.ts +242 -0
- package/src/review-api/authoring-tools.ts +211 -0
- package/src/review-api/blocks/call_stack_diff.ts +75 -0
- package/src/review-api/blocks/callout.ts +24 -0
- package/src/review-api/blocks/code.ts +9 -0
- package/src/review-api/blocks/code_peek.ts +13 -0
- package/src/review-api/blocks/database_lens.ts +150 -0
- package/src/review-api/blocks/definition.ts +40 -0
- package/src/review-api/blocks/divider.ts +6 -0
- package/src/review-api/blocks/flow_diagram.ts +111 -0
- package/src/review-api/blocks/image.ts +10 -0
- package/src/review-api/blocks/index.ts +90 -0
- package/src/review-api/blocks/markdown.ts +17 -0
- package/src/review-api/blocks/section.ts +24 -0
- package/src/review-api/blocks/sequence.ts +56 -0
- package/src/review-api/blocks/software_map.ts +9 -0
- package/src/review-api/blocks/trace_quote.ts +10 -0
- package/src/review-api/blocks/tutorial.ts +39 -0
- package/src/review-api/checkout-fs.ts +14 -0
- package/src/review-api/client.ts +1 -0
- package/src/review-api/comparison-coverage.ts +304 -0
- package/src/review-api/component-reference.ts +30 -0
- package/src/review-api/diff-lenses.ts +175 -0
- package/src/review-api/document-headings.ts +51 -0
- package/src/review-api/document-text.ts +200 -0
- package/src/review-api/document.ts +906 -0
- package/src/review-api/file-lenses.ts +100 -0
- package/src/review-api/http.ts +1877 -0
- package/src/review-api/image-decode.ts +29 -0
- package/src/review-api/input-error.ts +9 -0
- package/src/review-api/instructions.ts +89 -0
- package/src/review-api/lens-alignment.ts +52 -0
- package/src/review-api/local-data.ts +1837 -0
- package/src/review-api/map-input.ts +154 -0
- package/src/review-api/mcp-client-agent.ts +32 -0
- package/src/review-api/mcp.ts +240 -0
- package/src/review-api/origin.ts +43 -0
- package/src/review-api/profile.ts +146 -0
- package/src/review-api/public-tools.ts +102 -0
- package/src/review-api/pull-request.ts +389 -0
- package/src/review-api/read-schemas.ts +85 -0
- package/src/review-api/recovery.ts +2 -0
- package/src/review-api/request-origin.ts +33 -0
- package/src/review-api/review-progress.ts +382 -0
- package/src/review-api/status-tool.ts +10 -0
- package/src/review-api/store-schema.ts +22 -0
- package/src/review-api/store.ts +1647 -0
- package/src/review-api/tool-failure.ts +64 -0
- package/src/review-api/trace-schema.ts +13 -0
- package/src/review-api/traces.ts +119 -0
- package/src/review-api/unsupported-files.integration.ts +91 -0
- package/src/review-api/workspaces.ts +692 -0
- package/src/review-api/worktree-source.ts +170 -0
- package/src/review-api/worktree-structural.integration.ts +320 -0
- package/src/review-app-launcher.ts +432 -0
- package/src/review-app-picker.ts +168 -0
- package/src/review-app.ts +134 -0
- package/src/review-bundled-tools.ts +249 -0
- package/src/review-checkout-paths.ts +37 -0
- package/src/review-diff-files.ts +89 -0
- package/src/review-head-checkout.ts +300 -0
- package/src/review-home-paths.ts +73 -0
- package/src/review-info.ts +59 -0
- package/src/review-instances.ts +113 -0
- package/src/review-logger.ts +183 -0
- package/src/review-preferences.ts +88 -0
- package/src/review-prepare.ts +291 -0
- package/src/review-stack.ts +112 -0
- package/src/review-telemetry.ts +1066 -0
- package/src/runtime.ts +74 -0
- package/src/server/account-alias.ts +30 -0
- package/src/server/bounded-stream.ts +34 -0
- package/src/server/bug-report.ts +301 -0
- package/src/server/client-error-budget.ts +45 -0
- package/src/server/crash-report.ts +273 -0
- package/src/server/desktop-host-shutdown.ts +64 -0
- package/src/server/desktop-host.ts +190 -0
- package/src/server/desktop-server.ts +703 -0
- package/src/server/diffr-config.ts +509 -0
- package/src/server/diffr-languages.ts +97 -0
- package/src/server/global-verb-relay.ts +190 -0
- package/src/server/headless-host.ts +149 -0
- package/src/server/hono-http.ts +162 -0
- package/src/server/http-json.ts +24 -0
- package/src/server/json-review-reporting.ts +174 -0
- package/src/server/process-error-telemetry.ts +174 -0
- package/src/server/review-api-parsers.ts +90 -0
- package/src/server/review-lifecycle-telemetry.ts +73 -0
- package/src/server/review-open-watchdog.ts +46 -0
- package/src/server/review-server-core.ts +290 -0
- package/src/server/structural-comparisons.ts +153 -0
- package/src/server/structural-diff.ts +186 -0
- package/src/server/tutorial-service.ts +241 -0
- package/src/server/ui-telemetry.ts +198 -0
- package/src/server-discovery.ts +95 -0
- package/src/session-markers.ts +132 -0
- package/src/sharing/auth.ts +41 -0
- package/src/sharing/cli.ts +83 -0
- package/src/sharing/client.ts +334 -0
- package/src/sharing/export.ts +195 -0
- package/src/sharing/host.ts +316 -0
- package/src/sharing/import.ts +787 -0
- package/src/sharing/index.ts +16 -0
- package/src/sharing/repository.ts +145 -0
- package/src/sharing/routes.ts +34 -0
- package/src/slug.ts +23 -0
- package/src/software-map-diff-counts.ts +517 -0
- package/src/software-map-model.ts +1147 -0
- package/src/software-map-topology-diff.ts +260 -0
- package/src/source.ts +92 -0
- package/src/startup-trace.ts +232 -0
- package/src/stored-document-migration.ts +151 -0
- package/src/telemetry-clean-text.ts +257 -0
- package/src/telemetry-config.ts +298 -0
- package/src/telemetry-debug-sink.ts +38 -0
- package/src/telemetry.ts +13 -0
- package/src/trace-cli.ts +156 -0
- package/src/trace-storage-cli.ts +509 -0
- package/src/tutorial-conversation.ts +18 -0
- package/src/ui-telemetry-events.ts +765 -0
- package/src/unified-diff.ts +71 -0
- package/src/viewed-coverage.ts +259 -0
- package/src/windows-cli.ts +125 -0
- package/tutorial/document.json +275 -0
- package/tutorial/runtime-manifest.json +12 -0
- package/tutorial/sample-service/package.json +9 -0
- package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
- package/tutorial/sample-service/src/app.ts +26 -0
- package/tutorial/sample-service/src/database/schema.ts +10 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
- package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
- package/tutorial/sample-service/src/orders/order-service.ts +34 -0
- package/tutorial/sample-service/src/orders/order.ts +21 -0
- package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
- package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
- package/tutorial/sample-service/tsconfig.json +12 -0
- package/tutorial/software-map.json +139 -0
- package/tutorial/trace.json +20 -0
|
@@ -0,0 +1,1195 @@
|
|
|
1
|
+
import { c as jsonString, l as jsonValueSchema, o as jsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
|
|
2
|
+
import "./src-DnwdaQ2r.js";
|
|
3
|
+
import { A as devReviewHome, D as processIsAlive, M as writeFileAtomic, O as withFileLock } from "./s3-config-CAxhO9u_.js";
|
|
4
|
+
import "./review-home-paths-6zZH-1c9.js";
|
|
5
|
+
import { i as readReviewPackageVersion, t as findReviewPackageRoot } from "./package-paths-B6-zxvIO.js";
|
|
6
|
+
import "./request-origin-D_4QIdoN.js";
|
|
7
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
10
|
+
import { readFile, readdir, rm } from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import { valid } from "semver";
|
|
14
|
+
//#region src/agent-session-ref.ts
|
|
15
|
+
const AUTHORING_AGENT_SESSION_ENV = "DEV_FAST_AGENT_SESSION";
|
|
16
|
+
function parseAuthoringSessionKey(value) {
|
|
17
|
+
const separator = value?.indexOf(":") ?? -1;
|
|
18
|
+
if (!value || separator < 1 || separator === value.length - 1) return;
|
|
19
|
+
const harness = value.slice(0, separator);
|
|
20
|
+
const sessionId = value.slice(separator + 1);
|
|
21
|
+
return isReviewAgentHarness(harness) ? {
|
|
22
|
+
harness,
|
|
23
|
+
sessionId
|
|
24
|
+
} : void 0;
|
|
25
|
+
}
|
|
26
|
+
function resolveAuthoringSessionRef(env) {
|
|
27
|
+
const hosted = readEnvValue(env[AUTHORING_AGENT_SESSION_ENV]);
|
|
28
|
+
if (hosted) {
|
|
29
|
+
const ref = parseAuthoringSessionKey(hosted);
|
|
30
|
+
if (!ref) throw new Error(`${AUTHORING_AGENT_SESSION_ENV} has an invalid authoring session reference.`);
|
|
31
|
+
return ref;
|
|
32
|
+
}
|
|
33
|
+
const codexThreadId = readEnvValue(env.CODEX_THREAD_ID);
|
|
34
|
+
if (codexThreadId) return {
|
|
35
|
+
harness: "codex",
|
|
36
|
+
sessionId: codexThreadId
|
|
37
|
+
};
|
|
38
|
+
const claudeSessionId = readEnvValue(env.CLAUDE_CODE_SESSION_ID) ?? readEnvValue(env.CLAUDE_SESSION_ID);
|
|
39
|
+
if (claudeSessionId) return {
|
|
40
|
+
harness: "claude-code",
|
|
41
|
+
sessionId: claudeSessionId
|
|
42
|
+
};
|
|
43
|
+
const piSessionId = readEnvValue(env.PI_SESSION_ID);
|
|
44
|
+
if (piSessionId) return {
|
|
45
|
+
harness: "pi",
|
|
46
|
+
sessionId: piSessionId
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function isReviewAgentHarness(value) {
|
|
50
|
+
return value === "claude-code" || value === "codex" || value === "opencode" || value === "pi";
|
|
51
|
+
}
|
|
52
|
+
function readEnvValue(value) {
|
|
53
|
+
const trimmed = value?.trim();
|
|
54
|
+
return trimmed ? trimmed : void 0;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/exception-telemetry.ts
|
|
58
|
+
const FRAME_PATTERN = /^(.+):(\d+):(\d+)$/;
|
|
59
|
+
/** Processes that run on Node rather than in a browser renderer. */
|
|
60
|
+
const NODE_PROCESSES = /* @__PURE__ */ new Set(["main", "server"]);
|
|
61
|
+
/** Written by apps/review-desktop/scripts/upload-source-maps.mjs. */
|
|
62
|
+
const CHUNK_ID_MANIFEST = "review-chunk-ids.json";
|
|
63
|
+
const CHUNK_ID_PATTERN = /^[0-9a-f-]{36}$/;
|
|
64
|
+
/** The entry is `<app>/review-runtime/dist/server/desktop-host.js`. */
|
|
65
|
+
function readChunkIds(serverEntry) {
|
|
66
|
+
if (!serverEntry) return /* @__PURE__ */ new Map();
|
|
67
|
+
try {
|
|
68
|
+
const manifest = jsonObject(parseJsonText(readFileSync(path.resolve(path.dirname(serverEntry), "../../../out", CHUNK_ID_MANIFEST), "utf8")));
|
|
69
|
+
return new Map(Object.entries(manifest ?? {}).flatMap(([file, id]) => {
|
|
70
|
+
const chunkId = jsonString(id);
|
|
71
|
+
return chunkId && CHUNK_ID_PATTERN.test(chunkId) ? [[file, chunkId]] : [];
|
|
72
|
+
}));
|
|
73
|
+
} catch {
|
|
74
|
+
return /* @__PURE__ */ new Map();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function exceptionProperties(properties, chunkIds = /* @__PURE__ */ new Map()) {
|
|
78
|
+
const type = jsonString(properties.error_name);
|
|
79
|
+
const hash = jsonString(properties.message_hash);
|
|
80
|
+
if (type === void 0 && hash === void 0) return void 0;
|
|
81
|
+
const platform = NODE_PROCESSES.has(jsonString(properties.error_process) ?? "") ? "node:javascript" : "web:javascript";
|
|
82
|
+
const frames = (jsonString(properties.frames) ?? "").split("|").flatMap((frame) => parseFrame(frame, platform, chunkIds));
|
|
83
|
+
const exception = {
|
|
84
|
+
$exception_level: "error",
|
|
85
|
+
$exception_list: [{
|
|
86
|
+
type: type ?? "Error",
|
|
87
|
+
value: jsonString(properties.message) ?? (hash ? `[message withheld] ${hash}` : "[message withheld]"),
|
|
88
|
+
mechanism: {
|
|
89
|
+
handled: true,
|
|
90
|
+
synthetic: false
|
|
91
|
+
},
|
|
92
|
+
stacktrace: {
|
|
93
|
+
type: "raw",
|
|
94
|
+
frames
|
|
95
|
+
}
|
|
96
|
+
}]
|
|
97
|
+
};
|
|
98
|
+
const resolvable = frames.length > 0 && frames.every((frame) => frame.chunk_id);
|
|
99
|
+
return hash === void 0 || resolvable ? exception : {
|
|
100
|
+
...exception,
|
|
101
|
+
$exception_fingerprint: hash
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function parseFrame(frame, platform, chunkIds) {
|
|
105
|
+
const match = FRAME_PATTERN.exec(frame);
|
|
106
|
+
if (!match) return [];
|
|
107
|
+
const parsed = {
|
|
108
|
+
platform,
|
|
109
|
+
filename: match[1],
|
|
110
|
+
lineno: Number(match[2]),
|
|
111
|
+
colno: Number(match[3]),
|
|
112
|
+
function: "?",
|
|
113
|
+
in_app: true
|
|
114
|
+
};
|
|
115
|
+
const chunkId = chunkIds.get(match[1]);
|
|
116
|
+
if (chunkId) parsed.chunk_id = chunkId;
|
|
117
|
+
return [parsed];
|
|
118
|
+
}
|
|
119
|
+
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
|
|
120
|
+
const DEFAULT_CAPTURE_TIMEOUT_MS = 1e3;
|
|
121
|
+
const QUEUE_LIMIT = 1e3;
|
|
122
|
+
const BATCH_LIMIT = 50;
|
|
123
|
+
const QUEUE_MAX_AGE_MS = 6048e5;
|
|
124
|
+
const FLUSH_DELAY_MS = 5e3;
|
|
125
|
+
const MAX_BACKOFF_MS = 36e5;
|
|
126
|
+
const DROPPED_FILE = "dropped.json";
|
|
127
|
+
const DROP_REASONS = [
|
|
128
|
+
"queue_full",
|
|
129
|
+
"expired",
|
|
130
|
+
"corrupt",
|
|
131
|
+
"permanent_rejection",
|
|
132
|
+
"storage_failure"
|
|
133
|
+
];
|
|
134
|
+
/** The dropped-event tally as this module wrote it to disk. */
|
|
135
|
+
const droppedCountsSchema = z.partialRecord(z.enum(DROP_REASONS), z.number());
|
|
136
|
+
var PostHogCaptureClient = class PostHogCaptureClient {
|
|
137
|
+
apiKey;
|
|
138
|
+
batchUrl;
|
|
139
|
+
fetchImpl;
|
|
140
|
+
timeoutMs;
|
|
141
|
+
timeoutSignal;
|
|
142
|
+
queueDir;
|
|
143
|
+
now;
|
|
144
|
+
idFactory;
|
|
145
|
+
memoryDrops = {};
|
|
146
|
+
flushTimer;
|
|
147
|
+
queuedSinceFlush = 0;
|
|
148
|
+
defaultProperties = {};
|
|
149
|
+
constructor(options = {}) {
|
|
150
|
+
this.apiKey = options.apiKey?.trim() || void 0;
|
|
151
|
+
this.batchUrl = `${normalizeHost(options.host)}/batch/`;
|
|
152
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
153
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_CAPTURE_TIMEOUT_MS;
|
|
154
|
+
this.timeoutSignal = options.timeoutSignal ?? ((timeoutMs) => AbortSignal.timeout(timeoutMs));
|
|
155
|
+
this.queueDir = options.queueDir;
|
|
156
|
+
this.now = options.now ?? Date.now;
|
|
157
|
+
this.idFactory = options.idFactory ?? randomUUID;
|
|
158
|
+
}
|
|
159
|
+
static fromEnv(env = process.env, options = {}) {
|
|
160
|
+
const reviewHome = devReviewHome(env);
|
|
161
|
+
return new PostHogCaptureClient({
|
|
162
|
+
...options,
|
|
163
|
+
apiKey: nonEmpty$1(env["PROGRESSIVE_REVIEW_POSTHOG_KEY"]) ?? nonEmpty$1(env.DEV_FAST_POSTHOG_KEY) ?? nonEmpty$1(env.POSTHOG_KEY) ?? void 0,
|
|
164
|
+
host: nonEmpty$1(env["PROGRESSIVE_REVIEW_POSTHOG_HOST"]) ?? nonEmpty$1(env.DEV_FAST_POSTHOG_HOST) ?? nonEmpty$1(env.POSTHOG_HOST),
|
|
165
|
+
queueDir: path.join(reviewHome, "telemetry", "events")
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
get enabled() {
|
|
169
|
+
return Boolean(this.apiKey && this.fetchImpl);
|
|
170
|
+
}
|
|
171
|
+
/** Properties for events the client emits itself, such as drop diagnostics. */
|
|
172
|
+
setDefaultProperties(properties) {
|
|
173
|
+
this.defaultProperties = { ...properties };
|
|
174
|
+
}
|
|
175
|
+
async capture(input) {
|
|
176
|
+
if (!this.enabled) return;
|
|
177
|
+
const uuid = this.idFactory();
|
|
178
|
+
const queued = {
|
|
179
|
+
uuid,
|
|
180
|
+
event: input.event,
|
|
181
|
+
distinctId: input.distinctId,
|
|
182
|
+
properties: compactProperties(input.properties ?? {}),
|
|
183
|
+
createdAt: input.timestamp ?? this.now(),
|
|
184
|
+
attempts: 0,
|
|
185
|
+
nextAttemptAt: 0
|
|
186
|
+
};
|
|
187
|
+
if (!this.queueDir) {
|
|
188
|
+
await this.sendBatch([queued]);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
writeFileAtomic(path.join(this.queueDir, `${queued.createdAt}-${uuid}.json`), `${JSON.stringify(queued)}\n`, "utf8");
|
|
193
|
+
this.queuedSinceFlush += 1;
|
|
194
|
+
this.scheduleFlush(this.queuedSinceFlush >= BATCH_LIMIT ? 0 : FLUSH_DELAY_MS);
|
|
195
|
+
} catch {
|
|
196
|
+
this.incrementMemoryDrop("storage_failure");
|
|
197
|
+
throw new Error("Could not store a telemetry event");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async discard() {
|
|
201
|
+
this.clearFlushTimer();
|
|
202
|
+
if (!this.queueDir) return;
|
|
203
|
+
await withFileLock(path.join(this.queueDir, ".flush.lock"), {
|
|
204
|
+
retryMs: 10,
|
|
205
|
+
staleMs: 3e4,
|
|
206
|
+
timeoutMs: 1e3,
|
|
207
|
+
unownedGraceMs: 1e3,
|
|
208
|
+
heartbeatMs: 5e3
|
|
209
|
+
}, async () => {
|
|
210
|
+
const files = await readdir(this.queueDir).catch(() => []);
|
|
211
|
+
await Promise.all(files.filter((file) => file.endsWith(".json")).map((file) => rm(path.join(this.queueDir, file), { force: true })));
|
|
212
|
+
this.queuedSinceFlush = 0;
|
|
213
|
+
this.memoryDrops = {};
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async flush(deadlineMs = DEFAULT_CAPTURE_TIMEOUT_MS) {
|
|
217
|
+
if (!this.enabled || !this.queueDir) return;
|
|
218
|
+
this.clearFlushTimer();
|
|
219
|
+
const startedAt = this.now();
|
|
220
|
+
if (!(await withFileLock(path.join(this.queueDir, ".flush.lock"), {
|
|
221
|
+
retryMs: 10,
|
|
222
|
+
staleMs: 3e4,
|
|
223
|
+
timeoutMs: Math.min(100, Math.max(0, deadlineMs)),
|
|
224
|
+
unownedGraceMs: 1e3,
|
|
225
|
+
heartbeatMs: 5e3
|
|
226
|
+
}, async () => {
|
|
227
|
+
let result;
|
|
228
|
+
do
|
|
229
|
+
result = await this.flushBatchLocked(deadlineMs, startedAt);
|
|
230
|
+
while (result.state === "more" && this.now() - startedAt < deadlineMs);
|
|
231
|
+
if (result.state === "more") this.scheduleFlush(0);
|
|
232
|
+
if ("nextRetryAt" in result && result.nextRetryAt !== void 0) this.scheduleFlush(Math.max(0, result.nextRetryAt - this.now()));
|
|
233
|
+
}).catch(() => ({ acquired: false }))).acquired) this.scheduleFlush(FLUSH_DELAY_MS);
|
|
234
|
+
}
|
|
235
|
+
async shutdown(deadlineMs = DEFAULT_CAPTURE_TIMEOUT_MS) {
|
|
236
|
+
this.clearFlushTimer();
|
|
237
|
+
await this.flush(deadlineMs);
|
|
238
|
+
}
|
|
239
|
+
async flushBatchLocked(deadlineMs, startedAt) {
|
|
240
|
+
const queueDir = this.queueDir;
|
|
241
|
+
const now = this.now();
|
|
242
|
+
const fileNames = (await readdir(queueDir).catch(() => [])).filter((name) => name.endsWith(".json") && name !== DROPPED_FILE).sort();
|
|
243
|
+
const drops = mergeDropCounts(await this.readDroppedCounts(), this.memoryDrops);
|
|
244
|
+
this.memoryDrops = {};
|
|
245
|
+
const overflow = Math.max(0, fileNames.length - QUEUE_LIMIT);
|
|
246
|
+
const retainedNames = fileNames.slice(overflow);
|
|
247
|
+
for (const fileName of fileNames.slice(0, overflow)) await rm(path.join(queueDir, fileName), { force: true });
|
|
248
|
+
addDrop(drops, "queue_full", overflow);
|
|
249
|
+
const eligible = [];
|
|
250
|
+
let hasMoreEligible = false;
|
|
251
|
+
let nextRetryAt;
|
|
252
|
+
for (const fileName of retainedNames) {
|
|
253
|
+
const filePath = path.join(queueDir, fileName);
|
|
254
|
+
const event = await readQueuedEvent(filePath);
|
|
255
|
+
if (!event) {
|
|
256
|
+
await rm(filePath, { force: true });
|
|
257
|
+
addDrop(drops, "corrupt", 1);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (now - event.createdAt > QUEUE_MAX_AGE_MS) {
|
|
261
|
+
await rm(filePath, { force: true });
|
|
262
|
+
addDrop(drops, "expired", 1);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (event.nextAttemptAt <= now) {
|
|
266
|
+
if (eligible.length < BATCH_LIMIT) eligible.push({
|
|
267
|
+
fileName,
|
|
268
|
+
event
|
|
269
|
+
});
|
|
270
|
+
else hasMoreEligible = true;
|
|
271
|
+
} else nextRetryAt = Math.min(nextRetryAt ?? Infinity, event.nextAttemptAt);
|
|
272
|
+
}
|
|
273
|
+
await this.writeDroppedCounts(drops);
|
|
274
|
+
if (eligible.length === 0) {
|
|
275
|
+
this.queuedSinceFlush = 0;
|
|
276
|
+
return doneResult(nextRetryAt);
|
|
277
|
+
}
|
|
278
|
+
const diagnostics = await this.queueDropDiagnostics(drops, eligible[0].event.distinctId, now);
|
|
279
|
+
const sentEligible = [...diagnostics, ...eligible].slice(0, BATCH_LIMIT);
|
|
280
|
+
hasMoreEligible ||= sentEligible.length < diagnostics.length + eligible.length;
|
|
281
|
+
const batch = sentEligible.map(({ event }) => event);
|
|
282
|
+
const remainingMs = Math.max(1, deadlineMs - (this.now() - startedAt));
|
|
283
|
+
const result = await this.sendBatch(batch, remainingMs);
|
|
284
|
+
if (result === "success") {
|
|
285
|
+
await Promise.all(sentEligible.map(({ fileName }) => rm(path.join(queueDir, fileName), { force: true })));
|
|
286
|
+
this.queuedSinceFlush = 0;
|
|
287
|
+
return hasMoreEligible ? { state: "more" } : doneResult(nextRetryAt);
|
|
288
|
+
}
|
|
289
|
+
if (result === "permanent") {
|
|
290
|
+
await Promise.all(sentEligible.map(({ fileName }) => rm(path.join(queueDir, fileName), { force: true })));
|
|
291
|
+
await this.writeDroppedCounts({ permanent_rejection: sentEligible.length });
|
|
292
|
+
this.queuedSinceFlush = 0;
|
|
293
|
+
return hasMoreEligible ? { state: "more" } : doneResult(nextRetryAt);
|
|
294
|
+
}
|
|
295
|
+
let retryAt = Infinity;
|
|
296
|
+
for (const { fileName, event } of sentEligible) {
|
|
297
|
+
const attempts = event.attempts + 1;
|
|
298
|
+
const eventRetryAt = now + retryDelay(attempts);
|
|
299
|
+
retryAt = Math.min(retryAt, eventRetryAt);
|
|
300
|
+
writeFileAtomic(path.join(queueDir, fileName), `${JSON.stringify({
|
|
301
|
+
...event,
|
|
302
|
+
attempts,
|
|
303
|
+
nextAttemptAt: eventRetryAt
|
|
304
|
+
})}\n`, "utf8");
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
state: "retry",
|
|
308
|
+
nextRetryAt: retryAt
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
async sendBatch(events, timeoutMs = this.timeoutMs) {
|
|
312
|
+
if (!this.apiKey || !this.fetchImpl || events.length === 0) return "success";
|
|
313
|
+
try {
|
|
314
|
+
const response = await this.fetchImpl(this.batchUrl, {
|
|
315
|
+
method: "POST",
|
|
316
|
+
headers: { "content-type": "application/json" },
|
|
317
|
+
body: JSON.stringify({
|
|
318
|
+
api_key: this.apiKey,
|
|
319
|
+
batch: events.map((event) => ({
|
|
320
|
+
uuid: event.uuid,
|
|
321
|
+
event: event.event,
|
|
322
|
+
properties: {
|
|
323
|
+
$process_person_profile: false,
|
|
324
|
+
...compactProperties(event.properties ?? {}),
|
|
325
|
+
distinct_id: event.distinctId
|
|
326
|
+
},
|
|
327
|
+
timestamp: new Date(event.createdAt).toISOString()
|
|
328
|
+
}))
|
|
329
|
+
}),
|
|
330
|
+
signal: this.timeoutSignal(Math.min(this.timeoutMs, timeoutMs))
|
|
331
|
+
});
|
|
332
|
+
if (response.ok) return "success";
|
|
333
|
+
if (response.status === 408 || response.status === 429 || response.status >= 500) return "transient";
|
|
334
|
+
return "permanent";
|
|
335
|
+
} catch {
|
|
336
|
+
return "transient";
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Turns the pending drop tally into queued events, each with its own uuid,
|
|
341
|
+
* and clears the tally. A diagnostic then retries like any event, so a
|
|
342
|
+
* batch resent after a lost response cannot count a drop twice.
|
|
343
|
+
*/
|
|
344
|
+
async queueDropDiagnostics(drops, distinctId, now) {
|
|
345
|
+
const queueDir = this.queueDir;
|
|
346
|
+
const diagnostics = DROP_REASONS.flatMap((reason) => {
|
|
347
|
+
const count = drops[reason];
|
|
348
|
+
if (count === void 0 || count <= 0) return [];
|
|
349
|
+
const uuid = this.idFactory();
|
|
350
|
+
const event = {
|
|
351
|
+
uuid,
|
|
352
|
+
event: "review_telemetry_dropped",
|
|
353
|
+
distinctId,
|
|
354
|
+
properties: compactProperties({
|
|
355
|
+
...this.defaultProperties,
|
|
356
|
+
reason,
|
|
357
|
+
count
|
|
358
|
+
}),
|
|
359
|
+
createdAt: now,
|
|
360
|
+
attempts: 0,
|
|
361
|
+
nextAttemptAt: 0
|
|
362
|
+
};
|
|
363
|
+
return [{
|
|
364
|
+
fileName: `${now}-${uuid}.json`,
|
|
365
|
+
event
|
|
366
|
+
}];
|
|
367
|
+
});
|
|
368
|
+
if (diagnostics.length === 0) return [];
|
|
369
|
+
for (const { fileName, event } of diagnostics) writeFileAtomic(path.join(queueDir, fileName), `${JSON.stringify(event)}\n`, "utf8");
|
|
370
|
+
await rm(path.join(queueDir, DROPPED_FILE), { force: true });
|
|
371
|
+
return diagnostics;
|
|
372
|
+
}
|
|
373
|
+
scheduleFlush(delayMs) {
|
|
374
|
+
if (!this.queueDir || this.flushTimer) return;
|
|
375
|
+
this.flushTimer = setTimeout(() => {
|
|
376
|
+
this.flushTimer = void 0;
|
|
377
|
+
this.flush();
|
|
378
|
+
}, delayMs);
|
|
379
|
+
this.flushTimer.unref?.();
|
|
380
|
+
}
|
|
381
|
+
clearFlushTimer() {
|
|
382
|
+
if (this.flushTimer) clearTimeout(this.flushTimer);
|
|
383
|
+
this.flushTimer = void 0;
|
|
384
|
+
}
|
|
385
|
+
incrementMemoryDrop(reason) {
|
|
386
|
+
this.memoryDrops[reason] = (this.memoryDrops[reason] ?? 0) + 1;
|
|
387
|
+
}
|
|
388
|
+
async readDroppedCounts() {
|
|
389
|
+
if (!this.queueDir) return {};
|
|
390
|
+
return readFile(path.join(this.queueDir, DROPPED_FILE), "utf8").then((value) => {
|
|
391
|
+
const parsed = droppedCountsSchema.safeParse(parseJsonText(value));
|
|
392
|
+
return parsed.success ? parsed.data : {};
|
|
393
|
+
}).catch(() => ({}));
|
|
394
|
+
}
|
|
395
|
+
async writeDroppedCounts(drops) {
|
|
396
|
+
if (!this.queueDir) return;
|
|
397
|
+
const compact = Object.fromEntries(Object.entries(drops).filter(([, count]) => Number(count) > 0));
|
|
398
|
+
if (Object.keys(compact).length === 0) return;
|
|
399
|
+
writeFileAtomic(path.join(this.queueDir, DROPPED_FILE), `${JSON.stringify(compact)}\n`, "utf8");
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
function doneResult(nextRetryAt) {
|
|
403
|
+
const result = { state: "done" };
|
|
404
|
+
if (nextRetryAt) result.nextRetryAt = nextRetryAt;
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
/** A queued event as this module wrote it to disk. */
|
|
408
|
+
const QueuedPostHogEventSchema = z.object({
|
|
409
|
+
uuid: z.string().min(1).optional(),
|
|
410
|
+
event: z.string(),
|
|
411
|
+
distinctId: z.string(),
|
|
412
|
+
properties: z.record(z.string(), jsonValueSchema).optional(),
|
|
413
|
+
createdAt: z.number(),
|
|
414
|
+
attempts: z.number(),
|
|
415
|
+
nextAttemptAt: z.number()
|
|
416
|
+
});
|
|
417
|
+
async function readQueuedEvent(filePath) {
|
|
418
|
+
try {
|
|
419
|
+
const parsed = QueuedPostHogEventSchema.safeParse(parseJsonText(await readFile(filePath, "utf8")));
|
|
420
|
+
return parsed.success ? parsed.data : void 0;
|
|
421
|
+
} catch {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function mergeDropCounts(left, right) {
|
|
426
|
+
const merged = { ...left };
|
|
427
|
+
for (const reason of DROP_REASONS) {
|
|
428
|
+
const count = right[reason];
|
|
429
|
+
if (count !== void 0) addDrop(merged, reason, count);
|
|
430
|
+
}
|
|
431
|
+
return merged;
|
|
432
|
+
}
|
|
433
|
+
function addDrop(drops, reason, count) {
|
|
434
|
+
if (count > 0) drops[reason] = (drops[reason] ?? 0) + count;
|
|
435
|
+
}
|
|
436
|
+
function retryDelay(attempts) {
|
|
437
|
+
const base = Math.min(MAX_BACKOFF_MS, 1e3 * 2 ** Math.min(attempts - 1, 12));
|
|
438
|
+
return Math.round(base * (.75 + Math.random() * .5));
|
|
439
|
+
}
|
|
440
|
+
function normalizeHost(host) {
|
|
441
|
+
return (host?.trim() || DEFAULT_POSTHOG_HOST).replace(/\/+$/, "");
|
|
442
|
+
}
|
|
443
|
+
function nonEmpty$1(value) {
|
|
444
|
+
const trimmed = value?.trim();
|
|
445
|
+
return trimmed ? trimmed : void 0;
|
|
446
|
+
}
|
|
447
|
+
function compactProperties(properties) {
|
|
448
|
+
return Object.fromEntries(Object.entries(properties).filter((entry) => entry[1] !== void 0));
|
|
449
|
+
}
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/telemetry-config.ts
|
|
452
|
+
/** Set by Electron main from product.json `quality`; `dev` for an unpackaged run. */
|
|
453
|
+
const REVIEW_CHANNEL_ENV = "DEV_FAST_REVIEW_CHANNEL";
|
|
454
|
+
/** Set by the e2e harness (`e2e`) and the packaged smoke scripts (`smoke`). */
|
|
455
|
+
const REVIEW_TELEMETRY_ENV_ENV = "DEV_FAST_REVIEW_TELEMETRY_ENV";
|
|
456
|
+
const CHANNELS = [
|
|
457
|
+
"stable",
|
|
458
|
+
"preview",
|
|
459
|
+
"dev"
|
|
460
|
+
];
|
|
461
|
+
/**
|
|
462
|
+
* PostHog groups events into sessions by `$session_id` and accepts only a
|
|
463
|
+
* UUIDv7 there, so only a v7 app session id doubles as one.
|
|
464
|
+
*/
|
|
465
|
+
const uuidV7Schema = z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
|
466
|
+
function isUuidV7(value) {
|
|
467
|
+
return uuidV7Schema.safeParse(value).success;
|
|
468
|
+
}
|
|
469
|
+
function reviewTelemetryChannel(env) {
|
|
470
|
+
const value = env[REVIEW_CHANNEL_ENV]?.trim();
|
|
471
|
+
return CHANNELS.includes(value) ? value : "stable";
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* First match wins: a harness declares itself, then CI, then a dev.fast
|
|
475
|
+
* checkout or a persisted internal marker, else a real user.
|
|
476
|
+
*/
|
|
477
|
+
function reviewTelemetryEnvironment(env, config) {
|
|
478
|
+
const harness = env[REVIEW_TELEMETRY_ENV_ENV]?.trim();
|
|
479
|
+
if (harness === "e2e" || harness === "smoke") return harness;
|
|
480
|
+
if (env.CI) return "ci";
|
|
481
|
+
if (isInternalTelemetry(env, config)) return "internal";
|
|
482
|
+
return "production";
|
|
483
|
+
}
|
|
484
|
+
const TELEMETRY_CONFIG_RELATIVE_PATH = path.join("telemetry", "progressive-review.json");
|
|
485
|
+
const PREVIEW_TELEMETRY_CONFIG_RELATIVE_PATH = path.join("telemetry", "progressive-review.preview.json");
|
|
486
|
+
const LEGACY_APP_TELEMETRY_CONFIG_RELATIVE_PATH = path.join("telemetry", "install.json");
|
|
487
|
+
function reviewTelemetryConfigPath(env = process.env) {
|
|
488
|
+
return path.join(devReviewHome(env), reviewTelemetryChannel(env) === "preview" ? PREVIEW_TELEMETRY_CONFIG_RELATIVE_PATH : TELEMETRY_CONFIG_RELATIVE_PATH);
|
|
489
|
+
}
|
|
490
|
+
function legacyAppTelemetryConfigPath(env = process.env) {
|
|
491
|
+
return path.join(devReviewHome(env), LEGACY_APP_TELEMETRY_CONFIG_RELATIVE_PATH);
|
|
492
|
+
}
|
|
493
|
+
function isTelemetryOptedOut(env, config) {
|
|
494
|
+
if (isEnabledEnvValue$1(env.VITEST) || env.NODE_ENV === "test") return true;
|
|
495
|
+
if (config?.enabled === false) return true;
|
|
496
|
+
return [
|
|
497
|
+
env.DO_NOT_TRACK,
|
|
498
|
+
env.DNT,
|
|
499
|
+
env.PROGRESSIVE_REVIEW_TELEMETRY_DISABLED,
|
|
500
|
+
env.DEV_FAST_TELEMETRY_DISABLED,
|
|
501
|
+
env.DEV_FAST_PROGRESSIVE_REVIEW_TELEMETRY_DISABLED,
|
|
502
|
+
env.DEV_FAST_REVIEW_TELEMETRY_DISABLED
|
|
503
|
+
].some(isEnabledEnvValue$1);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* The on-disk install config as a hand-edited or older file may hold it: only
|
|
507
|
+
* the installation id is required, and a malformed optional field reads as
|
|
508
|
+
* absent.
|
|
509
|
+
*/
|
|
510
|
+
const storedTelemetryInstallConfigSchema = z.looseObject({
|
|
511
|
+
installationId: z.string().min(1),
|
|
512
|
+
createdAt: z.iso.datetime({ offset: true }).optional().catch(void 0),
|
|
513
|
+
installationCreatedSent: z.boolean().optional().catch(void 0),
|
|
514
|
+
firstReviewPresentedSent: z.boolean().optional().catch(void 0),
|
|
515
|
+
enabled: z.boolean().optional().catch(void 0),
|
|
516
|
+
internal: z.boolean().optional().catch(void 0),
|
|
517
|
+
accountAlias: z.string().min(1).optional().catch(void 0)
|
|
518
|
+
});
|
|
519
|
+
function normalizeTelemetryInstallConfig(parsed, now) {
|
|
520
|
+
const stored = storedTelemetryInstallConfigSchema.safeParse(parsed);
|
|
521
|
+
if (!stored.success) return void 0;
|
|
522
|
+
const config = {
|
|
523
|
+
installationId: stored.data.installationId,
|
|
524
|
+
createdAt: stored.data.createdAt ?? now().toISOString(),
|
|
525
|
+
installationCreatedSent: stored.data.installationCreatedSent === true,
|
|
526
|
+
firstReviewPresentedSent: stored.data.firstReviewPresentedSent === true,
|
|
527
|
+
enabled: stored.data.enabled !== false,
|
|
528
|
+
internal: stored.data.internal === true
|
|
529
|
+
};
|
|
530
|
+
if (stored.data.accountAlias) config.accountAlias = stored.data.accountAlias;
|
|
531
|
+
return config;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Whether normalizing changed a field an older or hand-edited file lacked, so
|
|
535
|
+
* the config must be written back once: a backfilled `createdAt` has to stay
|
|
536
|
+
* the first-seen time rather than move with every read.
|
|
537
|
+
*/
|
|
538
|
+
function telemetryInstallConfigNeedsWrite(parsed, config) {
|
|
539
|
+
const stored = storedTelemetryInstallConfigSchema.safeParse(parsed).data;
|
|
540
|
+
return stored?.internal !== config.internal || stored?.createdAt !== config.createdAt;
|
|
541
|
+
}
|
|
542
|
+
const DAY_MS = 864e5;
|
|
543
|
+
/** Whole days since the install was created; 0 for a clock set back. */
|
|
544
|
+
function installAgeDays(config, now) {
|
|
545
|
+
const createdAt = Date.parse(config.createdAt);
|
|
546
|
+
if (Number.isNaN(createdAt)) return 0;
|
|
547
|
+
return Math.max(0, Math.floor((now.getTime() - createdAt) / DAY_MS));
|
|
548
|
+
}
|
|
549
|
+
function createTelemetryInstallConfig(installationId, now) {
|
|
550
|
+
return {
|
|
551
|
+
installationId,
|
|
552
|
+
createdAt: now().toISOString(),
|
|
553
|
+
installationCreatedSent: false,
|
|
554
|
+
firstReviewPresentedSent: false,
|
|
555
|
+
enabled: true,
|
|
556
|
+
internal: false
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function isEnabledEnvValue$1(value) {
|
|
560
|
+
return value === "1" || value?.toLowerCase() === "true";
|
|
561
|
+
}
|
|
562
|
+
let cachedWorkspaceCheckout;
|
|
563
|
+
function isWorkspaceCheckout() {
|
|
564
|
+
if (cachedWorkspaceCheckout !== void 0) return cachedWorkspaceCheckout;
|
|
565
|
+
try {
|
|
566
|
+
const packageRoot = findReviewPackageRoot();
|
|
567
|
+
if (packageRoot.split(path.sep).includes("node_modules")) {
|
|
568
|
+
cachedWorkspaceCheckout = false;
|
|
569
|
+
return cachedWorkspaceCheckout;
|
|
570
|
+
}
|
|
571
|
+
let dir = packageRoot;
|
|
572
|
+
while (true) {
|
|
573
|
+
if (existsSync(path.join(dir, "pnpm-workspace.yaml"))) {
|
|
574
|
+
cachedWorkspaceCheckout = true;
|
|
575
|
+
return cachedWorkspaceCheckout;
|
|
576
|
+
}
|
|
577
|
+
const parent = path.dirname(dir);
|
|
578
|
+
if (parent === dir) break;
|
|
579
|
+
dir = parent;
|
|
580
|
+
}
|
|
581
|
+
cachedWorkspaceCheckout = false;
|
|
582
|
+
} catch {
|
|
583
|
+
cachedWorkspaceCheckout = false;
|
|
584
|
+
}
|
|
585
|
+
return cachedWorkspaceCheckout;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Whether telemetry from this process should carry `internal: true` so
|
|
589
|
+
* partner-facing dashboards can exclude it. The environment overrides a
|
|
590
|
+
* stored true marker, which overrides workspace detection.
|
|
591
|
+
*/
|
|
592
|
+
function isInternalTelemetry(env, config) {
|
|
593
|
+
if (env.PROGRESSIVE_REVIEW_TELEMETRY_INTERNAL === "1") return true;
|
|
594
|
+
if (env.PROGRESSIVE_REVIEW_TELEMETRY_INTERNAL === "0") return false;
|
|
595
|
+
if (config?.internal === true) return true;
|
|
596
|
+
return isWorkspaceCheckout();
|
|
597
|
+
}
|
|
598
|
+
//#endregion
|
|
599
|
+
//#region src/session-markers.ts
|
|
600
|
+
/**
|
|
601
|
+
* The envelope fields of the launch that opened a session which a later
|
|
602
|
+
* launch may not share. An abnormal end carries them so it counts against
|
|
603
|
+
* the launch that died, not the one that reported it. Closed values only.
|
|
604
|
+
*/
|
|
605
|
+
const launchEnvelopeSchema = z.object({
|
|
606
|
+
app_version: z.string().min(1).optional(),
|
|
607
|
+
cli_version: z.string().min(1),
|
|
608
|
+
version: z.string().min(1),
|
|
609
|
+
channel: z.enum([
|
|
610
|
+
"stable",
|
|
611
|
+
"preview",
|
|
612
|
+
"dev"
|
|
613
|
+
]),
|
|
614
|
+
environment: z.enum([
|
|
615
|
+
"production",
|
|
616
|
+
"ci",
|
|
617
|
+
"internal",
|
|
618
|
+
"e2e",
|
|
619
|
+
"smoke"
|
|
620
|
+
]),
|
|
621
|
+
surface: z.enum([
|
|
622
|
+
"desktop",
|
|
623
|
+
"cli",
|
|
624
|
+
"headless",
|
|
625
|
+
"mcp",
|
|
626
|
+
"api"
|
|
627
|
+
]),
|
|
628
|
+
node_major: z.number().int(),
|
|
629
|
+
arch: z.string().min(1),
|
|
630
|
+
os_version: z.string().min(1),
|
|
631
|
+
ci: z.boolean(),
|
|
632
|
+
internal: z.boolean(),
|
|
633
|
+
$session_id: uuidV7Schema.optional(),
|
|
634
|
+
install_age_days: z.number().int().nonnegative().optional()
|
|
635
|
+
});
|
|
636
|
+
/** Picks the launch fields out of an event envelope; undefined if malformed. */
|
|
637
|
+
function launchEnvelope(properties) {
|
|
638
|
+
return launchEnvelopeSchema.safeParse(properties).data;
|
|
639
|
+
}
|
|
640
|
+
const markersSchema = z.array(z.object({
|
|
641
|
+
presentationSessionId: z.string().min(1),
|
|
642
|
+
reviewUuid: z.string().min(1),
|
|
643
|
+
startedAt: z.number(),
|
|
644
|
+
appSessionId: z.string().min(1).optional(),
|
|
645
|
+
ownerPid: z.number().int().positive().optional(),
|
|
646
|
+
envelope: launchEnvelopeSchema.optional().catch(void 0)
|
|
647
|
+
}));
|
|
648
|
+
/** Per channel, like the telemetry config: each channel has its own identity. */
|
|
649
|
+
function openSessionMarkersPath(env) {
|
|
650
|
+
return path.join(devReviewHome(env), "telemetry", reviewTelemetryChannel(env) === "preview" ? "open-sessions.preview.json" : "open-sessions.json");
|
|
651
|
+
}
|
|
652
|
+
function readMarkers(file) {
|
|
653
|
+
try {
|
|
654
|
+
const parsed = markersSchema.safeParse(parseJsonText(readFileSync(file, "utf8")));
|
|
655
|
+
return parsed.success ? parsed.data : [];
|
|
656
|
+
} catch {
|
|
657
|
+
return [];
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function writeMarkers(file, markers) {
|
|
661
|
+
writeFileAtomic(file, `${JSON.stringify(markers)}\n`, "utf8");
|
|
662
|
+
}
|
|
663
|
+
function recordOpenSession(file, marker) {
|
|
664
|
+
writeMarkers(file, [...readMarkers(file).filter((existing) => existing.presentationSessionId !== marker.presentationSessionId), marker]);
|
|
665
|
+
}
|
|
666
|
+
function clearOpenSession(file, presentationSessionId) {
|
|
667
|
+
if (!existsSync(file)) return;
|
|
668
|
+
writeMarkers(file, readMarkers(file).filter((marker) => marker.presentationSessionId !== presentationSessionId));
|
|
669
|
+
}
|
|
670
|
+
/** Reads every marker and deletes the file. */
|
|
671
|
+
function takeOpenSessions(file) {
|
|
672
|
+
const markers = readMarkers(file);
|
|
673
|
+
rmSync(file, { force: true });
|
|
674
|
+
return markers;
|
|
675
|
+
}
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/telemetry-debug-sink.ts
|
|
678
|
+
/**
|
|
679
|
+
* The debug sink, or `undefined` when the switch is off. The sink replaces the
|
|
680
|
+
* PostHog client: a developer run never reaches the production project.
|
|
681
|
+
*/
|
|
682
|
+
function createTelemetryDebugSink(env, output = process.stderr) {
|
|
683
|
+
if (!isEnabledEnvValue(env.DEV_FAST_REVIEW_TELEMETRY_DEBUG)) return void 0;
|
|
684
|
+
return {
|
|
685
|
+
enabled: true,
|
|
686
|
+
ignoresOptOut: true,
|
|
687
|
+
capture(input) {
|
|
688
|
+
try {
|
|
689
|
+
output.write(`[review-telemetry] ${JSON.stringify(input)}\n`);
|
|
690
|
+
} catch {}
|
|
691
|
+
return Promise.resolve();
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
function isEnabledEnvValue(value) {
|
|
696
|
+
return value === "1" || value?.toLowerCase() === "true";
|
|
697
|
+
}
|
|
698
|
+
//#endregion
|
|
699
|
+
//#region src/review-telemetry.ts
|
|
700
|
+
const REVIEW_APP_VERSION_ENV = "DEV_FAST_REVIEW_APP_VERSION";
|
|
701
|
+
const REVIEW_APP_SESSION_ID_ENV = "DEV_FAST_REVIEW_APP_SESSION_ID";
|
|
702
|
+
const REVIEW_SERVER_ENTRY_ENV = "DEV_FAST_REVIEW_SERVER_ENTRY";
|
|
703
|
+
/**
|
|
704
|
+
* A fixed namespace, not the installation id: a key per install would give
|
|
705
|
+
* every install a different alias for one account and defeat the linking.
|
|
706
|
+
* The hash is one-way; the account id itself is never sent.
|
|
707
|
+
*/
|
|
708
|
+
const ACCOUNT_ALIAS_KEY = "dev.fast.review.telemetry.account.v1";
|
|
709
|
+
/** `gh_` + the first 16 bytes of HMAC-SHA256(namespace, account id). */
|
|
710
|
+
function accountAlias(accountId) {
|
|
711
|
+
return `gh_${createHmac("sha256", ACCOUNT_ALIAS_KEY).update(accountId).digest().subarray(0, 16).toString("base64url")}`;
|
|
712
|
+
}
|
|
713
|
+
/** A tool name is program-owned, but only an identifier is ever sent. */
|
|
714
|
+
const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]{0,39}$/;
|
|
715
|
+
const sharedInstallConfigs = /* @__PURE__ */ new Map();
|
|
716
|
+
/**
|
|
717
|
+
* Announcements the debug sink printed without persisting, keyed by install
|
|
718
|
+
* config path and field, so each prints once per process.
|
|
719
|
+
*/
|
|
720
|
+
const printedAnnouncements = /* @__PURE__ */ new Set();
|
|
721
|
+
var ReviewTelemetry = class ReviewTelemetry {
|
|
722
|
+
captureClient;
|
|
723
|
+
env;
|
|
724
|
+
installConfigPath;
|
|
725
|
+
legacyInstallConfigPath;
|
|
726
|
+
openSessionMarkersPath;
|
|
727
|
+
openSessionOwnerPid;
|
|
728
|
+
idFactory;
|
|
729
|
+
commandRunIdFactory;
|
|
730
|
+
now;
|
|
731
|
+
surface;
|
|
732
|
+
packageVersion;
|
|
733
|
+
desktopVersion;
|
|
734
|
+
installConfig;
|
|
735
|
+
chunkIds;
|
|
736
|
+
constructor(options = {}) {
|
|
737
|
+
this.env = options.env ?? process.env;
|
|
738
|
+
this.captureClient = options.captureClient ?? createTelemetryDebugSink(this.env) ?? (options.fetch ? directCaptureClient(this.env, options.fetch, options.timeoutMs) : PostHogCaptureClient.fromEnv(this.env));
|
|
739
|
+
this.installConfigPath = options.installConfigPath ?? reviewTelemetryConfigPath(this.env);
|
|
740
|
+
this.legacyInstallConfigPath = options.legacyInstallConfigPath ?? legacyAppTelemetryConfigPath(this.env);
|
|
741
|
+
this.openSessionMarkersPath = options.openSessionMarkersPath ?? openSessionMarkersPath(this.env);
|
|
742
|
+
this.openSessionOwnerPid = options.openSessionOwnerPid ?? process.ppid;
|
|
743
|
+
this.commandRunIdFactory = options.randomUUID ?? randomUUID;
|
|
744
|
+
this.idFactory = options.idFactory ?? this.commandRunIdFactory;
|
|
745
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
746
|
+
this.surface = options.surface ?? "cli";
|
|
747
|
+
this.packageVersion = readReviewPackageVersion(import.meta.url);
|
|
748
|
+
}
|
|
749
|
+
static fromEnv(env = process.env, options = {}) {
|
|
750
|
+
return new ReviewTelemetry({
|
|
751
|
+
...options,
|
|
752
|
+
env
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
async getInstallationId() {
|
|
756
|
+
return (await this.loadInstallConfig()).installationId;
|
|
757
|
+
}
|
|
758
|
+
createCommandRunId() {
|
|
759
|
+
return this.commandRunIdFactory();
|
|
760
|
+
}
|
|
761
|
+
/** Sets the surface for every later event, envelope included. */
|
|
762
|
+
setSurface(surface) {
|
|
763
|
+
this.surface = surface;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Records the release of the Desktop this process reached, for `app_version`
|
|
767
|
+
* on every later event. A process Desktop launched already has it in its env.
|
|
768
|
+
*/
|
|
769
|
+
setDesktopVersion(version) {
|
|
770
|
+
const value = nonEmpty(version);
|
|
771
|
+
this.desktopVersion = value && valid(value) ? value : void 0;
|
|
772
|
+
}
|
|
773
|
+
async setEnabled(enabled) {
|
|
774
|
+
await this.withConfigLock(async () => {
|
|
775
|
+
const config = await this.readOrCreateInstallConfig();
|
|
776
|
+
config.enabled = enabled;
|
|
777
|
+
this.writeInstallConfig(config);
|
|
778
|
+
this.installConfig = config;
|
|
779
|
+
sharedInstallConfigs.set(this.installConfigPath, config);
|
|
780
|
+
}, 5e3);
|
|
781
|
+
if (!enabled) {
|
|
782
|
+
await this.captureClient.discard?.().catch(() => void 0);
|
|
783
|
+
await this.lockOpenSessions(() => rmSync(this.openSessionMarkersPath, { force: true }));
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
async setInternal(internal) {
|
|
787
|
+
await this.withConfigLock(async () => {
|
|
788
|
+
const config = await this.readOrCreateInstallConfig();
|
|
789
|
+
config.internal = internal;
|
|
790
|
+
this.writeInstallConfig(config);
|
|
791
|
+
this.installConfig = config;
|
|
792
|
+
sharedInstallConfigs.set(this.installConfigPath, config);
|
|
793
|
+
}, 5e3);
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Whether events leave this machine right now: a real client, no opt-out.
|
|
797
|
+
* The debug sink prints instead of sending, so it answers false.
|
|
798
|
+
*/
|
|
799
|
+
async sendsEvents() {
|
|
800
|
+
if (this.captureClient.ignoresOptOut) return false;
|
|
801
|
+
return this.isEnabled();
|
|
802
|
+
}
|
|
803
|
+
/** The common properties every event carries; bug reports embed them. */
|
|
804
|
+
async envelope() {
|
|
805
|
+
return this.commonProperties(await this.loadInstallConfig());
|
|
806
|
+
}
|
|
807
|
+
async captureInstallationCreated() {
|
|
808
|
+
await this.announceOnce("installationCreatedSent", true, async (config) => {
|
|
809
|
+
await this.captureClient.capture({
|
|
810
|
+
event: "review_installation_created",
|
|
811
|
+
distinctId: config.installationId,
|
|
812
|
+
properties: await this.commonProperties(config)
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Link this installation to a signed-in account by a one-way hash, so
|
|
818
|
+
* several installs by one person count as one; the raw account id never
|
|
819
|
+
* leaves this process. Once per installation: the first account wins. A
|
|
820
|
+
* later login to another account sends nothing, because a second alias
|
|
821
|
+
* would merge two accounts, and every later install of either, into one
|
|
822
|
+
* PostHog person. The account is looked up only when an alias would be
|
|
823
|
+
* sent, so a login with telemetry off makes no network call.
|
|
824
|
+
*/
|
|
825
|
+
async captureAccountAlias(lookupAccountId) {
|
|
826
|
+
if (!await this.isEnabled() || this.installConfig?.accountAlias) return;
|
|
827
|
+
const alias = accountAlias(await lookupAccountId());
|
|
828
|
+
await this.announceOnce("accountAlias", alias, async (config) => {
|
|
829
|
+
await this.captureClient.capture({
|
|
830
|
+
event: "$create_alias",
|
|
831
|
+
distinctId: config.installationId,
|
|
832
|
+
properties: {
|
|
833
|
+
...await this.commonProperties(config),
|
|
834
|
+
alias,
|
|
835
|
+
$process_person_profile: true
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Sends an event exactly once per installation, guarded by a persisted
|
|
842
|
+
* install config field that `value` fills. The field is persisted before
|
|
843
|
+
* the send completes: under-counting
|
|
844
|
+
* is recoverable, announcing twice is not. A printed event is not a sent
|
|
845
|
+
* event, so the debug sink leaves the field alone (it still always sends,
|
|
846
|
+
* ignoring opt-out as today) and only remembers the announcement for the
|
|
847
|
+
* life of the process.
|
|
848
|
+
*/
|
|
849
|
+
async announceOnce(field, value, send) {
|
|
850
|
+
if (!this.captureClient.enabled || this.optedOut()) return;
|
|
851
|
+
await this.withConfigLock(async () => {
|
|
852
|
+
const config = await this.readOrCreateInstallConfig();
|
|
853
|
+
this.installConfig = config;
|
|
854
|
+
sharedInstallConfigs.set(this.installConfigPath, config);
|
|
855
|
+
if (this.optedOut(config) || config[field]) return;
|
|
856
|
+
if (this.captureClient.ignoresOptOut) {
|
|
857
|
+
const printed = `${this.installConfigPath}\0${field}`;
|
|
858
|
+
if (printedAnnouncements.has(printed)) return;
|
|
859
|
+
printedAnnouncements.add(printed);
|
|
860
|
+
} else {
|
|
861
|
+
config[field] = value;
|
|
862
|
+
this.writeInstallConfig(config);
|
|
863
|
+
}
|
|
864
|
+
await send(config);
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
async captureCommandSucceeded(input) {
|
|
868
|
+
await this.captureCommandEvent("review_command_succeeded", input);
|
|
869
|
+
}
|
|
870
|
+
async captureCommandFailed(input) {
|
|
871
|
+
await this.captureCommandEvent("review_command_failed", input);
|
|
872
|
+
}
|
|
873
|
+
async captureCommandStarted(input) {
|
|
874
|
+
const properties = {
|
|
875
|
+
command_path: input.command,
|
|
876
|
+
command_run_id: input.commandRunId,
|
|
877
|
+
agent_kind: this.sessionAgent()
|
|
878
|
+
};
|
|
879
|
+
await this.captureEvent("review_command_started", properties);
|
|
880
|
+
}
|
|
881
|
+
async captureToolCalled(call) {
|
|
882
|
+
const properties = {
|
|
883
|
+
tool: TOOL_NAME_PATTERN.test(call.tool) ? call.tool : "other",
|
|
884
|
+
via: call.via,
|
|
885
|
+
ok: call.ok,
|
|
886
|
+
duration_ms: Math.max(0, Math.round(call.durationMs)),
|
|
887
|
+
agent_kind: call.agentKind ?? this.sessionAgent()
|
|
888
|
+
};
|
|
889
|
+
if (!call.ok && call.errorName) properties.error_name = call.errorName;
|
|
890
|
+
if (!call.ok && call.errorCategory) properties.error_category = call.errorCategory;
|
|
891
|
+
await this.captureEvent("review_mcp_tool_called", properties);
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* A tab dwell period ended. Time on the files tab is also the diff dwell,
|
|
895
|
+
* so it doubles as `review_diff_viewed` without a second client beacon.
|
|
896
|
+
*/
|
|
897
|
+
async captureTabViewed(event, context) {
|
|
898
|
+
await this.captureEvent("review_tab_viewed", {
|
|
899
|
+
tab: event.tab,
|
|
900
|
+
duration_ms: event.durationMs,
|
|
901
|
+
reason: event.reason,
|
|
902
|
+
source: "review_app",
|
|
903
|
+
app_session_id: event.appSessionId
|
|
904
|
+
}, context);
|
|
905
|
+
if (event.tab !== "files") return;
|
|
906
|
+
await this.captureEvent("review_diff_viewed", {
|
|
907
|
+
duration_ms: event.durationMs,
|
|
908
|
+
source: "review_app",
|
|
909
|
+
app_session_id: event.appSessionId
|
|
910
|
+
}, context);
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* A session start leaves a marker until its end arrives, so a session the
|
|
914
|
+
* process never closed can be reported as abnormal on the next launch. The
|
|
915
|
+
* marker is cleared before the end is sent: a lost marker for a sent end
|
|
916
|
+
* is harmless, a stale one would report the session ended twice.
|
|
917
|
+
*/
|
|
918
|
+
async captureUiEvent(event, properties, context, occurredAt = this.now().getTime()) {
|
|
919
|
+
const reviewUuid = context?.reviewUuid;
|
|
920
|
+
const presentationSessionId = context?.presentationSessionId;
|
|
921
|
+
const inSession = reviewUuid && presentationSessionId;
|
|
922
|
+
if (inSession && event === "review_session_started") {
|
|
923
|
+
const marker = {
|
|
924
|
+
presentationSessionId,
|
|
925
|
+
reviewUuid,
|
|
926
|
+
startedAt: this.now().getTime(),
|
|
927
|
+
ownerPid: this.openSessionOwnerPid
|
|
928
|
+
};
|
|
929
|
+
const appSessionId = nonEmpty(properties.app_session_id?.toString());
|
|
930
|
+
if (appSessionId) marker.appSessionId = appSessionId;
|
|
931
|
+
if (await this.isEnabled()) {
|
|
932
|
+
const envelope = launchEnvelope(await this.envelope().catch(() => ({})));
|
|
933
|
+
if (envelope) marker.envelope = envelope;
|
|
934
|
+
await this.lockOpenSessions(() => recordOpenSession(this.openSessionMarkersPath, marker));
|
|
935
|
+
}
|
|
936
|
+
} else if (inSession && event === "review_session_ended") await this.updateOpenSessions(() => clearOpenSession(this.openSessionMarkersPath, presentationSessionId));
|
|
937
|
+
await this.captureEvent(event, {
|
|
938
|
+
source: "review_app",
|
|
939
|
+
...properties
|
|
940
|
+
}, context, occurredAt);
|
|
941
|
+
if (inSession && event === "review_review_presented") await this.captureFirstReviewPresented(context).catch(() => void 0);
|
|
942
|
+
if (event !== "review_client_error") return;
|
|
943
|
+
const exception = exceptionProperties(properties, this.chunkIds ??= readChunkIds(this.env[REVIEW_SERVER_ENTRY_ENV]));
|
|
944
|
+
if (!exception) return;
|
|
945
|
+
await this.captureEvent("$exception", {
|
|
946
|
+
source: "review_app",
|
|
947
|
+
...properties,
|
|
948
|
+
...exception
|
|
949
|
+
}, context);
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Reports sessions an earlier app launch never closed as abnormal ends.
|
|
953
|
+
* Sessions of the current launch survive a server restart untouched. Call
|
|
954
|
+
* once at startup.
|
|
955
|
+
*/
|
|
956
|
+
async reconcileOpenSessions() {
|
|
957
|
+
const currentAppSessionId = nonEmpty(this.env[REVIEW_APP_SESSION_ID_ENV]);
|
|
958
|
+
const ended = [];
|
|
959
|
+
await this.updateOpenSessions(() => {
|
|
960
|
+
for (const marker of takeOpenSessions(this.openSessionMarkersPath)) if (currentAppSessionId !== void 0 && marker.appSessionId === currentAppSessionId || marker.ownerPid !== void 0 && processIsAlive(marker.ownerPid)) recordOpenSession(this.openSessionMarkersPath, marker);
|
|
961
|
+
else ended.push(marker);
|
|
962
|
+
});
|
|
963
|
+
for (const marker of ended) {
|
|
964
|
+
const outcome = "abnormal";
|
|
965
|
+
const properties = {
|
|
966
|
+
...marker.envelope && {
|
|
967
|
+
app_version: void 0,
|
|
968
|
+
...marker.envelope
|
|
969
|
+
},
|
|
970
|
+
source: "review_app",
|
|
971
|
+
outcome,
|
|
972
|
+
app_session_id: marker.appSessionId
|
|
973
|
+
};
|
|
974
|
+
await this.captureEvent("review_session_ended", properties, {
|
|
975
|
+
reviewUuid: marker.reviewUuid,
|
|
976
|
+
presentationSessionId: marker.presentationSessionId
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* `occurredAt` defaults to the call, before any await: config and marker
|
|
982
|
+
* locks must not reorder events that happened in order.
|
|
983
|
+
*/
|
|
984
|
+
async captureEvent(event, properties = {}, context, occurredAt = this.now().getTime()) {
|
|
985
|
+
await this.withTelemetry(async (config) => {
|
|
986
|
+
const common = await this.commonProperties(config);
|
|
987
|
+
this.captureClient.setDefaultProperties?.(common);
|
|
988
|
+
await this.captureClient.capture({
|
|
989
|
+
event,
|
|
990
|
+
distinctId: config.installationId,
|
|
991
|
+
properties: withSessionId({
|
|
992
|
+
...common,
|
|
993
|
+
...properties,
|
|
994
|
+
...correlationProperties(config.installationId, context)
|
|
995
|
+
}),
|
|
996
|
+
timestamp: occurredAt
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
async captureFirstReviewPresented(context) {
|
|
1001
|
+
await this.announceOnce("firstReviewPresentedSent", true, async () => {
|
|
1002
|
+
await this.captureEvent("review_first_review_presented", { source: "review_app" }, context);
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Marker I/O is best effort, skipped entirely when telemetry is off, and
|
|
1007
|
+
* locked because concurrent Desktops share the file.
|
|
1008
|
+
*/
|
|
1009
|
+
async updateOpenSessions(update) {
|
|
1010
|
+
if (!await this.isEnabled()) return;
|
|
1011
|
+
await this.lockOpenSessions(update);
|
|
1012
|
+
}
|
|
1013
|
+
async lockOpenSessions(update) {
|
|
1014
|
+
try {
|
|
1015
|
+
await withFileLock(`${this.openSessionMarkersPath}.lock`, {
|
|
1016
|
+
retryMs: 10,
|
|
1017
|
+
staleMs: 3e4,
|
|
1018
|
+
timeoutMs: 250,
|
|
1019
|
+
unownedGraceMs: 1e3,
|
|
1020
|
+
heartbeatMs: 5e3
|
|
1021
|
+
}, async () => update());
|
|
1022
|
+
} catch {}
|
|
1023
|
+
}
|
|
1024
|
+
async flush(deadlineMs = 1e3) {
|
|
1025
|
+
if (!await this.isEnabled()) return;
|
|
1026
|
+
await this.captureClient.flush?.(deadlineMs).catch(() => void 0);
|
|
1027
|
+
}
|
|
1028
|
+
async shutdown(deadlineMs = 1e3) {
|
|
1029
|
+
if (!await this.isEnabled()) return;
|
|
1030
|
+
await this.captureClient.shutdown?.(deadlineMs).catch(() => void 0);
|
|
1031
|
+
}
|
|
1032
|
+
async captureCommandEvent(event, input) {
|
|
1033
|
+
const properties = {
|
|
1034
|
+
command_path: input.command,
|
|
1035
|
+
exit_code: input.exitCode
|
|
1036
|
+
};
|
|
1037
|
+
if (input.durationMs !== void 0) properties.duration_ms = input.durationMs;
|
|
1038
|
+
Object.assign(properties, input.properties);
|
|
1039
|
+
properties.command_run_id = input.commandRunId;
|
|
1040
|
+
if (input.errorName) properties.error_name = input.errorName;
|
|
1041
|
+
if (input.errorCategory) properties.error_category = input.errorCategory;
|
|
1042
|
+
await this.captureEvent(event, properties);
|
|
1043
|
+
}
|
|
1044
|
+
async withTelemetry(fn) {
|
|
1045
|
+
if (!this.captureClient.enabled || this.optedOut()) return;
|
|
1046
|
+
try {
|
|
1047
|
+
const config = await this.loadInstallConfig();
|
|
1048
|
+
if (this.optedOut(config)) return;
|
|
1049
|
+
await fn(config);
|
|
1050
|
+
} catch {}
|
|
1051
|
+
}
|
|
1052
|
+
async loadInstallConfig() {
|
|
1053
|
+
const shared = sharedInstallConfigs.get(this.installConfigPath);
|
|
1054
|
+
if (shared) {
|
|
1055
|
+
this.installConfig = shared;
|
|
1056
|
+
return shared;
|
|
1057
|
+
}
|
|
1058
|
+
if (this.installConfig) return this.installConfig;
|
|
1059
|
+
await this.withConfigLock(async () => {
|
|
1060
|
+
this.installConfig = await this.readOrCreateInstallConfig();
|
|
1061
|
+
sharedInstallConfigs.set(this.installConfigPath, this.installConfig);
|
|
1062
|
+
});
|
|
1063
|
+
return this.installConfig ?? createTelemetryInstallConfig(this.idFactory(), this.now);
|
|
1064
|
+
}
|
|
1065
|
+
optedOut(config) {
|
|
1066
|
+
if (this.captureClient.ignoresOptOut) return false;
|
|
1067
|
+
return isTelemetryOptedOut(this.env, config);
|
|
1068
|
+
}
|
|
1069
|
+
async isEnabled() {
|
|
1070
|
+
if (!this.captureClient.enabled || this.optedOut()) return false;
|
|
1071
|
+
try {
|
|
1072
|
+
const parsed = parseJsonText(await readFile(this.installConfigPath, "utf8"));
|
|
1073
|
+
const config = normalizeTelemetryInstallConfig(parsed, this.now);
|
|
1074
|
+
if (!config) return true;
|
|
1075
|
+
if (!telemetryInstallConfigNeedsWrite(parsed, config)) {
|
|
1076
|
+
this.installConfig = config;
|
|
1077
|
+
sharedInstallConfigs.set(this.installConfigPath, config);
|
|
1078
|
+
}
|
|
1079
|
+
return !this.optedOut(config);
|
|
1080
|
+
} catch {
|
|
1081
|
+
return true;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
async readOrCreateInstallConfig() {
|
|
1085
|
+
try {
|
|
1086
|
+
const parsed = parseJsonText(await readFile(this.installConfigPath, "utf8"));
|
|
1087
|
+
const config = normalizeTelemetryInstallConfig(parsed, this.now);
|
|
1088
|
+
if (config) {
|
|
1089
|
+
if (telemetryInstallConfigNeedsWrite(parsed, config)) try {
|
|
1090
|
+
this.writeInstallConfig(config);
|
|
1091
|
+
} catch {}
|
|
1092
|
+
return config;
|
|
1093
|
+
}
|
|
1094
|
+
} catch {}
|
|
1095
|
+
const legacyInstallId = reviewTelemetryChannel(this.env) === "preview" ? void 0 : await this.readLegacyInstallId();
|
|
1096
|
+
const config = createTelemetryInstallConfig(legacyInstallId ?? this.idFactory(), this.now);
|
|
1097
|
+
if (legacyInstallId) config.installationCreatedSent = true;
|
|
1098
|
+
this.writeInstallConfig(config);
|
|
1099
|
+
return config;
|
|
1100
|
+
}
|
|
1101
|
+
async readLegacyInstallId() {
|
|
1102
|
+
try {
|
|
1103
|
+
const installId = jsonString(jsonObject(parseJsonText(await readFile(this.legacyInstallConfigPath, "utf8")))?.installId);
|
|
1104
|
+
return installId ? installId : void 0;
|
|
1105
|
+
} catch {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
writeInstallConfig(config) {
|
|
1110
|
+
writeFileAtomic(this.installConfigPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
1111
|
+
}
|
|
1112
|
+
async withConfigLock(operation, timeoutMs = 250) {
|
|
1113
|
+
if (!(await withFileLock(`${this.installConfigPath}.lock`, {
|
|
1114
|
+
retryMs: 10,
|
|
1115
|
+
staleMs: 3e4,
|
|
1116
|
+
timeoutMs,
|
|
1117
|
+
unownedGraceMs: 1e3,
|
|
1118
|
+
heartbeatMs: 5e3
|
|
1119
|
+
}, operation)).acquired) throw new Error("Timed out while updating the telemetry configuration");
|
|
1120
|
+
}
|
|
1121
|
+
async commonProperties(config) {
|
|
1122
|
+
const appVersion = reviewAppVersion(this.env) ?? this.desktopVersion;
|
|
1123
|
+
const appSessionId = nonEmpty(this.env[REVIEW_APP_SESSION_ID_ENV]);
|
|
1124
|
+
const properties = {
|
|
1125
|
+
cli_version: this.packageVersion,
|
|
1126
|
+
version: this.packageVersion,
|
|
1127
|
+
channel: reviewTelemetryChannel(this.env),
|
|
1128
|
+
environment: reviewTelemetryEnvironment(this.env, config),
|
|
1129
|
+
surface: this.surface,
|
|
1130
|
+
node_major: Number(process.versions.node.split(".", 1)[0]),
|
|
1131
|
+
platform: process.platform,
|
|
1132
|
+
arch: process.arch,
|
|
1133
|
+
os_version: os.release(),
|
|
1134
|
+
ci: Boolean(this.env.CI),
|
|
1135
|
+
internal: isInternalTelemetry(this.env, config),
|
|
1136
|
+
install_age_days: installAgeDays(config, this.now()),
|
|
1137
|
+
$process_person_profile: config.accountAlias !== void 0
|
|
1138
|
+
};
|
|
1139
|
+
if (appVersion) properties.app_version = appVersion;
|
|
1140
|
+
if (appSessionId) properties.app_session_id = appSessionId;
|
|
1141
|
+
return withSessionId(properties);
|
|
1142
|
+
}
|
|
1143
|
+
sessionAgent() {
|
|
1144
|
+
return reviewSessionAgent(this.env);
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
/** The agent harness this process runs under, from its session environment. */
|
|
1148
|
+
function reviewSessionAgent(env) {
|
|
1149
|
+
const harness = resolveAuthoringSessionRef(env)?.harness;
|
|
1150
|
+
if (harness === "codex") return "codex";
|
|
1151
|
+
if (harness === "claude-code") return "claude";
|
|
1152
|
+
if (harness === "pi") return "pi";
|
|
1153
|
+
return "other";
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* `$session_id` always mirrors the event's final `app_session_id`, which an
|
|
1157
|
+
* event may override (a recovered end carries the launch that died). An id
|
|
1158
|
+
* that is not a UUIDv7, such as a canvas's own fallback id or one from a
|
|
1159
|
+
* launch before v7 ids, leaves `$session_id` out.
|
|
1160
|
+
*/
|
|
1161
|
+
function withSessionId(properties) {
|
|
1162
|
+
const { $session_id: _stale, ...rest } = properties;
|
|
1163
|
+
return isUuidV7(rest.app_session_id) ? {
|
|
1164
|
+
...rest,
|
|
1165
|
+
$session_id: rest.app_session_id
|
|
1166
|
+
} : rest;
|
|
1167
|
+
}
|
|
1168
|
+
function correlationProperties(installationId, context) {
|
|
1169
|
+
const properties = {};
|
|
1170
|
+
if (!context) return properties;
|
|
1171
|
+
if (context.reviewUuid) properties.review_id = opaqueCorrelationId("rv_", installationId, "review", context.reviewUuid);
|
|
1172
|
+
if (context.presentationSessionId) properties.presentation_id = opaqueCorrelationId("pr_", installationId, "presentation", context.presentationSessionId);
|
|
1173
|
+
return properties;
|
|
1174
|
+
}
|
|
1175
|
+
function opaqueCorrelationId(prefix, installationId, namespace, value) {
|
|
1176
|
+
return `${prefix}${createHmac("sha256", installationId).update(`dev.fast.review.telemetry.v1\0${namespace}\0${value}`).digest().subarray(0, 16).toString("base64url")}`;
|
|
1177
|
+
}
|
|
1178
|
+
function directCaptureClient(env, fetchImpl, timeoutMs) {
|
|
1179
|
+
return new PostHogCaptureClient({
|
|
1180
|
+
apiKey: nonEmpty(env["PROGRESSIVE_REVIEW_POSTHOG_KEY"]) ?? nonEmpty(env.DEV_FAST_POSTHOG_KEY) ?? nonEmpty(env.POSTHOG_KEY) ?? void 0,
|
|
1181
|
+
host: nonEmpty(env["PROGRESSIVE_REVIEW_POSTHOG_HOST"]) ?? nonEmpty(env.DEV_FAST_POSTHOG_HOST) ?? nonEmpty(env.POSTHOG_HOST),
|
|
1182
|
+
fetch: fetchImpl,
|
|
1183
|
+
timeoutMs
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
function nonEmpty(value) {
|
|
1187
|
+
const trimmed = value?.trim();
|
|
1188
|
+
return trimmed ? trimmed : void 0;
|
|
1189
|
+
}
|
|
1190
|
+
function reviewAppVersion(env) {
|
|
1191
|
+
const value = nonEmpty(env[REVIEW_APP_VERSION_ENV]);
|
|
1192
|
+
return value && valid(value) ? value : void 0;
|
|
1193
|
+
}
|
|
1194
|
+
//#endregion
|
|
1195
|
+
export { reviewSessionAgent as n, reviewTelemetryChannel as r, ReviewTelemetry as t };
|