diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 36b6ffad68..55c566d2a1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -192,7 +192,8 @@ const hasJsonFlag = process.argv.includes("--json"); // Captured references — populated when the lazy imports resolve. // Used in exit handlers where dynamic import() is unsafe (beforeExit loops, // exit handler is synchronous-only). -let _flush: (() => Promise) | undefined; +// Resolves to whether the batch was acknowledged; this exit path ignores it. +let _flush: (() => Promise) | undefined; let _flushSync: (() => void) | undefined; let _trackCliError: | ((props: { diff --git a/packages/cli/src/commands/add.oauth.test.ts b/packages/cli/src/commands/add.oauth.test.ts index 2f14911248..c1087ca3b9 100644 --- a/packages/cli/src/commands/add.oauth.test.ts +++ b/packages/cli/src/commands/add.oauth.test.ts @@ -8,12 +8,25 @@ const mocks = vi.hoisted(() => ({ authorize: vi.fn(), install: vi.fn(), resolve: vi.fn(), + trackEvent: vi.fn(), + shouldTrack: vi.fn(() => true), + writePrimitiveFunnelContext: vi.fn(), })); vi.mock("../registry/threadMessageStackAuthorization.js", () => ({ authorizeThreadMessageStackInstall: mocks.authorize, })); vi.mock("../registry/installer.js", () => ({ installItem: mocks.install })); +vi.mock("../telemetry/client.js", () => ({ + trackEvent: mocks.trackEvent, + shouldTrack: mocks.shouldTrack, +})); +vi.mock("../telemetry/config.js", () => ({ + readConfig: () => ({ anonymousId: "anonymous-direct-add" }), +})); +vi.mock("../telemetry/primitive-funnel-state.js", () => ({ + writePrimitiveFunnelContext: mocks.writePrimitiveFunnelContext, +})); vi.mock("../registry/resolver.js", () => ({ resolveItemWithDependencies: mocks.resolve, resolveItemsByTag: vi.fn(async () => []), @@ -45,6 +58,9 @@ describe("direct add thread-message-stack OAuth boundary", () => { mocks.resolve.mockReset().mockResolvedValue([item]); mocks.install.mockReset().mockResolvedValue({ written: [join(projectDir, "stack.html")] }); mocks.authorize.mockReset(); + mocks.trackEvent.mockReset(); + mocks.shouldTrack.mockReset().mockReturnValue(true); + mocks.writePrimitiveFunnelContext.mockReset(); }); afterEach(() => rmSync(projectDir, { recursive: true, force: true })); @@ -52,7 +68,10 @@ describe("direct add thread-message-stack OAuth boundary", () => { it.each(["api-key-only", "cancelled", "failed"] as const)( "does not download or materialize when verified HeyGen OAuth is %s", async (outcome) => { - mocks.authorize.mockResolvedValue(outcome); + mocks.authorize.mockImplementation(async (deps) => { + deps.onAuthStarted?.(); + return outcome; + }); await expect( runAdd({ name: "thread-message-stack", projectDir, skipClipboard: true }), @@ -60,16 +79,59 @@ describe("direct add thread-message-stack OAuth boundary", () => { expect(mocks.authorize).toHaveBeenCalledTimes(1); expect(mocks.resolve).not.toHaveBeenCalled(); expect(mocks.install).not.toHaveBeenCalled(); + expect(mocks.trackEvent.mock.calls.map(([name]) => name)).toEqual([ + "primitive_auth_started", + "primitive_auth_failed", + ]); + expect(mocks.writePrimitiveFunnelContext).not.toHaveBeenCalled(); }, ); it("downloads and materializes exactly once after verified HeyGen OAuth succeeds", async () => { - mocks.authorize.mockResolvedValue("authorized"); + mocks.authorize.mockImplementation(async (deps) => { + deps.onAuthStarted?.(); + deps.onVerified?.({ email: "verified@example.com" }, "oauth"); + return "authorized"; + }); + mocks.install.mockImplementationOnce(async () => { + expect(mocks.trackEvent.mock.calls.map(([name]) => name)).toEqual([ + "primitive_auth_started", + "$identify", + "primitive_auth_completed", + "primitive_install_started", + ]); + return { written: [join(projectDir, "stack.html")] }; + }); await expect( runAdd({ name: "thread-message-stack", projectDir, skipClipboard: true }), ).resolves.toMatchObject({ ok: true, name: "thread-message-stack" }); expect(mocks.authorize).toHaveBeenCalledTimes(1); expect(mocks.install).toHaveBeenCalledTimes(1); + expect(mocks.writePrimitiveFunnelContext).toHaveBeenCalledTimes(1); + const persisted = mocks.writePrimitiveFunnelContext.mock.calls[0]?.[1]; + expect(persisted).toMatchObject({ + primitiveId: "thread-message-stack", + artifactId: expect.any(String), + versionId: expect.any(String), + catalogVersion: expect.any(String), + funnelId: expect.any(String), + installId: expect.any(String), + }); + expect(mocks.trackEvent.mock.calls.map(([name]) => name)).toEqual([ + "primitive_auth_started", + "$identify", + "primitive_auth_completed", + "primitive_install_started", + "primitive_install_completed", + ]); + const installEvents = mocks.trackEvent.mock.calls.filter(([name]) => + String(name).startsWith("primitive_install_"), + ); + expect(installEvents[0]?.[1].funnel_id).toBe(installEvents[1]?.[1].funnel_id); + expect(installEvents[1]?.[1]).toMatchObject({ + duration_ms: expect.any(Number), + event_id: `${persisted.installId}:install-completed`, + }); }); }); diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index e48efa4381..3b7a8b1db9 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -11,6 +11,7 @@ export const examples: Example[] = [ ["Skip the clipboard copy (CI/headless)", "hyperframes add shader-wipe --no-clipboard"], ]; +import { createHash, randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { resolve, relative } from "node:path"; import { ITEM_TYPE_DIRS, type RegistryItem } from "@hyperframes/core"; @@ -30,10 +31,18 @@ import { DEFAULT_PROJECT_CONFIG, loadProjectConfig, projectConfigPath, + type ProjectConfig, writeProjectConfig, } from "../utils/projectConfig.js"; import { copyToClipboard } from "../utils/clipboard.js"; import { authorizeThreadMessageStackInstall } from "../registry/threadMessageStackAuthorization.js"; +import { PrimitiveFunnel, type PrimitiveFunnelContext } from "../telemetry/primitive-funnel.js"; +import { writePrimitiveFunnelContext } from "../telemetry/primitive-funnel-state.js"; +import { + THREAD_MESSAGE_STACK_ARTIFACT_ID, + THREAD_MESSAGE_STACK_CATALOG_DIGEST, + THREAD_MESSAGE_STACK_VERSION_ID, +} from "../registry/heygenverseCatalog.js"; // ── Target-path resolution ────────────────────────────────────────────────── // `registry-item.json` files specify `target` paths relative to the project @@ -91,6 +100,11 @@ export interface RunAddArgs { cliVersion?: string; /** Caller-owned messages materialized only for thread-message-stack. */ threadMessageStackData?: ThreadMessageStackData; + /** Caller-owned catalog session; direct add creates one without search/selection side effects. */ + primitiveFunnelSession?: { + context: PrimitiveFunnelContext; + funnel: PrimitiveFunnel; + }; } export interface RunAddResult { @@ -167,55 +181,33 @@ async function installAll( return written; } -export async function runAdd(opts: RunAddArgs): Promise { - const projectDir = resolve(opts.projectDir); - - // 1. Load (or write default) project config. - let config = loadProjectConfig(projectDir); - const hasConfig = existsSync(projectConfigPath(projectDir)); - if (!hasConfig && existsSync(resolve(projectDir, "index.html"))) { - writeProjectConfig(projectDir, DEFAULT_PROJECT_CONFIG); - config = DEFAULT_PROJECT_CONFIG; - } - - // This source-owned primitive is not downloadable through generic registry - // credentials. Gate its named command boundary before registry resolution so - // an API key, cancellation, or failed OAuth cannot fetch even its manifest. - if (opts.name === "thread-message-stack") { - const authorization = await authorizeThreadMessageStackInstall(); - if (authorization !== "authorized") { - throw new AddError( - `thread-message-stack requires verified HeyGen OAuth (${authorization}); no source was downloaded or materialized.`, - "oauth-required", - ); - } - } - - // 2. Resolve the requested item and its transitive registryDependencies. - // The list comes back topologically sorted: dependencies first, the - // requested item last. +async function resolveRequestedItem( + name: string, + registry: string, +): Promise<{ resolved: RegistryItem[]; item: RegistryItem }> { let resolved: RegistryItem[]; try { - resolved = await resolveItemWithDependencies(opts.name, { baseUrl: config.registry }); + resolved = await resolveItemWithDependencies(name, { baseUrl: registry }); } catch (err) { throw new AddError(err instanceof Error ? err.message : String(err), "unknown-item"); } - // `resolveItemWithDependencies` always pushes the requested item last (or throws), - // so the final element is the item the user asked for. const item = resolved[resolved.length - 1]!; - if (item.type === "hyperframes:example") { throw new AddError( `"${item.name}" is an example — use \`hyperframes init --example ${item.name}\` instead.`, "example-type", ); } + return { resolved, item }; +} - // 3. Compatibility-gate every item we're about to install (dependencies - // included) before writing anything. +async function resolveAndInstall( + opts: RunAddArgs, + projectDir: string, + config: ProjectConfig, +): Promise { + const { resolved, item } = await resolveRequestedItem(opts.name, config.registry); const warnings = assertCompatibleOrThrow(resolved, opts.cliVersion); - - // 4. Remap targets per project config — each item by its own type. const installPlan: RegistryItem[] = resolved.map((resolvedItem) => ({ ...resolvedItem, files: resolvedItem.files.map((f) => ({ @@ -223,8 +215,6 @@ export async function runAdd(opts: RunAddArgs): Promise { target: remapTarget(resolvedItem, f.target, config.paths), })), })); - - // 5. Install — dependencies first, requested item last. const written = await installAll( installPlan, projectDir, @@ -233,15 +223,12 @@ export async function runAdd(opts: RunAddArgs): Promise { opts.threadMessageStackData, ); - // 6. Build include snippet + clipboard copy for the requested item. const itemForInstall = installPlan[installPlan.length - 1]!; const primaryFile = itemForInstall.files.find((f) => f.type === "hyperframes:snippet") ?? itemForInstall.files.find((f) => f.type === "hyperframes:composition") ?? itemForInstall.files[0]; - const snippetTargetRel = primaryFile?.target ?? ""; - const snippet = buildSnippet(item, snippetTargetRel); - const clipboardCopied = !opts.skipClipboard && snippet ? copyToClipboard(snippet) : false; + const snippet = buildSnippet(item, primaryFile?.target ?? ""); return { ok: true, @@ -251,11 +238,87 @@ export async function runAdd(opts: RunAddArgs): Promise { written, installed: installPlan.map((planItem) => planItem.name), snippet, - clipboardCopied, + clipboardCopied: !opts.skipClipboard && snippet ? copyToClipboard(snippet) : false, warnings, }; } +export async function runAdd(opts: RunAddArgs): Promise { + const projectDir = resolve(opts.projectDir); + let primitiveSession = opts.primitiveFunnelSession; + let installStartedAt = 0; + + // 1. Load (or write default) project config. + let config = loadProjectConfig(projectDir); + const hasConfig = existsSync(projectConfigPath(projectDir)); + if (!hasConfig && existsSync(resolve(projectDir, "index.html"))) { + writeProjectConfig(projectDir, DEFAULT_PROJECT_CONFIG); + config = DEFAULT_PROJECT_CONFIG; + } + + // This source-owned primitive is not downloadable through generic registry + // credentials. Gate its named command boundary before registry resolution so + // an API key, cancellation, or failed OAuth cannot fetch even its manifest. + if (opts.name === "thread-message-stack") { + if (!primitiveSession) { + const context: PrimitiveFunnelContext = { + funnelId: randomUUID(), + installId: randomUUID(), + primitiveId: "thread-message-stack", + artifactId: THREAD_MESSAGE_STACK_ARTIFACT_ID, + versionId: THREAD_MESSAGE_STACK_VERSION_ID, + catalogVersion: THREAD_MESSAGE_STACK_CATALOG_DIGEST, + queryFingerprint: `sha256:${createHash("sha256").update("").digest("hex")}`, + }; + primitiveSession = { context, funnel: new PrimitiveFunnel(context) }; + } + const authStartedAt = performance.now(); + const authorization = await authorizeThreadMessageStackInstall({ + onAuthStarted: () => primitiveSession?.funnel.authStarted(), + onVerified: (user, authState) => + primitiveSession?.funnel.authCompleted( + user.email ?? user.username, + authState, + performance.now() - authStartedAt, + ), + }); + if (authorization !== "authorized") { + primitiveSession.funnel.authFailed( + `${primitiveSession.context.installId}:auth-failed`, + authorization === "cancelled" ? "auth_cancelled" : "auth_failed", + performance.now() - authStartedAt, + ); + throw new AddError( + `thread-message-stack requires verified HeyGen OAuth (${authorization}); no source was downloaded or materialized.`, + "oauth-required", + ); + } + primitiveSession.funnel.installStarted(); + installStartedAt = performance.now(); + } + + try { + const result = await resolveAndInstall(opts, projectDir, config); + if (primitiveSession) { + writePrimitiveFunnelContext(projectDir, primitiveSession.context); + primitiveSession.funnel.installCompleted( + `${primitiveSession.context.installId}:install-completed`, + performance.now() - installStartedAt, + ); + } + return result; + } catch (error) { + primitiveSession?.funnel.installFailed( + `${primitiveSession.context.installId}:install-failed`, + error instanceof AddError && error.code === "install-failed" + ? "install_failed" + : "invalid_payload", + performance.now() - installStartedAt, + ); + throw error; + } +} + // ── Command ───────────────────────────────────────────────────────────────── export default defineCommand({ diff --git a/packages/cli/src/commands/catalog.oauth.test.ts b/packages/cli/src/commands/catalog.oauth.test.ts index 1c4ed7b96d..b170ee93ff 100644 --- a/packages/cli/src/commands/catalog.oauth.test.ts +++ b/packages/cli/src/commands/catalog.oauth.test.ts @@ -106,6 +106,25 @@ describe("interactive catalog verified OAuth boundary", () => { expect(mocks.startAuthorizationCodeFlow).toHaveBeenCalledTimes(1); expect(mocks.getCurrentUser).toHaveBeenCalledWith(expect.objectContaining({ type: "oauth" })); expect(mocks.runAdd).toHaveBeenCalledTimes(1); + expect(mocks.runAdd).toHaveBeenCalledWith( + expect.objectContaining({ + primitiveFunnelSession: expect.objectContaining({ + context: expect.objectContaining({ primitiveId: "thread-message-stack" }), + }), + }), + ); + expect(mocks.trackEvent.mock.calls.map(([name]) => name)).toEqual([ + "primitive_catalog_searched", + "primitive_catalog_result_selected", + "primitive_auth_started", + "$identify", + "primitive_auth_completed", + ]); + expect( + mocks.trackEvent.mock.calls.find( + ([name]) => name === "primitive_catalog_result_selected", + )?.[1], + ).toMatchObject({ result_rank: 1, auth_state: "anonymous" }); }); it("stitches an already verified OAuth session exactly once before install", async () => { @@ -145,6 +164,12 @@ describe("interactive catalog verified OAuth boundary", () => { mocks.trackEvent.mock.calls.filter(([name]) => name === "primitive_auth_completed"), ).toHaveLength(1); expect(mocks.runAdd).toHaveBeenCalledTimes(1); + expect(mocks.trackEvent.mock.calls.map(([name]) => name)).toEqual([ + "primitive_catalog_searched", + "primitive_catalog_result_selected", + "$identify", + "primitive_auth_completed", + ]); }); it("emits no identity or auth-completed events when opted out", async () => { diff --git a/packages/cli/src/commands/catalog.telemetry.test.ts b/packages/cli/src/commands/catalog.telemetry.test.ts new file mode 100644 index 0000000000..f49971d374 --- /dev/null +++ b/packages/cli/src/commands/catalog.telemetry.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + trackEvent: vi.fn(), + shouldTrack: vi.fn(() => true), + startAuthorizationCodeFlow: vi.fn(), +})); + +vi.mock("@clack/prompts", () => ({ + select: vi.fn(), + isCancel: () => false, + cancel: vi.fn(), +})); +vi.mock("../telemetry/client.js", () => ({ + trackEvent: mocks.trackEvent, + shouldTrack: mocks.shouldTrack, +})); +vi.mock("../telemetry/config.js", () => ({ + readConfig: () => ({ anonymousId: "anonymous-catalog" }), +})); +vi.mock("../auth/index.js", () => ({ + tryResolveOAuthCredential: vi.fn(), + startAuthorizationCodeFlow: mocks.startAuthorizationCodeFlow, + AuthClient: class {}, +})); +vi.mock("./add.js", () => ({ runAdd: vi.fn() })); +vi.mock("../registry/resolver.js", () => ({ + listRegistryItems: vi.fn(async () => [ + { name: "thread-message-stack", type: "hyperframes:block" }, + { name: "data-chart", type: "hyperframes:block" }, + ]), + loadAllItems: vi.fn(async () => [ + { + name: "thread-message-stack", + type: "hyperframes:block", + title: "Thread Message Stack", + description: "Conversation messages", + tags: ["social"], + dimensions: { width: 1920, height: 1080 }, + duration: 8, + files: [], + }, + { + name: "data-chart", + type: "hyperframes:block", + title: "Data Chart", + description: "Animated chart", + tags: ["data"], + dimensions: { width: 1920, height: 1080 }, + duration: 8, + files: [], + }, + ]), +})); + +import catalogCommand from "./catalog.js"; + +describe("anonymous catalog result-delivery telemetry", () => { + beforeEach(() => { + mocks.trackEvent.mockReset(); + mocks.shouldTrack.mockReset().mockReturnValue(true); + mocks.startAuthorizationCodeFlow.mockReset(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + }); + + it.each([ + ["JSON", { json: true, query: "messages" }], + ["noninteractive table", { query: "messages" }], + ])("emits one privacy-safe canonical search event on %s result delivery", async (_name, args) => { + await catalogCommand.run!({ args, rawArgs: [], cmd: catalogCommand } as never); + + expect(mocks.startAuthorizationCodeFlow).not.toHaveBeenCalled(); + expect(mocks.trackEvent).toHaveBeenCalledTimes(1); + expect(mocks.trackEvent).toHaveBeenCalledWith( + "primitive_catalog_searched", + expect.objectContaining({ + primitive_id: "thread-message-stack", + result_count: 1, + auth_state: "anonymous", + funnel_id: expect.any(String), + event_id: expect.any(String), + }), + ); + const payload = JSON.stringify(mocks.trackEvent.mock.calls[0]?.[1]); + expect(payload).not.toContain("messages"); + expect(payload).not.toMatch(/raw_query|query_text|credential|token/); + }); +}); diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index 00931c199f..14ce22def4 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -16,7 +16,6 @@ import { c } from "../ui/colors.js"; import { listRegistryItems, loadAllItems } from "../registry/resolver.js"; import { loadProjectConfig, DEFAULT_PROJECT_CONFIG } from "../utils/projectConfig.js"; import { resolve } from "node:path"; -import { randomUUID } from "node:crypto"; import { runAdd } from "./add.js"; import { createFilePrimitiveInstallStateStore, @@ -30,7 +29,6 @@ import { tryResolveOAuthCredential, } from "../auth/index.js"; import { PrimitiveFunnel } from "../telemetry/primitive-funnel.js"; -import { writePrimitiveFunnelContext } from "../telemetry/primitive-funnel-state.js"; import { THREAD_MESSAGE_STACK_ARTIFACT_ID, THREAD_MESSAGE_STACK_CATALOG_DIGEST, @@ -107,6 +105,36 @@ export default defineCommand({ return; } + const primitiveResultRank = + matching.findIndex((item) => item.name === "thread-message-stack") + 1; + let primitiveSession: + | { + intent: ReturnType; + context: ConstructorParameters[0]; + funnel: PrimitiveFunnel; + } + | undefined; + if (primitiveResultRank > 0) { + const intent = createPrimitiveInstallIntent({ + itemName: "thread-message-stack", + query: args.query ?? "", + artifactId: THREAD_MESSAGE_STACK_ARTIFACT_ID, + versionId: THREAD_MESSAGE_STACK_VERSION_ID, + }); + const context = { + funnelId: intent.funnelId, + installId: intent.installId, + primitiveId: "thread-message-stack", + artifactId: intent.artifactId, + versionId: intent.versionId, + catalogVersion: THREAD_MESSAGE_STACK_CATALOG_DIGEST, + queryFingerprint: `sha256:${intent.queryFingerprint}`, + }; + const funnel = new PrimitiveFunnel(context); + funnel.catalogSearched(matching.length); + primitiveSession = { intent, context, funnel }; + } + if (json) { const output = matching.map((item) => ({ name: item.name, @@ -141,23 +169,11 @@ export default defineCommand({ const selectedName = selected as string; let result: Awaited> | undefined; if (selectedName === "thread-message-stack") { - const intent = createPrimitiveInstallIntent({ - itemName: selectedName, - query: args.query ?? "", - artifactId: THREAD_MESSAGE_STACK_ARTIFACT_ID, - versionId: THREAD_MESSAGE_STACK_VERSION_ID, - }); - const funnelContext = { - funnelId: intent.funnelId, - installId: intent.installId, - artifactId: intent.artifactId, - versionId: intent.versionId, - catalogVersion: THREAD_MESSAGE_STACK_CATALOG_DIGEST, - queryFingerprint: `sha256:${intent.queryFingerprint}`, - }; - const funnel = new PrimitiveFunnel(funnelContext); - funnel.searched(); - funnel.selected(); + if (!primitiveSession) + throw new Error("Selected primitive is absent from catalog results."); + const { intent, context: funnelContext, funnel } = primitiveSession; + funnel.catalogResultSelected(primitiveResultRank); + const authStartedAt = performance.now(); const outcome = await resumePrimitiveInstallExactlyOnce(intent, { store: createFilePrimitiveInstallStateStore(), isAuthenticated: async () => { @@ -165,38 +181,50 @@ export default defineCommand({ if (!credential) return false; try { const user = await new AuthClient().getCurrentUser(credential); - funnel.authCompleted(user.email ?? user.username); + funnel.authCompleted( + user.email ?? user.username, + "existing_session", + performance.now() - authStartedAt, + ); return true; } catch { return false; } }, authenticate: async () => { - funnel.authRequired(); + funnel.authStarted(); try { await startAuthorizationCodeFlow(); const credential = await tryResolveOAuthCredential(); if (!credential) return "failed"; const user = await new AuthClient().getCurrentUser(credential); - funnel.authCompleted(user.email ?? user.username); + funnel.authCompleted( + user.email ?? user.username, + "oauth", + performance.now() - authStartedAt, + ); return true; } catch { return "failed"; } }, install: async () => { - result = await runAdd({ name: selectedName, projectDir: dir, skipClipboard: false }); - writePrimitiveFunnelContext(dir, funnelContext); + result = await runAdd({ + name: selectedName, + projectDir: dir, + skipClipboard: false, + primitiveFunnelSession: primitiveSession, + }); }, }); if (outcome === "failed" || outcome === "cancelled") { - funnel.installFailed( - randomUUID(), + funnel.authFailed( + `${funnelContext.installId}:auth-failed`, outcome === "cancelled" ? "auth_cancelled" : "auth_failed", + performance.now() - authStartedAt, ); throw new Error(`Catalog authentication ${outcome}; no primitive was installed.`); } - if (outcome === "installed") funnel.installSucceeded(randomUUID()); if (!result) { console.log(`${c.success("✓")} ${c.accent(selectedName)} was already installed.`); return; diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index f101f57364..182aa1924b 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -211,6 +211,7 @@ export default defineCommand({ }, }, async run({ args }) { + const primitiveCommandStartedAt = performance.now(); const browserGpuMode = resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined); if (args["browser-gpu"] === true) process.env.PRODUCER_BROWSER_GPU_MODE = "hardware"; if (args["browser-gpu"] === false) process.env.PRODUCER_BROWSER_GPU_MODE = "software"; @@ -399,7 +400,11 @@ export default defineCommand({ browserGpuMode, }); } catch (error) { - trackPrimitivePreviewFailed(dir, "preview_failed"); + await trackPrimitivePreviewFailed( + dir, + "preview_failed", + performance.now() - primitiveCommandStartedAt, + ); clack.log.error(errorMessage(error)); setCommandExitCode(1); return; @@ -422,7 +427,7 @@ export default defineCommand({ remoteDebuggingPort, browserNoGpu, }); - trackPrimitivePreviewSucceeded(dir); + await trackPrimitivePreviewSucceeded(dir, performance.now() - primitiveCommandStartedAt); return; } @@ -918,11 +923,15 @@ function attachStudioReadyHandler( spinner: ReturnType, projectName: string, projectDir: string, + primitiveCommandStartedAt: number, options?: BrowserLaunchOptions, ): void { let detected = false; - function handleOutput(data: Buffer): void { + // Async because the terminal funnel event awaits delivery before its claim is + // considered spent. `detected` is latched before the await, so a second chunk + // arriving mid-flight still short-circuits. + async function handleOutput(data: Buffer): Promise { const url = data.toString().match(/Local:\s+(http:\/\/localhost:\d+)/)?.[1]; if (!url || detected) return; @@ -930,15 +939,19 @@ function attachStudioReadyHandler( spinner.stop(c.success("Studio running")); printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" }); openStudioBrowser(url, projectName, projectDir, options); - trackPrimitivePreviewSucceeded(projectDir); + await trackPrimitivePreviewSucceeded(projectDir, performance.now() - primitiveCommandStartedAt); child.stdout.removeListener("data", handleOutput); child.stderr.removeListener("data", handleOutput); } child.stdout.on("data", handleOutput); child.stderr.on("data", handleOutput); - child.on("error", (err) => { - trackPrimitivePreviewFailed(projectDir, "preview_failed"); + child.on("error", async (err) => { + await trackPrimitivePreviewFailed( + projectDir, + "preview_failed", + performance.now() - primitiveCommandStartedAt, + ); spinner.stop(c.error("Failed to start studio")); console.error(c.dim(err.message)); }); @@ -948,6 +961,7 @@ function attachStudioReadyHandler( * Dev mode: spawn the studio dev server from the monorepo. */ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise { + const primitiveCommandStartedAt = performance.now(); // Find monorepo root by navigating from packages/cli/src/commands/ const thisFile = fileURLToPath(import.meta.url); const repoRoot = resolve(dirname(thisFile), "..", "..", "..", ".."); @@ -970,7 +984,7 @@ async function runDevMode(dir: string, options?: StudioLaunchOptions): Promise { + const primitiveCommandStartedAt = performance.now(); const req = createRequire(join(dir, "package.json")); const studioPkgPath = dirname(req.resolve("@hyperframes/studio/package.json")); const pName = options?.projectName ?? basename(dir); @@ -1020,7 +1035,7 @@ async function runLocalStudioMode(dir: string, options?: StudioLaunchOptions): P env: studioProxyEnv(options?.autoProxy ?? true), }); - attachStudioReadyHandler(child, s, pName, dir, options); + attachStudioReadyHandler(child, s, pName, dir, primitiveCommandStartedAt, options); removeSymlinkOnExit(createdSymlink, symlinkPath); // Same tree-kill handler as dev mode. No-op on Windows (see comment above). @@ -1040,6 +1055,7 @@ async function runEmbeddedMode( startPort: number, options?: EmbeddedStudioOptions, ): Promise { + const primitiveCommandStartedAt = performance.now(); const { createStudioServer, loadPreviewServerBuildSignature, resolveStudioBundle } = await import("../server/studioServer.js"); @@ -1084,7 +1100,11 @@ async function runEmbeddedMode( options?.browserGpuMode, ); } catch (err: unknown) { - trackPrimitivePreviewFailed(dir, "preview_failed"); + await trackPrimitivePreviewFailed( + dir, + "preview_failed", + performance.now() - primitiveCommandStartedAt, + ); s.stop(c.error("Failed to start studio")); console.error(); console.error(` ${(err as Error).message}`); @@ -1100,7 +1120,7 @@ async function runEmbeddedMode( details: ["Reusing existing server. Use --force-new to start a fresh instance."], }); openStudioBrowser(url, pName, dir, options); - trackPrimitivePreviewSucceeded(dir); + await trackPrimitivePreviewSucceeded(dir, performance.now() - primitiveCommandStartedAt); return; } @@ -1119,7 +1139,7 @@ async function runEmbeddedMode( footer: "Press Ctrl+C to stop", }); openStudioBrowser(url, pName, dir, options); - trackPrimitivePreviewSucceeded(dir); + await trackPrimitivePreviewSucceeded(dir, performance.now() - primitiveCommandStartedAt); // Block until Ctrl+C. Node would normally exit on SIGINT, but the listening // HTTP server keeps handles open, so the event loop stays alive after the diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 8d2de1c049..c4c5f4d6bf 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -663,6 +663,7 @@ async function renderDocker( options: RenderOptions, ): Promise { const startTime = Date.now(); + const primitiveCommandStartedAt = performance.now(); // Dev mode (tsx/ts-node) uses "latest" since the local version isn't on npm const dockerVersion = isDevMode() ? "latest" : VERSION; @@ -741,7 +742,11 @@ async function renderDocker( child.on("error", (err) => reject(err)); }); } catch (error: unknown) { - trackPrimitiveRenderFailed(projectDir, "render_failed"); + await trackPrimitiveRenderFailed( + projectDir, + "render_failed", + performance.now() - primitiveCommandStartedAt, + ); handleRenderError(error, options, startTime, true, "Check Docker is running: docker info"); } @@ -752,7 +757,7 @@ async function renderDocker( // so any late throw here (telemetry flush, feedback prompt) cannot flip // the exit code. markRenderSucceeded(); - trackPrimitiveRenderSucceeded(projectDir); + await trackPrimitiveRenderSucceeded(projectDir, performance.now() - primitiveCommandStartedAt); // Track metrics (no job object available from Docker — use a minimal stub) runPostRenderStep("trackRenderComplete", () => @@ -853,6 +858,7 @@ export async function renderLocal( ); const startTime = Date.now(); + const primitiveCommandStartedAt = performance.now(); const logger = createRenderTelemetryLogger( producer.createConsoleLogger?.(options.debug ? "debug" : "info") ?? createNoopProducerLogger(), ); @@ -900,7 +906,11 @@ export async function renderLocal( try { await producer.executeRenderJob(job, projectDir, outputPath, onProgress); } catch (error: unknown) { - trackPrimitiveRenderFailed(projectDir, "render_failed"); + await trackPrimitiveRenderFailed( + projectDir, + "render_failed", + performance.now() - primitiveCommandStartedAt, + ); maybeConsumeDeParallelRouterTrial(deParallelRouterTrialArmed, job, options.quiet); handleRenderError( error, @@ -919,7 +929,7 @@ export async function renderLocal( // the exit code. Field signal ts=1784169760 / ts=1784171150 / ts=1784172467 // (win32/x64, CLI 0.7.58): valid MP4 on disk, exited 1 with no error print. markRenderSucceeded(); - trackPrimitiveRenderSucceeded(projectDir); + await trackPrimitiveRenderSucceeded(projectDir, performance.now() - primitiveCommandStartedAt); maybeConsumeDeParallelRouterTrial(deParallelRouterTrialArmed, job, options.quiet); const elapsed = Date.now() - startTime; diff --git a/packages/cli/src/registry/threadMessageStackAuthorization.test.ts b/packages/cli/src/registry/threadMessageStackAuthorization.test.ts index 5fab4aebc5..fd9e5e4ae8 100644 --- a/packages/cli/src/registry/threadMessageStackAuthorization.test.ts +++ b/packages/cli/src/registry/threadMessageStackAuthorization.test.ts @@ -45,4 +45,37 @@ describe("thread-message-stack verified OAuth authorization", () => { }), ).resolves.toBe("failed"); }); + + it("reports auth start and the verified user's bounded auth path at the real boundary", async () => { + const onAuthStarted = vi.fn(); + const onVerified = vi.fn(); + const authenticate = vi.fn(async () => { + await writeStore({ oauth: { access_token: "fresh-oauth-token" } }); + return true as const; + }); + const user = { email: "verified@example.com" }; + + await expect( + authorizeThreadMessageStackInstall({ + authenticate, + verify: async () => user, + onAuthStarted, + onVerified, + }), + ).resolves.toBe("authorized"); + expect(onAuthStarted).toHaveBeenCalledTimes(1); + expect(onVerified).toHaveBeenCalledWith(user, "oauth"); + + onAuthStarted.mockClear(); + onVerified.mockClear(); + await expect( + authorizeThreadMessageStackInstall({ + verify: async () => user, + onAuthStarted, + onVerified, + }), + ).resolves.toBe("authorized"); + expect(onAuthStarted).not.toHaveBeenCalled(); + expect(onVerified).toHaveBeenCalledWith(user, "existing_session"); + }); }); diff --git a/packages/cli/src/registry/threadMessageStackAuthorization.ts b/packages/cli/src/registry/threadMessageStackAuthorization.ts index 41b8d6193e..9dee42da2e 100644 --- a/packages/cli/src/registry/threadMessageStackAuthorization.ts +++ b/packages/cli/src/registry/threadMessageStackAuthorization.ts @@ -14,6 +14,8 @@ export type ThreadMessageStackAuthorizationOutcome = interface AuthorizationDeps { authenticate?: () => Promise; verify?: (credential: ResolvedCredential) => Promise; + onAuthStarted?: () => void; + onVerified?: (user: UserInfo, authState: "existing_session" | "oauth") => void; } async function defaultAuthenticate(): Promise { @@ -36,16 +38,20 @@ export async function authorizeThreadMessageStackInstall( const verify = deps.verify ?? (async (credential) => await new AuthClient().getCurrentUser(credential)); let credential = await tryResolveOAuthCredential(); + let authState: "existing_session" | "oauth" = "existing_session"; if (!credential) { + deps.onAuthStarted?.(); const outcome = await authenticate(); if (outcome !== true) return outcome; credential = await tryResolveOAuthCredential(); if (!credential) return "api-key-only"; + authState = "oauth"; } try { - await verify(credential); + const user = await verify(credential); + deps.onVerified?.(user, authState); return "authorized"; } catch { return "failed"; diff --git a/packages/cli/src/telemetry/primitive-funnel-command.test.ts b/packages/cli/src/telemetry/primitive-funnel-command.test.ts new file mode 100644 index 0000000000..1e94c48d89 --- /dev/null +++ b/packages/cli/src/telemetry/primitive-funnel-command.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const trackEvent = vi.fn(); +let delivered = true; +let tracking = true; +vi.mock("./client.js", () => ({ + trackEvent: (...args: unknown[]) => trackEvent(...args), + shouldTrack: () => tracking, + flush: () => Promise.resolve(delivered), +})); + +const { writePrimitiveFunnelContext } = await import("./primitive-funnel-state.js"); +const { + trackPrimitivePreviewSucceeded, + trackPrimitiveRenderFailed, + trackPrimitiveRenderSucceeded, +} = await import("./primitive-funnel-command.js"); + +function claimMarkerPath(projectDir: string, eventId: string): string { + const digest = createHash("sha256").update(eventId).digest("hex"); + return join(projectDir, ".hyperframes", "primitive-funnel-claims", `${digest}.claim`); +} + +function emittedEventIds(projectDir: string): string[] { + const state: unknown = JSON.parse( + readFileSync(join(projectDir, ".hyperframes", "primitive-funnel.json"), "utf8"), + ); + return (state as { emittedEventIds: string[] }).emittedEventIds; +} + +describe("persisted primitive funnel command continuity", () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "hf-funnel-command-")); + trackEvent.mockReset(); + delivered = true; + tracking = true; + writePrimitiveFunnelContext(projectDir, { + funnelId: "funnel-command", + installId: "install-command", + primitiveId: "thread-message-stack", + artifactId: "artifact-command", + versionId: "version-command", + catalogVersion: "catalog-command", + queryFingerprint: "sha256:query", + }); + }); + + afterEach(() => rmSync(projectDir, { recursive: true, force: true })); + + it("propagates bounded command duration and deduplicates preview/render terminals", async () => { + await trackPrimitivePreviewSucceeded(projectDir, 12.4); + await trackPrimitivePreviewSucceeded(projectDir, 98); + await trackPrimitiveRenderFailed(projectDir, "render_failed", 23.6); + await trackPrimitiveRenderSucceeded(projectDir, 99); + + expect(trackEvent.mock.calls.map(([name]) => name)).toEqual([ + "primitive_preview_succeeded", + "primitive_render_failed", + ]); + expect(trackEvent.mock.calls[0]?.[1]).toMatchObject({ + funnel_id: "funnel-command", + primitive_id: "thread-message-stack", + duration_ms: 12, + event_id: "install-command:preview", + funnel_step: 7, + }); + expect(trackEvent.mock.calls[1]?.[1]).toMatchObject({ + funnel_id: "funnel-command", + duration_ms: 24, + error_code: "render_failed", + event_id: "install-command:render", + funnel_step: 8, + }); + }); + + it("releases the claim when the batch is never acknowledged, so a later command retries", async () => { + delivered = false; + await trackPrimitivePreviewSucceeded(projectDir, 10); + + expect(trackEvent).toHaveBeenCalledTimes(1); + expect(existsSync(claimMarkerPath(projectDir, "install-command:preview"))).toBe(false); + expect(emittedEventIds(projectDir)).not.toContain("install-command:preview"); + + // Same install, later command: the step is emitted rather than lost forever. + delivered = true; + await trackPrimitivePreviewSucceeded(projectDir, 11); + expect(trackEvent.mock.calls.map(([name]) => name)).toEqual([ + "primitive_preview_succeeded", + "primitive_preview_succeeded", + ]); + expect(existsSync(claimMarkerPath(projectDir, "install-command:preview"))).toBe(true); + }); + + it("keeps the claim when the batch is acknowledged", async () => { + await trackPrimitivePreviewSucceeded(projectDir, 10); + + expect(existsSync(claimMarkerPath(projectDir, "install-command:preview"))).toBe(true); + expect(emittedEventIds(projectDir)).toContain("install-command:preview"); + }); + + it("does not spend a claim while telemetry is opted out", async () => { + tracking = false; + await trackPrimitivePreviewSucceeded(projectDir, 10); + + expect(trackEvent).not.toHaveBeenCalled(); + expect(existsSync(claimMarkerPath(projectDir, "install-command:preview"))).toBe(false); + + // Re-enabling tracking must still be able to emit the step. + tracking = true; + await trackPrimitivePreviewSucceeded(projectDir, 10); + expect(trackEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/telemetry/primitive-funnel-command.ts b/packages/cli/src/telemetry/primitive-funnel-command.ts index 35e45070ad..dc400d13ff 100644 --- a/packages/cli/src/telemetry/primitive-funnel-command.ts +++ b/packages/cli/src/telemetry/primitive-funnel-command.ts @@ -1,40 +1,71 @@ import { PrimitiveFunnel, type PrimitiveFunnelErrorCode } from "./primitive-funnel.js"; -import { claimPrimitiveFunnelEvent, readPrimitiveFunnelContext } from "./primitive-funnel-state.js"; +import { + claimPrimitiveFunnelEvent, + readPrimitiveFunnelContext, + releasePrimitiveFunnelEvent, +} from "./primitive-funnel-state.js"; +import { flush, shouldTrack } from "./client.js"; -function emitProjectTerminal( +/** + * Emit one terminal funnel event for a project, at most once per install under + * concurrency, and await delivery. + * + * The await is the point. Terminal events are the only telemetry in the CLI + * that consumes a durable single-use claim, so the usual fire-and-forget exit + * flush is not good enough: the claim outlives the process, the detached flush + * child does not, and a send that never lands leaves a claim burned on an event + * PostHog never saw. Confirming delivery here lets us give the claim back. + */ +async function emitProjectTerminal( projectDir: string, suffix: "preview" | "render", emit: (funnel: PrimitiveFunnel, eventId: string) => void, -): void { +): Promise { + // Claiming while telemetry is off would burn the claim on an event that is + // never enqueued, silently disabling the step if tracking is re-enabled. + if (!shouldTrack()) return; const context = readPrimitiveFunnelContext(projectDir); if (!context) return; const eventId = `${context.installId}:${suffix}`; if (!claimPrimitiveFunnelEvent(projectDir, eventId)) return; emit(new PrimitiveFunnel(context), eventId); + if (!(await flush())) releasePrimitiveFunnelEvent(projectDir, eventId); } -export function trackPrimitivePreviewSucceeded(projectDir: string): void { - emitProjectTerminal(projectDir, "preview", (funnel, eventId) => funnel.previewSucceeded(eventId)); +export async function trackPrimitivePreviewSucceeded( + projectDir: string, + durationMs: number, +): Promise { + await emitProjectTerminal(projectDir, "preview", (funnel, eventId) => + funnel.previewSucceeded(eventId, durationMs), + ); } -export function trackPrimitivePreviewFailed( +export async function trackPrimitivePreviewFailed( projectDir: string, errorCode: PrimitiveFunnelErrorCode, -): void { - emitProjectTerminal(projectDir, "preview", (funnel, eventId) => - funnel.previewFailed(eventId, errorCode), + durationMs: number, +): Promise { + await emitProjectTerminal(projectDir, "preview", (funnel, eventId) => + funnel.previewFailed(eventId, errorCode, durationMs), ); } -export function trackPrimitiveRenderSucceeded(projectDir: string): void { - emitProjectTerminal(projectDir, "render", (funnel, eventId) => funnel.renderSucceeded(eventId)); +export async function trackPrimitiveRenderSucceeded( + projectDir: string, + durationMs: number, +): Promise { + await emitProjectTerminal(projectDir, "render", (funnel, eventId) => + funnel.renderSucceeded(eventId, durationMs), + ); } -export function trackPrimitiveRenderFailed( +export async function trackPrimitiveRenderFailed( projectDir: string, errorCode: PrimitiveFunnelErrorCode, -): void { - emitProjectTerminal(projectDir, "render", (funnel, eventId) => - funnel.renderFailed(eventId, errorCode), + durationMs: number, +): Promise { + await emitProjectTerminal(projectDir, "render", (funnel, eventId) => + funnel.renderFailed(eventId, errorCode, durationMs), ); } diff --git a/packages/cli/src/telemetry/primitive-funnel-state.ts b/packages/cli/src/telemetry/primitive-funnel-state.ts index c817badf37..586808c7f0 100644 --- a/packages/cli/src/telemetry/primitive-funnel-state.ts +++ b/packages/cli/src/telemetry/primitive-funnel-state.ts @@ -1,10 +1,19 @@ -import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { randomUUID } from "node:crypto"; +import { + closeSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createHash, randomUUID } from "node:crypto"; import { join } from "node:path"; import type { PrimitiveFunnelContext } from "./primitive-funnel.js"; const FUNNEL_STATE_DIR = ".hyperframes"; const FUNNEL_STATE_FILE = "primitive-funnel.json"; +const FUNNEL_CLAIM_DIR = "primitive-funnel-claims"; interface PersistedPrimitiveFunnelContext extends PrimitiveFunnelContext { emittedEventIds: string[]; @@ -14,12 +23,33 @@ function statePath(projectDir: string): string { return join(projectDir, FUNNEL_STATE_DIR, FUNNEL_STATE_FILE); } +function claimMarkerPath(projectDir: string, eventId: string): string { + const directory = join(projectDir, FUNNEL_STATE_DIR, FUNNEL_CLAIM_DIR); + const digest = createHash("sha256").update(eventId).digest("hex"); + return join(directory, `${digest}.claim`); +} + +function createClaimMarker(projectDir: string, eventId: string): boolean { + mkdirSync(join(projectDir, FUNNEL_STATE_DIR, FUNNEL_CLAIM_DIR), { + recursive: true, + mode: 0o700, + }); + try { + const descriptor = openSync(claimMarkerPath(projectDir, eventId), "wx", 0o600); + closeSync(descriptor); + return true; + } catch { + return false; + } +} + function isContext(value: unknown): value is PrimitiveFunnelContext { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const record = value as Record; return [ "funnelId", "installId", + "primitiveId", "artifactId", "versionId", "catalogVersion", @@ -31,8 +61,24 @@ export function readPrimitiveFunnelContext(projectDir: string): PrimitiveFunnelC try { const value: unknown = JSON.parse(readFileSync(statePath(projectDir), "utf8")); if (!isContext(value)) return null; - const { funnelId, installId, artifactId, versionId, catalogVersion, queryFingerprint } = value; - return { funnelId, installId, artifactId, versionId, catalogVersion, queryFingerprint }; + const { + funnelId, + installId, + primitiveId, + artifactId, + versionId, + catalogVersion, + queryFingerprint, + } = value; + return { + funnelId, + installId, + primitiveId, + artifactId, + versionId, + catalogVersion, + queryFingerprint, + }; } catch { return null; } @@ -57,23 +103,69 @@ export function writePrimitiveFunnelContext( writeState(projectDir, { ...context, emittedEventIds: [] }); } +function readEmittedEventIds(projectDir: string): string[] { + try { + const value: unknown = JSON.parse(readFileSync(statePath(projectDir), "utf8")); + if (!isContext(value)) return []; + const record = value as PrimitiveFunnelContext & { emittedEventIds?: unknown }; + if (!Array.isArray(record.emittedEventIds)) return []; + return record.emittedEventIds.filter( + (candidate): candidate is string => typeof candidate === "string", + ); + } catch { + return []; + } +} + /** Atomically claim a stable terminal id before enqueueing cross-command telemetry. */ export function claimPrimitiveFunnelEvent(projectDir: string, eventId: string): boolean { try { const value: unknown = JSON.parse(readFileSync(statePath(projectDir), "utf8")); if (!isContext(value)) return false; - const record = value as PrimitiveFunnelContext & { emittedEventIds?: unknown }; - const emittedEventIds = Array.isArray(record.emittedEventIds) - ? record.emittedEventIds.filter( - (candidate): candidate is string => typeof candidate === "string", - ) - : []; + const emittedEventIds = readEmittedEventIds(projectDir); if (emittedEventIds.includes(eventId)) return false; const context = readPrimitiveFunnelContext(projectDir); if (!context) return false; - writeState(projectDir, { ...context, emittedEventIds: [...emittedEventIds, eventId] }); + if (!createClaimMarker(projectDir, eventId)) return false; + try { + writeState(projectDir, { ...context, emittedEventIds: [...emittedEventIds, eventId] }); + } catch { + // The permanent O_EXCL marker is the authoritative claim. Retaining it + // fails closed if the compatibility state rewrite cannot complete. + } return true; } catch { return false; } } + +/** + * Hand a claim back after a delivery attempt PostHog never acknowledged. + * + * The claim has to be taken before the send, because its whole job is to stop + * two concurrent processes both emitting one terminal event. That ordering + * means an unacknowledged send would otherwise burn the claim permanently and + * the step could never be emitted again — the funnel loses it for good. + * Releasing trades that permanent loss for a possible duplicate on a later + * command, which is the cheaper failure: every funnel event carries a stable + * `event_id`, so duplicates collapse downstream while a loss is unrecoverable. + */ +export function releasePrimitiveFunnelEvent(projectDir: string, eventId: string): void { + try { + rmSync(claimMarkerPath(projectDir, eventId), { force: true }); + } catch { + // Best effort. A retained marker only costs one un-emitted event, which is + // exactly the state we were already in. + } + try { + const context = readPrimitiveFunnelContext(projectDir); + if (!context) return; + writeState(projectDir, { + ...context, + emittedEventIds: readEmittedEventIds(projectDir).filter((id) => id !== eventId), + }); + } catch { + // The marker is the authoritative claim; the id list is a compatibility + // mirror, so failing to prune it cannot resurrect a consumed claim. + } +} diff --git a/packages/cli/src/telemetry/primitive-funnel-transport.test.ts b/packages/cli/src/telemetry/primitive-funnel-transport.test.ts new file mode 100644 index 0000000000..fce5088ca0 --- /dev/null +++ b/packages/cli/src/telemetry/primitive-funnel-transport.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const posture = { enabled: true }; +const fetchMock = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(null, { status: 200 }), +); + +vi.mock("./config.js", () => ({ + readConfig: () => ({ + anonymousId: "anonymous-transport", + telemetryEnabled: posture.enabled, + }), + writeConfig: vi.fn(), +})); +vi.mock("./policy.js", () => ({ telemetryRuntimeOverride: () => null })); +vi.mock("./system.js", () => ({ + getSystemMeta: () => ({ + os_release: "test", + cpu_count: 1, + memory_total_mb: 1, + is_docker: false, + is_ci: true, + is_wsl: false, + is_tty: false, + }), +})); +vi.mock("./canary.js", () => ({ canaryEventProperties: () => ({}) })); + +vi.stubGlobal("fetch", fetchMock); + +const { PrimitiveFunnel } = await import("./primitive-funnel.js"); +const { writePrimitiveFunnelContext } = await import("./primitive-funnel-state.js"); +const { trackPrimitivePreviewSucceeded, trackPrimitiveRenderSucceeded } = + await import("./primitive-funnel-command.js"); +const { flush, resetTelemetryPostureCache } = await import("./client.js"); + +describe("primitive funnel queued transport boundary", () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "hf-funnel-transport-")); + posture.enabled = true; + resetTelemetryPostureCache(); + fetchMock.mockClear(); + }); + + afterEach(() => rmSync(projectDir, { recursive: true, force: true })); + + it("delivers one ordered privacy-safe journey with a stable identity", async () => { + const context = { + funnelId: "funnel-transport", + installId: "install-transport", + primitiveId: "thread-message-stack", + artifactId: "artifact-transport", + versionId: "version-transport", + catalogVersion: "catalog-transport", + queryFingerprint: "sha256:non-content", + }; + const funnel = new PrimitiveFunnel(context); + funnel.catalogSearched(2); + funnel.catalogResultSelected(1); + funnel.authStarted(); + funnel.authCompleted("verified@example.com", "oauth", 5); + funnel.installStarted(); + writePrimitiveFunnelContext(projectDir, context); + funnel.installCompleted("install-transport:install-completed", 7); + // Terminal steps flush themselves, because they must know whether the send + // landed before their single-use claim counts as spent. So the journey is + // delivered across several batches; what has to hold is the ORDER, not the + // batch count. + await trackPrimitivePreviewSucceeded(projectDir, 11); + await trackPrimitivePreviewSucceeded(projectDir, 99); + await trackPrimitiveRenderSucceeded(projectDir, 13); + + await flush(); + + type DeliveredEvent = { + event: string; + distinct_id: string; + properties: Record; + }; + const bodies = fetchMock.mock.calls.map((call) => JSON.parse(String(call?.[1]?.body))); + const events = bodies.flatMap((body) => body.batch as DeliveredEvent[]); + const body = { batch: events }; + expect(events.map(({ event }) => event)).toEqual([ + "primitive_catalog_searched", + "primitive_catalog_result_selected", + "primitive_auth_started", + "$identify", + "primitive_auth_completed", + "primitive_install_started", + "primitive_install_completed", + "primitive_preview_succeeded", + "primitive_render_succeeded", + ]); + expect(events.filter(({ event }) => event === "$identify")).toHaveLength(1); + expect(events.filter(({ event }) => event === "primitive_preview_succeeded")).toHaveLength(1); + + // Timestamps cannot carry this order: auth completion and install start are + // emitted back to back and land in the same millisecond, so a consumer that + // sorts by time can report the install beginning before the auth that + // authorized it. funnel_step is what makes the order recoverable. + const steps = events + .filter(({ event }) => event !== "$identify") + .map(({ properties }) => properties.funnel_step as number); + expect(steps).toEqual([...steps].sort((a, b) => a - b)); + expect(steps).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + expect(events.every(({ properties }) => properties.funnel_id === "funnel-transport")).toBe( + true, + ); + expect(events.at(-2)?.properties.duration_ms).toBe(11); + expect(events.at(-1)?.properties.duration_ms).toBe(13); + expect(JSON.stringify(body)).not.toMatch( + /raw_query|query_text|messages|html_source|css_source|javascript|credential|access_token|raw_error|rendered_media/, + ); + expect(events.every(({ properties }) => properties.$ip === null)).toBe(true); + }); + + it("does not queue or fetch after opt-out", async () => { + posture.enabled = false; + resetTelemetryPostureCache(); + const funnel = new PrimitiveFunnel({ + funnelId: "funnel-opt-out", + installId: "install-opt-out", + primitiveId: "thread-message-stack", + artifactId: "artifact-opt-out", + versionId: "version-opt-out", + catalogVersion: "catalog-opt-out", + queryFingerprint: "sha256:non-content", + }); + funnel.catalogSearched(1); + funnel.authCompleted("private@example.com", "existing_session", 0); + await flush(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/telemetry/primitive-funnel.test.ts b/packages/cli/src/telemetry/primitive-funnel.test.ts index c1269b9217..5f6651d297 100644 --- a/packages/cli/src/telemetry/primitive-funnel.test.ts +++ b/packages/cli/src/telemetry/primitive-funnel.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,12 +9,63 @@ const shouldTrack = vi.fn(() => true); vi.mock("./client.js", () => ({ trackEvent: (...args: unknown[]) => trackEvent(...args), shouldTrack: () => shouldTrack(), + flush: () => Promise.resolve(true), })); const { PrimitiveFunnel } = await import("./primitive-funnel.js"); const { claimPrimitiveFunnelEvent, readPrimitiveFunnelContext, writePrimitiveFunnelContext } = await import("./primitive-funnel-state.js"); +async function runSynchronizedClaims( + projectDir: string, + eventId: string, + processCount: number, +): Promise { + const workerPath = join(projectDir, "primitive-funnel-claim.worker.ts"); + const gatePath = join(projectDir, "primitive-funnel-claim.gate"); + const stateModuleUrl = new URL("./primitive-funnel-state.ts", import.meta.url).href; + writeFileSync( + workerPath, + [ + 'import { existsSync, writeFileSync } from "node:fs";', + `import { claimPrimitiveFunnelEvent } from ${JSON.stringify(stateModuleUrl)};`, + "const [projectDir, eventId, gatePath, readyPath] = process.argv.slice(2);", + "writeFileSync(readyPath, String(process.pid));", + "while (!existsSync(gatePath)) await Bun.sleep(1);", + 'process.stdout.write(claimPrimitiveFunnelEvent(projectDir, eventId) ? "true" : "false");', + ].join("\n"), + ); + + const readyPaths = Array.from({ length: processCount }, (_, index) => + join(projectDir, `primitive-funnel-claim.ready-${index}`), + ); + const exits = readyPaths.map((readyPath) => { + const child = spawn("bun", [workerPath, projectDir, eventId, gatePath, readyPath], { + stdio: ["ignore", "pipe", "pipe"], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + return new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(Buffer.concat(stdout).toString("utf8")); + else reject(new Error(`claim worker exited ${code}: ${Buffer.concat(stderr)}`)); + }); + }); + }); + + const readinessDeadline = Date.now() + 10_000; + while (!readyPaths.every((path) => existsSync(path))) { + if (Date.now() >= readinessDeadline) throw new Error("claim workers did not become ready"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + writeFileSync(gatePath, "go"); + const outputs = await Promise.all(exits); + return outputs.filter((output) => output === "true").length; +} + // Funnel contract assertions intentionally share one mocked telemetry boundary. // fallow-ignore-next-line unit-size describe("primitive discovery funnel", () => { @@ -26,39 +78,66 @@ describe("primitive discovery funnel", () => { const funnel = new PrimitiveFunnel({ funnelId: "funnel-1", installId: "install-1", + primitiveId: "thread-message-stack", artifactId: "artifact-1", versionId: "version-1", catalogVersion: "catalog-1", queryFingerprint: "sha256:query", }); - funnel.searched(); - funnel.selected(); - funnel.authRequired(); - funnel.authCompleted("account-1"); - funnel.authCompleted("account-1"); - funnel.installSucceeded("event-install"); - funnel.installSucceeded("event-install"); - funnel.previewSucceeded("event-preview"); - funnel.renderFailed("event-render", "capture_failed"); + funnel.catalogSearched(4); + funnel.catalogResultSelected(2); + funnel.authStarted(); + funnel.authCompleted("account-1", "oauth", 17); + funnel.authCompleted("account-1", "oauth", 17); + funnel.installStarted(); + funnel.installCompleted("event-install", 23); + funnel.installCompleted("event-install", 23); + funnel.previewSucceeded("event-preview", 31); + funnel.renderFailed("event-render", "capture_failed", 41); const calls = trackEvent.mock.calls; + expect(calls.filter(([name]) => name !== "$identify").map(([name]) => name)).toEqual([ + "primitive_catalog_searched", + "primitive_catalog_result_selected", + "primitive_auth_started", + "primitive_auth_completed", + "primitive_install_started", + "primitive_install_completed", + "primitive_preview_succeeded", + "primitive_render_failed", + ]); expect(calls.filter(([name]) => name === "$identify")).toHaveLength(1); expect(calls.filter(([name]) => name === "primitive_auth_completed")).toHaveLength(1); expect(calls.find(([name]) => name === "$identify")?.[2]).toBe("account-1"); - expect(calls.filter(([name]) => name === "primitive_install_succeeded")).toHaveLength(1); + expect(calls.filter(([name]) => name === "primitive_install_completed")).toHaveLength(1); expect(calls.every(([, props]) => props.funnel_id === "funnel-1")).toBe(true); + expect(calls.find(([name]) => name === "primitive_catalog_searched")?.[1]).toMatchObject({ + primitive_id: "thread-message-stack", + result_count: 4, + auth_state: "anonymous", + event_id: "funnel-1:catalog-searched", + }); + expect(calls.find(([name]) => name === "primitive_catalog_result_selected")?.[1]).toMatchObject( + { result_rank: 2, event_id: "funnel-1:catalog-result-selected" }, + ); + expect(calls.find(([name]) => name === "primitive_install_completed")?.[1]).toMatchObject({ + duration_ms: 23, + auth_state: "authenticated", + event_id: "event-install", + }); }); it("emits only allowlisted non-content properties and bounded errors", () => { const funnel = new PrimitiveFunnel({ funnelId: "funnel-2", installId: "install-2", + primitiveId: "thread-message-stack", artifactId: "artifact-2", versionId: "version-2", catalogVersion: "catalog-2", queryFingerprint: "sha256:query", }); - funnel.installFailed("event-1", "invalid_payload"); + funnel.installFailed("event-1", "invalid_payload", Number.POSITIVE_INFINITY); const payload = trackEvent.mock.lastCall?.[1] as Record; expect(Object.keys(payload).sort()).toEqual( @@ -68,11 +147,16 @@ describe("primitive discovery funnel", () => { "error_code", "event_id", "funnel_id", + "funnel_step", "install_id", + "primitive_id", "query_fingerprint", "version_id", + "auth_state", + "duration_ms", ].sort(), ); + expect(payload.duration_ms).toBe(86_400_000); expect(JSON.stringify(payload)).not.toMatch( /messages|html|css|javascript|asset|token|raw_error/, ); @@ -83,14 +167,15 @@ describe("primitive discovery funnel", () => { const funnel = new PrimitiveFunnel({ funnelId: "funnel-3", installId: "install-3", + primitiveId: "thread-message-stack", artifactId: "artifact-3", versionId: "version-3", catalogVersion: "catalog-3", queryFingerprint: "sha256:query", }); - funnel.searched(); - funnel.authCompleted("account-3"); - funnel.renderSucceeded("event-3"); + funnel.catalogSearched(1); + funnel.authCompleted("account-3", "existing_session", 0); + funnel.renderSucceeded("event-3", 0); expect(trackEvent).not.toHaveBeenCalled(); }); @@ -99,6 +184,7 @@ describe("primitive discovery funnel", () => { const context = { funnelId: "funnel-4", installId: "install-4", + primitiveId: "thread-message-stack", artifactId: "artifact-4", versionId: "version-4", catalogVersion: "catalog-4", @@ -113,4 +199,31 @@ describe("primitive discovery funnel", () => { ); rmSync(dir, { recursive: true, force: true }); }); + + it("repeatedly claims one shared preview and render event across independent processes", async () => { + for (const suffix of ["preview", "render"] as const) { + for (let attempt = 0; attempt < 3; attempt += 1) { + const dir = mkdtempSync(join(tmpdir(), `hf-funnel-${suffix}-race-`)); + try { + const installId = `race-install-${suffix}-${attempt}`; + writePrimitiveFunnelContext(dir, { + funnelId: `race-funnel-${suffix}-${attempt}`, + installId, + primitiveId: "thread-message-stack", + artifactId: "race-artifact", + versionId: "race-version", + catalogVersion: "race-catalog", + queryFingerprint: "sha256:race", + }); + const trueCount = await runSynchronizedClaims(dir, `${installId}:${suffix}`, 32); + console.log( + `primitive-funnel ${suffix} race ${attempt} observed true-count=${trueCount}`, + ); + expect(trueCount).toBe(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + } + }, 30_000); }); diff --git a/packages/cli/src/telemetry/primitive-funnel.ts b/packages/cli/src/telemetry/primitive-funnel.ts index 8098989242..5557f3c569 100644 --- a/packages/cli/src/telemetry/primitive-funnel.ts +++ b/packages/cli/src/telemetry/primitive-funnel.ts @@ -14,6 +14,7 @@ export type PrimitiveFunnelErrorCode = export interface PrimitiveFunnelContext { funnelId: string; installId: string; + primitiveId: string; artifactId: string; versionId: string; catalogVersion: string; @@ -23,12 +24,65 @@ export interface PrimitiveFunnelContext { type PrimitiveFunnelBaseProperties = { funnel_id: string; install_id: string; + primitive_id: string; artifact_id: string; version_id: string; catalog_version: string; query_fingerprint: string; }; +type PrimitiveFunnelAuthState = + | "anonymous" + | "oauth_required" + | "existing_session" + | "oauth" + | "authenticated"; + +/** + * Single source of truth for canonical lifecycle side effects, stable event-id + * suffixes, and each step's position in the canonical order. + * + * `step` exists because timestamps cannot express this order. Auth completion + * and install start are emitted back to back inside one command and land in the + * same millisecond, so ordering by timestamp resolves them arbitrarily and can + * report an install that began before the auth that authorized it. Consumers + * order by `funnel_step`; the terminal steps share no number with a step that + * can co-occur, so ties are impossible rather than merely unlikely. + * + * Failure steps carry the number of the step they terminate, since a funnel + * either reaches that step or fails at it. Never renumber a shipped step: + * historical events keep the number they were emitted with. + */ +const PRIMITIVE_FUNNEL_SIDE_EFFECTS = { + catalogSearched: { event: "primitive_catalog_searched", suffix: "catalog-searched", step: 1 }, + catalogResultSelected: { + event: "primitive_catalog_result_selected", + suffix: "catalog-result-selected", + step: 2, + }, + authStarted: { event: "primitive_auth_started", suffix: "auth-started", step: 3 }, + authCompleted: { event: "primitive_auth_completed", suffix: "auth-completed", step: 4 }, + authFailed: { event: "primitive_auth_failed", suffix: "auth-failed", step: 4 }, + installStarted: { event: "primitive_install_started", suffix: "install-started", step: 5 }, + installCompleted: { event: "primitive_install_completed", suffix: "install-completed", step: 6 }, + installFailed: { event: "primitive_install_failed", suffix: "install-failed", step: 6 }, + previewSucceeded: { event: "primitive_preview_succeeded", suffix: "preview", step: 7 }, + previewFailed: { event: "primitive_preview_failed", suffix: "preview", step: 7 }, + renderSucceeded: { event: "primitive_render_succeeded", suffix: "render", step: 8 }, + renderFailed: { event: "primitive_render_failed", suffix: "render", step: 8 }, +} as const; + +type PrimitiveFunnelSideEffect = + (typeof PRIMITIVE_FUNNEL_SIDE_EFFECTS)[keyof typeof PRIMITIVE_FUNNEL_SIDE_EFFECTS]; + +const MAX_RESULT_COUNT = 1_000; +const MAX_DURATION_MS = 86_400_000; + +function boundedInteger(value: number, minimum: number, maximum: number): number { + if (!Number.isFinite(value)) return value < 0 ? minimum : maximum; + return Math.min(maximum, Math.max(minimum, Math.round(value))); +} + /** Privacy-safe telemetry for one catalog-selection lifecycle. */ export class PrimitiveFunnel { readonly #base: PrimitiveFunnelBaseProperties; @@ -39,6 +93,7 @@ export class PrimitiveFunnel { this.#base = { funnel_id: context.funnelId, install_id: context.installId, + primitive_id: context.primitiveId, artifact_id: context.artifactId, version_id: context.versionId, catalog_version: context.catalogVersion, @@ -46,66 +101,148 @@ export class PrimitiveFunnel { }; } - searched(): void { - this.#track("primitive_searched"); + catalogSearched(resultCount: number): void { + this.#track(PRIMITIVE_FUNNEL_SIDE_EFFECTS.catalogSearched, { + result_count: boundedInteger(resultCount, 0, MAX_RESULT_COUNT), + auth_state: "anonymous", + }); } - selected(): void { - this.#track("primitive_selected"); + catalogResultSelected(resultRank: number): void { + this.#track(PRIMITIVE_FUNNEL_SIDE_EFFECTS.catalogResultSelected, { + result_rank: boundedInteger(resultRank, 1, MAX_RESULT_COUNT), + auth_state: "anonymous", + }); } - authRequired(): void { - this.#track("primitive_auth_required"); + authStarted(): void { + this.#track(PRIMITIVE_FUNNEL_SIDE_EFFECTS.authStarted, { + auth_state: "oauth_required", + }); } - authCompleted(accountId?: string): void { + authCompleted( + accountId: string | undefined, + authState: "existing_session" | "oauth", + durationMs: number, + ): void { if (!shouldTrack() || this.#identified) return; this.#identified = true; + const properties = { + ...this.#base, + event_id: `${this.#base.funnel_id}:${PRIMITIVE_FUNNEL_SIDE_EFFECTS.authCompleted.suffix}`, + funnel_step: PRIMITIVE_FUNNEL_SIDE_EFFECTS.authCompleted.step, + auth_state: authState, + duration_ms: boundedInteger(durationMs, 0, MAX_DURATION_MS), + }; if (accountId) { trackEvent( "$identify", - { ...this.#base, $anon_distinct_id: readConfig().anonymousId }, + { ...properties, $anon_distinct_id: readConfig().anonymousId }, accountId, ); } - trackEvent("primitive_auth_completed", this.#base); + trackEvent(PRIMITIVE_FUNNEL_SIDE_EFFECTS.authCompleted.event, properties); } - installSucceeded(eventId: string): void { - this.#trackTerminal("primitive_install_succeeded", eventId); + authFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode, durationMs: number): void { + this.#trackTerminal( + PRIMITIVE_FUNNEL_SIDE_EFFECTS.authFailed, + eventId, + "oauth_required", + durationMs, + errorCode, + ); } - installFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode): void { - this.#trackTerminal("primitive_install_failed", eventId, errorCode); + installStarted(): void { + this.#track(PRIMITIVE_FUNNEL_SIDE_EFFECTS.installStarted, { + auth_state: "authenticated", + }); + } + + installCompleted(eventId: string, durationMs: number): void { + this.#trackTerminal( + PRIMITIVE_FUNNEL_SIDE_EFFECTS.installCompleted, + eventId, + "authenticated", + durationMs, + ); } - previewSucceeded(eventId: string): void { - this.#trackTerminal("primitive_preview_succeeded", eventId); + installFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode, durationMs: number): void { + this.#trackTerminal( + PRIMITIVE_FUNNEL_SIDE_EFFECTS.installFailed, + eventId, + "authenticated", + durationMs, + errorCode, + ); } - previewFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode): void { - this.#trackTerminal("primitive_preview_failed", eventId, errorCode); + previewSucceeded(eventId: string, durationMs: number): void { + this.#trackTerminal( + PRIMITIVE_FUNNEL_SIDE_EFFECTS.previewSucceeded, + eventId, + "authenticated", + durationMs, + ); } - renderSucceeded(eventId: string): void { - this.#trackTerminal("primitive_render_succeeded", eventId); + previewFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode, durationMs: number): void { + this.#trackTerminal( + PRIMITIVE_FUNNEL_SIDE_EFFECTS.previewFailed, + eventId, + "authenticated", + durationMs, + errorCode, + ); } - renderFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode): void { - this.#trackTerminal("primitive_render_failed", eventId, errorCode); + renderSucceeded(eventId: string, durationMs: number): void { + this.#trackTerminal( + PRIMITIVE_FUNNEL_SIDE_EFFECTS.renderSucceeded, + eventId, + "authenticated", + durationMs, + ); } - #track(name: string): void { + renderFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode, durationMs: number): void { + this.#trackTerminal( + PRIMITIVE_FUNNEL_SIDE_EFFECTS.renderFailed, + eventId, + "authenticated", + durationMs, + errorCode, + ); + } + + #track(sideEffect: PrimitiveFunnelSideEffect, properties: Record): void { if (!shouldTrack()) return; - trackEvent(name, this.#base); + trackEvent(sideEffect.event, { + ...this.#base, + event_id: `${this.#base.funnel_id}:${sideEffect.suffix}`, + funnel_step: sideEffect.step, + ...properties, + }); } - #trackTerminal(name: string, eventId: string, errorCode?: PrimitiveFunnelErrorCode): void { + #trackTerminal( + sideEffect: PrimitiveFunnelSideEffect, + eventId: string, + authState: PrimitiveFunnelAuthState, + durationMs: number, + errorCode?: PrimitiveFunnelErrorCode, + ): void { if (!shouldTrack() || this.#terminalEventIds.has(eventId)) return; this.#terminalEventIds.add(eventId); - trackEvent(name, { + trackEvent(sideEffect.event, { ...this.#base, event_id: eventId, + funnel_step: sideEffect.step, + auth_state: authState, + duration_ms: boundedInteger(durationMs, 0, MAX_DURATION_MS), ...(errorCode ? { error_code: errorCode } : {}), }); } diff --git a/packages/cli/src/telemetry/transport.ts b/packages/cli/src/telemetry/transport.ts index 42fe4971cd..f86ccfb624 100644 --- a/packages/cli/src/telemetry/transport.ts +++ b/packages/cli/src/telemetry/transport.ts @@ -94,19 +94,26 @@ function buildPayload(events: readonly QueuedEvent[]): string | null { * request that `beforeExit` had just started. Keeping the queue intact until * delivery lets the exit-time flushSync() child (which survives the parent) * re-send anything unconfirmed; event uuids make that re-send idempotent. + * + * Returns whether PostHog acknowledged the batch. Almost every caller ignores + * this — telemetry is fire-and-forget by design. The exception is the primitive + * funnel's terminal events, which consume a durable single-use claim before + * emitting: they need to know whether the send actually landed so they can hand + * the claim back instead of burning it on an event that never arrived. */ -export async function flush(): Promise { +export async function flush(): Promise { // Copy, not alias — events queued while the request is in flight must not // be swept into the "delivered" set below. const snapshot = eventQueue.slice(); const payload = buildPayload(snapshot); - if (payload == null) return; + // Nothing queued is not a delivery failure — there was nothing to deliver. + if (payload == null) return true; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS); try { - await fetch(`${POSTHOG_HOST}/batch/`, { + const response = await fetch(`${POSTHOG_HOST}/batch/`, { method: "POST", headers: { "Content-Type": "application/json", Connection: "close" }, body: payload, @@ -114,11 +121,15 @@ export async function flush(): Promise { }); // Delivered — forget exactly what was sent (events queued while the // request was in flight stay for the next flush). + // A rejected batch is dropped too: PostHog will reject the retry the same + // way, so re-queueing would just resend it on every later command. const sent = new Set(snapshot); eventQueue = eventQueue.filter((e) => !sent.has(e)); + return response.ok; } catch { // Silently ignore — telemetry must never break the CLI. The events stay // queued so the exit-time flushSync() fallback can still deliver them. + return false; } finally { clearTimeout(timeout); }