diff --git a/.changeset/posthog-ai-capture-ai-node-6.md b/.changeset/posthog-ai-capture-ai-node-6.md new file mode 100644 index 0000000000..b4ddbe432e --- /dev/null +++ b/.changeset/posthog-ai-capture-ai-node-6.md @@ -0,0 +1,5 @@ +--- +'@posthog/ai': minor +--- + +With posthog-node 6, every AI event is sent through `captureAi` on the capture v1 AI lane, and `enableFullAiCapture` only controls truncation and media redaction. With posthog-node 5, nothing changes: AI events use `capture` unless `enableFullAiCapture` is set. diff --git a/.changeset/posthog-ai-node-6.md b/.changeset/posthog-ai-node-6.md new file mode 100644 index 0000000000..7cbfd44556 --- /dev/null +++ b/.changeset/posthog-ai-node-6.md @@ -0,0 +1,5 @@ +--- +'@posthog/ai': patch +--- + +Supports posthog-node 6. Exception autocapture passes an options object to `captureException` when the client is posthog-node 6, and positional arguments for 5.x. diff --git a/.changeset/posthog-ai-personless-option.md b/.changeset/posthog-ai-personless-option.md new file mode 100644 index 0000000000..fddc7350fc --- /dev/null +++ b/.changeset/posthog-ai-personless-option.md @@ -0,0 +1,5 @@ +--- +'@posthog/ai': patch +--- + +An AI event with no user distinct id now also sets the `process_person_profile: false` event option, so with posthog-node 6 it stays personless even when a context or registered option turns person processing on. An empty or whitespace-only distinct id now counts as missing in every integration, so the event uses the trace or run id and stays personless. diff --git a/.changeset/posthog-core-debug-payload.md b/.changeset/posthog-core-debug-payload.md new file mode 100644 index 0000000000..53ccba17e9 --- /dev/null +++ b/.changeset/posthog-core-debug-payload.md @@ -0,0 +1,5 @@ +--- +'@posthog/core': patch +--- + +Debug mode logs an emitted event's payload through an overridable `describeDebugPayload`, so an SDK can log a summary instead. The default is unchanged. diff --git a/.changeset/posthog-core-fetch-redirect.md b/.changeset/posthog-core-fetch-redirect.md new file mode 100644 index 0000000000..7460224fbc --- /dev/null +++ b/.changeset/posthog-core-fetch-redirect.md @@ -0,0 +1,5 @@ +--- +'@posthog/core': patch +--- + +`PostHogFetchOptions` has an optional `redirect` field, so a custom `fetch` sees how capture asks it to handle redirects. diff --git a/.changeset/posthog-core-v7-generator.md b/.changeset/posthog-core-v7-generator.md new file mode 100644 index 0000000000..b34e1b9cff --- /dev/null +++ b/.changeset/posthog-core-v7-generator.md @@ -0,0 +1,5 @@ +--- +'@posthog/core': patch +--- + +Export `V7Generator`, so an SDK can generate v7 uuids from its own random source. diff --git a/.changeset/posthog-mcp-node-6.md b/.changeset/posthog-mcp-node-6.md new file mode 100644 index 0000000000..1c15915227 --- /dev/null +++ b/.changeset/posthog-mcp-node-6.md @@ -0,0 +1,5 @@ +--- +'@posthog/mcp': patch +--- + +Accepts posthog-node 6 as a peer dependency. diff --git a/.changeset/posthog-node-capture-api-shapes.md b/.changeset/posthog-node-capture-api-shapes.md new file mode 100644 index 0000000000..068e118999 --- /dev/null +++ b/.changeset/posthog-node-capture-api-shapes.md @@ -0,0 +1,6 @@ +--- +'posthog-node': major +--- + +`captureException()` and `captureExceptionImmediate()` take an options object (`distinctId`, `properties`, `options`, `uuid`, `flags`, `level`); the old positional form still works in 6.x and logs a deprecation error once per method. `level` is read case-insensitively, with the same aliases as posthog-python (`critical` → `fatal`, `warn` → `warning`); an unknown level keeps `error`. +`registerOptions()` and `unregisterOptions()` return a promise, and `prepareEventMessage()` returns a flat message with top-level `options`, `uuid`, `timestamp` and `disableGeoip`. diff --git a/.changeset/posthog-node-capture-crypto-uuids.md b/.changeset/posthog-node-capture-crypto-uuids.md new file mode 100644 index 0000000000..5e4b91b5e9 --- /dev/null +++ b/.changeset/posthog-node-capture-crypto-uuids.md @@ -0,0 +1,5 @@ +--- +'posthog-node': major +--- + +Generated event uuids and personless distinct ids take their random bits from `crypto.getRandomValues` instead of `Math.random`. diff --git a/.changeset/posthog-node-capture-hardening.md b/.changeset/posthog-node-capture-hardening.md new file mode 100644 index 0000000000..92aeb233b8 --- /dev/null +++ b/.changeset/posthog-node-capture-hardening.md @@ -0,0 +1,7 @@ +--- +'posthog-node': major +--- + +A personal API key (`phx_...`) passed as the project API key now disables the client, like a missing key, and either case logs one error that never includes the key. +Debug mode logs event names, uuids and counts instead of whole events, and a `before_send` function that throws is logged with the error's type instead of its message. In debug mode, a capture error can still include the server or proxy response text. +A new client option, `captureTracingHeaders` (default `true`), controls whether the Express and NestJS integrations read the `X-POSTHOG-DISTINCT-ID` and `X-POSTHOG-SESSION-ID` request headers into the request context. diff --git a/.changeset/posthog-node-capture-lanes.md b/.changeset/posthog-node-capture-lanes.md new file mode 100644 index 0000000000..fdc9e021f6 --- /dev/null +++ b/.changeset/posthog-node-capture-lanes.md @@ -0,0 +1,5 @@ +--- +'posthog-node': major +--- + +Analytics and AI events are batched and sent on separate lanes, in batches of about 5 MiB at most, and events that fail with a network error are kept and retried on the next flush. `flush()` no longer rejects on delivery failures (they arrive through the `'error'` event), and `maxQueueSize` now applies to analytics events only. diff --git a/.changeset/posthog-node-capture-layers.md b/.changeset/posthog-node-capture-layers.md new file mode 100644 index 0000000000..02dd6d4fba --- /dev/null +++ b/.changeset/posthog-node-capture-layers.md @@ -0,0 +1,8 @@ +--- +'posthog-node': major +--- + +Values set on the event, in context or with `register()` / the new `registerOptions()` now win over the values the SDK adds (`$is_server`, `$geoip_disable`, feature flag properties, trace ids), and `before_send` sees and can change all of them; context and registered properties and options, including the new context `options`, also reach identify, alias and group identify events. +An empty or whitespace distinct id counts as missing, and `$groupidentify` without a distinct id uses the context id, then `$_`. +`$geoip_disable` and `$groups` are filled before `before_send`, so a hook that changes the message's `disableGeoip` or `groups` field has no effect: edit the properties instead. +Reserved exception properties such as `$exception_level` passed in `captureException()` properties are now ignored, so the SDK's value is sent. diff --git a/.changeset/posthog-node-capture-options.md b/.changeset/posthog-node-capture-options.md new file mode 100644 index 0000000000..3020c0c5fa --- /dev/null +++ b/.changeset/posthog-node-capture-options.md @@ -0,0 +1,7 @@ +--- +'posthog-node': major +--- + +Every capture call takes a per-event `options` map that is sent to capture unchanged (the SDK no longer converts option values); legacy `$` option properties such as `$process_person_profile` still fill unset options and are removed from `properties`. +A `$session_id` or `$window_id` that is not a string is dropped with a warning. +Capture drops an event whose option value it cannot read, such as `cookieless_mode: [1, 2, 3]`, and the SDK reports it through `'error'` with `details: 'invalid_options'`. diff --git a/.changeset/posthog-node-capture-sender-rules.md b/.changeset/posthog-node-capture-sender-rules.md new file mode 100644 index 0000000000..58acea1e4a --- /dev/null +++ b/.changeset/posthog-node-capture-sender-rules.md @@ -0,0 +1,5 @@ +--- +'posthog-node': major +--- + +Capture follows only same-origin 307 and 308 redirects; invalid event uuids are replaced with a warning, and dropped duplicate copies are reported. diff --git a/.changeset/posthog-node-capture-sender.md b/.changeset/posthog-node-capture-sender.md new file mode 100644 index 0000000000..13e5a3b77a --- /dev/null +++ b/.changeset/posthog-node-capture-sender.md @@ -0,0 +1,6 @@ +--- +'posthog-node': major +--- + +Capture retries only 404, 408, 429, 500, 502, 503, 504 and network errors, waiting 100 ms doubled per retry with jitter (at most 30 s, at least `Retry-After`). +Adds `captureMaxRetries`, `captureCompression`, `captureAiCompression`, `captureAiMaxQueueSize` and `captureAiRequestTimeoutMs`. 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/.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. diff --git a/.changeset/posthog-node-capture-wire-checks.md b/.changeset/posthog-node-capture-wire-checks.md new file mode 100644 index 0000000000..ac537c2669 --- /dev/null +++ b/.changeset/posthog-node-capture-wire-checks.md @@ -0,0 +1,6 @@ +--- +'posthog-node': major +--- + +An event whose name is missing, empty or not a string, or whose name, distinct id, session id or window id is not well-formed Unicode (a lone surrogate), is dropped before sending and reported through the `'error'` event, instead of being sent and failing at capture (an object name was sent as `"[object Object]"`). Lone surrogates in property strings are replaced with U+FFFD, so one bad character no longer fails the whole request. +An unparseable event timestamp is still replaced with the current time, and now logs a warning that never includes the value. diff --git a/.github/workflows/sdk-compliance-tests-node-v2.yml b/.github/workflows/sdk-compliance-tests-node-v2.yml index 142d5e7a70..82bac7310c 100644 --- a/.github/workflows/sdk-compliance-tests-node-v2.yml +++ b/.github/workflows/sdk-compliance-tests-node-v2.yml @@ -46,18 +46,13 @@ jobs: continue-on-error: true permissions: contents: read - strategy: - fail-fast: false - matrix: - mode: [v0, v1] defaults: run: shell: bash env: 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 }} + # Harness release 1.26.0; pin the published multi-platform image index. + HARNESS_IMAGE: ghcr.io/posthog/sdk-test-harness-v2@sha256:74dae248b19cc6030d803e2904b08d3bd1036b5c215414636a42d090ac27cba0 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/.github/workflows/sdk-compliance-tests-node.yml b/.github/workflows/sdk-compliance-tests-node.yml new file mode 100644 index 0000000000..8428a35c9e --- /dev/null +++ b/.github/workflows/sdk-compliance-tests-node.yml @@ -0,0 +1,106 @@ +name: Node SDK compliance + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - 'packages/node/**' + - 'packages/core/**' + - 'packages/types/**' + - 'tooling/**' + - 'compliance/node/adapter.mjs' + - 'compliance/node/Dockerfile' + - '.github/workflows/sdk-compliance-tests-node.yml' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'patches/**' + pull_request: + paths: + - 'packages/node/**' + - 'packages/core/**' + - 'packages/types/**' + - 'tooling/**' + - 'compliance/node/adapter.mjs' + - 'compliance/node/Dockerfile' + - '.github/workflows/sdk-compliance-tests-node.yml' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'patches/**' + +permissions: {} + +jobs: + compliance: + # The harness has one enable_compression flag but a test per codec, so each + # job runs the adapter with one codec. The SDK has no brotli support. + name: Node SDK compliance (${{ matrix.compression }}) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + compression: [gzip, deflate, zstd] + concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ matrix.compression }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: + HARNESS_IMAGE: ghcr.io/posthog/sdk-test-harness:1.26.0@sha256:9db022b5729dcf1557b9e622a36b6d6afa42e1b42653f031b0da6d73ca5d77fe + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Pull test harness + run: docker pull "$HARNESS_IMAGE" + + - name: Build SDK adapter + run: docker build -f compliance/node/Dockerfile -t sdk-adapter:test . + + - name: Start SDK adapter + env: + COMPRESSION: ${{ matrix.compression }} + run: | + docker network create test-network + docker run -d --name sdk-adapter --network test-network -e COMPRESSION="$COMPRESSION" sdk-adapter:test + + - name: Run compliance suites + run: | + mkdir -p report + docker run --rm \ + --name test-harness \ + --network test-network \ + -v "${GITHUB_WORKSPACE}/report:/report" \ + "$HARNESS_IMAGE" \ + run \ + --adapter-url http://sdk-adapter:8080 \ + --mock-url http://test-harness:8081 \ + --output text \ + --report /report/sdk-compliance-report.md \ + --sdk-type server + + - name: Publish report to the job summary + if: always() + run: | + if [ -f report/sdk-compliance-report.md ]; then + cat report/sdk-compliance-report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload compliance report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: node-sdk-compliance-report-${{ matrix.compression }} + path: report/sdk-compliance-report.md + + - name: Show adapter logs on failure + if: failure() + run: docker logs sdk-adapter || true + + - name: Stop containers + if: always() + run: | + docker rm -f sdk-adapter || true + docker network rm test-network || true diff --git a/.oxlintrc.json b/.oxlintrc.json index d1a516b370..7705802637 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -125,12 +125,13 @@ }, "overrides": [ { - "files": ["compliance/node/v2/**"], + "files": ["compliance/node/v2/**", "compliance/node/adapter.mjs"], "env": { "node": true }, "rules": { "compat/compat": "off", "posthog-js/no-direct-null-check": "off", "posthog-js/no-direct-array-check": "off", + "posthog-js/no-direct-function-check": "off", "posthog-js/no-direct-number-check": "off", "posthog-js/no-direct-undefined-check": "off" } diff --git a/compliance/node/Dockerfile b/compliance/node/Dockerfile index b38aee351e..06601ede4a 100644 --- a/compliance/node/Dockerfile +++ b/compliance/node/Dockerfile @@ -25,7 +25,7 @@ RUN pnpm --filter posthog-node build WORKDIR /app/adapter # Copy adapter code -COPY compliance/node/adapter.js ./ +COPY compliance/node/adapter.mjs ./ # Install adapter dependencies (express) in clean directory RUN npm init -y && npm install express @@ -33,4 +33,4 @@ RUN npm init -y && npm install express EXPOSE 8080 USER node -CMD ["node", "adapter.js"] +CMD ["node", "adapter.mjs"] diff --git a/compliance/node/Dockerfile.v1 b/compliance/node/Dockerfile.v1 deleted file mode 100644 index 90e3e65f1e..0000000000 --- a/compliance/node/Dockerfile.v1 +++ /dev/null @@ -1,42 +0,0 @@ -FROM node:24-alpine - -WORKDIR /app - -# Copy the entire monorepo (needed for workspace dependencies) -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY patches ./patches -COPY tooling ./tooling -COPY packages/types ./packages/types -COPY packages/core ./packages/core -COPY packages/node ./packages/node - -# Install pnpm -RUN npm install -g pnpm@11.7.0 - -# Install dependencies -RUN pnpm install --frozen-lockfile - -# Build the types, core, and node packages -RUN pnpm --filter @posthog/types build -RUN pnpm --filter @posthog/core build -RUN pnpm --filter posthog-node build - -# Set up adapter in separate directory -WORKDIR /app/adapter - -# Copy adapter code -COPY compliance/node/adapter.js ./ - -# Install adapter dependencies (express) in clean directory -RUN npm init -y && npm install express - -# Run the adapter in Capture V1 mode so it advertises the capture_v1 capability -# and the harness runs the /i/v1/analytics/events contract against it. The -# reusable compliance workflow only passes PORT at runtime, so the mode is baked -# into the image here. -ENV POSTHOG_CAPTURE_MODE=v1 - -EXPOSE 8080 - -USER node -CMD ["node", "adapter.js"] diff --git a/compliance/node/README.md b/compliance/node/README.md index 20a2c9e862..5622c07ce8 100644 --- a/compliance/node/README.md +++ b/compliance/node/README.md @@ -1,21 +1,21 @@ # PostHog Node SDK Compliance Adapter Compliance adapter for the posthog-node SDK with the [PostHog SDK Test Harness](https://github.com/PostHog/posthog-sdk-test-harness). +It runs the capture v1 suites (`capture_v1`, `capture_ai_v1`) and the feature flag suites. ## Running Tests ```bash -# From compliance/node — legacy /batch/ contract (capture_v0) +# From compliance/node docker-compose up --build --abort-on-container-exit -# Capture V1 contract (POST /i/v1/analytics/events) -POSTHOG_CAPTURE_MODE=v1 docker-compose up --build --abort-on-container-exit +# Another codec for the compression tests +COMPRESSION=zstd docker-compose up --build --abort-on-container-exit ``` -The adapter's capture mode is fixed per process: with `POSTHOG_CAPTURE_MODE=v1` -it advertises the `capture_v1` capability and the harness runs the V1 contract; -otherwise it advertises `capture_v0`. CI runs both as separate jobs -(`Dockerfile` for v0, `Dockerfile.v1` for v1). +Each adapter process runs one codec (`COMPRESSION`: `gzip`, `deflate` or `zstd`, default `gzip`) and advertises only that `encoding_` capability. +CI runs one job per codec (`.github/workflows/sdk-compliance-tests-node.yml`). +The Gherkin harness adapter lives in `v2/`. ## Documentation diff --git a/compliance/node/adapter.js b/compliance/node/adapter.js deleted file mode 100644 index 959949e016..0000000000 --- a/compliance/node/adapter.js +++ /dev/null @@ -1,324 +0,0 @@ -/** - * PostHog Node SDK Compliance Adapter - * - * Wraps the posthog-node SDK for compliance testing. - */ - -const express = require('express') -const { PostHog } = require('../packages/node/dist/entrypoints/index.node') - -const app = express() -app.use(express.json()) - -// Capture mode is fixed per adapter process (baked into the image via env) so the -// harness runs exactly one capture contract against it: v1 advertises capture_v1, -// otherwise capture_v0. -const CAPTURE_MODE = process.env.POSTHOG_CAPTURE_MODE === 'v1' ? 'v1' : 'v0' - -// Harness EventOptions -> posthog-node sentinel properties. The SDK lifts these -// sentinels out of properties into the v1 event `options` object. -const OPTION_SENTINELS = { - cookieless_mode: '$cookieless_mode', - disable_skew_correction: '$ignore_sent_at', - process_person_profile: '$process_person_profile', - product_tour_id: '$product_tour_id', -} - -const state = { - client: null, - totalEventsCaptured: 0, - totalEventsSent: 0, - totalRetries: 0, - lastError: null, - requestsMade: [], - pendingEvents: 0, -} - -async function discardClient() { - if (!state.client) { - return - } - - const client = state.client - state.client = null - - // Test resets should discard queued events instead of flushing them into - // the next mock-server scenario. - try { - client.clearFlushTimer?.() - client.setPersistedProperty?.('queue', []) - // v1 mode routes $ai_* events to a separate queue, and captureAi() events to - // their own dedicated queue; clear both so they can't leak into the next scenario. - client.setPersistedProperty?.('ai_queue', []) - client.setPersistedProperty?.('ai_capture_queue', []) - await client.shutdown(1) - } catch (error) { - // Ignore reset-time shutdown errors; the next test starts with a fresh client. - } -} - -app.get('/health', (req, res) => { - res.json({ - sdk_name: 'posthog-node', - sdk_version: require('../packages/node/package.json').version, - adapter_version: '1.0.0', - capabilities: - CAPTURE_MODE === 'v1' - ? ['capture_v1', 'capture_ai_v0', 'encoding_gzip'] - : ['capture_v0', 'capture_ai_v0', 'encoding_gzip'], - }) -}) - -app.post('/init', async (req, res) => { - const { - api_key, - host, - flush_at, - flush_interval_ms, - max_retries, - enable_compression, - disable_geoip, - historical_migration, - } = req.body - - await discardClient() - - // Reset state - state.totalEventsCaptured = 0 - state.totalEventsSent = 0 - state.totalRetries = 0 - state.lastError = null - state.requestsMade = [] - state.pendingEvents = 0 - - // Create new client - state.client = new PostHog(api_key, { - host, - // Respect the harness batch-size configuration, including flush_at: 1 - // for auto-flush compliance tests. Default to 2 only when omitted so - // ad-hoc single-event captures wait for an explicit /flush call. - flushAt: flush_at ?? 2, - flushInterval: flush_interval_ms ?? 100, - fetchRetryCount: max_retries ?? 3, - // Keep v1 partial-retry backoff short so retry compliance tests stay well - // within their wait windows (the mock also sends Retry-After: 1s). - fetchRetryDelay: CAPTURE_MODE === 'v1' ? 250 : undefined, - disableCompression: enable_compression === undefined ? undefined : !enable_compression, - disableGeoip: disable_geoip ?? false, - historicalMigration: historical_migration ?? undefined, - // Capture V1 opt-in is env-var-only: the SDK reads POSTHOG_CAPTURE_MODE - // (set by docker-compose / Dockerfile.v1) itself, so no option is passed. - // Use before_send to track events being sent - before_send: (event) => { - // Track that event is being sent - // Note: This runs before the HTTP request - return event - }, - // Override fetch to track HTTP requests - fetch: async (url, options) => { - const response = await fetch(url, options) - - // Track the request - try { - const body = options?.body ? JSON.parse(options.body) : null - const events = body?.batch || (body ? [body] : []) - - state.requestsMade.push({ - timestamp_ms: Date.now(), - status_code: response.status, - retry_attempt: 0, - event_count: events.length, - uuid_list: events.map(e => e.uuid).filter(Boolean), - }) - - if (response.status === 200) { - state.totalEventsSent += events.length - state.pendingEvents -= events.length - if (state.pendingEvents < 0) state.pendingEvents = 0 - } - } catch (e) { - // Ignore parsing errors - } - - return response - }, - }) - - res.json({ success: true }) -}) - -app.post('/capture', (req, res) => { - if (!state.client) { - return res.status(400).json({ error: 'SDK not initialized' }) - } - - const { distinct_id, event, properties, timestamp, options } = req.body - - if (!distinct_id || !event) { - return res.status(400).json({ error: 'distinct_id and event are required' }) - } - - try { - // Translate harness EventOptions into the SDK's sentinel properties; the SDK - // lifts them into the v1 event `options` object (no-op in v0 mode). - const mergedProperties = { ...(properties || {}) } - if (options && typeof options === 'object') { - for (const [optionKey, sentinel] of Object.entries(OPTION_SENTINELS)) { - if (Object.prototype.hasOwnProperty.call(options, optionKey)) { - mergedProperties[sentinel] = options[optionKey] - } - } - } - - // Capture event - state.client.capture({ - distinctId: distinct_id, - event, - properties: mergedProperties, - timestamp: timestamp ? new Date(timestamp) : undefined, - }) - - state.totalEventsCaptured++ - state.pendingEvents++ - - // TODO: Get actual UUID from SDK - res.json({ success: true, uuid: 'generated-uuid' }) - } catch (error) { - state.lastError = error.message - res.status(500).json({ error: error.message }) - } -}) - -app.post('/capture_ai', (req, res) => { - if (!state.client) { - return res.status(400).json({ error: 'SDK not initialized' }) - } - - const { distinct_id, event, properties, timestamp, options, uuid } = req.body - - if (!distinct_id || !event) { - return res.status(400).json({ error: 'distinct_id and event are required' }) - } - - try { - const mergedProperties = { ...(properties || {}) } - if (options && typeof options === 'object') { - for (const [optionKey, sentinel] of Object.entries(OPTION_SENTINELS)) { - if (Object.prototype.hasOwnProperty.call(options, optionKey)) { - mergedProperties[sentinel] = options[optionKey] - } - } - } - - // Unlike /capture, forward a supplied uuid so it's echoed back to the caller. - const returnedUuid = state.client.captureAi({ - distinctId: distinct_id, - event, - properties: mergedProperties, - timestamp: timestamp ? new Date(timestamp) : undefined, - uuid, - }) - - state.totalEventsCaptured++ - state.pendingEvents++ - - res.json({ success: true, uuid: returnedUuid }) - } catch (error) { - state.lastError = error.message - res.status(500).json({ error: error.message }) - } -}) - -app.post('/flush', async (req, res) => { - if (!state.client) { - return res.status(400).json({ error: 'SDK not initialized' }) - } - - const sentBeforeFlush = state.totalEventsSent - - try { - await state.client.flush() - res.json({ success: true, events_flushed: state.totalEventsSent }) - } catch (error) { - // The harness deliberately configures mock-server failures for retry - // assertions. Treat SDK flush rejections as a completed adapter action - // so the harness can inspect the outbound requests it caused. - state.lastError = error.message - res.json({ - success: false, - events_flushed: Math.max(0, state.totalEventsSent - sentBeforeFlush), - error: error.message, - }) - } -}) - -app.get('/state', (req, res) => { - res.json({ - pending_events: state.pendingEvents, - total_events_captured: state.totalEventsCaptured, - total_events_sent: state.totalEventsSent, - total_retries: state.totalRetries, - last_error: state.lastError, - requests_made: state.requestsMade, - }) -}) - -app.post('/get_feature_flag', async (req, res) => { - if (!state.client) { - return res.status(400).json({ error: 'SDK not initialized' }) - } - - const { - key, - distinct_id, - person_properties, - groups, - group_properties, - disable_geoip, - force_remote = true, - } = req.body || {} - - if (!key) { - return res.status(400).json({ error: 'key is required' }) - } - if (!distinct_id) { - return res.status(400).json({ error: 'distinct_id is required' }) - } - - try { - const value = await state.client.getFeatureFlag(key, distinct_id, { - groups, - personProperties: person_properties, - groupProperties: group_properties, - disableGeoip: disable_geoip, - onlyEvaluateLocally: !force_remote, - }) - - // Feature flag calls enqueue a $feature_flag_called event by default. - // Flush it before returning so it cannot leak into the next test case. - await state.client.flush() - - res.json({ success: true, value }) - } catch (error) { - state.lastError = error.message - res.status(500).json({ error: error.message }) - } -}) - -app.post('/reset', async (req, res) => { - await discardClient() - - state.totalEventsCaptured = 0 - state.totalEventsSent = 0 - state.totalRetries = 0 - state.lastError = null - state.requestsMade = [] - state.pendingEvents = 0 - - res.json({ success: true }) -}) - -const port = process.env.PORT || 8080 -app.listen(port, () => { - console.log(`PostHog Node SDK adapter listening on port ${port}`) -}) diff --git a/compliance/node/adapter.mjs b/compliance/node/adapter.mjs new file mode 100644 index 0000000000..b6d0dca852 --- /dev/null +++ b/compliance/node/adapter.mjs @@ -0,0 +1,381 @@ +/** + * PostHog Node SDK Compliance Adapter + * + * Wraps the posthog-node SDK for the YAML compliance harness (capture v1). + */ + +import { readFileSync } from 'node:fs' +import zlib from 'node:zlib' +import express from 'express' +import { PostHog } from '../packages/node/dist/entrypoints/index.node.mjs' + +const SDK_VERSION = JSON.parse(readFileSync(new URL('../packages/node/package.json', import.meta.url), 'utf8')).version + +const app = express() +app.use(express.json()) + +// The harness has one enable_compression flag but a test per codec, so each +// adapter process runs one codec and advertises only that encoding capability. +const COMPRESSION = process.env.COMPRESSION || 'gzip' +const CODECS = ['gzip', 'deflate', 'zstd'] +if (!CODECS.includes(COMPRESSION)) { + throw new Error(`unsupported COMPRESSION ${JSON.stringify(COMPRESSION)}: want one of ${CODECS.join(', ')}`) +} + +const DECOMPRESS = { + gzip: zlib.gunzipSync, + deflate: zlib.inflateSync, + zstd: zlib.zstdDecompressSync, +} + +const state = { + client: null, + totalEventsCaptured: 0, + totalEventsSent: 0, + lastError: null, + requestsMade: [], + pendingEvents: 0, + reportedDrops: [], + logLines: [], + // Event names per uuid in send order (from requests) and in call order (from captureAi), so a reported drop can + // name its event; a duplicate uuid has several names. Kept apart so one event is never counted twice. + sentNames: new Map(), + calledNames: new Map(), +} + +function resetCounters() { + state.totalEventsCaptured = 0 + state.totalEventsSent = 0 + state.lastError = null + state.requestsMade = [] + state.pendingEvents = 0 + state.reportedDrops = [] + state.logLines = [] + state.sentNames = new Map() + state.calledNames = new Map() +} + +// The SDK logs through console; /state returns those lines (log_capture). +for (const level of ['log', 'info', 'warn', 'error', 'debug']) { + const write = console[level].bind(console) + console[level] = (...args) => { + state.logLines.push(args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' ')) + write(...args) + } +} + +function rememberName(names, uuid, event) { + if (uuid) { + names.set(uuid, [...(names.get(uuid) ?? []), event]) + } +} + +function recordDrops(error) { + const duplicatesSeen = new Map() + for (const { uuid, details } of error?.drops ?? []) { + const names = state.sentNames.get(uuid) ?? state.calledNames.get(uuid) ?? [] + let index = 0 + if (details === 'duplicate_event_uuid') { + // Capture keeps the first copy, so the dropped copies are the later names, in order. + index = 1 + (duplicatesSeen.get(uuid) ?? 0) + duplicatesSeen.set(uuid, index) + } + state.reportedDrops.push({ uuid, event: names[index] ?? names[0] ?? null, details: details ?? null }) + } +} + +const PADDING_KEY = '$test_padding' + +// One ASCII padding property makes the properties' compact JSON exactly `size` bytes (generated_payloads). +function padProperties(properties, size, marker = '') { + const padded = { ...properties, [PADDING_KEY]: '' } + const fill = size - Buffer.byteLength(JSON.stringify(padded), 'utf8') + // The marker's bytes as written in JSON, escapes included, without its quotes. + const markerBytes = Buffer.byteLength(JSON.stringify(marker), 'utf8') - 2 + if (fill < markerBytes) { + throw new Error(`pad_properties_to_bytes ${size} is smaller than the properties`) + } + padded[PADDING_KEY] = marker + 'x'.repeat(fill - markerBytes) + return padded +} + +async function discardClient() { + if (!state.client) { + return + } + + const client = state.client + state.client = null + + // Test resets should not flush queued events into the next mock-server scenario. + try { + await client.shutdown(1) + } catch { + // The next test starts with a fresh client. + } +} + +function headerValue(headers, name) { + if (!headers) { + return undefined + } + if (typeof headers.get === 'function') { + return headers.get(name) ?? undefined + } + const key = Object.keys(headers).find((k) => k.toLowerCase() === name) + return key === undefined ? undefined : headers[key] +} + +// Best effort: /state is diagnostic, so an unreadable body only skips the counters. +function parseEvents(fetchOptions) { + const body = fetchOptions?.body + if (!body) { + return [] + } + let raw = + typeof body === 'string' + ? Buffer.from(body) + : Buffer.from(body.buffer ?? body, body.byteOffset, body.byteLength) + const encoding = headerValue(fetchOptions.headers, 'content-encoding') + if (encoding && DECOMPRESS[encoding]) { + raw = DECOMPRESS[encoding](raw) + } + const parsed = JSON.parse(raw.toString('utf8')) + return Array.isArray(parsed?.batch) ? parsed.batch : [] +} + +function captureMessage({ distinct_id, event, properties, timestamp, options, uuid, pad_properties_to_bytes, marker }) { + return { + distinctId: distinct_id, + event, + properties: + pad_properties_to_bytes === undefined + ? properties || {} + : padProperties(properties || {}, pad_properties_to_bytes, marker), + timestamp: timestamp ? new Date(timestamp) : undefined, + // Options and caller uuids go to the SDK unchanged; the SDK owns validation. + options, + uuid, + } +} + +app.get('/health', (req, res) => { + res.json({ + sdk_name: 'posthog-node', + sdk_version: SDK_VERSION, + adapter_version: '2.0.0', + capabilities: [ + 'capture_v1', + 'capture_ai_v1', + 'event_options', + 'event_uuid_override', + 'bounded_retries', + 'disable_geoip', + 'historical_migration', + 'reported_drops', + 'log_capture', + 'generated_payloads', + 'property_layers', + 'fail_open', + `encoding_${COMPRESSION}`, + ], + }) +}) + +app.post('/init', async (req, res) => { + const { + api_key, + host, + flush_at, + flush_interval_ms, + max_retries, + enable_compression, + disable_geoip, + historical_migration, + super_properties, + before_send, + } = req.body + + await discardClient() + resetCounters() + + // The SDK compresses both lanes by default; the harness flag picks this process's codec or none. + const compression = enable_compression === undefined ? undefined : enable_compression ? COMPRESSION : 'none' + + state.client = new PostHog(api_key, { + host, + // flush_at: 1 sends on capture; default to 2 so a lone capture waits for /flush. + flushAt: flush_at ?? 2, + flushInterval: flush_interval_ms ?? 100, + captureMaxRetries: max_retries ?? 3, + captureCompression: compression, + captureAiCompression: compression, + disableGeoip: disable_geoip ?? false, + historicalMigration: historical_migration ?? undefined, + ...(before_send + ? { + before_send: (event) => ({ + ...event, + properties: { ...event.properties, ...before_send.set_properties }, + }), + } + : {}), + fetch: async (url, fetchOptions) => { + const response = await fetch(url, fetchOptions) + try { + const events = parseEvents(fetchOptions) + const attempt = Number(headerValue(fetchOptions?.headers, 'posthog-attempt') ?? 1) + if (attempt === 1) { + for (const event of events) { + rememberName(state.sentNames, event.uuid, event.event) + } + } + state.requestsMade.push({ + timestamp_ms: Date.now(), + status_code: response.status, + retry_attempt: Math.max(0, attempt - 1), + event_count: events.length, + uuid_list: events.map((e) => e.uuid).filter(Boolean), + }) + if (response.status === 200) { + state.totalEventsSent += events.length + state.pendingEvents = Math.max(0, state.pendingEvents - events.length) + } + } catch { + // Diagnostic counters only. + } + return response + }, + }) + state.client.on('error', (error) => { + state.lastError = error?.message ?? String(error) + recordDrops(error) + }) + if (super_properties) { + await state.client.register(super_properties) + } + + res.json({ success: true }) +}) + +app.post('/capture', (req, res) => { + if (!state.client) { + return res.status(400).json({ error: 'SDK not initialized' }) + } + + try { + const message = captureMessage(req.body) + state.client.capture(message) + state.totalEventsCaptured++ + state.pendingEvents++ + // capture() returns nothing, so only a caller-supplied uuid is known here. + res.json({ success: true, uuid: message.uuid ?? null }) + } catch (error) { + state.lastError = error.message + res.status(500).json({ error: error.message }) + } +}) + +app.post('/capture_ai', (req, res) => { + if (!state.client) { + return res.status(400).json({ error: 'SDK not initialized' }) + } + + try { + const uuid = state.client.captureAi(captureMessage(req.body)) + // A local drop is never sent, so it is named from the call. + rememberName(state.calledNames, uuid, req.body.event) + state.totalEventsCaptured++ + state.pendingEvents++ + res.json({ success: true, uuid: uuid ?? null }) + } catch (error) { + state.lastError = error.message + res.status(500).json({ error: error.message }) + } +}) + +app.post('/flush', async (req, res) => { + if (!state.client) { + return res.status(400).json({ error: 'SDK not initialized' }) + } + + const sentBeforeFlush = state.totalEventsSent + + try { + await state.client.flush() + res.json({ success: true, events_flushed: state.totalEventsSent - sentBeforeFlush }) + } catch (error) { + // Retry tests configure failing responses on purpose; the harness inspects the requests instead. + state.lastError = error.message + res.json({ + success: false, + events_flushed: Math.max(0, state.totalEventsSent - sentBeforeFlush), + error: error.message, + }) + } +}) + +app.get('/state', (req, res) => { + res.json({ + pending_events: state.pendingEvents, + total_events_captured: state.totalEventsCaptured, + total_events_sent: state.totalEventsSent, + total_retries: state.requestsMade.filter((r) => r.retry_attempt > 0).length, + last_error: state.lastError, + requests_made: state.requestsMade, + reported_drops: state.reportedDrops, + log_lines: state.logLines, + }) +}) + +app.post('/get_feature_flag', async (req, res) => { + if (!state.client) { + return res.status(400).json({ error: 'SDK not initialized' }) + } + + const { + key, + distinct_id, + person_properties, + groups, + group_properties, + disable_geoip, + force_remote = true, + } = req.body || {} + + if (!key) { + return res.status(400).json({ error: 'key is required' }) + } + if (!distinct_id) { + return res.status(400).json({ error: 'distinct_id is required' }) + } + + try { + const value = await state.client.getFeatureFlag(key, distinct_id, { + groups, + personProperties: person_properties, + groupProperties: group_properties, + disableGeoip: disable_geoip, + onlyEvaluateLocally: !force_remote, + }) + + // Flush the $feature_flag_called event so it cannot leak into the next test case. + await state.client.flush() + + res.json({ success: true, value }) + } catch (error) { + state.lastError = error.message + res.status(500).json({ error: error.message }) + } +}) + +app.post('/reset', async (req, res) => { + await discardClient() + resetCounters() + res.json({ success: true }) +}) + +const port = process.env.PORT || 8080 +app.listen(port, () => { + process.stdout.write(`PostHog Node SDK adapter listening on port ${port} (compression=${COMPRESSION})\n`) +}) diff --git a/compliance/node/docker-compose.yml b/compliance/node/docker-compose.yml index 8ab0598b93..bf9813ca8f 100644 --- a/compliance/node/docker-compose.yml +++ b/compliance/node/docker-compose.yml @@ -5,16 +5,15 @@ services: dockerfile: compliance/node/Dockerfile expose: - "8080" - # Set POSTHOG_CAPTURE_MODE=v1 to run the Capture V1 contract locally; the - # adapter then advertises capture_v1 and the harness selects the v1 suite. + # One codec per adapter process: gzip, deflate or zstd. environment: - - POSTHOG_CAPTURE_MODE=${POSTHOG_CAPTURE_MODE:-} + - COMPRESSION=${COMPRESSION:-gzip} networks: - test-network test-harness: - image: ghcr.io/posthog/sdk-test-harness:0.10.0 - command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081"] + image: ghcr.io/posthog/sdk-test-harness:1.26.0 + command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081", "--sdk-type", "server"] networks: - test-network depends_on: 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, /