Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion cli/src/build/prescan/engine.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// src/build/prescan/engine.ts
import type { Finding, OutcomeOptions, PrescanCheck, PrescanOutcome, PrescanReport, ScanContext, Severity } from './types'
import type { Finding, OutcomeOptions, PrescanCheck, PrescanOutcome, PrescanReport, ScanContext } from './types'
import { enforcedCounts } from './enforcement'
import {
applyWarnOverrides,
Expand Down
6 changes: 6 additions & 0 deletions cli/src/init/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface InitReplaySnapshotBody {
event: '$snapshot'
properties: {
$current_url: string
$identify_person?: boolean
$lib: string
$lib_version: string
$session_id: string
Expand Down Expand Up @@ -470,6 +471,7 @@ export function createTerminalInteractionEvents(input: {
export function buildInitReplayBody(input: {
currentUrl?: string
events: eventWithTime[]
identifyPerson?: boolean
sessionId: string
timestamp: string
windowId: string
Expand All @@ -478,6 +480,7 @@ export function buildInitReplayBody(input: {
event: '$snapshot',
properties: {
$current_url: input.currentUrl || DEFAULT_REPLAY_CURRENT_URL,
...(input.identifyPerson ? { $identify_person: true } : {}),
$lib: '@capgo/cli',
$lib_version: pack.version,
$session_id: input.sessionId,
Expand Down Expand Up @@ -517,6 +520,7 @@ class InitReplayRecorder implements InitReplayController {
private captureTimer: NodeJS.Timeout | undefined
private disposed = false
private hasSentMeta = false
private hasQueuedIdentity = false
private metaGeneration = 0
private lastSnapshotText = ''
private pendingTerminalWrite = Promise.resolve()
Expand Down Expand Up @@ -700,10 +704,12 @@ class InitReplayRecorder implements InitReplayController {
const body = buildInitReplayBody({
currentUrl: this.currentUrl,
events,
identifyPerson: !this.hasQueuedIdentity,
sessionId: this.sessionId,
timestamp: new Date(timestamp).toISOString(),
windowId: this.windowId,
})
this.hasQueuedIdentity = true
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
const pending = this.transport(replayUrl, body, this.apikey, controller.signal)
.then((sent) => {
if (sent && frameIncludesMeta && metaGenerationAtCapture === this.metaGeneration)
Expand Down
6 changes: 6 additions & 0 deletions cli/test/test-init-replay.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ const event = {
}
const body = buildInitReplayBody({
events: [event],
identifyPerson: true,
sessionId: 'init-session-123',
timestamp: '2026-06-16T00:00:00.000Z',
windowId: 'window-123',
Expand All @@ -139,6 +140,7 @@ assert.equal(body.event, '$snapshot')
assert.equal(body.properties.$session_id, 'init-session-123')
assert.equal(body.properties.$window_id, 'window-123')
assert.equal(body.properties.$current_url, 'capgo-cli://init')
assert.equal(body.properties.$identify_person, true)
assert.deepEqual(body.properties.$snapshot_data, [event])
assert.equal(typeof body.properties.$snapshot_bytes, 'number')
assert.ok(body.properties.$snapshot_bytes > 0, 'snapshot byte size is included')
Expand Down Expand Up @@ -362,6 +364,10 @@ assert.equal(applyRawCommandAnalyticsOptOut(['node', 'capgo', 'bundle', 'upload'
await new Promise(resolve => setTimeout(resolve, 80))
await replay.finish()

assert.equal(captured.filter(body => body.properties.$identify_person === true).length, 1, 'only one replay snapshot requests person identification')
assert.equal(captured[0]?.properties.$identify_person, true, 'the first queued replay snapshot requests person identification')
assert.equal(captured.slice(1).some(body => '$identify_person' in body.properties), false, 'later replay snapshots omit person identification')

const resizedMeta = captured.flatMap(body => body.properties.$snapshot_data.filter(event => event.type === 4))
.find(event => event.data.width === getReplayViewportSize(100, 30).width
&& event.data.height === getReplayViewportSize(100, 30).height)
Expand Down
4 changes: 2 additions & 2 deletions supabase/functions/_backend/private/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface CliReplayBody {
interface ValidatedReplayPayload {
currentUrl: string
events: unknown[]
identifyPerson: boolean
lib: string
libVersion: string
sessionId: string
Expand Down Expand Up @@ -67,6 +68,7 @@ function validateReplayBody(body: CliReplayBody): ValidatedReplayPayload {
return {
currentUrl: readRequiredString(properties, '$current_url'),
events,
identifyPerson: properties.$identify_person === true,
lib: readOptionalString(properties, '$lib', '@capgo/cli'),
libVersion: readOptionalString(properties, '$lib_version', 'unknown'),
sessionId: readRequiredString(properties, '$session_id'),
Expand Down Expand Up @@ -108,8 +110,6 @@ app.post('/', middlewareAuth(), async (c) => {
const body = await parseBody<CliReplayBody>(c)
const replay = validateReplayBody(body)
const userEmail = await getAuthenticatedUserEmail(c, userId)
if (!userEmail)
throw quickError(500, 'missing_replay_user_email', 'Could not resolve replay user email')

const sent = await capturePosthogReplaySnapshot(c, {
...replay,
Expand Down
34 changes: 29 additions & 5 deletions supabase/functions/_backend/utils/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ const POSTHOG_CAPTURE_URL = 'https://eu.i.posthog.com/capture/'
const POSTHOG_EXCEPTION_URL = 'https://eu.i.posthog.com/i/v0/e/'
const POSTHOG_SNAPSHOT_URL = 'https://eu.i.posthog.com/s/'
const POSTHOG_DELIVERY_TIMEOUT_MS = 5000
const POSTHOG_IDENTIFY_TIMEOUT_MS = 250

export type PostHogGroups = Record<string, string>

interface PostHogCapturePayload extends Pick<TrackOptions, 'event'>, Pick<TrackOptions, 'channel' | 'description'> {
distinct_id?: string
groups?: PostHogGroups
ip?: string
personProperties?: Record<string, unknown>
setPersonProperties?: boolean
tags?: Record<string, unknown>
nonPersonTags?: Record<string, unknown>
timestamp?: string
timeoutMs?: number
user_id?: string
}

Expand All @@ -29,9 +32,7 @@ export async function trackPosthogEvent(c: Context, payload: PostHogCapturePaylo
}

const host = getEnv(c, 'POSTHOG_API_HOST') || POSTHOG_CAPTURE_URL
const posthogUrl = host.endsWith('/capture/')
? host
: new URL('capture/', host.endsWith('/') ? host : `${host}/`).toString()
const posthogUrl = getPostHogCaptureUrl(host)

const distinctId = payload.user_id || payload.distinct_id || 'anonymous'

Expand All @@ -46,7 +47,7 @@ export async function trackPosthogEvent(c: Context, payload: PostHogCapturePaylo
...(payload.tags || {}),
channel: payload.channel,
description: payload.description,
...(payload.setPersonProperties === false ? {} : { $set: payload.tags }),
...(payload.setPersonProperties === false ? {} : { $set: { ...payload.tags, ...payload.personProperties } }),
...(hasGroups ? { $groups: payload.groups } : {}),
}

Expand All @@ -59,13 +60,16 @@ export async function trackPosthogEvent(c: Context, payload: PostHogCapturePaylo
timestamp: payload.timestamp ?? new Date().toISOString(),
}

const controller = payload.timeoutMs ? new AbortController() : undefined
const timeoutId = controller ? setTimeout(() => controller.abort(), payload.timeoutMs) : undefined
try {
const res = await fetch(posthogUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: controller?.signal,
})

if (!res.ok) {
Expand All @@ -81,6 +85,10 @@ export async function trackPosthogEvent(c: Context, payload: PostHogCapturePaylo
cloudlogErr({ requestId: c.get('requestId'), message: 'PostHog fetch failed', error: serializeError(e), event: payload.event, distinctId })
return false
}
finally {
if (timeoutId)
clearTimeout(timeoutId)
}
}

function trimTrailingSlashes(value: string) {
Expand All @@ -91,13 +99,18 @@ function trimTrailingSlashes(value: string) {
}

function stripPostHogEndpoint(host: string) {
for (const suffix of ['/i/v0/e', '/capture', '/e']) {
for (const suffix of ['/i/v0/e', '/capture', '/s', '/e']) {
if (host.endsWith(suffix))
return `${host.slice(0, -suffix.length)}/`
}
return host
}

function getPostHogCaptureUrl(host: string) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
const normalizedHost = stripPostHogEndpoint(trimTrailingSlashes(host))
return new URL('capture/', normalizedHost.endsWith('/') ? normalizedHost : `${normalizedHost}/`).toString()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

function getPostHogSnapshotUrl(host: string) {
const trimmedHost = trimTrailingSlashes(host)
if (trimmedHost.endsWith('/s'))
Expand All @@ -114,6 +127,7 @@ export interface PostHogReplaySnapshotPayload {
currentUrl: string
distinctId: string
events: unknown[]
identifyPerson?: boolean
lib: string
libVersion: string
sessionId: string
Expand All @@ -132,6 +146,16 @@ export async function capturePosthogReplaySnapshot(c: Context, payload: PostHogR
}

const host = getEnv(c, 'POSTHOG_API_HOST') || POSTHOG_SNAPSHOT_URL
if (payload.identifyPerson && payload.userEmail) {
await trackPosthogEvent(c, {
channel: 'cli',
event: '$identify',
personProperties: { email: payload.userEmail },
timeoutMs: POSTHOG_IDENTIFY_TIMEOUT_MS,
user_id: payload.userId,
})
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
}

let posthogUrl: string
try {
posthogUrl = getPostHogSnapshotUrl(host)
Expand Down
118 changes: 104 additions & 14 deletions tests/posthog.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ beforeEach(() => {
})

afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
cloudlogErrMock.mockReset()
cloudlogMock.mockReset()
Expand Down Expand Up @@ -122,7 +123,7 @@ describe('posthog helper', () => {
expect(body.properties).not.toHaveProperty('$set')
})

it('sends replay snapshots with backend identity email', async () => {
it('identifies the replay person before sending the initial snapshot', async () => {
const { capturePosthogReplaySnapshot } = await import('../supabase/functions/_backend/utils/posthog.ts')

await capturePosthogReplaySnapshot(createContext(), {
Expand All @@ -131,27 +132,116 @@ describe('posthog helper', () => {
events: [{ data: { height: 600, href: 'capgo-cli://init', width: 900 }, timestamp: 123, type: 4 }],
lib: '@capgo/cli',
libVersion: '8.9.0',
identifyPerson: true,
sessionId: 'init-session',
timestamp: '2026-06-16T00:00:00.000Z',
userEmail: 'user@example.com',
userId: 'user-id',
windowId: 'window-id',
})

const request = fetchMock.mock.calls[0]
const body = JSON.parse(request?.[1]?.body as string)
expect(fetchMock).toHaveBeenCalledTimes(2)
const identifyRequest = fetchMock.mock.calls[0]
const identifyBody = JSON.parse(identifyRequest?.[1]?.body as string)
const snapshotRequest = fetchMock.mock.calls[1]
const snapshotBody = JSON.parse(snapshotRequest?.[1]?.body as string)

expect(request?.[0]).toBe('https://eu.i.posthog.com/s/')
expect(body.event).toBe('$snapshot')
expect(body.api_key).toBe('posthog-key')
expect(body.distinct_id).toBe('user-id')
expect(body.properties.$set).toEqual({ email: 'user@example.com' })
expect(body.properties.token).toBe('posthog-key')
expect(body.properties.user_id).toBe('user-id')
expect(body.properties.$session_id).toBe('init-session')
expect(body.properties.$window_id).toBe('window-id')
expect(body.properties.$snapshot_data).toHaveLength(1)
expect(request?.[1]?.signal).toBeInstanceOf(AbortSignal)
expect(identifyRequest?.[0]).toBe('https://eu.i.posthog.com/capture/')
expect(identifyBody.event).toBe('$identify')
expect(identifyBody.distinct_id).toBe('user-id')
expect(identifyBody.properties.$set).toEqual({ email: 'user@example.com' })
expect(identifyBody.properties).not.toHaveProperty('email')
expect(identifyRequest?.[1]?.signal).toBeInstanceOf(AbortSignal)
expect(snapshotRequest?.[0]).toBe('https://eu.i.posthog.com/s/')
expect(snapshotBody.event).toBe('$snapshot')
expect(snapshotBody.api_key).toBe('posthog-key')
expect(snapshotBody.distinct_id).toBe('user-id')
expect(snapshotBody.properties.token).toBe('posthog-key')
expect(snapshotBody.properties.user_id).toBe('user-id')
expect(snapshotBody.properties.$session_id).toBe('init-session')
expect(snapshotBody.properties.$window_id).toBe('window-id')
expect(snapshotBody.properties.$snapshot_data).toHaveLength(1)
expect(snapshotRequest?.[1]?.signal).toBeInstanceOf(AbortSignal)
})

it.each([
'https://eu.i.posthog.com',
'https://eu.i.posthog.com/capture/',
'https://eu.i.posthog.com/i/v0/e',
'https://eu.i.posthog.com/s/',
])('normalizes %s before identifying and recording', async (host) => {
const { capturePosthogReplaySnapshot } = await import('../supabase/functions/_backend/utils/posthog.ts')
envState.posthogApiHost = host

await capturePosthogReplaySnapshot(createContext(), {
currentUrl: 'capgo-cli://init',
distinctId: 'user-id',
events: [{}],
identifyPerson: true,
lib: '@capgo/cli',
libVersion: '8.9.0',
sessionId: 'init-session',
timestamp: '2026-06-16T00:00:00.000Z',
userEmail: 'user@example.com',
userId: 'user-id',
windowId: 'window-id',
})

expect(fetchMock.mock.calls.map(call => call[0])).toEqual([
'https://eu.i.posthog.com/capture/',
'https://eu.i.posthog.com/s/',
])
})

it('still records when the replay email is unavailable', async () => {
const { capturePosthogReplaySnapshot } = await import('../supabase/functions/_backend/utils/posthog.ts')

await capturePosthogReplaySnapshot(createContext(), {
currentUrl: 'capgo-cli://init',
distinctId: 'user-id',
events: [{}],
identifyPerson: true,
lib: '@capgo/cli',
libVersion: '8.9.0',
sessionId: 'init-session',
timestamp: '2026-06-16T00:00:00.000Z',
userId: 'user-id',
windowId: 'window-id',
})

expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://eu.i.posthog.com/s/')
})

it('still records when replay identification times out', async () => {
vi.useFakeTimers()
fetchMock
.mockImplementationOnce((_url, init) => new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')))
}))
.mockResolvedValueOnce({ ok: true, text: vi.fn().mockResolvedValue('') })
const { capturePosthogReplaySnapshot } = await import('../supabase/functions/_backend/utils/posthog.ts')

const request = capturePosthogReplaySnapshot(createContext(), {
currentUrl: 'capgo-cli://init',
distinctId: 'user-id',
events: [{}],
identifyPerson: true,
lib: '@capgo/cli',
libVersion: '8.9.0',
sessionId: 'init-session',
timestamp: '2026-06-16T00:00:00.000Z',
userEmail: 'user@example.com',
userId: 'user-id',
windowId: 'window-id',
})
await vi.advanceTimersByTimeAsync(250)

await expect(request).resolves.toBe(true)
expect(fetchMock.mock.calls.map(call => call[0])).toEqual([
'https://eu.i.posthog.com/capture/',
'https://eu.i.posthog.com/s/',
])
})

it('uses the full exception endpoint host and only sends the request path for exceptions', async () => {
Expand Down
Loading