From 1d7348ce9a988275781f542b59f50a0d6ce20343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 5 Aug 2026 14:49:32 +0000 Subject: [PATCH 1/3] feat(cli): align primitive funnel measurement --- packages/cli/src/commands/add.oauth.test.ts | 66 ++++++- packages/cli/src/commands/add.ts | 147 ++++++++++----- .../cli/src/commands/catalog.oauth.test.ts | 25 +++ .../src/commands/catalog.telemetry.test.ts | 88 +++++++++ packages/cli/src/commands/catalog.ts | 82 ++++++--- packages/cli/src/commands/preview.ts | 35 +++- packages/cli/src/commands/render.ts | 18 +- .../threadMessageStackAuthorization.test.ts | 33 ++++ .../threadMessageStackAuthorization.ts | 8 +- .../primitive-funnel-command.test.ts | 61 +++++++ .../src/telemetry/primitive-funnel-command.ts | 18 +- .../src/telemetry/primitive-funnel-state.ts | 21 ++- .../primitive-funnel-transport.test.ts | 123 +++++++++++++ .../src/telemetry/primitive-funnel.test.ts | 61 +++++-- .../cli/src/telemetry/primitive-funnel.ts | 167 +++++++++++++++--- 15 files changed, 822 insertions(+), 131 deletions(-) create mode 100644 packages/cli/src/commands/catalog.telemetry.test.ts create mode 100644 packages/cli/src/telemetry/primitive-funnel-command.test.ts create mode 100644 packages/cli/src/telemetry/primitive-funnel-transport.test.ts 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..496e2f6ef5 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"); + 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); + trackPrimitivePreviewSucceeded(dir, performance.now() - primitiveCommandStartedAt); return; } @@ -918,6 +923,7 @@ function attachStudioReadyHandler( spinner: ReturnType, projectName: string, projectDir: string, + primitiveCommandStartedAt: number, options?: BrowserLaunchOptions, ): void { let detected = false; @@ -930,7 +936,7 @@ function attachStudioReadyHandler( spinner.stop(c.success("Studio running")); printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" }); openStudioBrowser(url, projectName, projectDir, options); - trackPrimitivePreviewSucceeded(projectDir); + trackPrimitivePreviewSucceeded(projectDir, performance.now() - primitiveCommandStartedAt); child.stdout.removeListener("data", handleOutput); child.stderr.removeListener("data", handleOutput); } @@ -938,7 +944,11 @@ function attachStudioReadyHandler( child.stdout.on("data", handleOutput); child.stderr.on("data", handleOutput); child.on("error", (err) => { - trackPrimitivePreviewFailed(projectDir, "preview_failed"); + trackPrimitivePreviewFailed( + projectDir, + "preview_failed", + performance.now() - primitiveCommandStartedAt, + ); spinner.stop(c.error("Failed to start studio")); console.error(c.dim(err.message)); }); @@ -948,6 +958,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 +981,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 +1032,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 +1052,7 @@ async function runEmbeddedMode( startPort: number, options?: EmbeddedStudioOptions, ): Promise { + const primitiveCommandStartedAt = performance.now(); const { createStudioServer, loadPreviewServerBuildSignature, resolveStudioBundle } = await import("../server/studioServer.js"); @@ -1084,7 +1097,11 @@ async function runEmbeddedMode( options?.browserGpuMode, ); } catch (err: unknown) { - trackPrimitivePreviewFailed(dir, "preview_failed"); + 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 +1117,7 @@ async function runEmbeddedMode( details: ["Reusing existing server. Use --force-new to start a fresh instance."], }); openStudioBrowser(url, pName, dir, options); - trackPrimitivePreviewSucceeded(dir); + trackPrimitivePreviewSucceeded(dir, performance.now() - primitiveCommandStartedAt); return; } @@ -1119,7 +1136,7 @@ async function runEmbeddedMode( footer: "Press Ctrl+C to stop", }); openStudioBrowser(url, pName, dir, options); - trackPrimitivePreviewSucceeded(dir); + 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..97620f92d7 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"); + 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); + 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"); + 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); + 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..1f278731af --- /dev/null +++ b/packages/cli/src/telemetry/primitive-funnel-command.test.ts @@ -0,0 +1,61 @@ +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 trackEvent = vi.fn(); +vi.mock("./client.js", () => ({ + trackEvent: (...args: unknown[]) => trackEvent(...args), + shouldTrack: () => true, +})); + +const { writePrimitiveFunnelContext } = await import("./primitive-funnel-state.js"); +const { + trackPrimitivePreviewSucceeded, + trackPrimitiveRenderFailed, + trackPrimitiveRenderSucceeded, +} = await import("./primitive-funnel-command.js"); + +describe("persisted primitive funnel command continuity", () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "hf-funnel-command-")); + trackEvent.mockReset(); + 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", () => { + trackPrimitivePreviewSucceeded(projectDir, 12.4); + trackPrimitivePreviewSucceeded(projectDir, 98); + trackPrimitiveRenderFailed(projectDir, "render_failed", 23.6); + 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", + }); + expect(trackEvent.mock.calls[1]?.[1]).toMatchObject({ + funnel_id: "funnel-command", + duration_ms: 24, + error_code: "render_failed", + event_id: "install-command:render", + }); + }); +}); diff --git a/packages/cli/src/telemetry/primitive-funnel-command.ts b/packages/cli/src/telemetry/primitive-funnel-command.ts index 35e45070ad..7347c783fc 100644 --- a/packages/cli/src/telemetry/primitive-funnel-command.ts +++ b/packages/cli/src/telemetry/primitive-funnel-command.ts @@ -13,28 +13,34 @@ function emitProjectTerminal( emit(new PrimitiveFunnel(context), eventId); } -export function trackPrimitivePreviewSucceeded(projectDir: string): void { - emitProjectTerminal(projectDir, "preview", (funnel, eventId) => funnel.previewSucceeded(eventId)); +export function trackPrimitivePreviewSucceeded(projectDir: string, durationMs: number): void { + emitProjectTerminal(projectDir, "preview", (funnel, eventId) => + funnel.previewSucceeded(eventId, durationMs), + ); } export function trackPrimitivePreviewFailed( projectDir: string, errorCode: PrimitiveFunnelErrorCode, + durationMs: number, ): void { emitProjectTerminal(projectDir, "preview", (funnel, eventId) => - funnel.previewFailed(eventId, errorCode), + funnel.previewFailed(eventId, errorCode, durationMs), ); } -export function trackPrimitiveRenderSucceeded(projectDir: string): void { - emitProjectTerminal(projectDir, "render", (funnel, eventId) => funnel.renderSucceeded(eventId)); +export function trackPrimitiveRenderSucceeded(projectDir: string, durationMs: number): void { + emitProjectTerminal(projectDir, "render", (funnel, eventId) => + funnel.renderSucceeded(eventId, durationMs), + ); } export function trackPrimitiveRenderFailed( projectDir: string, errorCode: PrimitiveFunnelErrorCode, + durationMs: number, ): void { emitProjectTerminal(projectDir, "render", (funnel, eventId) => - funnel.renderFailed(eventId, errorCode), + 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..cf7689b639 100644 --- a/packages/cli/src/telemetry/primitive-funnel-state.ts +++ b/packages/cli/src/telemetry/primitive-funnel-state.ts @@ -20,6 +20,7 @@ function isContext(value: unknown): value is PrimitiveFunnelContext { return [ "funnelId", "installId", + "primitiveId", "artifactId", "versionId", "catalogVersion", @@ -31,8 +32,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; } 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..9d4d2286a1 --- /dev/null +++ b/packages/cli/src/telemetry/primitive-funnel-transport.test.ts @@ -0,0 +1,123 @@ +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); + trackPrimitivePreviewSucceeded(projectDir, 11); + trackPrimitiveRenderSucceeded(projectDir, 13); + + await flush(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + const events = body.batch as Array<{ + event: string; + distinct_id: string; + properties: Record; + }>; + 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.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..ef239184a4 100644 --- a/packages/cli/src/telemetry/primitive-funnel.test.ts +++ b/packages/cli/src/telemetry/primitive-funnel.test.ts @@ -26,39 +26,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( @@ -69,10 +96,14 @@ describe("primitive discovery funnel", () => { "event_id", "funnel_id", "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 +114,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 +131,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", diff --git a/packages/cli/src/telemetry/primitive-funnel.ts b/packages/cli/src/telemetry/primitive-funnel.ts index 8098989242..254b0b47e0 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,47 @@ 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 and stable event-id suffixes. */ +const PRIMITIVE_FUNNEL_SIDE_EFFECTS = { + catalogSearched: { event: "primitive_catalog_searched", suffix: "catalog-searched" }, + catalogResultSelected: { + event: "primitive_catalog_result_selected", + suffix: "catalog-result-selected", + }, + authStarted: { event: "primitive_auth_started", suffix: "auth-started" }, + authCompleted: { event: "primitive_auth_completed", suffix: "auth-completed" }, + authFailed: { event: "primitive_auth_failed", suffix: "auth-failed" }, + installStarted: { event: "primitive_install_started", suffix: "install-started" }, + installCompleted: { event: "primitive_install_completed", suffix: "install-completed" }, + installFailed: { event: "primitive_install_failed", suffix: "install-failed" }, + previewSucceeded: { event: "primitive_preview_succeeded", suffix: "preview" }, + previewFailed: { event: "primitive_preview_failed", suffix: "preview" }, + renderSucceeded: { event: "primitive_render_succeeded", suffix: "render" }, + renderFailed: { event: "primitive_render_failed", suffix: "render" }, +} as const; + +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 +75,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 +83,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}`, + 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.event, + 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.event, + 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.event, + 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.event, + 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.event, + 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.event, + eventId, + "authenticated", + durationMs, + ); } - #track(name: string): void { + renderFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode, durationMs: number): void { + this.#trackTerminal( + PRIMITIVE_FUNNEL_SIDE_EFFECTS.renderFailed.event, + eventId, + "authenticated", + durationMs, + errorCode, + ); + } + + #track( + sideEffect: { event: string; suffix: string }, + properties: Record, + ): void { if (!shouldTrack()) return; - trackEvent(name, this.#base); + trackEvent(sideEffect.event, { + ...this.#base, + event_id: `${this.#base.funnel_id}:${sideEffect.suffix}`, + ...properties, + }); } - #trackTerminal(name: string, eventId: string, errorCode?: PrimitiveFunnelErrorCode): void { + #trackTerminal( + name: string, + eventId: string, + authState: PrimitiveFunnelAuthState, + durationMs: number, + errorCode?: PrimitiveFunnelErrorCode, + ): void { if (!shouldTrack() || this.#terminalEventIds.has(eventId)) return; this.#terminalEventIds.add(eventId); trackEvent(name, { ...this.#base, event_id: eventId, + auth_state: authState, + duration_ms: boundedInteger(durationMs, 0, MAX_DURATION_MS), ...(errorCode ? { error_code: errorCode } : {}), }); } From 5227714cc5bd81b12b287317bfed04d3d109fd96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 5 Aug 2026 15:10:21 +0000 Subject: [PATCH 2/3] fix(cli): make primitive claims process-atomic --- .../src/telemetry/primitive-funnel-state.ts | 26 +++++- .../primitive-funnel-transport.test.ts | 2 + .../src/telemetry/primitive-funnel.test.ts | 80 ++++++++++++++++++- 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/telemetry/primitive-funnel-state.ts b/packages/cli/src/telemetry/primitive-funnel-state.ts index cf7689b639..d8f5d70ae6 100644 --- a/packages/cli/src/telemetry/primitive-funnel-state.ts +++ b/packages/cli/src/telemetry/primitive-funnel-state.ts @@ -1,10 +1,11 @@ -import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { randomUUID } from "node:crypto"; +import { closeSync, mkdirSync, openSync, readFileSync, renameSync, 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,6 +15,19 @@ function statePath(projectDir: string): string { return join(projectDir, FUNNEL_STATE_DIR, FUNNEL_STATE_FILE); } +function createClaimMarker(projectDir: string, eventId: string): boolean { + const directory = join(projectDir, FUNNEL_STATE_DIR, FUNNEL_CLAIM_DIR); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const digest = createHash("sha256").update(eventId).digest("hex"); + try { + const descriptor = openSync(join(directory, `${digest}.claim`), "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; @@ -88,7 +102,13 @@ export function claimPrimitiveFunnelEvent(projectDir: string, eventId: string): 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; diff --git a/packages/cli/src/telemetry/primitive-funnel-transport.test.ts b/packages/cli/src/telemetry/primitive-funnel-transport.test.ts index 9d4d2286a1..81e016661f 100644 --- a/packages/cli/src/telemetry/primitive-funnel-transport.test.ts +++ b/packages/cli/src/telemetry/primitive-funnel-transport.test.ts @@ -69,6 +69,7 @@ describe("primitive funnel queued transport boundary", () => { writePrimitiveFunnelContext(projectDir, context); funnel.installCompleted("install-transport:install-completed", 7); trackPrimitivePreviewSucceeded(projectDir, 11); + trackPrimitivePreviewSucceeded(projectDir, 99); trackPrimitiveRenderSucceeded(projectDir, 13); await flush(); @@ -92,6 +93,7 @@ describe("primitive funnel queued transport boundary", () => { "primitive_render_succeeded", ]); expect(events.filter(({ event }) => event === "$identify")).toHaveLength(1); + expect(events.filter(({ event }) => event === "primitive_preview_succeeded")).toHaveLength(1); expect(events.every(({ properties }) => properties.funnel_id === "funnel-transport")).toBe( true, ); diff --git a/packages/cli/src/telemetry/primitive-funnel.test.ts b/packages/cli/src/telemetry/primitive-funnel.test.ts index ef239184a4..58a4ee3849 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"; @@ -14,6 +15,56 @@ 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", () => { @@ -146,4 +197,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); }); From 9fcf1d07ec7bb14c63f6b54b71d0f9e4b5621a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 6 Aug 2026 04:03:05 +0000 Subject: [PATCH 3/3] fix(cli): keep primitive funnel terminals from losing events Terminal funnel events took a durable single-use claim before they were sent, but delivery was the fire-and-forget exit flush: a detached child with no retry and no way to report failure. An unacknowledged send left the claim spent on an event PostHog never received, so that step could never be emitted again. Observed in a real install, where primitive_preview_succeeded was claimed and never arrived. The terminal path now awaits delivery and hands the claim back when the batch is not acknowledged. That trades a permanent loss for a possible duplicate, which is the cheaper failure: every funnel event carries a stable event_id, so duplicates collapse downstream while a loss is unrecoverable. Claims are also no longer spent while telemetry is opted out. Separately, funnel order is now carried by an explicit funnel_step rather than inferred from timestamps. Auth completion and install start are emitted back to back inside one command and land in the same millisecond, so ordering by time resolved them arbitrarily and could report an install beginning before the auth that authorized it. --- packages/cli/src/cli.ts | 3 +- packages/cli/src/commands/preview.ts | 21 +++--- packages/cli/src/commands/render.ts | 8 +- .../primitive-funnel-command.test.ts | 72 ++++++++++++++++-- .../src/telemetry/primitive-funnel-command.ts | 51 +++++++++---- .../src/telemetry/primitive-funnel-state.ts | 75 ++++++++++++++++--- .../primitive-funnel-transport.test.ts | 29 +++++-- .../src/telemetry/primitive-funnel.test.ts | 2 + .../cli/src/telemetry/primitive-funnel.ts | 68 ++++++++++------- packages/cli/src/telemetry/transport.ts | 17 ++++- 10 files changed, 267 insertions(+), 79 deletions(-) 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/preview.ts b/packages/cli/src/commands/preview.ts index 496e2f6ef5..182aa1924b 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -400,7 +400,7 @@ export default defineCommand({ browserGpuMode, }); } catch (error) { - trackPrimitivePreviewFailed( + await trackPrimitivePreviewFailed( dir, "preview_failed", performance.now() - primitiveCommandStartedAt, @@ -427,7 +427,7 @@ export default defineCommand({ remoteDebuggingPort, browserNoGpu, }); - trackPrimitivePreviewSucceeded(dir, performance.now() - primitiveCommandStartedAt); + await trackPrimitivePreviewSucceeded(dir, performance.now() - primitiveCommandStartedAt); return; } @@ -928,7 +928,10 @@ function attachStudioReadyHandler( ): 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; @@ -936,15 +939,15 @@ function attachStudioReadyHandler( spinner.stop(c.success("Studio running")); printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" }); openStudioBrowser(url, projectName, projectDir, options); - trackPrimitivePreviewSucceeded(projectDir, performance.now() - primitiveCommandStartedAt); + 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( + child.on("error", async (err) => { + await trackPrimitivePreviewFailed( projectDir, "preview_failed", performance.now() - primitiveCommandStartedAt, @@ -1097,7 +1100,7 @@ async function runEmbeddedMode( options?.browserGpuMode, ); } catch (err: unknown) { - trackPrimitivePreviewFailed( + await trackPrimitivePreviewFailed( dir, "preview_failed", performance.now() - primitiveCommandStartedAt, @@ -1117,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, performance.now() - primitiveCommandStartedAt); + await trackPrimitivePreviewSucceeded(dir, performance.now() - primitiveCommandStartedAt); return; } @@ -1136,7 +1139,7 @@ async function runEmbeddedMode( footer: "Press Ctrl+C to stop", }); openStudioBrowser(url, pName, dir, options); - trackPrimitivePreviewSucceeded(dir, performance.now() - primitiveCommandStartedAt); + 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 97620f92d7..c4c5f4d6bf 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -742,7 +742,7 @@ async function renderDocker( child.on("error", (err) => reject(err)); }); } catch (error: unknown) { - trackPrimitiveRenderFailed( + await trackPrimitiveRenderFailed( projectDir, "render_failed", performance.now() - primitiveCommandStartedAt, @@ -757,7 +757,7 @@ async function renderDocker( // so any late throw here (telemetry flush, feedback prompt) cannot flip // the exit code. markRenderSucceeded(); - trackPrimitiveRenderSucceeded(projectDir, performance.now() - primitiveCommandStartedAt); + await trackPrimitiveRenderSucceeded(projectDir, performance.now() - primitiveCommandStartedAt); // Track metrics (no job object available from Docker — use a minimal stub) runPostRenderStep("trackRenderComplete", () => @@ -906,7 +906,7 @@ export async function renderLocal( try { await producer.executeRenderJob(job, projectDir, outputPath, onProgress); } catch (error: unknown) { - trackPrimitiveRenderFailed( + await trackPrimitiveRenderFailed( projectDir, "render_failed", performance.now() - primitiveCommandStartedAt, @@ -929,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, performance.now() - primitiveCommandStartedAt); + await trackPrimitiveRenderSucceeded(projectDir, performance.now() - primitiveCommandStartedAt); maybeConsumeDeParallelRouterTrial(deParallelRouterTrialArmed, job, options.quiet); const elapsed = Date.now() - startTime; diff --git a/packages/cli/src/telemetry/primitive-funnel-command.test.ts b/packages/cli/src/telemetry/primitive-funnel-command.test.ts index 1f278731af..1e94c48d89 100644 --- a/packages/cli/src/telemetry/primitive-funnel-command.test.ts +++ b/packages/cli/src/telemetry/primitive-funnel-command.test.ts @@ -1,12 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; +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: () => true, + shouldTrack: () => tracking, + flush: () => Promise.resolve(delivered), })); const { writePrimitiveFunnelContext } = await import("./primitive-funnel-state.js"); @@ -16,12 +20,26 @@ const { 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", @@ -35,11 +53,11 @@ describe("persisted primitive funnel command continuity", () => { afterEach(() => rmSync(projectDir, { recursive: true, force: true })); - it("propagates bounded command duration and deduplicates preview/render terminals", () => { - trackPrimitivePreviewSucceeded(projectDir, 12.4); - trackPrimitivePreviewSucceeded(projectDir, 98); - trackPrimitiveRenderFailed(projectDir, "render_failed", 23.6); - trackPrimitiveRenderSucceeded(projectDir, 99); + 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", @@ -50,12 +68,52 @@ describe("persisted primitive funnel command continuity", () => { 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 7347c783fc..dc400d13ff 100644 --- a/packages/cli/src/telemetry/primitive-funnel-command.ts +++ b/packages/cli/src/telemetry/primitive-funnel-command.ts @@ -1,46 +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, durationMs: number): void { - emitProjectTerminal(projectDir, "preview", (funnel, 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, durationMs: number, -): void { - emitProjectTerminal(projectDir, "preview", (funnel, eventId) => +): Promise { + await emitProjectTerminal(projectDir, "preview", (funnel, eventId) => funnel.previewFailed(eventId, errorCode, durationMs), ); } -export function trackPrimitiveRenderSucceeded(projectDir: string, durationMs: number): void { - emitProjectTerminal(projectDir, "render", (funnel, 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, durationMs: number, -): void { - emitProjectTerminal(projectDir, "render", (funnel, eventId) => +): 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 d8f5d70ae6..586808c7f0 100644 --- a/packages/cli/src/telemetry/primitive-funnel-state.ts +++ b/packages/cli/src/telemetry/primitive-funnel-state.ts @@ -1,4 +1,12 @@ -import { closeSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +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"; @@ -15,12 +23,19 @@ function statePath(projectDir: string): string { return join(projectDir, FUNNEL_STATE_DIR, FUNNEL_STATE_FILE); } -function createClaimMarker(projectDir: string, eventId: string): boolean { +function claimMarkerPath(projectDir: string, eventId: string): string { const directory = join(projectDir, FUNNEL_STATE_DIR, FUNNEL_CLAIM_DIR); - mkdirSync(directory, { recursive: true, mode: 0o700 }); 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(join(directory, `${digest}.claim`), "wx", 0o600); + const descriptor = openSync(claimMarkerPath(projectDir, eventId), "wx", 0o600); closeSync(descriptor); return true; } catch { @@ -88,17 +103,26 @@ 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; @@ -114,3 +138,34 @@ export function claimPrimitiveFunnelEvent(projectDir: string, eventId: string): 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 index 81e016661f..fce5088ca0 100644 --- a/packages/cli/src/telemetry/primitive-funnel-transport.test.ts +++ b/packages/cli/src/telemetry/primitive-funnel-transport.test.ts @@ -68,19 +68,24 @@ describe("primitive funnel queued transport boundary", () => { funnel.installStarted(); writePrimitiveFunnelContext(projectDir, context); funnel.installCompleted("install-transport:install-completed", 7); - trackPrimitivePreviewSucceeded(projectDir, 11); - trackPrimitivePreviewSucceeded(projectDir, 99); - trackPrimitiveRenderSucceeded(projectDir, 13); + // 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(); - expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); - const events = body.batch as Array<{ + 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", @@ -94,6 +99,16 @@ describe("primitive funnel queued transport boundary", () => { ]); 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, ); diff --git a/packages/cli/src/telemetry/primitive-funnel.test.ts b/packages/cli/src/telemetry/primitive-funnel.test.ts index 58a4ee3849..5f6651d297 100644 --- a/packages/cli/src/telemetry/primitive-funnel.test.ts +++ b/packages/cli/src/telemetry/primitive-funnel.test.ts @@ -9,6 +9,7 @@ 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"); @@ -146,6 +147,7 @@ describe("primitive discovery funnel", () => { "error_code", "event_id", "funnel_id", + "funnel_step", "install_id", "primitive_id", "query_fingerprint", diff --git a/packages/cli/src/telemetry/primitive-funnel.ts b/packages/cli/src/telemetry/primitive-funnel.ts index 254b0b47e0..5557f3c569 100644 --- a/packages/cli/src/telemetry/primitive-funnel.ts +++ b/packages/cli/src/telemetry/primitive-funnel.ts @@ -38,25 +38,43 @@ type PrimitiveFunnelAuthState = | "oauth" | "authenticated"; -/** Single source of truth for canonical lifecycle side effects and stable event-id suffixes. */ +/** + * 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" }, + 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" }, - authCompleted: { event: "primitive_auth_completed", suffix: "auth-completed" }, - authFailed: { event: "primitive_auth_failed", suffix: "auth-failed" }, - installStarted: { event: "primitive_install_started", suffix: "install-started" }, - installCompleted: { event: "primitive_install_completed", suffix: "install-completed" }, - installFailed: { event: "primitive_install_failed", suffix: "install-failed" }, - previewSucceeded: { event: "primitive_preview_succeeded", suffix: "preview" }, - previewFailed: { event: "primitive_preview_failed", suffix: "preview" }, - renderSucceeded: { event: "primitive_render_succeeded", suffix: "render" }, - renderFailed: { event: "primitive_render_failed", suffix: "render" }, + 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; @@ -113,6 +131,7 @@ export class PrimitiveFunnel { 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), }; @@ -128,7 +147,7 @@ export class PrimitiveFunnel { authFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode, durationMs: number): void { this.#trackTerminal( - PRIMITIVE_FUNNEL_SIDE_EFFECTS.authFailed.event, + PRIMITIVE_FUNNEL_SIDE_EFFECTS.authFailed, eventId, "oauth_required", durationMs, @@ -144,7 +163,7 @@ export class PrimitiveFunnel { installCompleted(eventId: string, durationMs: number): void { this.#trackTerminal( - PRIMITIVE_FUNNEL_SIDE_EFFECTS.installCompleted.event, + PRIMITIVE_FUNNEL_SIDE_EFFECTS.installCompleted, eventId, "authenticated", durationMs, @@ -153,7 +172,7 @@ export class PrimitiveFunnel { installFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode, durationMs: number): void { this.#trackTerminal( - PRIMITIVE_FUNNEL_SIDE_EFFECTS.installFailed.event, + PRIMITIVE_FUNNEL_SIDE_EFFECTS.installFailed, eventId, "authenticated", durationMs, @@ -163,7 +182,7 @@ export class PrimitiveFunnel { previewSucceeded(eventId: string, durationMs: number): void { this.#trackTerminal( - PRIMITIVE_FUNNEL_SIDE_EFFECTS.previewSucceeded.event, + PRIMITIVE_FUNNEL_SIDE_EFFECTS.previewSucceeded, eventId, "authenticated", durationMs, @@ -172,7 +191,7 @@ export class PrimitiveFunnel { previewFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode, durationMs: number): void { this.#trackTerminal( - PRIMITIVE_FUNNEL_SIDE_EFFECTS.previewFailed.event, + PRIMITIVE_FUNNEL_SIDE_EFFECTS.previewFailed, eventId, "authenticated", durationMs, @@ -182,7 +201,7 @@ export class PrimitiveFunnel { renderSucceeded(eventId: string, durationMs: number): void { this.#trackTerminal( - PRIMITIVE_FUNNEL_SIDE_EFFECTS.renderSucceeded.event, + PRIMITIVE_FUNNEL_SIDE_EFFECTS.renderSucceeded, eventId, "authenticated", durationMs, @@ -191,7 +210,7 @@ export class PrimitiveFunnel { renderFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode, durationMs: number): void { this.#trackTerminal( - PRIMITIVE_FUNNEL_SIDE_EFFECTS.renderFailed.event, + PRIMITIVE_FUNNEL_SIDE_EFFECTS.renderFailed, eventId, "authenticated", durationMs, @@ -199,20 +218,18 @@ export class PrimitiveFunnel { ); } - #track( - sideEffect: { event: string; suffix: string }, - properties: Record, - ): void { + #track(sideEffect: PrimitiveFunnelSideEffect, properties: Record): void { if (!shouldTrack()) return; trackEvent(sideEffect.event, { ...this.#base, event_id: `${this.#base.funnel_id}:${sideEffect.suffix}`, + funnel_step: sideEffect.step, ...properties, }); } #trackTerminal( - name: string, + sideEffect: PrimitiveFunnelSideEffect, eventId: string, authState: PrimitiveFunnelAuthState, durationMs: number, @@ -220,9 +237,10 @@ export class PrimitiveFunnel { ): 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); }