From b0a671d0e2702b54f7a7437cebbdae94e1477e45 Mon Sep 17 00:00:00 2001 From: itelo Date: Thu, 3 Sep 2026 15:48:46 -0300 Subject: [PATCH] feat(wizard): name the workspace group, and stamp events on capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes found while checking a real run's events in PostHog. Events already join a `workspace` group, but nothing ever identified it, so every workspace read as a bare UUID in the group breakdowns the funnel dashboard is built on. Identify it once, when the run settles on a workspace, with the name and whether it is a sandbox — the only workspace facts the wizard knows first-hand. Group properties are shared with everything else that reports to that workspace, so nothing else goes in. The call is attributed to the install's own distinct id, or PostHog mints a person for the workspace as a side effect. The second is an ordering bug. The SDK assigns a timestamp inside an async path, so two events captured in the same tick can be stamped out of order, and PostHog orders funnel steps by timestamp. A run yesterday reported its screens as: screen_index 1 init 18:27:17.181 screen_index 2 method 18:27:17.045 The wizard's own ordering was right and the stamps were inverted, which is enough to make a screens funnel read a step as skipped. Stamp each event where it is captured instead. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/analytics.ts | 38 ++++++++++++++++++++++++------ test/analytics.test.ts | 53 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index d6ef98f..761577b 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -127,6 +127,10 @@ export const track = ( current.client.capture({ distinctId: current.distinct_id, event, + // Stamped here rather than left to the SDK, which assigns the timestamp + // inside an async path: two events captured in the same tick can then + // land out of order, and PostHog orders funnel steps by timestamp. + timestamp: new Date(), properties: sanitize({ ...current.common, seconds_since_start: secondsSince(current.started_at), @@ -156,15 +160,35 @@ export const trackScreen = (screen: string): void => { } /** - * Attribute the rest of the run to the workspace it connected to. The id (not - * the name) rides along on every later event, and on the workspace group so - * PostHog can roll a workspace's runs up together. + * Attribute the rest of the run to the workspace it connected to: its id rides + * along on every later event and on the workspace group, so a workspace's runs + * roll up together. The name is sent once, here, to label that group. */ export const setAnalyticsWorkspace = (workspace: SeamWorkspace): void => { - if (run == null) return - run.workspace_id = workspace.workspace_id - run.common['workspace_id'] = workspace.workspace_id - run.common['workspace_is_sandbox'] = workspace.is_sandbox + const current = run + if (current == null) return + current.workspace_id = workspace.workspace_id + current.common['workspace_id'] = workspace.workspace_id + current.common['workspace_is_sandbox'] = workspace.is_sandbox + + try { + // Name the group, or a workspace reads as a bare UUID everywhere it is + // grouped. Group properties are shared across everything that reports to + // this workspace, so only what the wizard knows first-hand goes here. + current.client.groupIdentify({ + groupType: 'workspace', + groupKey: workspace.workspace_id, + // Attributed to this install, so the call does not mint a person of its + // own for the workspace. + distinctId: current.distinct_id, + properties: { + name: workspace.name, + is_sandbox: workspace.is_sandbox, + }, + }) + } catch { + // Analytics never reports itself to the developer, and never fails a run. + } } /** Attribute the rest of the run to the SDK it is setting up. */ diff --git a/test/analytics.test.ts b/test/analytics.test.ts index f9a36f6..7d63e1c 100644 --- a/test/analytics.test.ts +++ b/test/analytics.test.ts @@ -205,7 +205,10 @@ test('carries the workspace and SDK on every event after they are known', async track('wizard_connected') await flushAnalytics() - const [before, after] = captured.events() + const byName = (name: string): CapturedEvent | undefined => + captured.events().find((event) => event.event === name) + const before = byName('wizard_init_finished') + const after = byName('wizard_connected') expect(before?.properties['workspace_id']).toBeUndefined() expect(after?.properties).toMatchObject({ workspace_id: 'workspace-1', @@ -277,3 +280,51 @@ test('a run reports even when the network is gone', async () => { await expect(flushAnalytics()).resolves.toBeUndefined() }) + +test('names the workspace group so it is not a bare id', async () => { + vi.stubEnv('SEAM_WIZARD_POSTHOG_KEY', 'phc_test_project') + const captured = captureRequests() + + await startAnalytics({ command: 'seam wizard' }) + setAnalyticsWorkspace(workspace) + track('wizard_connected') + await flushAnalytics() + + const identify = captured + .events() + .find((event) => event.event === '$groupidentify') + expect(identify?.properties).toMatchObject({ + $group_type: 'workspace', + $group_key: 'workspace-1', + $group_set: { name: 'Acme', is_sandbox: false }, + }) + // Attributed to the install, so the workspace gets no person of its own. + expect(identify?.distinct_id).toMatch(/^wizard_cli_/) +}) + +test('stamps every event at the moment it is captured', async () => { + vi.stubEnv('SEAM_WIZARD_POSTHOG_KEY', 'phc_test_project') + const captured = captureRequests() + + await startAnalytics({ command: 'seam wizard' }) + const before = Date.now() + trackScreen('welcome') + trackScreen('init') + trackScreen('method') + const after = Date.now() + await flushAnalytics() + + const stamps = captured + .events() + .filter((event) => event.event === 'wizard_screen_viewed') + .map((event) => Date.parse(event.timestamp)) + + expect(stamps).toHaveLength(3) + // Pins the invariant the funnels depend on: a stamp belongs to the capture + // call, so capture order is timestamp order. + for (const stamp of stamps) { + expect(stamp).toBeGreaterThanOrEqual(before) + expect(stamp).toBeLessThanOrEqual(after) + } + expect(stamps).toEqual([...stamps].sort((a, b) => a - b)) +})