From 7cdf1d4e7b93a3b1017fe914b8c0faa4574d3215 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Fri, 23 Oct 2026 11:28:40 -0700 Subject: [PATCH 01/20] chore(node): start the posthog-node 6 capture v1 release branch --- .changeset/posthog-node-capture-v1.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/posthog-node-capture-v1.md diff --git a/.changeset/posthog-node-capture-v1.md b/.changeset/posthog-node-capture-v1.md new file mode 100644 index 0000000000..c74d172a8a --- /dev/null +++ b/.changeset/posthog-node-capture-v1.md @@ -0,0 +1,5 @@ +--- +'posthog-node': major +--- + +Send events only through capture v1, with AI events on their own lane. See the 6.0 migration guide. From c716deb6ce8ed5e16229309f27f56e5bc6830efd Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Mon, 26 Oct 2026 20:43:36 -0700 Subject: [PATCH 02/20] test(node): read capture events from either wire format (#5414) * test(node): read capture events from either wire format Add shared helpers that find analytics capture requests on the legacy /batch/ endpoint or the capture v1 analytics endpoint, and return their events in the legacy shape. A v1 event's options, session and window ids and SDK info header map back to the properties they replace, so one assertion holds for both formats. Port the tests that read events off the wire to these helpers, and answer capture requests with a body that both senders accept. The same tests pass on the legacy default and with POSTHOG_CAPTURE_MODE=v1. Co-Authored-By: Claude Opus 5.5 * test(ai): accept capture v1 requests in the cassette collector The collector accepted only the legacy /batch/ request. It now also accepts the capture v1 analytics and AI endpoints with a bearer token, and returns their events in the legacy shape, so the replay tests hold for both wire formats. Co-Authored-By: Claude Opus 5.5 * test(convex): read example events from either capture endpoint The example tests read events only from /batch/ calls and checked the api_key in the body. Read them from the legacy or the capture v1 analytics endpoint, and accept the project key from the body or the bearer token. Co-Authored-By: Claude Opus 5.5 * test(convex): give the sequenced fetch mock the headers field its type requires Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../example-convex/convex/example.test.ts | 61 ++++----- packages/ai/harness/collector.test.ts | 121 +++++++++++++----- packages/ai/harness/collector.ts | 72 +++++++++-- .../node/src/__tests__/bot-detection.spec.ts | 76 ++++++----- .../capture-v1/route-isolation.spec.ts | 3 +- .../src/__tests__/capture-v1/wiring.spec.ts | 3 +- packages/node/src/__tests__/context.spec.ts | 34 +---- .../node/src/__tests__/edge-exports.spec.ts | 13 +- .../src/__tests__/extensions/express.spec.ts | 16 +-- .../src/__tests__/extensions/nestjs.spec.ts | 25 +--- .../extensions/sentry-integration.spec.ts | 23 +--- .../node/src/__tests__/posthog-node.spec.ts | 96 +++++--------- .../__tests__/server-wire-snapshots.spec.ts | 3 +- packages/node/src/__tests__/utils/index.ts | 108 +++++++++++++--- .../node/src/__tests__/utils/v1-wiring.ts | 14 +- .../src/__tests__/waituntil-flush.spec.ts | 48 ++----- 16 files changed, 388 insertions(+), 328 deletions(-) diff --git a/examples/example-convex/convex/example.test.ts b/examples/example-convex/convex/example.test.ts index 64e8629dde..31f9043e3b 100644 --- a/examples/example-convex/convex/example.test.ts +++ b/examples/example-convex/convex/example.test.ts @@ -6,21 +6,24 @@ import { api, components } from './_generated/api.js' // Keep a guard above Jest's 5s default; scheduled functions are driven deterministically below. vi.setConfig({ testTimeout: 15000 }) +// posthog-node sends events to the legacy `/batch/` endpoint or the capture v1 analytics endpoint. +const CAPTURE_PATHS = ['/batch/', '/i/v1/analytics/events'] + // Collect all fetch calls for assertion -let fetchCalls: Array<{ url: string; body: unknown }> = [] +let fetchCalls: Array<{ url: string; headers: Record; body: unknown }> = [] const originalFetch = global.fetch function mockFetch(responseByUrl?: Record, statusByUrl?: Record) { fetchCalls = [] return vi.fn(async (url: string | URL, init?: RequestInit) => { const urlStr = url.toString() + const headers = (init?.headers ?? {}) as Record const status = Object.entries(statusByUrl ?? {}).find(([pattern]) => urlStr.includes(pattern))?.[1] ?? 200 let body: unknown if (init?.body) { let rawText: string if (init.body instanceof Blob) { - const headers = init.headers as Record | undefined - if (headers?.['Content-Encoding'] === 'gzip') { + if (headers['Content-Encoding'] === 'gzip') { const ds = new DecompressionStream('gzip') rawText = await new Response(init.body.stream().pipeThrough(ds)).text() } else { @@ -35,7 +38,7 @@ function mockFetch(responseByUrl?: Record, statusByUrl?: Record body = rawText } } - fetchCalls.push({ url: urlStr, body }) + fetchCalls.push({ url: urlStr, headers, body }) if (responseByUrl) { for (const [pattern, response] of Object.entries(responseByUrl)) { @@ -56,7 +59,17 @@ function mockFetch(responseByUrl?: Record, statusByUrl?: Record } function batchCalls() { - return fetchCalls.filter((c) => c.url.includes('/batch')) + return fetchCalls.filter((c) => CAPTURE_PATHS.some((path) => new URL(c.url).pathname === path)) +} + +// The project key travels as `api_key` in a legacy body and as a bearer token in capture v1. +function apiKeyOf(call: (typeof fetchCalls)[number]): string | undefined { + return (call.body as { api_key?: string }).api_key ?? call.headers.Authorization?.replace(/^Bearer /, '') +} + +// Fails every capture request with `status`, whichever endpoint the client uses. +function captureStatus(status: number): Record { + return Object.fromEntries(CAPTURE_PATHS.map((path) => [path, status])) } function flagsCalls() { @@ -71,9 +84,7 @@ function firstBatchEvent(): Record { } function allBatchEvents(): Record[] { - return batchCalls().flatMap( - (call) => (call.body as { batch: Record[] }).batch - ) + return batchCalls().flatMap((call) => (call.body as { batch: Record[] }).batch) } async function finishScheduledFunctions(t: ReturnType) { @@ -110,8 +121,7 @@ describe('capture', () => { await finishScheduledFunctions(t) expect(batchCalls().length).toBeGreaterThanOrEqual(1) - const batch = batchCalls()[0].body as { api_key: string } - expect(batch.api_key).toBe('phc_test_key') + expect(apiKeyOf(batchCalls()[0])).toBe('phc_test_key') const event = firstBatchEvent() expect(event.distinct_id).toBe('user-123') @@ -483,7 +493,9 @@ describe('getFeatureFlag (local eval)', () => { filters: { groups: [ { - properties: [{ key: 'email', value: ['user@acme.com'], operator: 'exact', type: 'person' }], + properties: [ + { key: 'email', value: ['user@acme.com'], operator: 'exact', type: 'person' }, + ], rollout_percentage: 100, }, ], @@ -786,9 +798,7 @@ describe('getAllFlags (local eval)', () => { }) test('respects flagKeys filter', async () => { - global.fetch = mockFetch( - definitionsResponse([flagDef('flag-a'), flagDef('flag-b'), flagDef('flag-c')]) - ) + global.fetch = mockFetch(definitionsResponse([flagDef('flag-a'), flagDef('flag-b'), flagDef('flag-c')])) const t = initConvexTest() await loadDefinitions(t) @@ -872,15 +882,13 @@ describe('refreshFlagDefinitions cron action', () => { const noArgs = {} /** Builds a fetch mock whose responses are picked per call from the supplied sequence. */ - function sequencedFetch( - responses: Array<{ status: number; body?: unknown; headers?: Record }> - ) { + function sequencedFetch(responses: Array<{ status: number; body?: unknown; headers?: Record }>) { fetchCalls = [] let i = 0 // Statuses where the spec forbids a body (Response constructor throws on non-null body). const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]) return vi.fn(async (url: string | URL) => { - fetchCalls.push({ url: url.toString(), body: undefined }) + fetchCalls.push({ url: url.toString(), headers: {}, body: undefined }) const r = responses[Math.min(i, responses.length - 1)] i++ const payload = @@ -964,9 +972,7 @@ describe('refreshFlagDefinitions cron action', () => { await t.action(components.posthog.lib.refreshFlagDefinitions, noArgs) // Now PostHog flaps cold-cache 503s. Cache is < 5min old so we keep what we have. - global.fetch = sequencedFetch([ - { status: 503, body: 'Required data not found in cache.' }, - ]) + global.fetch = sequencedFetch([{ status: 503, body: 'Required data not found in cache.' }]) const result = (await t.action(components.posthog.lib.refreshFlagDefinitions, noArgs)) as { status: string } @@ -988,9 +994,7 @@ describe('refreshFlagDefinitions cron action', () => { // Jump 6 minutes forward; the cached defs now count as stale. vi.setSystemTime(new Date(Date.now() + 6 * 60 * 1000)) - global.fetch = sequencedFetch([ - { status: 503, body: 'Required data not found in cache.' }, - ]) + global.fetch = sequencedFetch([{ status: 503, body: 'Required data not found in cache.' }]) const result = (await t.action(components.posthog.lib.refreshFlagDefinitions, noArgs)) as { status: string } @@ -1101,7 +1105,7 @@ describe('evaluateFlag (remote)', () => { }) test('returns the flag value when telemetry fails', async () => { - global.fetch = mockFetch(flagsResponse({ 'test-flag': 'variant-a' }), { '/batch': 400 }) + global.fetch = mockFetch(flagsResponse({ 'test-flag': 'variant-a' }), captureStatus(400)) const t = initConvexTest() const value = await t.action(components.posthog.lib.evaluateFlag, { @@ -1198,10 +1202,7 @@ describe('evaluateAllFlags (remote)', () => { test('returns all flags and payloads', async () => { global.fetch = mockFetch( - flagsResponse( - { 'flag-a': true, 'flag-b': 'variant', 'flag-c': false }, - { 'flag-a': { config: 'value' } } - ) + flagsResponse({ 'flag-a': true, 'flag-b': 'variant', 'flag-c': false }, { 'flag-a': { config: 'value' } }) ) const t = initConvexTest() @@ -1220,7 +1221,7 @@ describe('evaluateAllFlags (remote)', () => { }) test('returns all flags when telemetry fails', async () => { - global.fetch = mockFetch(flagsResponse({ 'flag-a': true }), { '/batch': 400 }) + global.fetch = mockFetch(flagsResponse({ 'flag-a': true }), captureStatus(400)) const t = initConvexTest() const result = await t.action(components.posthog.lib.evaluateAllFlags, { diff --git a/packages/ai/harness/collector.test.ts b/packages/ai/harness/collector.test.ts index b550b43855..17f3855cd7 100644 --- a/packages/ai/harness/collector.test.ts +++ b/packages/ai/harness/collector.test.ts @@ -2,43 +2,102 @@ import { gzipSync } from 'node:zlib' import { expect, it } from 'vitest' import { startCollector } from './collector' -it.each(['$ai_generation', '$ai_embedding'])( - 'accepts compressed %s analytics and retains decode failures', - async (eventName) => { - const collector = await startCollector() - try { - const event = { event: eventName, distinct_id: 'test', properties: {} } - const response = await fetch(`${collector.url}/batch/`, { - method: 'POST', - headers: { 'content-encoding': 'gzip' }, - body: gzipSync(JSON.stringify({ api_key: 'phc_cassette_test', batch: [event] })), - }) - expect(response.status).toBe(200) - expect(collector.events).toEqual([event]) - collector.verify() - const invalid = await fetch(`${collector.url}/batch/`, { - method: 'POST', - headers: { 'content-encoding': 'gzip' }, - body: 'not gzip', - }) - expect(invalid.status).toBe(500) - expect(() => collector.verify()).toThrow('Analytics collector failed') - } finally { - await collector.close() - } +const AUTH = { authorization: 'Bearer phc_cassette_test' } + +function legacyBody(batch: unknown[]): string { + return JSON.stringify({ api_key: 'phc_cassette_test', batch }) +} + +function v1Body(batch: unknown[]): string { + return JSON.stringify({ created_at: '2026-01-01T00:00:00.000Z', batch }) +} + +function v1Event(event: string, options: Record = {}) { + return { + event, + uuid: '01890000-0000-7000-8000-000000000000', + distinct_id: 'test', + timestamp: '2026-01-01T00:00:00.000Z', + session_id: 'session-1', + options, + properties: {}, + } +} + +it.each([ + ['$ai_generation', '/batch/'], + ['$ai_embedding', '/batch/'], + ['$ai_generation', '/i/v1/ai/events'], + ['$ai_embedding', '/i/v1/ai/events/'], + ['$ai_generation', '/i/v1/analytics/events'], +])('accepts compressed %s analytics on %s and retains decode failures', async (eventName, path) => { + const collector = await startCollector() + try { + const legacy = path === '/batch/' + const body = legacy + ? legacyBody([{ event: eventName, distinct_id: 'test', properties: {} }]) + : v1Body([v1Event(eventName)]) + const response = await fetch(`${collector.url}${path}`, { + method: 'POST', + headers: { ...AUTH, 'content-encoding': 'gzip' }, + body: gzipSync(body), + }) + expect(response.status).toBe(200) + expect(collector.events).toEqual([ + legacy + ? { event: eventName, distinct_id: 'test', properties: {} } + : { + event: eventName, + uuid: '01890000-0000-7000-8000-000000000000', + distinct_id: 'test', + timestamp: '2026-01-01T00:00:00.000Z', + properties: { $session_id: 'session-1' }, + }, + ]) + collector.verify() + const invalid = await fetch(`${collector.url}${path}`, { + method: 'POST', + headers: { ...AUTH, 'content-encoding': 'gzip' }, + body: 'not gzip', + }) + expect(invalid.status).toBe(500) + expect(() => collector.verify()).toThrow('Analytics collector failed') + } finally { + await collector.close() } -) +}) -it('rejects non-AI events', async () => { +it('reads capture v1 options as the legacy properties they replace', async () => { const collector = await startCollector() try { - const response = await fetch(`${collector.url}/batch/`, { + const response = await fetch(`${collector.url}/i/v1/analytics/events`, { method: 'POST', - body: JSON.stringify({ - api_key: 'phc_cassette_test', - batch: [{ event: '$pageview', distinct_id: 'test', properties: {} }], - }), + headers: AUTH, + body: v1Body([v1Event('$ai_generation', { process_person_profile: false })]), }) + expect(response.status).toBe(200) + expect(collector.events[0].properties).toEqual({ $session_id: 'session-1', $process_person_profile: false }) + collector.verify() + } finally { + await collector.close() + } +}) + +it.each([ + ['non-AI events', '/batch/', {}, legacyBody([{ event: '$pageview', distinct_id: 'test', properties: {} }])], + ['non-AI v1 events', '/i/v1/ai/events', AUTH, v1Body([v1Event('$pageview')])], + ['a missing bearer token', '/i/v1/ai/events', {}, v1Body([v1Event('$ai_generation')])], + ['a legacy body on a v1 endpoint', '/i/v1/ai/events', AUTH, legacyBody([])], + [ + 'v1 events without v1 fields', + '/i/v1/ai/events', + AUTH, + v1Body([{ event: '$ai_generation', distinct_id: 'test', properties: {} }]), + ], +])('rejects %s', async (_name, path, headers, body) => { + const collector = await startCollector() + try { + const response = await fetch(`${collector.url}${path}`, { method: 'POST', headers, body }) expect(response.status).toBe(500) expect(collector.events).toHaveLength(0) expect(() => collector.verify()).toThrow('Analytics collector failed') diff --git a/packages/ai/harness/collector.ts b/packages/ai/harness/collector.ts index 0d69765c8d..a8e6d17bda 100644 --- a/packages/ai/harness/collector.ts +++ b/packages/ai/harness/collector.ts @@ -1,8 +1,57 @@ -import { createServer } from 'node:http' +import { createServer, type IncomingHttpHeaders } from 'node:http' import { gunzipSync } from 'node:zlib' +const API_KEY = 'phc_cassette_test' +const LEGACY_CAPTURE_PATH = '/batch/' +const V1_CAPTURE_PATHS = new Set(['/i/v1/analytics/events', '/i/v1/ai/events']) + +// Capture v1 per-event `options`, keyed by the legacy property each one replaces. +const V1_OPTION_PROPERTIES: Record = { + cookieless_mode: '$cookieless_mode', + disable_skew_correction: '$ignore_sent_at', + process_person_profile: '$process_person_profile', + product_tour_id: '$product_tour_id', +} + +type CollectedEvent = { event: string; distinct_id: string; properties: Record } + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** One capture v1 event in the legacy `/batch/` shape, so assertions hold for both wire formats. */ +function toLegacyEvent(item: unknown): unknown { + if ( + !isObject(item) || + typeof item.uuid !== 'string' || + typeof item.timestamp !== 'string' || + !isObject(item.options) || + !isObject(item.properties) + ) + throw new Error('Unexpected analytics event') + const { options, session_id, window_id, properties, ...event } = item + const legacyProperties: Record = { ...properties } + for (const [option, value] of Object.entries(options)) + legacyProperties[V1_OPTION_PROPERTIES[option] ?? option] = value + if (session_id !== undefined) legacyProperties.$session_id = session_id + if (window_id !== undefined) legacyProperties.$window_id = window_id + return { ...event, properties: legacyProperties } +} + +/** The events of one capture request, from either the legacy `/batch/` or the capture v1 wire format. */ +function readEvents(isLegacy: boolean, headers: IncomingHttpHeaders, payload: Record): unknown[] { + if (isLegacy) { + if (payload.api_key !== API_KEY || !Array.isArray(payload.batch)) throw new Error('Invalid analytics payload') + return payload.batch + } + if (headers.authorization !== `Bearer ${API_KEY}`) throw new Error('Invalid analytics credentials') + if (typeof payload.created_at !== 'string' || !Array.isArray(payload.batch)) + throw new Error('Invalid analytics payload') + return payload.batch.map(toLegacyEvent) +} + export async function startCollector() { - const events: Array<{ event: string; distinct_id: string; properties: Record }> = [] + const events: CollectedEvent[] = [] const errors: Error[] = [] const server = createServer(async (request, response) => { try { @@ -10,7 +59,9 @@ export async function startCollector() { response.end(JSON.stringify({ count: events.length })) return } - if (request.method !== 'POST' || request.url !== '/batch/') throw new Error('Unexpected collector request') + const isLegacy = request.url === LEGACY_CAPTURE_PATH + const isV1 = V1_CAPTURE_PATHS.has(request.url?.replace(/\/$/, '') ?? '') + if (request.method !== 'POST' || (!isLegacy && !isV1)) throw new Error('Unexpected collector request') const chunks: Buffer[] = [] let size = 0 for await (const chunk of request) { @@ -23,18 +74,19 @@ export async function startCollector() { if (encoding && encoding !== 'gzip') throw new Error('Unsupported collector encoding') const bytes = encoding === 'gzip' ? gunzipSync(compressed, { maxOutputLength: 1024 * 1024 }) : compressed const payload = JSON.parse(bytes.toString('utf8')) - if (payload.api_key !== 'phc_cassette_test' || !Array.isArray(payload.batch)) - throw new Error('Invalid analytics payload') - for (const item of payload.batch) { + if (!isObject(payload)) throw new Error('Invalid analytics payload') + for (const item of readEvents(isLegacy, request.headers, payload)) { if ( - !['$ai_generation', '$ai_embedding'].includes(item.event) || + !isObject(item) || + !['$ai_generation', '$ai_embedding'].includes(item.event as string) || typeof item.distinct_id !== 'string' || - !item.properties + !isObject(item.properties) ) throw new Error('Unexpected analytics event') - events.push(item) + events.push(item as CollectedEvent) } - response.end('{}') + response.setHeader('content-type', 'application/json') + response.end('{"results":{}}') } catch (error) { errors.push(error instanceof Error ? error : new Error(String(error))) response.statusCode = 500 diff --git a/packages/node/src/__tests__/bot-detection.spec.ts b/packages/node/src/__tests__/bot-detection.spec.ts index 5e5997c4b4..2cc354ae86 100644 --- a/packages/node/src/__tests__/bot-detection.spec.ts +++ b/packages/node/src/__tests__/bot-detection.spec.ts @@ -1,5 +1,5 @@ import { PostHog } from '@/entrypoints/index.node' -import { waitForPromises } from './utils' +import { captureRequests, sentEventsOf, v1Response, waitForPromises } from './utils' const mockedFetch = vi.spyOn(globalThis, 'fetch').mockImplementation() @@ -18,11 +18,7 @@ describe('bot detection and pageview collection (Node SDK)', () => { let client: PostHog beforeEach(() => { - mockedFetch.mockResolvedValue({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => Promise.resolve({ status: 1 }), - } as any) + mockedFetch.mockResolvedValue(v1Response()) }) afterEach(async () => { @@ -53,10 +49,10 @@ describe('bot detection and pageview collection (Node SDK)', () => { await waitForFlushTimer() expect(mockedFetch).toHaveBeenCalled() - const call = mockedFetch.mock.calls.find((x) => (x[0] as string).includes('/batch/')) + const [call] = captureRequests(mockedFetch) expect(call).toBeDefined() - const body = JSON.parse((call![1] as any).body) - expect(body.batch[0].event).toBe('$pageview') + const [event] = sentEventsOf(call) + expect(event.event).toBe('$pageview') }) it('should allow events without user agent', async () => { @@ -77,10 +73,10 @@ describe('bot detection and pageview collection (Node SDK)', () => { await waitForFlushTimer() expect(mockedFetch).toHaveBeenCalled() - const call = mockedFetch.mock.calls.find((x) => (x[0] as string).includes('/batch/')) + const [call] = captureRequests(mockedFetch) expect(call).toBeDefined() - const body = JSON.parse((call![1] as any).body) - expect(body.batch[0].event).toBe('$pageview') + const [event] = sentEventsOf(call) + expect(event.event).toBe('$pageview') }) }) @@ -106,12 +102,12 @@ describe('bot detection and pageview collection (Node SDK)', () => { await waitForFlushTimer() expect(mockedFetch).toHaveBeenCalled() - const call = mockedFetch.mock.calls.find((x) => (x[0] as string).includes('/batch/')) + const [call] = captureRequests(mockedFetch) expect(call).toBeDefined() - const body = JSON.parse((call![1] as any).body) - expect(body.batch[0].event).toBe('$bot_pageview') - expect(body.batch[0].properties.$browser_type).toBe('bot') - expect(body.batch[0].properties.$raw_user_agent).toBe( + const [event] = sentEventsOf(call) + expect(event.event).toBe('$bot_pageview') + expect(event.properties.$browser_type).toBe('bot') + expect(event.properties.$raw_user_agent).toBe( 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' ) }) @@ -137,11 +133,11 @@ describe('bot detection and pageview collection (Node SDK)', () => { await waitForFlushTimer() expect(mockedFetch).toHaveBeenCalled() - const call = mockedFetch.mock.calls.find((x) => (x[0] as string).includes('/batch/')) + const [call] = captureRequests(mockedFetch) expect(call).toBeDefined() - const body = JSON.parse((call![1] as any).body) - expect(body.batch[0].event).toBe('$pageview') - expect(body.batch[0].properties.$raw_user_agent).toBe( + const [event] = sentEventsOf(call) + expect(event.event).toBe('$pageview') + expect(event.properties.$raw_user_agent).toBe( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' ) }) @@ -167,10 +163,10 @@ describe('bot detection and pageview collection (Node SDK)', () => { await waitForFlushTimer() expect(mockedFetch).toHaveBeenCalled() - const call = mockedFetch.mock.calls.find((x) => (x[0] as string).includes('/batch/')) + const [call] = captureRequests(mockedFetch) expect(call).toBeDefined() - const body = JSON.parse((call![1] as any).body) - expect(body.batch[0].event).toBe('custom_event') + const [event] = sentEventsOf(call) + expect(event.event).toBe('custom_event') }) it('should work with various bot user agents', async () => { @@ -205,11 +201,11 @@ describe('bot detection and pageview collection (Node SDK)', () => { await waitForFlushTimer() expect(mockedFetch).toHaveBeenCalled() - const call = mockedFetch.mock.calls.find((x) => (x[0] as string).includes('/batch/')) + const [call] = captureRequests(mockedFetch) expect(call).toBeDefined() - const body = JSON.parse((call![1] as any).body) - expect(body.batch[0].event).toBe('$bot_pageview') - expect(body.batch[0].properties.$raw_user_agent).toBe(ua) + const [event] = sentEventsOf(call) + expect(event.event).toBe('$bot_pageview') + expect(event.properties.$raw_user_agent).toBe(ua) await client.shutdown() } @@ -234,10 +230,10 @@ describe('bot detection and pageview collection (Node SDK)', () => { await waitForFlushTimer() expect(mockedFetch).toHaveBeenCalled() - const call = mockedFetch.mock.calls.find((x) => (x[0] as string).includes('/batch/')) + const [call] = captureRequests(mockedFetch) expect(call).toBeDefined() - const body = JSON.parse((call![1] as any).body) - expect(body.batch[0].event).toBe('$pageview') + const [event] = sentEventsOf(call) + expect(event.event).toBe('$pageview') }) it('should support custom_blocked_useragents', async () => { @@ -262,11 +258,11 @@ describe('bot detection and pageview collection (Node SDK)', () => { await waitForFlushTimer() expect(mockedFetch).toHaveBeenCalled() - const call = mockedFetch.mock.calls.find((x) => (x[0] as string).includes('/batch/')) + const [call] = captureRequests(mockedFetch) expect(call).toBeDefined() - const body = JSON.parse((call![1] as any).body) - expect(body.batch[0].event).toBe('$bot_pageview') - expect(body.batch[0].properties.$raw_user_agent).toBe('MyCustomBot/1.0') + const [event] = sentEventsOf(call) + expect(event.event).toBe('$bot_pageview') + expect(event.properties.$raw_user_agent).toBe('MyCustomBot/1.0') }) it('should preserve other event properties when renaming', async () => { @@ -292,12 +288,12 @@ describe('bot detection and pageview collection (Node SDK)', () => { await waitForFlushTimer() expect(mockedFetch).toHaveBeenCalled() - const call = mockedFetch.mock.calls.find((x) => (x[0] as string).includes('/batch/')) + const [call] = captureRequests(mockedFetch) expect(call).toBeDefined() - const body = JSON.parse((call![1] as any).body) - expect(body.batch[0].event).toBe('$bot_pageview') - expect(body.batch[0].properties.custom_prop).toBe('test_value') - expect(body.batch[0].properties.$current_url).toBe('https://example.com') + const [event] = sentEventsOf(call) + expect(event.event).toBe('$bot_pageview') + expect(event.properties.custom_prop).toBe('test_value') + expect(event.properties.$current_url).toBe('https://example.com') }) }) }) diff --git a/packages/node/src/__tests__/capture-v1/route-isolation.spec.ts b/packages/node/src/__tests__/capture-v1/route-isolation.spec.ts index 6da40f5e16..fbeed6e55c 100644 --- a/packages/node/src/__tests__/capture-v1/route-isolation.spec.ts +++ b/packages/node/src/__tests__/capture-v1/route-isolation.spec.ts @@ -2,7 +2,8 @@ import { PostHogPersistedProperty } from '@posthog/core' import { PostHog } from '@/entrypoints/index.node' -import { V1WiringHarness, v1Response, waitForFlushTimer } from '../utils/v1-wiring' +import { v1Response } from '../utils' +import { V1WiringHarness, waitForFlushTimer } from '../utils/v1-wiring' vi.mock('../../version', () => ({ version: '1.2.3' })) diff --git a/packages/node/src/__tests__/capture-v1/wiring.spec.ts b/packages/node/src/__tests__/capture-v1/wiring.spec.ts index 25eafd47a1..ad2f35cac4 100644 --- a/packages/node/src/__tests__/capture-v1/wiring.spec.ts +++ b/packages/node/src/__tests__/capture-v1/wiring.spec.ts @@ -2,7 +2,8 @@ import { gunzipSync } from 'node:zlib' import { PostHog, PostHogOptions } from '@/entrypoints/index.node' import { CaptureV1Error } from '@/capture-v1/errors' -import { V1_URL, V1WiringHarness, v0Response, v1Response, waitForFlushTimer } from '../utils/v1-wiring' +import { v1Response } from '../utils' +import { V1_URL, V1WiringHarness, v0Response, waitForFlushTimer } from '../utils/v1-wiring' vi.mock('../../version', () => ({ version: '1.2.3' })) diff --git a/packages/node/src/__tests__/context.spec.ts b/packages/node/src/__tests__/context.spec.ts index 015d1bb874..909d3e2581 100644 --- a/packages/node/src/__tests__/context.spec.ts +++ b/packages/node/src/__tests__/context.spec.ts @@ -1,6 +1,5 @@ import { PostHog } from '@/entrypoints/index.node' -import { apiImplementation } from './utils' -import { waitForPromises } from './utils' +import { apiImplementation, lastSentBatch, sentEvents, v1Response, waitForPromises } from './utils' vi.mock('../version', () => ({ version: '1.2.3' })) @@ -12,14 +11,7 @@ const waitForFlush = async (): Promise => { await waitForPromises() } -const getLastBatchEvents = (): any[] | undefined => { - const call = mockedFetch.mock.calls - .slice() - .reverse() - .find((x) => (x[0] as string).includes('/batch/')) - if (!call) return undefined - return JSON.parse((call[1] as any).body as any).batch -} +const getLastBatchEvents = (): any[] | undefined => lastSentBatch(mockedFetch) describe('PostHog Context', () => { let posthog: PostHog @@ -35,11 +27,7 @@ describe('PostHog Context', () => { disableCompression: true, }) - mockedFetch.mockResolvedValue({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => Promise.resolve({ status: 'ok' }), - } as any) + mockedFetch.mockResolvedValue(v1Response()) }) afterEach(async () => { @@ -200,13 +188,7 @@ describe('PostHog Context', () => { await waitForFlush() - const allEvents: any[] = [] - mockedFetch.mock.calls.forEach((call) => { - if ((call[0] as string).includes('/batch/')) { - const batch = JSON.parse((call[1] as any).body as any).batch - allEvents.push(...batch) - } - }) + const allEvents = sentEvents(mockedFetch) expect(allEvents).toHaveLength(50) @@ -254,13 +236,7 @@ describe('PostHog Context', () => { await waitForFlush() - const allEvents: any[] = [] - mockedFetch.mock.calls.forEach((call) => { - if ((call[0] as string).includes('/batch/')) { - const batch = JSON.parse((call[1] as any).body as any).batch - allEvents.push(...batch) - } - }) + const allEvents = sentEvents(mockedFetch) expect(allEvents).toHaveLength(4) diff --git a/packages/node/src/__tests__/edge-exports.spec.ts b/packages/node/src/__tests__/edge-exports.spec.ts index dd04c2d556..8db27fd6d6 100644 --- a/packages/node/src/__tests__/edge-exports.spec.ts +++ b/packages/node/src/__tests__/edge-exports.spec.ts @@ -3,6 +3,8 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' +import { sentEventsOf } from './utils' + const packageRoot = fileURLToPath(new URL('../../', import.meta.url)) describe('built edge entrypoints', () => { @@ -21,30 +23,29 @@ describe('built edge entrypoints', () => { ` import { createRequire } from 'node:module' const { PostHog } = ${loader === 'import' ? `await import('${specifier}')` : `createRequire(import.meta.url)('${specifier}')`} - const batches = [] + const requests = [] const client = new PostHog('test-key', { host: 'https://example.test', disableCompression: true, fetchRetryCount: 0, - fetch: async (_url, options) => { - batches.push(...JSON.parse(options.body).batch) + fetch: async (url, options) => { + requests.push([url, { headers: options.headers, body: options.body }]) return { status: 200, text: async () => '{}', json: async () => ({}) } }, }) client.capture({ distinctId: 'edge-user', event: 'edge-event' }) await client.shutdown() - console.log(JSON.stringify(batches)) + console.log(JSON.stringify(requests)) `, ], { cwd: packageRoot, encoding: 'utf8', timeout: 10_000, - env: { ...process.env, POSTHOG_CAPTURE_MODE: 'v0' }, } ) - expect(JSON.parse(output)).toEqual([ + expect(JSON.parse(output).flatMap(sentEventsOf)).toEqual([ expect.objectContaining({ distinct_id: 'edge-user', event: 'edge-event', diff --git a/packages/node/src/__tests__/extensions/express.spec.ts b/packages/node/src/__tests__/extensions/express.spec.ts index e0eed1b1ca..83e78e40cd 100644 --- a/packages/node/src/__tests__/extensions/express.spec.ts +++ b/packages/node/src/__tests__/extensions/express.spec.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'node:events' import type { IncomingHttpHeaders } from 'node:http' import { PostHog } from '@/entrypoints/index.node' +import { anyCaptureUrl, lastSentBatch, v1Response } from '../utils' import { setupExpressErrorHandler, setupExpressRequestContext } from '@/extensions/express' vi.mock('../../version', () => ({ version: '1.2.3' })) @@ -12,13 +13,8 @@ const waitForFlushTimer = async (posthog: PostHog): Promise => { } const getLastBatchEvents = (): any[] | undefined => { - expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.objectContaining({ method: 'POST' })) - - const call = [...mockedFetch.mock.calls].reverse().find((x) => (x[0] as string).includes('/batch/')) - if (!call) { - return undefined - } - return JSON.parse((call[1] as any).body as any).batch + expect(mockedFetch).toHaveBeenCalledWith(anyCaptureUrl, expect.objectContaining({ method: 'POST' })) + return lastSentBatch(mockedFetch) } const createMockRequest = (overrides?: { @@ -80,11 +76,7 @@ describe('Express extension', () => { beforeEach(() => { posthog = createPostHog() - mockedFetch.mockResolvedValue({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => Promise.resolve({ status: 'ok' }), - } as any) + mockedFetch.mockResolvedValue(v1Response()) }) afterEach(async () => { diff --git a/packages/node/src/__tests__/extensions/nestjs.spec.ts b/packages/node/src/__tests__/extensions/nestjs.spec.ts index ae836a6dfe..472d330a00 100644 --- a/packages/node/src/__tests__/extensions/nestjs.spec.ts +++ b/packages/node/src/__tests__/extensions/nestjs.spec.ts @@ -2,6 +2,7 @@ import type { IncomingHttpHeaders } from 'node:http' import { defer, of, throwError, lastValueFrom } from 'rxjs' import { PostHog } from '@/entrypoints/index.node' +import { anyCaptureUrl, captureRequests, lastSentBatch, v1Response } from '../utils' import { PostHogInterceptor } from '@/extensions/nestjs' vi.mock('../../version', () => ({ version: '1.2.3' })) @@ -17,16 +18,8 @@ const waitForFlushTimer = async (posthog: PostHog): Promise => { } const getLastBatchEvents = (): any[] | undefined => { - expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.objectContaining({ method: 'POST' })) - - const call = mockedFetch.mock.calls - .slice() - .reverse() - .find((x) => (x[0] as string).includes('/batch/')) - if (!call) { - return undefined - } - return JSON.parse((call[1] as any).body as any).batch + expect(mockedFetch).toHaveBeenCalledWith(anyCaptureUrl, expect.objectContaining({ method: 'POST' })) + return lastSentBatch(mockedFetch) } const createMockContext = (overrides?: { @@ -75,11 +68,7 @@ describe('PostHogInterceptor', () => { beforeEach(() => { posthog = createPostHog() - mockedFetch.mockResolvedValue({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => Promise.resolve({ status: 'ok' }), - } as any) + mockedFetch.mockResolvedValue(v1Response()) }) afterEach(async () => { @@ -345,7 +334,7 @@ describe('PostHogInterceptor', () => { await expect(lastValueFrom(interceptor.intercept(context, createMockCallHandler(error)))).rejects.toThrow(error) await waitForFlushTimer(posthog) - const batchCalls = mockedFetch.mock.calls.filter((c) => (c[0] as string).includes('/batch/')) + const batchCalls = captureRequests(mockedFetch) expect(batchCalls.length).toBe(0) }) @@ -418,7 +407,7 @@ describe('PostHogInterceptor', () => { await expect(lastValueFrom(interceptor.intercept(context, createMockCallHandler(error)))).rejects.toThrow(error) await waitForFlushTimer(posthog) - const batchCalls = mockedFetch.mock.calls.filter((c) => (c[0] as string).includes('/batch/')) + const batchCalls = captureRequests(mockedFetch) expect(batchCalls.length).toBe(0) }) @@ -482,7 +471,7 @@ describe('PostHogInterceptor', () => { await expect(lastValueFrom(interceptor.intercept(context, createMockCallHandler(error)))).rejects.toThrow(error) await waitForFlushTimer(posthog) - const batchCalls = mockedFetch.mock.calls.filter((c) => (c[0] as string).includes('/batch/')) + const batchCalls = captureRequests(mockedFetch) expect(batchCalls.length).toBe(0) }) diff --git a/packages/node/src/__tests__/extensions/sentry-integration.spec.ts b/packages/node/src/__tests__/extensions/sentry-integration.spec.ts index 76de5da82a..0ca79aba77 100644 --- a/packages/node/src/__tests__/extensions/sentry-integration.spec.ts +++ b/packages/node/src/__tests__/extensions/sentry-integration.spec.ts @@ -1,4 +1,5 @@ import { PostHog } from '@/entrypoints/index.node' +import { anyCaptureUrl, lastSentBatch, v1Response } from '../utils' import { PostHogSentryIntegration, sentryIntegration, @@ -10,17 +11,8 @@ vi.mock('../../version', () => ({ version: '1.2.3' })) const mockedFetch = vi.spyOn(globalThis, 'fetch').mockImplementation() const getLastBatchEvents = (): any[] | undefined => { - expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.objectContaining({ method: 'POST' })) - - // reverse mock calls array to get the last call - const call = mockedFetch.mock.calls - .slice() - .reverse() - .find((x) => (x[0] as string).includes('/batch/')) - if (!call) { - return undefined - } - return JSON.parse((call[1] as any).body as any).batch + expect(mockedFetch).toHaveBeenCalledWith(anyCaptureUrl, expect.objectContaining({ method: 'POST' })) + return lastSentBatch(mockedFetch) } const createMockSentryException = (): any => ({ @@ -79,14 +71,7 @@ describe('PostHogSentryIntegration', () => { posthogSentry = new PostHogSentryIntegration(posthog) - mockedFetch.mockResolvedValue({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => - Promise.resolve({ - status: 'ok', - }), - } as any) + mockedFetch.mockResolvedValue(v1Response()) }) afterEach(async () => { diff --git a/packages/node/src/__tests__/posthog-node.spec.ts b/packages/node/src/__tests__/posthog-node.spec.ts index bdbb9c03c9..40b6263942 100644 --- a/packages/node/src/__tests__/posthog-node.spec.ts +++ b/packages/node/src/__tests__/posthog-node.spec.ts @@ -1,7 +1,17 @@ import { PostHog, PostHogOptions } from '@/entrypoints/index.node' import ErrorTracking from '@/extensions/error-tracking' import type { IPostHog } from '@/types' -import { anyFlagsCall, anyLocalEvalCall, apiImplementation, wait, waitForPromises } from './utils' +import { + anyCaptureUrl, + anyFlagsCall, + anyLocalEvalCall, + apiImplementation, + lastSentBatch, + sentEvents, + v1Response, + wait, + waitForPromises, +} from './utils' import { randomUUID } from 'crypto' import { setTimeout as waitWithRealTimers } from 'node:timers/promises' import { UUID_REGEX } from '@posthog/core' @@ -31,17 +41,8 @@ const waitForFlushTimer = async (): Promise => { } const getLastBatchEvents = (): any[] | undefined => { - expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.objectContaining({ method: 'POST' })) - - // reverse mock calls array to get the last call - const call = mockedFetch.mock.calls - .slice() - .reverse() - .find((x) => (x[0] as string).includes('/batch/')) - if (!call) { - return undefined - } - return JSON.parse((call[1] as any).body as any).batch + expect(mockedFetch).toHaveBeenCalledWith(anyCaptureUrl, expect.objectContaining({ method: 'POST' })) + return lastSentBatch(mockedFetch) } describe('PostHog Node.js', () => { @@ -66,25 +67,11 @@ describe('PostHog Node.js', () => { disableCompression: true, }) - mockedFetch.mockResolvedValue({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => - Promise.resolve({ - status: 'ok', - }), - } as any) + mockedFetch.mockResolvedValue(v1Response()) }) afterEach(async () => { - mockedFetch.mockResolvedValue({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => - Promise.resolve({ - status: 'ok', - }), - } as any) + mockedFetch.mockResolvedValue(v1Response()) // ensure clean shutdown & no test interdependencies await posthog.shutdown() @@ -728,7 +715,7 @@ describe('PostHog Node.js', () => { await waitForFlushTimer() expect(beforeSendFn).toHaveBeenCalledTimes(1) - expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.anything()) + expect(mockedFetch).not.toHaveBeenCalledWith(anyCaptureUrl, expect.anything()) }) it('should support array of before_send functions', async () => { @@ -775,7 +762,7 @@ describe('PostHog Node.js', () => { expect(beforeSend1).toHaveBeenCalledTimes(1) expect(beforeSend2).toHaveBeenCalledTimes(1) expect(beforeSend3).not.toHaveBeenCalled() - expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.anything()) + expect(mockedFetch).not.toHaveBeenCalledWith(anyCaptureUrl, expect.anything()) }) it('should fail closed when a before_send function throws', async () => { @@ -800,7 +787,7 @@ describe('PostHog Node.js', () => { expect(sentinel).not.toHaveBeenCalled() expect(loggerSpy).toHaveBeenCalledWith("Error in before_send function for event 'test-event':", error) - expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.anything()) + expect(mockedFetch).not.toHaveBeenCalledWith(anyCaptureUrl, expect.anything()) loggerSpy.mockRestore() }) @@ -964,7 +951,7 @@ describe('PostHog Node.js', () => { await send(ph) expect(beforeSendFn).toHaveBeenCalledTimes(1) - expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.anything()) + expect(mockedFetch).not.toHaveBeenCalledWith(anyCaptureUrl, expect.anything()) } ) @@ -1078,14 +1065,7 @@ describe('PostHog Node.js', () => { // simulate network delay await wait(500) - return Promise.resolve({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => - Promise.resolve({ - status: 'ok', - }), - } as any) + return Promise.resolve(v1Response()) }) vi.useRealTimers() @@ -1144,10 +1124,7 @@ describe('PostHog Node.js', () => { } expect(flushSettled).toBe(true) expect(shutdownSettled).toBe(true) - const flushedEvents = mockedFetch.mock.calls - .filter(([url]) => String(url).endsWith('/batch/')) - .flatMap(([, options]) => JSON.parse(options!.body as string).batch) - expect(flushedEvents).toMatchObject([ + expect(sentEvents(mockedFetch)).toMatchObject([ { event: 'test-event-1' }, { event: 'test-event-2' }, { event: 'test-event-3' }, @@ -2192,15 +2169,9 @@ describe('PostHog Node.js', () => { expect(mockedFetch).toHaveBeenCalledWith(...anyLocalEvalCall) // Should make immediate batch call - expect(mockedFetch).toHaveBeenCalledWith( - 'http://example.com/batch/', - expect.objectContaining({ method: 'POST' }) - ) + expect(mockedFetch).toHaveBeenCalledWith(anyCaptureUrl, expect.objectContaining({ method: 'POST' })) - const lastCall = mockedFetch.mock.calls.find((call) => (call[0] as string).includes('/batch/')) - const body = JSON.parse(lastCall?.[1]?.body as string) - - expect(body.batch[0]).toEqual( + expect(sentEvents(mockedFetch)[0]).toEqual( expect.objectContaining({ distinct_id: 'user123', event: 'test event', @@ -2397,10 +2368,7 @@ describe('PostHog Node.js', () => { await hashStarted await vi.advanceTimersByTimeAsync(0) expect(flushFinished).toBe(false) - expect(mockedFetch).not.toHaveBeenCalledWith( - 'http://example.com/batch/', - expect.objectContaining({ method: 'POST' }) - ) + expect(mockedFetch).not.toHaveBeenCalledWith(anyCaptureUrl, expect.objectContaining({ method: 'POST' })) releaseHash() await flushing @@ -2887,7 +2855,7 @@ describe('PostHog Node.js', () => { await waitForPromises() await posthog.flush() - expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object)) + expect(mockedFetch).toHaveBeenCalledWith(anyCaptureUrl, expect.any(Object)) expect(getLastBatchEvents()?.[0]).toEqual( expect.objectContaining({ @@ -2916,7 +2884,7 @@ describe('PostHog Node.js', () => { await waitForPromises() await posthog.flush() - expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object)) + expect(mockedFetch).not.toHaveBeenCalledWith(anyCaptureUrl, expect.any(Object)) // # called for different user, should call capture again expect( @@ -2929,7 +2897,7 @@ describe('PostHog Node.js', () => { vi.runOnlyPendingTimers() await waitForPromises() await posthog.flush() - expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object)) + expect(mockedFetch).toHaveBeenCalledWith(anyCaptureUrl, expect.any(Object)) expect(getLastBatchEvents()?.[0]).toEqual( expect.objectContaining({ @@ -2958,7 +2926,7 @@ describe('PostHog Node.js', () => { vi.runOnlyPendingTimers() await waitForPromises() await posthog.flush() - expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object)) + expect(mockedFetch).not.toHaveBeenCalledWith(anyCaptureUrl, expect.any(Object)) // # called for different flag, falls back to flags, should call capture again expect( @@ -2972,7 +2940,7 @@ describe('PostHog Node.js', () => { await posthog.flush() // one to flags, one to batch expect(mockedFetch).toHaveBeenCalledWith(...anyFlagsCall) - expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object)) + expect(mockedFetch).toHaveBeenCalledWith(anyCaptureUrl, expect.any(Object)) expect(getLastBatchEvents()?.[0]).toEqual( expect.objectContaining({ @@ -3002,7 +2970,7 @@ describe('PostHog Node.js', () => { await posthog.flush() // call flags, but not batch expect(mockedFetch).toHaveBeenCalledWith(...anyFlagsCall) - expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.any(Object)) + expect(mockedFetch).not.toHaveBeenCalledWith(anyCaptureUrl, expect.any(Object)) }) describe('`sendFeatureFlagEvent` client option', () => { @@ -3057,7 +3025,7 @@ describe('PostHog Node.js', () => { await posthog.flush() // Should NOT send $feature_flag_called event because global setting is false - expect(mockedFetch).not.toHaveBeenCalledWith('http://example.com/batch/', expect.anything()) + expect(mockedFetch).not.toHaveBeenCalledWith(anyCaptureUrl, expect.anything()) }) it('overrides client sendFeatureFlagEvent option when method-level option is provided', async () => { @@ -3078,7 +3046,7 @@ describe('PostHog Node.js', () => { await posthog.flush() // The client option should have been overridden, allowing the event to be sent - expect(mockedFetch).toHaveBeenCalledWith('http://example.com/batch/', expect.anything()) + expect(mockedFetch).toHaveBeenCalledWith(anyCaptureUrl, expect.anything()) }) }) }) diff --git a/packages/node/src/__tests__/server-wire-snapshots.spec.ts b/packages/node/src/__tests__/server-wire-snapshots.spec.ts index 4886826498..9a14e30fdc 100644 --- a/packages/node/src/__tests__/server-wire-snapshots.spec.ts +++ b/packages/node/src/__tests__/server-wire-snapshots.spec.ts @@ -4,7 +4,8 @@ import type { PostHogFetchOptions } from '@posthog/core' import { V1CaptureSender } from '@/capture-v1/sender' import { PostHog } from '@/entrypoints/index.node' -import { v0Response, v1Response } from './utils/v1-wiring' +import { v1Response } from './utils' +import { v0Response } from './utils/v1-wiring' type FetchCall = [string, PostHogFetchOptions] diff --git a/packages/node/src/__tests__/utils/index.ts b/packages/node/src/__tests__/utils/index.ts index 779801d124..225d2cee3d 100644 --- a/packages/node/src/__tests__/utils/index.ts +++ b/packages/node/src/__tests__/utils/index.ts @@ -1,6 +1,92 @@ import { PostHogV2FlagsResponse } from '@posthog/core' import util from 'util' +const LEGACY_CAPTURE_PATH = '/batch/' +const V1_ANALYTICS_CAPTURE_PATH = '/i/v1/analytics/events' +const ANALYTICS_CAPTURE_PATHS = [LEGACY_CAPTURE_PATH, V1_ANALYTICS_CAPTURE_PATH] + +const isAnalyticsCaptureUrl = (url: unknown): boolean => { + const { pathname } = new URL(String(url)) + return ANALYTICS_CAPTURE_PATHS.includes(pathname) +} + +/** Matches the analytics capture URL of either wire format: legacy `/batch/` or Capture V1. */ +export const anyCaptureUrl = expect.stringMatching(/^https?:\/\/[^/]+(\/batch\/|\/i\/v1\/analytics\/events)$/) + +/** A capture 2xx response. The legacy sender ignores the body; the Capture V1 sender reads `results`. */ +export function v1Response(results: Record = {}): any { + const body = JSON.stringify({ results }) + return { + status: 200, + text: () => Promise.resolve(body), + json: () => Promise.resolve({ results }), + headers: { get: () => null }, + body: null, + } +} + +type FetchMock = { mock: { calls: any[][] } } +type FetchCall = [string, any] + +/** Capture V1 per-event `options`, keyed by the legacy property each one replaces. */ +const V1_OPTION_PROPERTIES: Record = { + cookieless_mode: '$cookieless_mode', + disable_skew_correction: '$ignore_sent_at', + process_person_profile: '$process_person_profile', + product_tour_id: '$product_tour_id', +} + +function legacyPropertiesOf(v1Event: any, sdkInfo: string): Record { + const versionAt = sdkInfo.lastIndexOf('/') + const properties: Record = { + ...v1Event.properties, + $lib: sdkInfo.slice(0, versionAt), + $lib_version: sdkInfo.slice(versionAt + 1), + } + for (const [option, value] of Object.entries(v1Event.options ?? {})) { + properties[V1_OPTION_PROPERTIES[option] ?? option] = value + } + if (v1Event.session_id !== undefined) { + properties.$session_id = v1Event.session_id + } + if (v1Event.window_id !== undefined) { + properties.$window_id = v1Event.window_id + } + return properties +} + +/** + * The events of one analytics capture request, in the legacy `/batch/` event shape whichever wire + * format carried them. A Capture V1 event's `options`, `session_id`, `window_id` and SDK info + * header go back into the properties they replace, so one assertion holds for both formats. + */ +export function sentEventsOf([url, options]: FetchCall): any[] { + const { batch } = JSON.parse(options.body) + if (new URL(url).pathname !== V1_ANALYTICS_CAPTURE_PATH) { + return batch + } + return batch.map((v1Event: any) => { + const event = { ...v1Event, properties: legacyPropertiesOf(v1Event, options.headers['PostHog-Sdk-Info']) } + delete event.options + delete event.session_id + delete event.window_id + return event + }) +} + +/** Analytics capture requests of either wire format, as `[url, options]` tuples in call order. */ +export const captureRequests = (fetchMock: FetchMock): FetchCall[] => + fetchMock.mock.calls.filter((call) => isAnalyticsCaptureUrl(call[0])) as FetchCall[] + +/** Every event sent to the analytics capture endpoint, in the shape {@link sentEventsOf} returns. */ +export const sentEvents = (fetchMock: FetchMock): any[] => captureRequests(fetchMock).flatMap(sentEventsOf) + +/** The events of the most recent analytics capture request, or `undefined` if none was sent. */ +export const lastSentBatch = (fetchMock: FetchMock): any[] | undefined => { + const requests = captureRequests(fetchMock) + return requests.length ? sentEventsOf(requests[requests.length - 1]) : undefined +} + type ErrorResponse = { status: number json: () => Promise @@ -23,15 +109,8 @@ export const apiImplementationV4 = (flagsResponse: PostHogV2FlagsResponse | Erro }) } - if ((url as any).includes('batch/')) { - return Promise.resolve({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => - Promise.resolve({ - status: 'ok', - }), - }) as any + if (isAnalyticsCaptureUrl(url)) { + return Promise.resolve(v1Response()) } return Promise.resolve({ @@ -100,15 +179,8 @@ export const apiImplementation = ({ }) as any } - if ((url as any).includes('batch/')) { - return Promise.resolve({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => - Promise.resolve({ - status: 'ok', - }), - }) as any + if (isAnalyticsCaptureUrl(url)) { + return Promise.resolve(v1Response()) } return Promise.resolve({ diff --git a/packages/node/src/__tests__/utils/v1-wiring.ts b/packages/node/src/__tests__/utils/v1-wiring.ts index 37b8251113..c440f4913b 100644 --- a/packages/node/src/__tests__/utils/v1-wiring.ts +++ b/packages/node/src/__tests__/utils/v1-wiring.ts @@ -1,21 +1,9 @@ import { PostHog, PostHogOptions } from '@/entrypoints/index.node' -import { waitForPromises } from '.' +import { v1Response, waitForPromises } from '.' export const V1_URL = 'http://example.com/i/v1/analytics/events' -/** A Capture V1 2xx response whose per-event `results` map defaults to all-accepted. */ -export function v1Response(results: Record = {}): any { - const body = JSON.stringify({ results }) - return { - status: 200, - text: () => Promise.resolve(body), - json: () => Promise.resolve({ results }), - headers: { get: () => null }, - body: null, - } -} - /** A legacy `/batch/` 2xx response. */ export function v0Response(): any { return { diff --git a/packages/node/src/__tests__/waituntil-flush.spec.ts b/packages/node/src/__tests__/waituntil-flush.spec.ts index c147d9bdb3..e41c33f50d 100644 --- a/packages/node/src/__tests__/waituntil-flush.spec.ts +++ b/packages/node/src/__tests__/waituntil-flush.spec.ts @@ -1,5 +1,7 @@ import { PostHog } from '@/entrypoints/index.node' +import { captureRequests, sentEventsOf, v1Response } from './utils' + vi.mock('../version', () => ({ version: '1.2.3' })) const mockedFetch = vi.spyOn(globalThis, 'fetch').mockImplementation() @@ -7,9 +9,7 @@ const DEBOUNCE_MS = 50 const HALF_DEBOUNCE_MS = DEBOUNCE_MS / 2 function getFlushedBatches(): any[][] { - return mockedFetch.mock.calls - .filter((c) => (c[0] as string).includes('/batch/')) - .map((c) => JSON.parse((c[1] as any).body).batch) + return captureRequests(mockedFetch).map(sentEventsOf) } describe('flush combines events and spans', () => { @@ -25,7 +25,7 @@ describe('flush combines events and spans', () => { order.push('traces-done') return { status: 200, text: () => Promise.resolve('{}'), json: () => Promise.resolve({}) } as any } - throw new Error('events endpoint down') + return v1Response() }) const posthog = new PostHog('key', { host: 'http://example.com', @@ -34,6 +34,8 @@ describe('flush combines events and spans', () => { traces: { serviceName: 'svc' }, disableCompression: true, }) + // Fail the send seam itself: only the legacy sender throws when the events request fails. + vi.spyOn(posthog as any, 'sendBatch').mockRejectedValue(new Error('events endpoint down')) posthog.capture({ distinctId: 'u', event: 'e' }) posthog.startSpan('s').end() @@ -72,11 +74,7 @@ describe('waitUntil debounced flush', () => { waitUntilDebounceMs: DEBOUNCE_MS, }) - mockedFetch.mockResolvedValue({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => Promise.resolve({ status: 'ok' }), - } as any) + mockedFetch.mockResolvedValue(v1Response()) }) afterEach(async () => { @@ -142,7 +140,7 @@ describe('waitUntil debounced flush', () => { }) mockedFetch.mockImplementationOnce(async () => { await delivery - return { status: 200, text: async () => 'ok', json: async () => ({ status: 'ok' }) } as any + return v1Response() }) try { @@ -248,11 +246,7 @@ describe('waitUntil debounced flush', () => { waitUntilMaxWaitMs: MAX_DEBOUNCE_MS, }) - mockedFetch.mockResolvedValue({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => Promise.resolve({ status: 'ok' }), - } as any) + mockedFetch.mockResolvedValue(v1Response()) }) afterEach(async () => { @@ -332,11 +326,7 @@ describe('waitUntil debounced flush', () => { waitUntil: mockWaitUntil, }) - mockedFetch.mockResolvedValue({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => Promise.resolve({ status: 'ok' }), - } as any) + mockedFetch.mockResolvedValue(v1Response()) }) afterEach(async () => { @@ -570,11 +560,7 @@ describe('waitUntil debounced flush', () => { waitUntilDebounceMs: DEBOUNCE_MS, }) - mockedFetch.mockResolvedValue({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => Promise.resolve({ status: 'ok' }), - } as any) + mockedFetch.mockResolvedValue(v1Response()) posthog.capture({ distinctId: 'user-1', event: 'event_1' }) posthog.capture({ distinctId: 'user-1', event: 'event_2' }) @@ -606,11 +592,7 @@ describe('waitUntil debounced flush', () => { waitUntilDebounceMs: DEBOUNCE_MS, }) - mockedFetch.mockResolvedValue({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => Promise.resolve({ status: 'ok' }), - } as any) + mockedFetch.mockResolvedValue(v1Response()) posthog.capture({ distinctId: 'user-1', event: 'event_1' }) posthog.capture({ distinctId: 'user-1', event: 'event_2' }) @@ -642,11 +624,7 @@ describe('waitUntil debounced flush', () => { waitUntilDebounceMs: 200, }) - mockedFetch.mockResolvedValue({ - status: 200, - text: () => Promise.resolve('ok'), - json: () => Promise.resolve({ status: 'ok' }), - } as any) + mockedFetch.mockResolvedValue(v1Response()) }) afterEach(async () => { From 5db747f0f254d5f17a829fa9b39788f2fffad265 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Mon, 26 Oct 2026 21:53:18 -0700 Subject: [PATCH 03/20] feat(node)!: send every event through capture v1 (#5409) * refactor(node): name the capture lane on CaptureError and export it Rename CaptureV1Error to CaptureError (and its params and drop types), add the endpoint the batch was sent to, and export the class so callers can instanceof it. The sender now takes its lane path from config so the analytics and AI lanes can share it. Co-Authored-By: Claude Opus 5.5 * feat(node)!: send every event through capture v1 Remove the POSTHOG_CAPTURE_MODE switch and the legacy /batch/ path. Every capture() event, including $ai_* events, goes to /i/v1/analytics/events; the SDK no longer reroutes by event name. captureAi and captureAiImmediate send to /i/v1/ai/events through the same sender, with one lazily created sender per lane. A 413 on the AI lane is now a terminal CaptureError instead of a split-and-retry. A client built while POSTHOG_CAPTURE_MODE is set logs one warning that the variable is no longer read. Tests that read events through the shared helpers pass unchanged; the wire snapshot, the capture lane wiring tests and the delivery error tests now assert the v1 wire. Co-Authored-By: Claude Opus 5.5 * ci(compliance): run the node v2 harness on the capture v1 profile only posthog-node no longer has a v0 capture mode, so drop the v0 matrix leg and the node-legacy profile. The adapter stops setting POSTHOG_CAPTURE_MODE and stops advertising capture_v0, capture_v0_batch and capture_ai_v0. run-compliance.sh no longer takes a mode argument, and the PR comment reports both suites for the one profile. The job stays advisory. Co-Authored-By: Claude Opus 5.5 * chore(node): add changeset for capture v1 only Co-Authored-By: Claude Opus 5.5 * fix(node): keep the process alive while a capture send retries The v1 sender waited between retries and timed each attempt with unref'd timers. A script ending in `await posthog.shutdown()` could exit during a retry wait and lose the batch. Use ref'd timers inside an in-flight send, as the v0 path did. Co-Authored-By: Claude Opus 5.5 * test(ai): accept only capture v1 requests in the cassette collector posthog-node no longer sends to /batch/, so the collector now rejects the legacy endpoint and body, and requires the bearer token and the v1 event fields on every request. Co-Authored-By: Claude Opus 5.5 * test(convex): assert the capture v1 wire in the example tests posthog-node sends events only to the capture v1 analytics endpoint. Read events from that endpoint alone, and check the bearer token and the absence of an api_key in the body. Co-Authored-By: Claude Opus 5.5 * fix(ci): download the node compliance artifact into the folder the report reads With one artifact left, a pattern download extracts it straight into the target path, so the report found no node-v2-v1 folder. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changeset/posthog-node-capture-v1-only.md | 5 + .../sdk-compliance-tests-node-v2.yml | 18 +- compliance/node/v2/Dockerfile | 2 +- compliance/node/v2/README.md | 20 +-- compliance/node/v2/binding.mjs | 10 +- compliance/node/v2/binding.test.mjs | 23 ++- compliance/node/v2/report-ci.test.mjs | 126 +++++++------- compliance/node/v2/run-compliance.sh | 15 +- compliance/node/v2/run-compliance.test.mjs | 16 +- compliance/node/v2/server.mjs | 14 +- compliance/node/v2/server.test.mjs | 24 +-- compliance/node/v2/snapshot-http.test.mjs | 6 +- compliance/node/v2/worker.mjs | 2 +- .../example-convex/convex/example.test.ts | 13 +- packages/ai/harness/collector.test.ts | 36 ++-- packages/ai/harness/collector.ts | 18 +- .../posthog-node-references-latest.json | 53 ++++++ .../server-wire-snapshots.spec.ts.snap | 13 +- .../src/__tests__/ai-capture/wiring.spec.ts | 163 +++++++----------- .../src/__tests__/capture-v1/config.spec.ts | 33 ---- .../__tests__/capture-v1/process-exit.spec.ts | 81 +++++++++ .../capture-v1/route-isolation.spec.ts | 80 ++++----- .../src/__tests__/capture-v1/routing.spec.ts | 35 ---- .../src/__tests__/capture-v1/sender.spec.ts | 119 +++++++------ .../src/__tests__/capture-v1/wiring.spec.ts | 163 +++++++++--------- .../node/src/__tests__/edge-exports.spec.ts | 4 +- .../src/__tests__/immediate-delivery.spec.ts | 18 +- .../node/src/__tests__/posthog-node.spec.ts | 22 ++- .../__tests__/server-wire-snapshots.spec.ts | 42 ++--- packages/node/src/__tests__/utils/index.ts | 44 ++++- .../node/src/__tests__/utils/v1-wiring.ts | 69 ++------ packages/node/src/ai-capture/routing.ts | 2 - packages/node/src/capture-v1/config.ts | 18 -- packages/node/src/capture-v1/errors.ts | 29 ++-- packages/node/src/capture-v1/routing.ts | 28 --- packages/node/src/capture-v1/sender.ts | 53 +++--- packages/node/src/capture-v1/types.ts | 10 +- packages/node/src/client.ts | 157 +++++++---------- packages/node/src/exports.ts | 3 + 39 files changed, 775 insertions(+), 812 deletions(-) create mode 100644 .changeset/posthog-node-capture-v1-only.md delete mode 100644 packages/node/src/__tests__/capture-v1/config.spec.ts create mode 100644 packages/node/src/__tests__/capture-v1/process-exit.spec.ts delete mode 100644 packages/node/src/__tests__/capture-v1/routing.spec.ts delete mode 100644 packages/node/src/capture-v1/config.ts delete mode 100644 packages/node/src/capture-v1/routing.ts diff --git a/.changeset/posthog-node-capture-v1-only.md b/.changeset/posthog-node-capture-v1-only.md new file mode 100644 index 0000000000..aa10db4c60 --- /dev/null +++ b/.changeset/posthog-node-capture-v1-only.md @@ -0,0 +1,5 @@ +--- +'posthog-node': major +--- + +Capture always uses capture v1: `POSTHOG_CAPTURE_MODE` is no longer read, `captureAi` sends to `/i/v1/ai/events`, and `CaptureV1Error` is now `CaptureError`. diff --git a/.github/workflows/sdk-compliance-tests-node-v2.yml b/.github/workflows/sdk-compliance-tests-node-v2.yml index 142d5e7a70..463d257823 100644 --- a/.github/workflows/sdk-compliance-tests-node-v2.yml +++ b/.github/workflows/sdk-compliance-tests-node-v2.yml @@ -46,10 +46,6 @@ jobs: continue-on-error: true permissions: contents: read - strategy: - fail-fast: false - matrix: - mode: [v0, v1] defaults: run: shell: bash @@ -57,7 +53,6 @@ jobs: NODE_IMAGE: node:24.21.0-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # Harness release 1.16.0; pin the published multi-platform image index. HARNESS_IMAGE: ghcr.io/posthog/sdk-test-harness-v2@sha256:14c539d87c758a8798d3681d26e081cd156b739052d4fdefc51065f92e7cd2f2 - CAPTURE_MODE: ${{ matrix.mode }} steps: # Test the PR head or the triggering commit for push/manual runs. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -81,7 +76,7 @@ jobs: timeout-minutes: 35 run: | bash compliance/node/v2/run-compliance.sh node-compliance:local "$HARNESS_IMAGE" \ - "$CAPTURE_MODE" "$RUNNER_TEMP/node-compliance/reports" \ + "$RUNNER_TEMP/node-compliance/reports" \ 2>&1 | tee "$RUNNER_TEMP/node-compliance/run.log" - name: Run isolated acceptance profile if: steps.prepare.outcome == 'success' @@ -89,14 +84,14 @@ jobs: timeout-minutes: 25 run: | bash compliance/node/v2/run-compliance.sh node-compliance:local "$HARNESS_IMAGE" \ - "$CAPTURE_MODE" "$RUNNER_TEMP/node-compliance/acceptance" acceptance \ + "$RUNNER_TEMP/node-compliance/acceptance" acceptance \ 2>&1 | tee "$RUNNER_TEMP/node-compliance/acceptance-run.log" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() continue-on-error: true timeout-minutes: 3 with: - name: node-v2-${{ matrix.mode }} + name: node-v2-v1 path: ${{ runner.temp }}/node-compliance if-no-files-found: error retention-days: 14 @@ -121,8 +116,9 @@ jobs: - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 continue-on-error: true with: - pattern: node-v2-* - path: ${{ runner.temp }}/node-compliance-results + # A download by name extracts into `path` itself, so name the folder the report reads. + name: node-v2-v1 + path: ${{ runner.temp }}/node-compliance-results/node-v2-v1 # No checkout or execution of code from the tested revision in this job. - name: Summarize results and update PR comment continue-on-error: true @@ -167,9 +163,7 @@ jobs: const lines = [marker, '# Node SDK compliance v2', '', `Commit: ${sha}`, `[Workflow run and artifacts](${runUrl})`, '']; for (const [mode, profile, suite, reports] of [ - ['v0', 'node-legacy', 'acceptance', 'acceptance'], ['v1', 'node-analytics-v1', 'acceptance', 'acceptance'], - ['v0', 'node-legacy', 'migration', 'reports'], ['v1', 'node-analytics-v1', 'migration', 'reports'], ]) { lines.push(`## Capture ${mode} — ${suite}`, ''); diff --git a/compliance/node/v2/Dockerfile b/compliance/node/v2/Dockerfile index 41b6ddde8b..04ea894da7 100644 --- a/compliance/node/v2/Dockerfile +++ b/compliance/node/v2/Dockerfile @@ -18,6 +18,6 @@ WORKDIR /adapter COPY --from=build /output/consumer /consumer COPY --from=build /output/build.json /build.json COPY compliance/node/v2/binding.mjs compliance/node/v2/server.mjs compliance/node/v2/worker.mjs ./ -ENV POSTHOG_NODE_CONSUMER=/consumer HOST=0.0.0.0 PORT=8080 POSTHOG_CAPTURE_MODE=v0 POSTHOG_NODE_MODULE=cjs +ENV POSTHOG_NODE_CONSUMER=/consumer HOST=0.0.0.0 PORT=8080 POSTHOG_NODE_MODULE=cjs USER node ENTRYPOINT ["node", "/adapter/server.mjs"] diff --git a/compliance/node/v2/README.md b/compliance/node/v2/README.md index d45e6bf691..537ea44523 100644 --- a/compliance/node/v2/README.md +++ b/compliance/node/v2/README.md @@ -8,11 +8,11 @@ Use Node 24 and a consumer directory with `posthog-node` installed from the sele ```sh POSTHOG_NODE_CONSUMER=/absolute/path/to/consumer \ -POSTHOG_CAPTURE_MODE=v0 POSTHOG_NODE_MODULE=cjs \ +POSTHOG_NODE_MODULE=cjs \ HOST=127.0.0.1 PORT=8080 node compliance/node/v2/server.mjs ``` -`POSTHOG_CAPTURE_MODE` is fixed for the process: `v0` advertises `node-legacy`, `v1` advertises `node-analytics-v1`. `POSTHOG_NODE_MODULE=esm` selects native public ESM imports instead of CommonJS. These mappings were initially checked with Node SDK 5.52.4, core 1.54.2 and types 1.412.1; fresh source builds validate the selected revision independently. +The adapter advertises one profile, `node-analytics-v1`. `POSTHOG_NODE_MODULE=esm` selects native public ESM imports instead of CommonJS. These mappings were initially checked with Node SDK 5.52.4, core 1.54.2 and types 1.412.1; fresh source builds validate the selected revision independently. The startup JSON line reports the actual bound address and port (`PORT=0` is supported). One listener owns one port. `HOST=::` is a dual-stack wildcard listener; use `127.0.0.1` or `::1` for loopback-only access. DNS hosts select one resolved address, preferring IPv4 when available so Linux clients that omit loopback IPv6 can reach `localhost`. Only bind wildcard addresses on an isolated test network. This unauthenticated service is not a production endpoint. @@ -55,13 +55,9 @@ The final adapter image contains Node, the installed consumer and three runtime ```sh bash compliance/node/v2/run-compliance.sh posthog-node-compliance:local \ - "$HARNESS_IMAGE" v0 /absolute/new/legacy-reports + "$HARNESS_IMAGE" /absolute/new/analytics-reports bash compliance/node/v2/run-compliance.sh posthog-node-compliance:local \ - "$HARNESS_IMAGE" v1 /absolute/new/analytics-reports -bash compliance/node/v2/run-compliance.sh posthog-node-compliance:local \ - "$HARNESS_IMAGE" v0 /absolute/new/legacy-acceptance acceptance -bash compliance/node/v2/run-compliance.sh posthog-node-compliance:local \ - "$HARNESS_IMAGE" v1 /absolute/new/analytics-acceptance acceptance + "$HARNESS_IMAGE" /absolute/new/analytics-acceptance acceptance ``` The default suite is migration parity. Passing `acceptance` as the fifth argument selects the opted-in server acceptance cases. Use a new report directory for each invocation. @@ -70,13 +66,13 @@ Pull the harness image before running. Each invocation creates a separate `--int ## CI activation and validation -[Node SDK v2 compliance](../../../.github/workflows/sdk-compliance-tests-node-v2.yml) is the canonical SDK-owned workflow; there is no separate template to synchronize. It runs both profiles on PRs and pushes to `main` affecting the adapter, SDKs or build inputs, including its own workflow file, and supports manual dispatch. It checks out the actual PR head for pull requests or the triggering commit for push and manual runs, runs the adapter with only `contents: read`, and needs no secrets or registry login. +[Node SDK v2 compliance](../../../.github/workflows/sdk-compliance-tests-node-v2.yml) is the canonical SDK-owned workflow; there is no separate template to synchronize. It runs the profile on PRs and pushes to `main` affecting the adapter, SDKs or build inputs, including its own workflow file, and supports manual dispatch. It checks out the actual PR head for pull requests or the triggering commit for push and manual runs, runs the adapter with only `contents: read`, and needs no secrets or registry login. The workflow pins public multi-platform image digests for harness release `1.16.0` and Node `24.21.0-bookworm-slim` directly in `HARNESS_IMAGE` and `NODE_IMAGE`. Image updates are reviewed workflow changes. Preparation rejects missing or malformed image identities; unavailable images fail during pull/build. -When updating the pins, verify anonymous image access, `linux/amd64` support, the Debian-based Node build tools, the draft2 adapter protocol, and strict `check-report` support. Validate both profiles in GitHub Actions against the released images. Compliance is advisory: assertion, preparation and execution failures do not block the workflow. Raw exit codes and diagnostics remain in the artifacts. A separate reporting job reads those artifacts without checking out or executing the tested code, writes both suites for both profiles to the job log and summary, and creates or updates one bot-owned PR comment. The comment shows ✅ passed / ❌ non-passing / ⚠️ not-selected totals without listing successful cases. Expand each failure for its message, assertion code, exact failed-step source and, when matching diagnostics provide them, step text, operation, public getter arguments or wire field, and expected/actual values. Clean specs provenance links to the pinned `PostHog/sdk-specs` commit; otherwise the source remains plain text. Older reports retain their message and step source without inferred values. The run-artifacts link names the exact `node-v2-v0` / `node-v2-v1` artifact and the migration `reports/report.json.diagnostics.json` or acceptance `acceptance/report.json.diagnostics.json` file; search that file by `case_id` for full diagnostics. Missing or invalid reports are explicitly marked unavailable or incomplete. Diagnostics are size-limited and matched to the report run, profile, case and source before use. Published assertion fields are allowlisted, escaped and bounded; full wire requests are not published. Each suite/profile section shows at most 20 failures within a 24,000-byte detail budget, with omitted-case counts; the whole comment is limited to 60,000 UTF-8 bytes with an explicit truncation notice. Push, manual, fork and Dependabot runs retain the summary without posting a comment. A green CI check is not a conformance claim; a human decides whether the reported results are acceptable. +When updating the pins, verify anonymous image access, `linux/amd64` support, the Debian-based Node build tools, the draft2 adapter protocol, and strict `check-report` support. Validate the profile in GitHub Actions against the released images. Compliance is advisory: assertion, preparation and execution failures do not block the workflow. Raw exit codes and diagnostics remain in the artifacts. A separate reporting job reads those artifacts without checking out or executing the tested code, writes both suites to the job log and summary, and creates or updates one bot-owned PR comment. The comment shows ✅ passed / ❌ non-passing / ⚠️ not-selected totals without listing successful cases. Expand each failure for its message, assertion code, exact failed-step source and, when matching diagnostics provide them, step text, operation, public getter arguments or wire field, and expected/actual values. Clean specs provenance links to the pinned `PostHog/sdk-specs` commit; otherwise the source remains plain text. Older reports retain their message and step source without inferred values. The run-artifacts link names the `node-v2-v1` artifact and the migration `reports/report.json.diagnostics.json` or acceptance `acceptance/report.json.diagnostics.json` file; search that file by `case_id` for full diagnostics. Missing or invalid reports are explicitly marked unavailable or incomplete. Diagnostics are size-limited and matched to the report run, profile, case and source before use. Published assertion fields are allowlisted, escaped and bounded; full wire requests are not published. Each suite section shows at most 20 failures within a 24,000-byte detail budget, with omitted-case counts; the whole comment is limited to 60,000 UTF-8 bytes with an explicit truncation notice. Push, manual, fork and Dependabot runs retain the summary without posting a comment. A green CI check is not a conformance claim; a human decides whether the reported results are acceptable. -Each matrix job has an 85-minute limit, with 20 minutes for preparation, 35 minutes for migration and 25 minutes for acceptance (including bounded startup and cleanup), leaving time for artifact upload even after a step timeout. Preparation bounds the harness pull to 3 minutes, the fresh source build to 15 minutes, and build-identity export to 30 seconds. Bash `pipefail` preserves failures through log capture. Artifacts (`node-v2-v0` / `node-v2-v1`, retained for 14 days) are uploaded even after preparation or run failures: selected source and working-tree status, validated image identities, preparation output/exit, successful build identity, wrapper output, raw harness exit, strict report-check exit, separate migration and acceptance reports and diagnostics, and container/cleanup logs where produced. A build failure cannot produce `build.json`; its build output remains in `prepare.log`. +The compliance job has an 85-minute limit, with 20 minutes for preparation, 35 minutes for migration and 25 minutes for acceptance (including bounded startup and cleanup), leaving time for artifact upload even after a step timeout. Preparation bounds the harness pull to 3 minutes, the fresh source build to 15 minutes, and build-identity export to 30 seconds. Bash `pipefail` preserves failures through log capture. The `node-v2-v1` artifact (retained for 14 days) is uploaded even after preparation or run failures: selected source and working-tree status, validated image identities, preparation output/exit, successful build identity, wrapper output, raw harness exit, strict report-check exit, separate migration and acceptance reports and diagnostics, and container/cleanup logs where produced. A build failure cannot produce `build.json`; its build output remains in `prepare.log`. Local script tests use Docker stand-ins to check prerequisite failures, build failures, exit propagation, and private-network lifecycle. They do not validate image availability, Docker builds, migrated fixtures, or hosted fork behavior. Validate those paths separately with the selected released images. @@ -94,4 +90,4 @@ shellcheck --exclude=SC2329 compliance/node/v2/{prepare-ci,run-compliance}.sh node --test compliance/node/v2/{prepare-ci,run-compliance}.test.mjs ``` -Binding tests use public-method spies for exact translations; HTTP tests also exercise real installed packages in both formats/modes. Controlled SDK stand-ins test deadline, cleanup and process failures, not SDK conformance. Caller tests check raw failure exits and private-network lifecycle. Full migrated profiles and current-base comparisons are separate integration gates owned by the harness run. +Binding tests use public-method spies for exact translations; HTTP tests also exercise real installed packages in both module formats. Controlled SDK stand-ins test deadline, cleanup and process failures, not SDK conformance. Caller tests check raw failure exits and private-network lifecycle. Full migrated profiles and current-base comparisons are separate integration gates owned by the harness run. diff --git a/compliance/node/v2/binding.mjs b/compliance/node/v2/binding.mjs index 6f2e0fa58c..17076bde10 100644 --- a/compliance/node/v2/binding.mjs +++ b/compliance/node/v2/binding.mjs @@ -104,9 +104,9 @@ const optionProperties = { process_person_profile: '$process_person_profile', product_tour_id: '$product_tour_id', } -function capture(args, captureMode) { +function capture(args) { if (!own(args, 'options')) return rename(args, captureFields, '/capture') - if (captureMode !== 'v1' || !object(args.options)) unsupported('/capture/options') + if (!object(args.options)) unsupported('/capture/options') checkKeys(args.options, Object.keys(optionProperties), '/capture/options') const mapped = rename( Object.fromEntries(Object.entries(args).filter(([key]) => key !== 'options')), @@ -223,9 +223,8 @@ async function snapshotReads(snapshot, reads) { return { results } } export class Binding { - constructor(PostHog, captureMode = 'v0') { + constructor(PostHog) { this.PostHog = PostHog - this.captureMode = captureMode this.client = undefined this.exceptions = new Map() } @@ -253,8 +252,7 @@ export class Binding { if (this.client === undefined) return failure('unsupported_binding', 'before-setup', 'This instance API requires public construction') if (route === '/capture' || route === '/capture_ai') { - const mapped = - route === '/capture' ? capture(args, this.captureMode) : rename(args, captureFields, route) + const mapped = route === '/capture' ? capture(args) : rename(args, captureFields, route) if (own(mapped, 'timestamp')) mapped.timestamp = timestamp(mapped.timestamp) if (own(mapped, 'sendFeatureFlags')) mapped.sendFeatureFlags = rename( diff --git a/compliance/node/v2/binding.test.mjs b/compliance/node/v2/binding.test.mjs index ed96884226..bff9fc4f1b 100644 --- a/compliance/node/v2/binding.test.mjs +++ b/compliance/node/v2/binding.test.mjs @@ -10,7 +10,7 @@ const { PostHog } = createRequire(resolve(consumer, 'package.json'))('posthog-no // Observe the genuine packaged public constructor and replace only its public // methods. The binding never receives a different SDK-shaped implementation. -async function spies(run, captureMode = 'v0') { +async function spies(run) { const originals = new Map() const calls = [] const results = { @@ -43,7 +43,7 @@ async function spies(run, captureMode = 'v0') { }, }) try { - await run(new Binding(NativeConstructor, captureMode), calls, results) + await run(new Binding(NativeConstructor), calls, results) } finally { for (const [name, descriptor] of originals) { if (descriptor) Object.defineProperty(PostHog.prototype, name, descriptor) @@ -105,7 +105,7 @@ test('local reload calls its public method with native arguments and results', a }) test('AI capture invokes its public method once and preserves native results and timestamp input', async () => { - for (const mode of ['v0', 'v1']) { + { await spies(async (binding, calls, results) => { await setup(binding) const args = { @@ -159,12 +159,12 @@ test('AI capture invokes its public method once and preserves native results and assert.equal(calls.filter(([name]) => name === 'capture' || name === 'flush').length, 0) results.captureAi = new Error('native AI failure') assert.equal((await binding.invoke('/capture_ai', {})).outcome.kind, 'thrown') - }, mode) + }) } }) test('identify and alias preserve exact public arguments, omission, native void and failures', async () => { - for (const mode of ['v0', 'v1']) { + { await spies(async (binding, calls, results) => { for (const route of ['/identify', '/alias']) { assert.equal((await binding.invoke(route, {})).failure.code, 'before-setup') @@ -224,12 +224,12 @@ test('identify and alias preserve exact public arguments, omission, native void assert.deepEqual(await binding.invoke(`/${name}`, {}), thrown) } assert.equal(calls.filter(([name]) => ['capture', 'captureAi', 'flush'].includes(name)).length, 0) - }, mode) + }) } }) test('captureException constructs a native fixture and preserves positional arguments and outcomes', async () => { - for (const mode of ['v0', 'v1']) { + { await spies(async (binding, calls, results) => { assert.equal((await binding.invoke('/capture_exception', {})).failure.code, 'before-setup') await setup(binding) @@ -268,7 +268,7 @@ test('captureException constructs a native fixture and preserves positional argu assert.equal(thrown.outcome.kind, 'thrown') assert.deepEqual(await binding.invoke('/capture_exception', { error }), thrown) assert.equal(calls.filter(([name]) => ['capture', 'captureAi', 'flush'].includes(name)).length, 0) - }, mode) + }) } }) @@ -300,7 +300,7 @@ test('captureException reports unsupported or unrepresentable fixture arguments }) test('groupIdentify preserves public arguments, omission and native outcomes', async () => { - for (const mode of ['v0', 'v1']) { + { await spies(async (binding, calls, results) => { assert.equal((await binding.invoke('/group_identify', {})).failure.code, 'before-setup') await setup(binding) @@ -360,7 +360,7 @@ test('groupIdentify preserves public arguments, omission and native outcomes', a assert.equal(thrown.outcome.error.kind, 'exception') assert.deepEqual(await binding.invoke('/group_identify', {}), thrown) assert.equal(calls.filter(([name]) => ['capture', 'captureAi', 'flush'].includes(name)).length, 0) - }, mode) + }) } }) @@ -512,7 +512,6 @@ test('unsupported supplied fields remain attributed gaps before native work', as await setup(binding) for (const [route, args] of [ ['/capture', { event: 'event', set: null }], - ['/capture', { options: {} }], ['/capture', { send_feature_flags: { device_id: 'd' } }], ['/flush', { timeout_ms: 0 }], ['/get_feature_flag', { key: 'f', fresh: false }], @@ -595,4 +594,4 @@ test('analytics-v1 options translate to native input properties without coercion ]) assert.equal((await binding.invoke('/capture', args)).failure.kind, kind) assert.equal(calls.length, 4) - }, 'v1')) + })) diff --git a/compliance/node/v2/report-ci.test.mjs b/compliance/node/v2/report-ci.test.mjs index 5e4d90dc9a..4ff8f0a35e 100644 --- a/compliance/node/v2/report-ci.test.mjs +++ b/compliance/node/v2/report-ci.test.mjs @@ -136,7 +136,7 @@ async function exercise(options = {}) { }, } try { - for (const mode of ['v0', 'v1']) { + for (const mode of ['v1']) { if (options.missing?.includes(mode)) continue const dir = join(root, `node-v2-${mode}`) mkdirSync(dir) @@ -180,14 +180,13 @@ async function exercise(options = {}) { } } -test('creates one combined comment for both profiles and writes the same summary', async () => { +test('creates one combined comment for both suites and writes the same summary', async () => { const { summary, calls, error } = await exercise() assert.equal(error, undefined) - assert.match(summary, /Capture v0/) - assert.match(summary, /Capture v1/) - assert.match(summary, /Capture v0 — acceptance/) + assert.doesNotMatch(summary, /Capture v0/) + assert.match(summary, /Capture v1 — acceptance/) assert.match(summary, /Capture v1 — migration/) - assert.equal((summary.match(/All selected cases passed/g) || []).length, 4) + assert.equal((summary.match(/All selected cases passed/g) || []).length, 2) assert.doesNotMatch(summary, /example|
/) assert.equal(calls[0], 'summary') assert.equal(calls.at(-1)[0], 'create') @@ -215,31 +214,30 @@ test('shows acceptance failures without losing migration results', async () => { assert.match(summary, /Capture v1 — acceptance[\s\S]*❌ 1 non-passing/) assert.match(summary, /acceptance\/report.json.diagnostics.json/) assert.match(summary, /identify-example/) - assert.match(summary, /Capture v0 — migration/) - assert.equal((summary.match(/All selected cases passed/g) || []).length, 2) + assert.match(summary, /Capture v1 — migration/) + assert.equal((summary.match(/All selected cases passed/g) || []).length, 1) }) -test('shows failed cases without losing the other profile', async () => { +test('shows migration failures without losing acceptance results', async () => { const { summary, error } = await exercise({ - reports: { v0: report('v0', 'failed_assertion') }, + reports: { v1: report('v1', 'failed_assertion') }, exit: '1', validation: '1', }) assert.equal(error, undefined) - assert.match(summary, /✅ 0 passed \/ ❌ 1 non-passing/) + assert.match(summary, /Capture v1 — migration[\s\S]*✅ 0 passed \/ ❌ 1 non-passing/) assert.match(summary, /Unexpected flag value/) - assert.match(summary, /Capture v1/) - assert.equal((summary.match(/All selected cases passed/g) || []).length, 2) + assert.match(summary, /Capture v1 — acceptance/) + assert.equal((summary.match(/All selected cases passed/g) || []).length, 1) }) for (const options of [ - { missing: ['v0'] }, - { missing: ['v0', 'v1'] }, - { reports: { v0: 'not json' } }, + { missing: ['v1'] }, + { reports: { v1: 'not json' } }, { acceptanceReports: { v1: 'not json' } }, - { missingAcceptance: ['v0'] }, + { missingAcceptance: ['v1'] }, { source: 'wrong-sha' }, - { reports: { v0: report('v1') } }, + { reports: { v1: report('v0') } }, ]) { test(`reports unavailable/incomplete data: ${JSON.stringify(options)}`, async () => { const { summary, calls, error } = await exercise(options) @@ -252,7 +250,7 @@ for (const options of [ for (const options of [{ noPr: true }, { fork: true }, { actor: 'dependabot[bot]' }, { currentSha: 'b'.repeat(40) }]) { test(`retains summary without posting: ${JSON.stringify(options)}`, async () => { const { summary, calls } = await exercise(options) - assert.match(summary, /Capture v0/) + assert.match(summary, /Capture v1/) assert.ok(!calls.some((call) => Array.isArray(call))) }) } @@ -264,30 +262,27 @@ test('preserves summary when comment publication fails', async () => { }) test('escapes artifact markup and mentions and bounds the comment size', async () => { - const data = report('v0', 'failed_assertion') + const data = report('v1', 'failed_assertion') data.results[0].case_id = '' data.results[0].result.failure.message = '[click](https://example.com)'.repeat(1000) data.results = Array(100).fill(data.results[0]) - const v1 = JSON.parse(JSON.stringify(data)) - v1.profiles[0].id = 'node-analytics-v1' - for (const result of v1.results) result.profile_id = 'node-analytics-v1' - const { summary } = await exercise({ reports: { v0: data, v1 }, diagnostics: { v0: null, v1: null } }) + const { summary } = await exercise({ reports: { v1: data }, diagnostics: { v1: null } }) assert.doesNotMatch(summary, /