Skip to content

fix(node): honor privacyMode set as a client option - #5296

Draft
posthog[bot] wants to merge 1 commit into
mainfrom
posthog/node-client-privacy-mode
Draft

posthog[bot] wants to merge 1 commit into
mainfrom
posthog/node-client-privacy-mode

Conversation

@posthog

@posthog posthog Bot commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Problem

PostHogOptions.privacyMode is documented as "privacy mode always wins", but the Node client never read, stored or exposed it. @posthog/ai's withPrivacyMode resolves the client-level setting by reading it off the client instance, so for a real new PostHog(key, { privacyMode: true }) client it always saw undefined and redacted nothing. Only the per-call privacyMode argument worked. The existing @posthog/ai tests covered this path by monkey-patching privacy_mode onto a mock client — something a real client could never reproduce.

This brings the Capture AI contract into compliance with openspec/specs/capture-ai/spec.md, which requires that enableFullAiCapture be overridden by privacy mode "regardless of client-level or per-call configuration", with the scenario "privacy mode wins over the flag" — given the client has privacy mode enabled and the flag set to true, the captured event should carry no input or output content.

Compliance matrix note (compliance/posthog-node.md, n6 — Capture AI, 🟡 Partial):

Gap: the "privacy mode always wins" guarantee is broken at the client-config level. withPrivacyMode correctly ORs a client-level privacy_mode with a per-call privacyMode argument and redacts if either is true — but PostHogOptions.privacyMode is never read, stored, or assigned anywhere in packages/node/src/client.ts or packages/core/src — no constructor assignment, no getter. […] The per-call privacyMode argument passed directly to an AI-wrapper call does work correctly and unconditionally redacts — only the persistent client-level override is dead.

Remediation: store the normalized privacyMode option as a real property/getter so a real client instance carries the value the type declares; add a regression test.

Changes

  • packages/node/src/client.ts — store the normalized option as public readonly privacyMode, mirroring how enableFullAiCapture is already resolved in the constructor.
  • packages/node/src/types.ts — document the privacyMode option (it previously had no doc comment).
  • packages/ai/src/utils.ts — withPrivacyMode now redacts on either privacyMode (the PostHogOptions name the client now carries) or the historical privacy_mode spelling callers set directly on a client.

This also fixes packages/ai/src/claude-agent-sdk/processor.ts, which already read posthog?.privacyMode off the client and so was subject to the same dead option.

Backwards compatibility

Backwards-compatible, and strictly strengthening. No caller gets any redaction benefit from the client-level privacyMode option today, because it does nothing — wiring it up can only add redaction for callers who already believed they had set it. The legacy privacy_mode spelling keeps working, no public signature changes, and nothing is removed.

Testing performed

  • New regression test in packages/node/src/__tests__/ai-capture/wiring.spec.ts: privacyMode defaults to false, is carried onto the client when set, and stays set alongside enableFullAiCapture: true.
  • New withPrivacyMode unit tests in packages/ai/tests/utils.test.ts covering the per-call value and both client-level spellings.
  • pnpm --filter posthog-node test:unit — 1114 passed.
  • pnpm --filter @posthog/ai test:unit — 1129 passed, 1 skipped.
  • Lint and check-types clean for both packages.
  • No manual/end-to-end run against a live provider.

Follow-up work

Not in scope here, from the same matrix note / neighbouring rows: the @posthog/ai provider tests still assert this path by monkey-patching a mock client, so they could be moved onto a real client now that the option exists.

Release info Sub-libraries affected

Libraries affected

  • All of them
  • posthog-js (web)
  • posthog-js-lite (web lite)
  • posthog-node
  • posthog-react-native
  • @posthog/react-native-plugin
  • @posthog/react
  • @posthog/ai
  • @posthog/convex
  • @posthog/next
  • @posthog/nextjs-config
  • @posthog/nuxt
  • @posthog/openfeature-node-provider
  • @posthog/openfeature-web-provider
  • @posthog/rollup-plugin
  • @posthog/webpack-plugin
  • @posthog/types
  • @posthog/browser-common

Checklist

  • Tests for new code
  • Accounted for the impact of any changes across different platforms
  • Accounted for backwards compatibility of any changes (no breaking changes!)
  • Took care not to unnecessarily increase the bundle size

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Fully autonomous

