Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
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
2 changes: 0 additions & 2 deletions supabase/functions/_backend/private/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,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
44 changes: 34 additions & 10 deletions supabase/functions/_backend/utils/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@ 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
const RRWEB_META_EVENT_TYPE = 4

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,10 +33,6 @@ 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 distinctId = payload.user_id || payload.distinct_id || 'anonymous'

const hasGroups = payload.groups && Object.keys(payload.groups).length > 0
Expand All @@ -46,7 +46,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 +59,17 @@ 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 posthogUrl = getPostHogCaptureUrl(host)
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.
const normalizedHost = stripPostHogEndpoint(trimTrailingSlashes(host))
return new URL('capture/', normalizedHost.endsWith('/') ? normalizedHost : `${normalizedHost}/`).toString()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function getPostHogSnapshotUrl(host: string) {
const trimmedHost = trimTrailingSlashes(host)
if (trimmedHost.endsWith('/s'))
Expand Down Expand Up @@ -141,6 +154,20 @@ export async function capturePosthogReplaySnapshot(c: Context, payload: PostHogR
return false
}

const isInitialSnapshot = payload.events.some(event => typeof event === 'object' && event !== null && 'type' in event && event.type === RRWEB_META_EVENT_TYPE)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (isInitialSnapshot && payload.userEmail) {
await trackPosthogEvent(c, {
channel: 'cli',
event: '$identify',
// Resizes emit another rrweb Meta event. Keep retries backend-only while
// letting PostHog deduplicate them into one identify event per session.
nonPersonTags: { $insert_id: `cli-replay-identify:${payload.sessionId}` },
personProperties: { email: payload.userEmail },
timeoutMs: POSTHOG_IDENTIFY_TIMEOUT_MS,
user_id: payload.userId,
}).catch(error => cloudlogErr({ requestId: c.get('requestId'), message: 'PostHog identification failed', error: serializeError(error), userId: payload.userId }))
}

const body = {
api_key: apiKey,
distinct_id: payload.distinctId,
Expand Down Expand Up @@ -364,10 +391,6 @@ export async function groupIdentifyPosthog(c: Context, payload: PostHogGroupIden
}

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 body = {
api_key: apiKey,
event: '$groupidentify',
Expand All @@ -381,6 +404,7 @@ export async function groupIdentifyPosthog(c: Context, payload: PostHogGroupIden
}

try {
const posthogUrl = getPostHogCaptureUrl(host)
const res = await fetch(posthogUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand Down
6 changes: 3 additions & 3 deletions tests/admin-stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1356,8 +1356,8 @@ describe('/private/admin_stats', () => {
expect(payload.success).toBe(true)
expect(payload.data.trend).toHaveLength(1)
expect(payload.data.trend[0]?.date).toBe('2026-02-01')
expect(payload.data.trend[0]?.total).toBe(3)
expect(payload.data.trend[0]?.plans[soloPlan?.name ?? 'Solo']).toBe(3)
expect(payload.data.totals.find(plan => plan.plan_name === (soloPlan?.name ?? 'Solo'))?.total).toBe(3)
expect(payload.data.trend[0]?.total).toBe(4)
expect(payload.data.trend[0]?.plans[soloPlan?.name ?? 'Solo']).toBe(4)
expect(payload.data.totals.find(plan => plan.plan_name === (soloPlan?.name ?? 'Solo'))?.total).toBe(4)
})
})
216 changes: 202 additions & 14 deletions tests/posthog.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
})

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

it('sends replay snapshots with backend identity email', async () => {
it('normalizes endpoint-shaped hosts for group identification', async () => {
const { groupIdentifyPosthog } = await import('../supabase/functions/_backend/utils/posthog.ts')
envState.posthogApiHost = 'https://eu.i.posthog.com/s/'

await groupIdentifyPosthog(createContext(), {
groupKey: 'org-id',
groupType: 'organization',
properties: { name: 'Capgo' },
})

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

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 @@ -138,20 +152,194 @@
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.$insert_id).toBe('cli-replay-identify:init-session')
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: [{ type: 4 }],
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: [{ type: 4 }],
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('does not identify follow-up replay snapshots without a meta event', async () => {
const { capturePosthogReplaySnapshot } = await import('../supabase/functions/_backend/utils/posthog.ts')

await capturePosthogReplaySnapshot(createContext(), {
currentUrl: 'capgo-cli://init',
distinctId: 'user-id',
events: [{ type: 2 }],
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).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://eu.i.posthog.com/s/')
})

it('uses one deterministic insert ID for meta events repeated after resize', async () => {
const { capturePosthogReplaySnapshot } = await import('../supabase/functions/_backend/utils/posthog.ts')
const payload = {
currentUrl: 'capgo-cli://init',
distinctId: 'user-id',
events: [{ type: 4 }],
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 capturePosthogReplaySnapshot(createContext(), payload)
await capturePosthogReplaySnapshot(createContext(), payload)

const identifyBodies = [fetchMock.mock.calls[0], fetchMock.mock.calls[2]]
.map(call => JSON.parse(call?.[1]?.body as string))
expect(identifyBodies.map(body => body.properties.$insert_id)).toEqual([
'cli-replay-identify:init-session',
'cli-replay-identify:init-session',
])
})

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: [{ type: 4 }],
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('fails safely when replay identification receives an invalid PostHog host', async () => {
const { capturePosthogReplaySnapshot } = await import('../supabase/functions/_backend/utils/posthog.ts')
envState.posthogApiHost = '://bad-host'

await expect(capturePosthogReplaySnapshot(createContext(), {
currentUrl: 'capgo-cli://init',
distinctId: 'user-id',
events: [{ type: 4 }],
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',
})).resolves.toBe(false)

expect(fetchMock).not.toHaveBeenCalled()
})

it('fails safely when event tracking receives an invalid PostHog host', async () => {
const { trackPosthogEvent } = await import('../supabase/functions/_backend/utils/posthog.ts')
envState.posthogApiHost = '://bad-host'

await expect(trackPosthogEvent(createContext(), {

Check failure on line 327 in tests/posthog.unit.test.ts

View workflow job for this annotation

GitHub Actions / Lint and typecheck

Property 'channel' is missing in type '{ event: string; user_id: string; }' but required in type 'PostHogCapturePayload'.

Check failure on line 327 in tests/posthog.unit.test.ts

View workflow job for this annotation

GitHub Actions / Lint and typecheck

Property 'channel' is missing in type '{ event: string; user_id: string; }' but required in type 'PostHogCapturePayload'.
event: 'test-event',
user_id: 'user-id',
})).resolves.toBe(false)
expect(fetchMock).not.toHaveBeenCalled()
})

it('fails safely when group identification receives an invalid PostHog host', async () => {
const { groupIdentifyPosthog } = await import('../supabase/functions/_backend/utils/posthog.ts')
envState.posthogApiHost = '://bad-host'

await expect(groupIdentifyPosthog(createContext(), {
groupKey: 'org-id',
groupType: 'organization',
})).resolves.toBe(false)
expect(fetchMock).not.toHaveBeenCalled()
})

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