Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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. */
Expand Down
53 changes: 52 additions & 1 deletion test/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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))
})
Loading