Opened by the scheduled SDK-compliance agent, which reads the compliance matrices in PostHog/sdk-specs and implements one backwards-compatible gap per run. This run's candidate was posthog-node × Capture AI, picked over runner-ups (posthog-node HTTP Client flags-retry backoff timing; posthog-python $release_id on minimized $feature_flag_called) because it is a user-facing privacy guarantee that is silently inert today, and the remediation is self-contained.

Tools: Claude Code (Read/Edit/Bash, vitest, oxlint, tsc). The one judgement call: withPrivacyMode now checks both privacyMode and privacy_mode rather than renaming the client field to snake_case — keeping the camelCase PostHogOptions name on the client matches the surrounding code, and keeping the old spelling avoids breaking anyone who sets it directly.

Agent-authored; requires human review.


Created with PostHog Desktop

🤖 Generated with Claude Code

PostHogOptions declared `privacyMode`, but the Node client never read, stored or
exposed it, so `@posthog/ai`'s `withPrivacyMode` — which reads the value off the
client — always saw `undefined`. Only the per-call `privacyMode` argument worked.

Store the normalized option as `public readonly privacyMode` alongside
`enableFullAiCapture`, and let `withPrivacyMode` accept either that name or the
historical `privacy_mode` spelling callers set directly on a client.

Brings posthog-node in line with the capture-ai spec's "privacy mode always wins"
requirement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: cd2e9a45-38e5-4bda-b490-640185728783
@github-actions

Copy link
Copy Markdown
Contributor

Coherence

2 spec problems · 0 failing chokepoints · 2 spec gaps · 0 guard failures

44 components, 5 bullets (5 invariants, 0 requirements), 2 problems

Spec problems (spec --check)

  • Posthog-js.practice.md:20 practice add an invariant cites 187a5c5, which is no commit in this repository
  • Posthog-js.practice.md:20 practice add an invariant cites 8922ab2, which is no commit in this repository

Chokepoints: none declared.

Spec gaps: 2 entrances with outside or unknown trust and no traced control, and 10 more held by the adoption baseline

  • packages/web/Web.spec.md entrance public api (trust host-app): packages/web
  • packages/webpack-plugin/Webpack-plugin.spec.md entrance webpack plugin (trust build): packages/webpack-plugin

Close one with guard: <chokepoint>, an invariant that names it on its entrances: line, or control: none — <reason>; scaffold control proposes it.

@github-actions

Copy link
Copy Markdown
Contributor

Node SDK compliance v2

Commit: 6810c1f
Workflow run and artifacts

Capture v0 — acceptance

✅ 5 passed / ❌ 0 non-passing / ⚠️ 812 not selected

Drill-down: run artifacts → node-v2-v0 → acceptance/report.json.diagnostics.json (match case_id below).

Harness exit: 0; report validation exit: 0.

All selected cases passed and report validation succeeded.

Capture v1 — acceptance

✅ 5 passed / ❌ 0 non-passing / ⚠️ 812 not selected

Drill-down: run artifacts → node-v2-v1 → acceptance/report.json.diagnostics.json (match case_id below).

Harness exit: 0; report validation exit: 0.

All selected cases passed and report validation succeeded.

Capture v0 — migration

✅ 55 passed / ❌ 1 non-passing / ⚠️ 101 not selected

Drill-down: run artifacts → node-v2-v0 → reports/report.json.diagnostics.json (match case_id below).

❌ migration/yaml-parity-v1/remote-flags-v1.feature::Omitted GeoIP settings default to enabled in flag requests — failed_assertion

Flags field differs

Code: flag_request_field

Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134

the first flags request field "geoip_disable" should equal JSON false

Operation: /flags/

Field: geoip_disable

Expected:

false

Actual value:

true

Harness exit: 1; report validation exit: 1.

Capture v1 — migration

✅ 120 passed / ❌ 1 non-passing / ⚠️ 36 not selected

Drill-down: run artifacts → node-v2-v1 → reports/report.json.diagnostics.json (match case_id below).

❌ migration/yaml-parity-v1/remote-flags-v1.feature::Omitted GeoIP settings default to enabled in flag requests — failed_assertion

Flags field differs

Code: flag_request_field

Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134

the first flags request field "geoip_disable" should equal JSON false

Operation: /flags/

Field: geoip_disable

Expected:

false

Actual value:

true

Harness exit: 1; report validation exit: 1.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants