Skip to content

feat: PostHog destination (web device-mode + server cloud-mode) - #21

Merged
tyssejc merged 15 commits into
mainfrom
feat/destination-posthog
Sep 4, 2026
Merged

tyssejc merged 15 commits into
mainfrom
feat/destination-posthog

Conversation

@tyssejc

@tyssejc tyssejc commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Adds @junctionjs/destination-posthog — a PostHog destination offered as two tree-shakeable factories in one package, mirroring the device-mode vs cloud-mode split. Import only the one you need; the other is eliminated by tree-shaking, and posthog-js is loaded from PostHog's CDN at runtime, never bundled.

What's included

  • createPostHogServer (cloud-mode) — transforms events and forwards them to PostHog's HTTP capture API. Batching (/capture for single, /batch above threshold), retry with exponential backoff on 429/5xx only (4xx short-circuits), requeue-on-failure with a bounded maxBufferSize, and a shutdown path that awaits in-flight requests so events aren't silently dropped when a short-lived server/edge process exits. Forwards $raw_user_agent so PostHog derives browser/OS correctly.
  • createPostHogWeb (device-mode) — installs PostHog's queue-before-load stub (so init() and pre-load captures actually queue instead of being dropped), routes to posthog.capture() / posthog.identify(), keeps Junction the event source of truth (autocapture / pageview / session replay off by default), and honors consent both ways (opt-out on revoke, opt-in on re-grant).
  • Identity-projection convention — documented in .claude/rules/destinations.md: how each destination projects Junction's canonical event.user into the vendor's shape, and that merge/alias vendors (PostHog) must act on user:identified.
  • Docs page + destinations-overview row, env-gated demo wiring (inert without a key), and a changeset (minor).
  • Repo hygiene: Biome now ignores generated .next/.astro output (it was emitting 900+ noise errors, making npm run lint unusable).

Testing

npm run typecheck, npm run lint, and the full vitest suite (225 tests) all pass; the package builds ESM + dts cleanly. New tests cover the server reliability paths (retry classes, requeue, buffer cap, timer flush, in-flight-await on teardown) and the web queue-before-load stub + consent symmetry.

Known gaps (tracked in the rules doc)

  • UserIdentity has no sessionId; GA4/Amplitude/PostHog session mapping is a cross-destination follow-up.
  • Web-mode delegates the anonymous distinct_id to posthog-js's own cookie ID rather than binding Junction's anonymousId (posthog-js needs it at init()); reconcile via bootstrap.distinctID later.

🤖 Generated with Claude Code

tyssejc and others added 15 commits July 3, 2026 15:28
Web (device-mode) + server (cloud-mode) destinations in one package with
two tree-shakeable factories. Establishes the canonical identity-projection
convention that GA4/Amplitude retrofit and the session-ID gap follow from.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 3 review surfaced three reliability defects in the originally
specified flush/retry design; all three fixed:

- flush() requeues its batch on exhausted-retry failure instead of
  dropping it; new maxBufferSize config (default 1000) bounds memory,
  dropping + logging oldest past the cap
- timer-driven flush failures always log (were gated behind debug,
  and run outside the collector's send() error wrapper)
- postWithRetry short-circuits non-retryable 4xx (retries 429 + 5xx only)

+5 reliability tests; suite 208/208. Plan amended to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC
Review of the web factory surfaced two plan-mandated defects:

- loadSnippet injected array.js but never created window.posthog, so
  init() and every capture()/identify() before the async script loaded
  were dropped — the destination never actually initialized in-browser.
  Now installs PostHog's official queuing stub; array.js replays queued
  calls (the queue-before-load pattern our destination rules require).
- onConsent only opted out on revoke; re-granting analytics consent was a
  no-op. Now calls opt_in_capturing() on re-grant (consent-first).

+3 tests. Plan Task 5 amended to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC
biome check . was linting Next.js and Astro build artifacts (900+ noise
errors), making `npm run lint` unusable. Both dirs are gitignored
generated output, same as dist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC
Barrel exports both factories + PostHogBaseConfig/POSTHOG_DEFAULT_HOST.
Full build verification (tsup ESM + dts, typecheck, lint, tests) surfaced
a type error in the Task 5 queue stub: [key].concat(unknown[]) fails tsc;
switched to [key, ...args] (identical runtime). Package builds clean,
223 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC
Canonical event.user.{anonymousId,userId,traits} + user:identified, and
how each destination projects it: parallel-fields vendors (GA4/Amplitude)
no-op identify; merge/alias vendors (PostHog) must act on it. Notes the
tracked session-ID gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC
- docs page + overview table row (Client + Server)
- demo: env-gated createPostHogWeb registration (inert without a key)
- root tsconfig paths entry for @junctionjs/destination-posthog
- minor changeset for the new package

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC
…ntity gap

Final whole-branch review findings:

- Critical: teardown() could return while a flush's fetch was still in
  flight (send() is awaited internally but the collector dispatches it
  fire-and-forget; the timer uses void flush()). On shutdown the runtime
  could exit mid-request and silently drop events. Now tracks in-flight
  flush promises and teardown awaits them (Promise.allSettled) after
  draining the buffer. Regression test fires an un-awaited send.
- Important: server-mode capture now forwards device.userAgent as
  $raw_user_agent so PostHog derives browser/OS correctly (it otherwise
  reads our server's outbound UA).
- Docs: qualify the web-mode identity claim — posthog-js manages its own
  anonymous distinct_id; Junction's anonymousId isn't bound at init.
  Recorded as a tracked known-gap alongside the sessionId gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC
@vercel

vercel Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
junctionjs Ready Ready Preview Sep 4, 2026 6:51pm UTC

@tyssejc
tyssejc merged commit e9c6155 into main Sep 4, 2026
6 checks passed
@tyssejc
tyssejc deleted the feat/destination-posthog branch September 4, 2026 19:01
@github-actions github-actions Bot mentioned this pull request Sep 4, 2026

This branch was successfully deployed

1 active deployment
Preview — 41bd0496 Deployed Sep 4, 2026 by vercel[bot]
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.

1 participant