From 0bfc97470d252bcf6a0c4550f3b4cbea01c607a1 Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Fri, 3 Jul 2026 15:28:40 -0400 Subject: [PATCH 01/15] docs: add PostHog destination design spec (Phase 2.1) 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) --- .../2026-06-16-posthog-destination-design.md | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-16-posthog-destination-design.md diff --git a/docs/superpowers/specs/2026-06-16-posthog-destination-design.md b/docs/superpowers/specs/2026-06-16-posthog-destination-design.md new file mode 100644 index 0000000..f568f9a --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-posthog-destination-design.md @@ -0,0 +1,242 @@ +# PostHog Destination — Design Spec + +**Date:** 2026-06-16 +**Status:** Approved (design), pending implementation plan +**Roadmap:** Phase 2.1 — PostHog Destination + +## Summary + +Add `@junctionjs/destination-posthog`: a single package exporting two tree-shakeable +factory functions — `createPostHogWeb()` (device-mode) and `createPostHogServer()` +(cloud-mode) — that let an engineering team send Junction events to PostHog with an +explicit web/server split, modeled on Segment's device-mode vs cloud-mode distinction +but adapted to Junction's conventions. + +This is the **first Junction destination to model the web/server split as a first-class +product concept**. GA4 is client-only; Amplitude quietly conflates both behind one HTTP +`runtime: "both"`. PostHog makes device-mode vs cloud-mode a deliberate choice the +engineer makes based on whether they need browser-side signals. + +## Background & Motivation + +Junction positions itself as a tag-manager replacement for engineers ("bring your own +device collection layer"). An engineering team choosing PostHog without Adobe Launch or +GTM has two legitimate needs: + +- **"I want the full PostHog library"** — sessionization, person profiles, session replay, + feature flags, browser-signal enrichment (`$current_url`, referrer, UTM, device). This is + device-mode: load `posthog-js` on the page. +- **"I just want events in my PostHog dataset"** — forward events server-side to the capture + API. This is cloud-mode: explicitly *not* a full replacement (no replay, no flags, no + client sessionization, no automatic browser enrichment), consistent with how Segment + frames its cloud-mode destinations. + +**Scope framing (short-term goal):** demonstrate multiple *configurable* destinations, not +100% feature parity with PostHog. Expose PostHog's marquee capabilities as configurable +knobs, default them conservatively, do not attempt to wrap every posthog-js method in v1. + +## Key Design Decisions + +### 1. Two destinations: web (device-mode) and server (cloud-mode) + +Maps onto Junction's existing `runtime: "client" | "server"` field. + +- **PostHog Web** (`runtime: "client"`) — loads `posthog-js`, routes Junction's tracked + events through `posthog.capture()`. Full library available for the asking. +- **PostHog Server** (`runtime: "server"`) — forwards to PostHog's `/capture` (and `/batch`) + HTTP API. No browser signals, no replay/flags/sessionization. + +**Junction stays the event source of truth.** Even in web mode, posthog-js's autonomous +capture (`autocapture`, `capture_pageview`) is **off by default** — Junction feeds it events +rather than letting it independently capture. Otherwise double-counting and two competing +event models. The engineer can opt into autocapture explicitly. + +### 2. One package, two tree-shakeable factories + +`@junctionjs/destination-posthog` exports `createPostHogWeb` and `createPostHogServer` as +independent, side-effect-free named exports (`sideEffects: false`, ESM-only — Junction +conventions). An engineer importing only `createPostHogServer` gets **only** the server code +path in their bundle; the web factory is statically eliminated. + +**Rejected alternatives:** +- *Two separate packages* (`-web`, `-server`) — would force the same split on GA4, Amplitude, + and every future vendor; messy for engineers who must know which package to install; more + release/version overhead. Diverges from the one-package-per-vendor norm. +- *One factory, mode option* (`posthog({ mode })`) — hides two very different implementations + behind one entry, less discoverable, conflates the distinction we want to make explicit. + +**Footprint:** the "one package = everything ships to everyone" worry is dissolved by two +mechanisms already used in Junction — tree-shaking (server-only import excludes web code) and +runtime script loading (posthog-js is loaded from PostHog's CDN snippet at runtime, never +bundled as an npm dependency). Net: server-only engineers ship a few KB of `fetch`-based +code; web engineers ship a thin script-loader and pull posthog-js from the edge. No +`posthog-js` in anyone's `package.json`. + +**Deliberate v1 cut:** script-load only, no npm-import option for posthog-js. Some teams +eventually want the npm package (typing, strict CSP) — that's a config knob to add later on +signal (YAGNI). + +### 3. posthog-js init posture — conservative, everything opt-in + +| posthog-js capability | Web default | Rationale | +|---|---|---| +| `autocapture` | **off** | Junction owns the event stream; would double-fire alongside `track()` | +| `capture_pageview` | **off** | Junction emits `page:viewed`, routed through `posthog.capture()` | +| `capture_pageleave` | **off** | Tied to pageview ownership | +| Session replay | **off**, opt-in via `sessionReplay: true` | Heavy payload + privacy-sensitive | +| Feature flags | **loaded** (posthog-js fetches on init), **no Junction wrapper API in v1** | Reading flags is orthogonal to event collection; call `posthog.isFeatureEnabled()` directly. Typed wrapper is a later, signal-driven addition | +| `persistence` (cookies/localStorage) | PostHog default — safe | Destination only inits *after* consent resolves; no consent → posthog-js never loads → no cookies | + +Throughline: Junction calls `posthog.capture()` for events it already tracks; everything +posthog-js would do autonomously is off unless the engineer turns it on. + +**Consent revocation mid-session:** since the script is already loaded, `onConsent`/`teardown` +must stop capture (`posthog.opt_out_capturing()`). This is real logic, not free — same problem +GA4 already solves, so there is precedent. + +### 4. Identity — canonical projection convention + +Junction already has a canonical identity model consumed by GA4 and Amplitude: + +- `event.user.anonymousId` — first-party anonymous/device ID, always present +- `event.user.userId` — known ID, populated after `collector.identify()` +- `event.user.traits` — persistent traits from `identify()` +- `collector.identify(userId, traits)` — mutates canonical user *and* emits `user:identified` +- `event.id` — unique per event, dedup key + +**Canonical → vendor projection:** + +| Junction canonical | GA4 (today) | Amplitude (today) | PostHog (this spec) | +|---|---|---|---| +| `anonymousId` | `client_id` | `device_id` | `distinct_id` (while anonymous) | +| `userId` | `user_id` | `user_id` | `distinct_id` after merge + `$identify` | +| `traits` | `user_properties` | `user_properties` | person props via `$set` | +| `user:identified` | no-op (carries `user_id`) | no-op (carries `user_id`) | `posthog.identify()` / `$identify` w/ `$anon_distinct_id` | +| `event.id` | n/a | `insert_id` | `uuid` (dedup) | + +**Why PostHog forces this to be a named concept:** GA4/Amplitude use a *parallel-fields* +identity model (carry `device_id` + `user_id` side by side forever). PostHog uses a +*merge/alias* model — a single `distinct_id` that starts anonymous and, on identify, gets +stitched to the known person so pre-login history follows them. PostHog is therefore the first +destination that must *act* on `user:identified` rather than passively stamp a field. + +**Convention introduced by this spec:** document the canonical→vendor identity projection in +`.claude/rules/destinations.md`, and add "handle `user:identified`" as an explicit step in the +destination contract (even though GA4/Amplitude currently no-op it). + +**Out of scope (tracked fast-follows, not this spec):** +- Audit GA4/Amplitude against the named convention (they already comply on field mapping). +- **Session-ID gap** — `UserIdentity` has no `sessionId`; GA4/Amplitude don't map + `session_id`/`$session_id`. A genuine cross-destination hole; its own ticket. + +## Configuration Surface + +```ts +interface PostHogBaseConfig { + apiKey: string; // PostHog project API key + host?: string; // default "https://us.i.posthog.com"; eu.i... or self-hosted + consent?: ConsentCategory[]; // default ["analytics"] + eventNameMap?: Record; + debug?: boolean; +} + +interface PostHogWebConfig extends PostHogBaseConfig { + loadScript?: boolean; // default true (queue-before-load snippet) + scriptUrl?: string; // override for reverse-proxy / self-host + sessionReplay?: boolean; // default false + autocapture?: boolean; // default false + capturePageview?: boolean; // default false — Junction owns page:viewed +} + +interface PostHogServerConfig extends PostHogBaseConfig { + batchSize?: number; // default 20 → uses /batch; else /capture + flushIntervalMs?: number; // default 5000 + maxRetries?: number; // default 3, exponential backoff +} +``` + +## Event Name Mapping + +Junction's 3-tier fallback: config override → `DEFAULT_MAP` → generated `entity_action`. + +`DEFAULT_MAP` is deliberately thin — PostHog accepts arbitrary event names: + +```ts +const POSTHOG_DEFAULT_MAP: Record = { + "page:viewed": "$pageview", +}; +``` + +Two events sit **outside** `capture()`: +- `_system` entity → `transform` returns `null` (filtered, per the reserved-entity rule). +- `user:identified` → routed to identify/`$identify`, not a generic capture. + +## Data Flow + +### Web (device-mode) + +1. `init` — SSR guard (`typeof window === "undefined"` → skip). Inject posthog snippet via + queue-before-load pattern (idempotent, `script.async = true`, `loadScript`-gated, custom + `scriptUrl` supported). Init posthog-js with `autocapture`/`capture_pageview` off, + `sessionReplay` respected. +2. `transform` — map entity:action → event name, build properties. `_system` → `null`. + `user:identified` marked for identify routing. +3. `send` — `posthog.capture(name, properties)` using posthog-js's managed `distinct_id`. + `user:identified` → `posthog.identify(userId, { $set: traits })` (auto-aliases prior anon ID). +4. `onConsent`/`teardown` — on consent revocation, `posthog.opt_out_capturing()`. + +### Server (cloud-mode) + +1. `init` — set up batch buffer if `batchSize > 1`. No window, no cookies. +2. `transform` — build `{ api_key, event, distinct_id: userId ?? anonymousId, properties, + timestamp, uuid: event.id }`. `_system` → `null`. `user:identified` → `$identify` event + carrying `$anon_distinct_id: anonymousId` + `distinct_id: userId`. +3. `send` — POST to `/capture`, or buffer and flush to `/batch` when `batchSize` reached or + `flushIntervalMs` elapses. Retry with exponential backoff up to `maxRetries`. + +## Consent + +- Default `consent: ["analytics"]`. +- The destination only initializes after consent resolves, so cookie/persistence concerns are + handled by the existing consent gate (no consent → posthog-js never loads → no cookies). +- Session replay stays under `analytics` for v1 but is flagged as sensitive and worth + revisiting (may warrant a stricter category). + +## Error Isolation + +Follows `.claude/rules/destinations.md`: + +- try/catch at `init`, `transform`, `send` boundaries. +- `emit("destination:error", …)` so consumers can observe failures. +- `[Junction]` prefix on all console output. +- `send` never blocks the collector — `.catch()` logs, no awaited blocking. +- One destination's failure never affects another. + +## Testing + +Co-located Vitest (`packages/destination-posthog/src/**/*.test.ts`), `make*` factories for +data, `mock*` for spies. + +- **Server:** mock `fetch`. Cover payload shape, `distinct_id` precedence (`userId ?? + anonymousId`), `uuid` dedup, `/capture` vs `/batch` selection, batch flush on size + interval, + retry/backoff on failure, `$identify` on `user:identified`. +- **Web:** posthog-js stub + `window` guard. Cover SSR skip, idempotent script load, + `loadScript: false`, init options (autocapture/pageview off, sessionReplay), `capture()` + routing, `identify()` on `user:identified`, `opt_out_capturing()` on consent revocation. +- **Shared:** 3-tier event-name mapping, `_system` filtering, config defaults. + +## Deliverables (Phase 2.1) + +- `packages/destination-posthog/` — package + tests, following `.claude/rules/packages.md` + (ESM-only, peerDependency on core, `sideEffects: false`, tsup build flags). +- Demo-app wiring (`apps/demo/`) showing both web and server destinations. +- Starlight docs page (`apps/docs/src/content/docs/destinations/posthog.mdx`). +- Identity-convention addition to `.claude/rules/destinations.md`. +- Changeset + npm publish. + +## Follow-ups (out of scope) + +- Audit GA4/Amplitude against the identity-projection convention. +- Session-ID cross-destination gap (add `sessionId` to `UserIdentity`, map per vendor). +- Optional npm-import mode for posthog-js (typing / strict CSP). +- Typed Junction wrapper for PostHog feature flags. From 694bbaf174b8ca06cfb2e92c23484abc71520c51 Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Fri, 3 Jul 2026 16:10:45 -0400 Subject: [PATCH 02/15] docs: add PostHog destination implementation plan (8 tasks, TDD) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-16-posthog-destination.md | 1496 +++++++++++++++++ 1 file changed, 1496 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-16-posthog-destination.md diff --git a/docs/superpowers/plans/2026-06-16-posthog-destination.md b/docs/superpowers/plans/2026-06-16-posthog-destination.md new file mode 100644 index 0000000..0ce68a9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-posthog-destination.md @@ -0,0 +1,1496 @@ +# PostHog Destination Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `@junctionjs/destination-posthog` — one package exporting two tree-shakeable factories, `createPostHogServer()` (cloud-mode) and `createPostHogWeb()` (device-mode) — that send Junction events to PostHog. + +**Architecture:** Follows the Plausible destination's factory pattern: each factory captures config in a closure and returns a `Destination` object with `init`/`transform`/`send` (+ `onConsent`/`teardown` for web). Pure helpers (event-name mapping, distinct_id resolution, `_system` filtering) live in a shared module. Server uses `fetch` against PostHog's `/capture` and `/batch` HTTP APIs with buffering + retry. Web loads `posthog-js` from PostHog's CDN via the queue-before-load snippet and routes Junction events through `posthog.capture()`/`posthog.identify()`. + +**Tech Stack:** TypeScript (strict), ESM-only, tsup build, Vitest, Biome. Peer dependency on `@junctionjs/core`. No runtime dependency on `posthog-js` (loaded via script snippet). + +## Global Constraints + +Copied verbatim from `.claude/rules/packages.md`, `.claude/rules/destinations.md`, `.claude/rules/events.md`, and the design spec: + +- **ESM only.** Build with `tsup src/index.ts --format esm --dts --sourcemap --target es2022 --no-config --external @junctionjs/core`. No CJS. No tsup config files — CLI flags only. +- **Dependency layering.** Destinations depend on core via `peerDependencies` only. Never import from `client` or `gateway`. +- **Package exports.** `@junctionjs/destination-posthog` scope. `exports` maps `.` to `./dist/index.js` + `./dist/index.d.ts`. `files: ["dist", "README.md"]`. `publishConfig: { access: "public", provenance: true }`. Add `"sideEffects": false` (required for consumer tree-shaking of the two factories). +- **Biome style.** Double quotes, semicolons always, trailing commas everywhere, 2-space indent, 120 char line width. Import organization on. `noExplicitAny` off, `noNonNullAssertion` off. Never add ESLint/Prettier. +- **Factory pattern.** Use factory functions returning plain objects, not classes, not `new`. +- **Event name mapping** — 3-tier: `config.eventNameMap[key]` → `DEFAULT_MAP[key]` → generated `entity_action`. +- **System events** — `transform` returns `null` when `event.entity === "_system"`. +- **Error isolation** — throw from `init`/`send` on real failures; the collector wraps every boundary. Prefix all console output with `[Junction:posthog-*]`. +- **Consent default** — `["analytics"]` for both factories. +- **Identity** — `distinct_id = event.user.userId ?? event.user.anonymousId`. `user:identified` is NOT a normal capture; it maps to `$identify` (server) / `posthog.identify()` (web). +- **PostHog default host** — `https://us.i.posthog.com`. +- **Node** — `engines.node >= 18`. + +--- + +## File Structure + +``` +packages/destination-posthog/ +├── package.json # New — copy Plausible's, adjust name/keywords, add sideEffects:false +├── tsconfig.json # New — copy Plausible's verbatim +├── README.md # New — usage for both factories +└── src/ + ├── shared.ts # New — types (PostHogBaseConfig), event-name mapping, distinct_id, _system helpers + ├── shared.test.ts # New + ├── server.ts # New — createPostHogServer (transform + send + batching + retry + factory) + ├── server.test.ts # New + ├── web.ts # New — createPostHogWeb (loader + transform + send + consent + factory) + ├── web.test.ts # New + ├── index.ts # New — barrel: re-export both factories + public types + └── index.test.ts # New — smoke test that barrel exports resolve +``` + +Other files touched: +- `vitest.config.ts` — add `@junctionjs/destination-posthog` resolve alias +- `.claude/rules/destinations.md` — add identity-projection convention (Task 7) +- `apps/docs/src/content/docs/destinations/posthog.mdx` — docs page (Task 8) +- `apps/docs/src/content/docs/destinations/overview.mdx` — add PostHog row (Task 8) +- `.changeset/.md` — changeset (Task 8) + +--- + +## Task 1: Scaffold package + shared helpers + +**Files:** +- Create: `packages/destination-posthog/package.json` +- Create: `packages/destination-posthog/tsconfig.json` +- Create: `packages/destination-posthog/src/shared.ts` +- Test: `packages/destination-posthog/src/shared.test.ts` +- Modify: `vitest.config.ts` (add resolve alias) + +**Interfaces:** +- Produces: + - `interface PostHogBaseConfig { apiKey: string; host?: string; consent?: ConsentCategory[]; eventNameMap?: Record; debug?: boolean; }` + - `POSTHOG_DEFAULT_HOST = "https://us.i.posthog.com"` + - `getEventName(event: JctEvent, eventNameMap?: Record): string` + - `resolveDistinctId(event: JctEvent): string` + - `isSystemEvent(event: JctEvent): boolean` + - `isIdentifyEvent(event: JctEvent): boolean` + - `resolveHost(host?: string): string` (strips trailing slash, defaults to `POSTHOG_DEFAULT_HOST`) + - `buildEventProperties(event: JctEvent, extra?: Record): Record` (merges event.properties + page context, PostHog `$` conventions) + +- [ ] **Step 1: Create `package.json`** + +```json +{ + "name": "@junctionjs/destination-posthog", + "version": "0.1.0", + "description": "PostHog destination for Junction — web (device-mode) and server (cloud-mode)", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsup src/index.ts --format esm --dts --sourcemap --target es2022 --no-config --external @junctionjs/core", + "dev": "tsup src/index.ts --format esm --dts --watch --no-config --external @junctionjs/core", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@junctionjs/core": "^0.3.0" + }, + "devDependencies": { + "@junctionjs/core": "*", + "tsup": "^8.0.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "registry": "https://registry.npmjs.org/" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/tyssejc/junction.git", + "directory": "packages/destination-posthog" + }, + "keywords": ["analytics", "posthog", "product-analytics", "junction-destination"] +} +``` + +- [ ] **Step 2: Create `tsconfig.json`** (verbatim copy of the other destinations') + +```json +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} +``` + +- [ ] **Step 3: Add resolve alias in `vitest.config.ts`** + +In the `resolve.alias` object, after the `destination-plausible` line, add: + +```ts + "@junctionjs/destination-posthog": path.resolve(__dirname, "packages/destination-posthog/src"), +``` + +- [ ] **Step 4: Write the failing test** — `packages/destination-posthog/src/shared.test.ts` + +```ts +import type { JctEvent } from "@junctionjs/core"; +import { describe, expect, it } from "vitest"; +import { + buildEventProperties, + getEventName, + isIdentifyEvent, + isSystemEvent, + POSTHOG_DEFAULT_HOST, + resolveDistinctId, + resolveHost, +} from "./shared.js"; + +function makeEvent(overrides?: Partial): JctEvent { + return { + entity: "page", + action: "viewed", + properties: {}, + context: { + page: { + url: "https://example.com/blog", + path: "/blog", + title: "Blog", + referrer: "https://google.com", + search: "", + hash: "", + }, + device: { type: "desktop", userAgent: "Mozilla/5.0 TestAgent", language: "en-US" }, + }, + user: { anonymousId: "anon-123" }, + timestamp: "2026-06-16T12:00:00.000Z", + id: "evt-001", + version: "1.0.0", + source: { type: "server", name: "test", version: "0.0.0" }, + ...overrides, + }; +} + +describe("shared", () => { + describe("getEventName (3-tier)", () => { + it("uses config override first", () => { + const name = getEventName(makeEvent({ entity: "product", action: "viewed" }), { + "product:viewed": "Custom Name", + }); + expect(name).toBe("Custom Name"); + }); + + it("falls back to DEFAULT_MAP ($pageview for page:viewed)", () => { + expect(getEventName(makeEvent(), undefined)).toBe("$pageview"); + }); + + it("generates entity_action when no map matches", () => { + expect(getEventName(makeEvent({ entity: "product", action: "added" }), undefined)).toBe("product_added"); + }); + }); + + describe("resolveDistinctId", () => { + it("prefers userId when present", () => { + expect(resolveDistinctId(makeEvent({ user: { anonymousId: "anon-1", userId: "user-9" } }))).toBe("user-9"); + }); + + it("falls back to anonymousId", () => { + expect(resolveDistinctId(makeEvent())).toBe("anon-123"); + }); + }); + + describe("isSystemEvent / isIdentifyEvent", () => { + it("detects _system entity", () => { + expect(isSystemEvent(makeEvent({ entity: "_system", action: "init" }))).toBe(true); + expect(isSystemEvent(makeEvent())).toBe(false); + }); + + it("detects user:identified", () => { + expect(isIdentifyEvent(makeEvent({ entity: "user", action: "identified" }))).toBe(true); + expect(isIdentifyEvent(makeEvent())).toBe(false); + }); + }); + + describe("resolveHost", () => { + it("defaults to PostHog US cloud", () => { + expect(resolveHost(undefined)).toBe(POSTHOG_DEFAULT_HOST); + }); + + it("strips trailing slash", () => { + expect(resolveHost("https://eu.i.posthog.com/")).toBe("https://eu.i.posthog.com"); + }); + }); + + describe("buildEventProperties", () => { + it("merges event properties with $current_url and $referrer from page context", () => { + const props = buildEventProperties(makeEvent({ properties: { plan: "pro" } })); + expect(props.plan).toBe("pro"); + expect(props.$current_url).toBe("https://example.com/blog"); + expect(props.$referrer).toBe("https://google.com"); + }); + + it("merges extra properties", () => { + const props = buildEventProperties(makeEvent(), { $lib: "junction-server" }); + expect(props.$lib).toBe("junction-server"); + }); + }); +}); +``` + +- [ ] **Step 5: Run the test to verify it fails** + +Run: `npx vitest run packages/destination-posthog/src/shared.test.ts` +Expected: FAIL — cannot resolve `./shared.js` (module does not exist). + +- [ ] **Step 6: Implement `packages/destination-posthog/src/shared.ts`** + +```ts +/** + * @junctionjs/destination-posthog — shared helpers + * + * Pure functions shared by the web (device-mode) and server (cloud-mode) + * PostHog destinations. No side effects, no network, no DOM. + */ + +import type { ConsentCategory, JctEvent } from "@junctionjs/core"; + +export const POSTHOG_DEFAULT_HOST = "https://us.i.posthog.com"; + +export interface PostHogBaseConfig { + /** PostHog project API key */ + apiKey: string; + + /** PostHog host. Default US cloud; use https://eu.i.posthog.com or a self-hosted/proxy URL. */ + host?: string; + + /** Consent categories required (AND logic). Default ["analytics"]. */ + consent?: ConsentCategory[]; + + /** Override entity:action → PostHog event name. */ + eventNameMap?: Record; + + /** Verbose logging. */ + debug?: boolean; +} + +/** PostHog accepts arbitrary event names, so the default map is deliberately thin. */ +const POSTHOG_DEFAULT_MAP: Record = { + "page:viewed": "$pageview", +}; + +/** 3-tier event name resolution: config override → default map → generated entity_action. */ +export function getEventName(event: JctEvent, eventNameMap?: Record): string { + const key = `${event.entity}:${event.action}`; + return eventNameMap?.[key] ?? POSTHOG_DEFAULT_MAP[key] ?? `${event.entity}_${event.action}`; +} + +/** PostHog uses a single distinct_id that flips from anonymous to known on identify. */ +export function resolveDistinctId(event: JctEvent): string { + return event.user.userId ?? event.user.anonymousId; +} + +export function isSystemEvent(event: JctEvent): boolean { + return event.entity === "_system"; +} + +export function isIdentifyEvent(event: JctEvent): boolean { + return event.entity === "user" && event.action === "identified"; +} + +export function resolveHost(host?: string): string { + return (host ?? POSTHOG_DEFAULT_HOST).replace(/\/$/, ""); +} + +/** Build PostHog event properties from a Junction event, adding $-prefixed browser context. */ +export function buildEventProperties( + event: JctEvent, + extra?: Record, +): Record { + const props: Record = { ...event.properties, ...extra }; + if (event.context.page) { + props.$current_url = event.context.page.url; + if (event.context.page.referrer) props.$referrer = event.context.page.referrer; + } + return props; +} +``` + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `npx vitest run packages/destination-posthog/src/shared.test.ts` +Expected: PASS (all cases in the `shared` describe block). + +- [ ] **Step 8: Install workspace deps so the package is linked** + +Run: `npm install` +Expected: completes; `@junctionjs/destination-posthog` now resolvable in the workspace. + +- [ ] **Step 9: Commit** + +```bash +git add packages/destination-posthog/package.json packages/destination-posthog/tsconfig.json \ + packages/destination-posthog/src/shared.ts packages/destination-posthog/src/shared.test.ts \ + vitest.config.ts package-lock.json +git commit -m "feat(posthog): scaffold package + shared helpers" +``` + +--- + +## Task 2: Server destination — transform + +**Files:** +- Create: `packages/destination-posthog/src/server.ts` +- Test: `packages/destination-posthog/src/server.test.ts` + +**Interfaces:** +- Consumes (from Task 1): `PostHogBaseConfig`, `getEventName`, `resolveDistinctId`, `isSystemEvent`, `isIdentifyEvent`, `resolveHost`, `buildEventProperties`. +- Produces: + - `interface PostHogServerConfig extends PostHogBaseConfig { batchSize?: number; flushIntervalMs?: number; maxRetries?: number; }` + - `interface PostHogCaptureEvent { event: string; distinct_id: string; properties: Record; timestamp: string; uuid: string; }` + - `transformServerEvent(event: JctEvent, config: PostHogServerConfig): PostHogCaptureEvent | null` (exported for testing) + +- [ ] **Step 1: Write the failing test** — add to `packages/destination-posthog/src/server.test.ts` + +```ts +import type { JctEvent } from "@junctionjs/core"; +import { describe, expect, it } from "vitest"; +import { transformServerEvent } from "./server.js"; + +function makeEvent(overrides?: Partial): JctEvent { + return { + entity: "page", + action: "viewed", + properties: {}, + context: { + page: { + url: "https://example.com/blog", + path: "/blog", + title: "Blog", + referrer: "https://google.com", + search: "", + hash: "", + }, + }, + user: { anonymousId: "anon-123" }, + timestamp: "2026-06-16T12:00:00.000Z", + id: "evt-001", + version: "1.0.0", + source: { type: "server", name: "test", version: "0.0.0" }, + ...overrides, + }; +} + +describe("server transform", () => { + it("returns null for _system events", () => { + expect(transformServerEvent(makeEvent({ entity: "_system", action: "init" }), { apiKey: "k" })).toBeNull(); + }); + + it("maps a capture event with distinct_id, uuid, timestamp", () => { + const result = transformServerEvent(makeEvent({ entity: "product", action: "added" }), { apiKey: "k" }); + expect(result).toEqual({ + event: "product_added", + distinct_id: "anon-123", + properties: expect.objectContaining({ $current_url: "https://example.com/blog" }), + timestamp: "2026-06-16T12:00:00.000Z", + uuid: "evt-001", + }); + }); + + it("prefers userId as distinct_id", () => { + const result = transformServerEvent(makeEvent({ user: { anonymousId: "a", userId: "u" } }), { apiKey: "k" }); + expect(result?.distinct_id).toBe("u"); + }); + + it("maps user:identified to a $identify event with $anon_distinct_id and $set", () => { + const event = makeEvent({ + entity: "user", + action: "identified", + user: { anonymousId: "anon-123", userId: "user-9", traits: { email: "a@b.co" } }, + }); + const result = transformServerEvent(event, { apiKey: "k" }); + expect(result?.event).toBe("$identify"); + expect(result?.distinct_id).toBe("user-9"); + expect(result?.properties.$anon_distinct_id).toBe("anon-123"); + expect(result?.properties.$set).toEqual({ email: "a@b.co" }); + }); + + it("applies eventNameMap override", () => { + const result = transformServerEvent(makeEvent({ entity: "order", action: "completed" }), { + apiKey: "k", + eventNameMap: { "order:completed": "purchase" }, + }); + expect(result?.event).toBe("purchase"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run packages/destination-posthog/src/server.test.ts` +Expected: FAIL — cannot resolve `./server.js`. + +- [ ] **Step 3: Implement the transform in `packages/destination-posthog/src/server.ts`** + +```ts +/** + * @junctionjs/destination-posthog — server (cloud-mode) + * + * Forwards Junction events to PostHog's HTTP capture API. No browser + * signals, no session replay, no feature flags, no client sessionization. + * The "just get events into my PostHog dataset" path. + */ + +import type { Destination, JctEvent } from "@junctionjs/core"; +import { + buildEventProperties, + getEventName, + isIdentifyEvent, + isSystemEvent, + type PostHogBaseConfig, + resolveDistinctId, + resolveHost, +} from "./shared.js"; + +export interface PostHogServerConfig extends PostHogBaseConfig { + /** Buffer up to this many events, then POST to /batch. 1 = send each event to /capture. Default 20. */ + batchSize?: number; + + /** Flush the buffer at least this often (ms). Default 5000. */ + flushIntervalMs?: number; + + /** Retry a failed flush this many times with exponential backoff. Default 3. */ + maxRetries?: number; +} + +export interface PostHogCaptureEvent { + event: string; + distinct_id: string; + properties: Record; + timestamp: string; + uuid: string; +} + +export function transformServerEvent(event: JctEvent, config: PostHogServerConfig): PostHogCaptureEvent | null { + if (isSystemEvent(event)) return null; + + const distinctId = resolveDistinctId(event); + + if (isIdentifyEvent(event)) { + return { + event: "$identify", + distinct_id: distinctId, + properties: { + $anon_distinct_id: event.user.anonymousId, + ...(event.user.traits ? { $set: event.user.traits } : {}), + }, + timestamp: event.timestamp, + uuid: event.id, + }; + } + + return { + event: getEventName(event, config.eventNameMap), + distinct_id: distinctId, + properties: buildEventProperties(event, { $lib: "junction-server" }), + timestamp: event.timestamp, + uuid: event.id, + }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run packages/destination-posthog/src/server.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/destination-posthog/src/server.ts packages/destination-posthog/src/server.test.ts +git commit -m "feat(posthog): server transform (capture + \$identify)" +``` + +--- + +## Task 3: Server destination — send, batching, retry, factory + +**Files:** +- Modify: `packages/destination-posthog/src/server.ts` +- Test: `packages/destination-posthog/src/server.test.ts` (add describe blocks) + +**Interfaces:** +- Consumes: everything from Task 2, plus `resolveHost` from Task 1. +- Produces: + - `createPostHogServer(config: PostHogServerConfig): Destination` + - Destination shape: `name: "posthog-server"`, `runtime: "server"`, `consent: config.consent ?? ["analytics"]`. + - Behavior: `init` validates `apiKey`; `transform` = `transformServerEvent`; `send` buffers and flushes to `/batch/` (or `/capture/` when `batchSize <= 1`); `teardown` flushes remaining buffer. + +- [ ] **Step 1: Write the failing tests** — add to `packages/destination-posthog/src/server.test.ts` + +```ts +import { beforeEach, vi } from "vitest"; +import { createPostHogServer } from "./server.js"; + +describe("server factory", () => { + it("has correct defaults", () => { + const dest = createPostHogServer({ apiKey: "phc_test" }); + expect(dest.name).toBe("posthog-server"); + expect(dest.runtime).toBe("server"); + expect(dest.consent).toEqual(["analytics"]); + }); + + it("throws on init when apiKey missing", () => { + const dest = createPostHogServer({ apiKey: "" }); + expect(() => dest.init({} as any)).toThrow("apiKey is required"); + }); + + it("honors a custom consent config", () => { + const dest = createPostHogServer({ apiKey: "k", consent: ["analytics", "marketing"] }); + expect(dest.consent).toEqual(["analytics", "marketing"]); + }); +}); + +describe("server send", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("POSTs a single event to /capture when batchSize is 1", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "phc_test", batchSize: 1 }); + dest.init({} as any); + const payload = dest.transform(makeEvent({ entity: "product", action: "added" }), {} as any); + await dest.send(payload, {} as any); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe("https://us.i.posthog.com/capture/"); + const body = JSON.parse(options.body); + expect(body.api_key).toBe("phc_test"); + expect(body.event).toBe("product_added"); + }); + + it("buffers events and flushes to /batch when batchSize is reached", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "phc_test", batchSize: 2 }); + dest.init({} as any); + + await dest.send(dest.transform(makeEvent({ entity: "a", action: "x" }), {} as any), {} as any); + expect(mockFetch).not.toHaveBeenCalled(); // buffered, not yet flushed + + await dest.send(dest.transform(makeEvent({ entity: "b", action: "y" }), {} as any), {} as any); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe("https://us.i.posthog.com/batch/"); + const body = JSON.parse(options.body); + expect(body.batch).toHaveLength(2); + }); + + it("teardown flushes a partial buffer", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "phc_test", batchSize: 10 }); + dest.init({} as any); + await dest.send(dest.transform(makeEvent({ entity: "a", action: "x" }), {} as any), {} as any); + expect(mockFetch).not.toHaveBeenCalled(); + + await dest.teardown?.(); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(JSON.parse(mockFetch.mock.calls[0][1].body).batch).toHaveLength(1); + }); + + it("uses the EU host when configured", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "k", host: "https://eu.i.posthog.com", batchSize: 1 }); + dest.init({} as any); + await dest.send(dest.transform(makeEvent(), {} as any), {} as any); + expect(mockFetch.mock.calls[0][0]).toBe("https://eu.i.posthog.com/capture/"); + }); + + it("retries on failure then throws after maxRetries", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("boom") }); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "k", batchSize: 1, maxRetries: 2 }); + dest.init({} as any); + await expect(dest.send(dest.transform(makeEvent(), {} as any), {} as any)).rejects.toThrow("500"); + // initial attempt + 2 retries = 3 calls + expect(mockFetch).toHaveBeenCalledTimes(3); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run packages/destination-posthog/src/server.test.ts` +Expected: FAIL — `createPostHogServer` is not exported. + +- [ ] **Step 3: Implement send + factory — append to `packages/destination-posthog/src/server.ts`** + +```ts +const BACKOFF_BASE_MS = 100; + +async function postWithRetry( + url: string, + apiKey: string, + batch: PostHogCaptureEvent[], + single: boolean, + maxRetries: number, +): Promise { + const body = single + ? JSON.stringify({ api_key: apiKey, ...batch[0] }) + : JSON.stringify({ api_key: apiKey, batch }); + + let attempt = 0; + // total attempts = 1 + maxRetries + while (true) { + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + if (response.ok) return; + const text = await response.text(); + throw new Error(`[Junction:posthog-server] POST ${url} returned ${response.status}: ${text}`); + } catch (err) { + if (attempt >= maxRetries) throw err; + attempt += 1; + // exponential backoff: 100ms, 200ms, 400ms, ... + await new Promise((resolve) => setTimeout(resolve, BACKOFF_BASE_MS * 2 ** (attempt - 1))); + } + } +} + +/** + * Create a server-side (cloud-mode) PostHog destination. + * + * @example + * ```ts + * import { createPostHogServer } from "@junctionjs/destination-posthog"; + * const dest = createPostHogServer({ apiKey: "phc_...", host: "https://eu.i.posthog.com" }); + * ``` + */ +export function createPostHogServer(config: PostHogServerConfig): Destination { + const host = resolveHost(config.host); + const batchSize = config.batchSize ?? 20; + const flushIntervalMs = config.flushIntervalMs ?? 5000; + const maxRetries = config.maxRetries ?? 3; + + let buffer: PostHogCaptureEvent[] = []; + let timer: ReturnType | undefined; + + async function flush(): Promise { + if (buffer.length === 0) return; + const batch = buffer; + buffer = []; + const single = batchSize <= 1; + const url = single ? `${host}/capture/` : `${host}/batch/`; + await postWithRetry(url, config.apiKey, batch, single, maxRetries); + } + + return { + name: "posthog-server", + description: "PostHog (server / cloud-mode)", + version: "0.1.0", + consent: config.consent ?? ["analytics"], + runtime: "server", + + init() { + if (!config.apiKey) { + throw new Error("[Junction:posthog-server] apiKey is required"); + } + if (batchSize > 1 && !timer) { + timer = setInterval(() => { + void flush().catch((err) => { + if (config.debug) console.error("[Junction:posthog-server] scheduled flush failed:", err); + }); + }, flushIntervalMs); + // Do not keep the Node process alive solely for the flush timer. + (timer as { unref?: () => void }).unref?.(); + } + }, + + transform(event: JctEvent) { + return transformServerEvent(event, config); + }, + + async send(payload: unknown) { + buffer.push(payload as PostHogCaptureEvent); + if (buffer.length >= batchSize) { + await flush(); + } + }, + + async teardown() { + if (timer) { + clearInterval(timer); + timer = undefined; + } + await flush(); + }, + }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run packages/destination-posthog/src/server.test.ts` +Expected: PASS. (The retry test exercises real `setTimeout` backoff of ~100ms + 200ms; it completes well under Vitest's default timeout.) + +- [ ] **Step 5: Commit** + +```bash +git add packages/destination-posthog/src/server.ts packages/destination-posthog/src/server.test.ts +git commit -m "feat(posthog): server send with batching, retry, and factory" +``` + +--- + +## Task 4: Web destination — transform + +**Files:** +- Create: `packages/destination-posthog/src/web.ts` +- Test: `packages/destination-posthog/src/web.test.ts` + +**Interfaces:** +- Consumes (Task 1): `PostHogBaseConfig`, `getEventName`, `isSystemEvent`, `isIdentifyEvent`, `buildEventProperties`. +- Produces: + - `interface PostHogWebConfig extends PostHogBaseConfig { loadScript?: boolean; scriptUrl?: string; sessionReplay?: boolean; autocapture?: boolean; capturePageview?: boolean; }` + - `type WebPayload = { type: "capture"; name: string; properties: Record } | { type: "identify"; distinctId: string; traits?: Record };` + - `transformWebEvent(event: JctEvent, config: PostHogWebConfig): WebPayload | null` (exported for testing) + +- [ ] **Step 1: Write the failing test** — `packages/destination-posthog/src/web.test.ts` + +```ts +import type { JctEvent } from "@junctionjs/core"; +import { describe, expect, it } from "vitest"; +import { transformWebEvent } from "./web.js"; + +function makeEvent(overrides?: Partial): JctEvent { + return { + entity: "page", + action: "viewed", + properties: {}, + context: { + page: { + url: "https://example.com/blog", + path: "/blog", + title: "Blog", + referrer: "https://google.com", + search: "", + hash: "", + }, + }, + user: { anonymousId: "anon-123" }, + timestamp: "2026-06-16T12:00:00.000Z", + id: "evt-001", + version: "1.0.0", + source: { type: "client", name: "browser", version: "0.0.0" }, + ...overrides, + }; +} + +describe("web transform", () => { + it("returns null for _system events", () => { + expect(transformWebEvent(makeEvent({ entity: "_system", action: "init" }), { apiKey: "k" })).toBeNull(); + }); + + it("maps a capture event", () => { + const result = transformWebEvent(makeEvent({ entity: "product", action: "added" }), { apiKey: "k" }); + expect(result).toEqual({ + type: "capture", + name: "product_added", + properties: expect.objectContaining({ $current_url: "https://example.com/blog" }), + }); + }); + + it("maps user:identified to an identify payload", () => { + const event = makeEvent({ + entity: "user", + action: "identified", + user: { anonymousId: "anon-123", userId: "user-9", traits: { email: "a@b.co" } }, + }); + const result = transformWebEvent(event, { apiKey: "k" }); + expect(result).toEqual({ type: "identify", distinctId: "user-9", traits: { email: "a@b.co" } }); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run packages/destination-posthog/src/web.test.ts` +Expected: FAIL — cannot resolve `./web.js`. + +- [ ] **Step 3: Implement the transform in `packages/destination-posthog/src/web.ts`** + +```ts +/** + * @junctionjs/destination-posthog — web (device-mode) + * + * Loads posthog-js on the page and routes Junction's tracked events + * through posthog.capture(). Junction stays the event source of truth: + * posthog-js autocapture / capture_pageview are OFF by default. + */ + +import type { ConsentState, Destination, JctEvent } from "@junctionjs/core"; +import { + buildEventProperties, + getEventName, + isIdentifyEvent, + isSystemEvent, + type PostHogBaseConfig, + resolveDistinctId, +} from "./shared.js"; + +export interface PostHogWebConfig extends PostHogBaseConfig { + /** Inject the posthog-js snippet. Default true. */ + loadScript?: boolean; + + /** Override the snippet URL (reverse proxy / self-hosted). */ + scriptUrl?: string; + + /** Enable session replay. Default false (heavy + privacy-sensitive). */ + sessionReplay?: boolean; + + /** Let posthog-js autocapture clicks/inputs on its own. Default false. */ + autocapture?: boolean; + + /** Let posthog-js fire its own pageviews. Default false (Junction owns page:viewed). */ + capturePageview?: boolean; +} + +export type WebPayload = + | { type: "capture"; name: string; properties: Record } + | { type: "identify"; distinctId: string; traits?: Record }; + +export function transformWebEvent(event: JctEvent, config: PostHogWebConfig): WebPayload | null { + if (isSystemEvent(event)) return null; + + if (isIdentifyEvent(event)) { + return { type: "identify", distinctId: resolveDistinctId(event), traits: event.user.traits }; + } + + return { + type: "capture", + name: getEventName(event, config.eventNameMap), + properties: buildEventProperties(event, { $lib: "junction-web" }), + }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run packages/destination-posthog/src/web.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/destination-posthog/src/web.ts packages/destination-posthog/src/web.test.ts +git commit -m "feat(posthog): web transform (capture + identify intents)" +``` + +--- + +## Task 5: Web destination — loader, send, consent, factory + +**Files:** +- Modify: `packages/destination-posthog/src/web.ts` +- Test: `packages/destination-posthog/src/web.test.ts` (add describe blocks) + +**Interfaces:** +- Consumes: everything from Task 4, plus `resolveHost` from Task 1. +- Produces: + - `createPostHogWeb(config: PostHogWebConfig): Destination` + - Destination shape: `name: "posthog-web"`, `runtime: "client"`, `consent: config.consent ?? ["analytics"]`. + - Behavior: `init` guards SSR, loads snippet (unless `loadScript === false` or already present), calls `posthog.init(apiKey, options)` with autocapture/pageview off; `send` routes to `posthog.capture()` / `posthog.identify()`; `onConsent` calls `posthog.opt_out_capturing()` when analytics is revoked. +- The tests provide a fake `window.posthog` so no real script loads. + +- [ ] **Step 1: Write the failing tests** — add to `packages/destination-posthog/src/web.test.ts` + +```ts +import { afterEach, beforeEach, vi } from "vitest"; +import { createPostHogWeb } from "./web.js"; + +interface FakePostHog { + init: ReturnType; + capture: ReturnType; + identify: ReturnType; + opt_out_capturing: ReturnType; + __loaded: boolean; +} + +function installFakePostHog(): FakePostHog { + const fake: FakePostHog = { + init: vi.fn(), + capture: vi.fn(), + identify: vi.fn(), + opt_out_capturing: vi.fn(), + __loaded: true, + }; + vi.stubGlobal("window", { posthog: fake }); + return fake; +} + +describe("web factory", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("has correct defaults", () => { + const dest = createPostHogWeb({ apiKey: "phc_test" }); + expect(dest.name).toBe("posthog-web"); + expect(dest.runtime).toBe("client"); + expect(dest.consent).toEqual(["analytics"]); + }); + + it("no-ops init in SSR (no window)", () => { + vi.stubGlobal("window", undefined); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + expect(() => dest.init({} as any)).not.toThrow(); + }); + + it("inits posthog with autocapture and pageview off by default", () => { + const fake = installFakePostHog(); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + dest.init({} as any); + expect(fake.init).toHaveBeenCalledWith( + "phc_test", + expect.objectContaining({ + api_host: "https://us.i.posthog.com", + autocapture: false, + capture_pageview: false, + }), + ); + }); + + it("enables autocapture/pageview/replay when opted in", () => { + const fake = installFakePostHog(); + const dest = createPostHogWeb({ + apiKey: "phc_test", + autocapture: true, + capturePageview: true, + sessionReplay: true, + }); + dest.init({} as any); + const opts = fake.init.mock.calls[0][1]; + expect(opts.autocapture).toBe(true); + expect(opts.capture_pageview).toBe(true); + expect(opts.disable_session_recording).toBe(false); + }); +}); + +describe("web send", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("routes a capture payload to posthog.capture", async () => { + const fake = installFakePostHog(); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + dest.init({} as any); + const payload = dest.transform(makeEvent({ entity: "product", action: "added" }), {} as any); + await dest.send(payload, {} as any); + expect(fake.capture).toHaveBeenCalledWith("product_added", expect.objectContaining({ $lib: "junction-web" })); + }); + + it("routes an identify payload to posthog.identify", async () => { + const fake = installFakePostHog(); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + dest.init({} as any); + const event = makeEvent({ + entity: "user", + action: "identified", + user: { anonymousId: "anon-1", userId: "user-9", traits: { email: "a@b.co" } }, + }); + await dest.send(dest.transform(event, {} as any), {} as any); + expect(fake.identify).toHaveBeenCalledWith("user-9", { $set: { email: "a@b.co" } }); + }); +}); + +describe("web consent", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("opts out of capturing when analytics consent is revoked", () => { + const fake = installFakePostHog(); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + dest.init({} as any); + dest.onConsent?.({ analytics: false } as any); + expect(fake.opt_out_capturing).toHaveBeenCalled(); + }); + + it("does not opt out while analytics consent is granted", () => { + const fake = installFakePostHog(); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + dest.init({} as any); + dest.onConsent?.({ analytics: true } as any); + expect(fake.opt_out_capturing).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run packages/destination-posthog/src/web.test.ts` +Expected: FAIL — `createPostHogWeb` is not exported. + +- [ ] **Step 3: Implement loader + send + factory — append to `packages/destination-posthog/src/web.ts`** + +Add `resolveHost` to the existing import from `./shared.js`, then append: + +```ts +interface PostHogJs { + init: (apiKey: string, options: Record) => void; + capture: (name: string, properties?: Record) => void; + identify: (distinctId: string, properties?: Record) => void; + opt_out_capturing: () => void; + __loaded?: boolean; +} + +function getPostHog(): PostHogJs | undefined { + if (typeof window === "undefined") return undefined; + return (window as unknown as { posthog?: PostHogJs }).posthog; +} + +/** Inject the posthog-js snippet with an array stub that queues calls until the script loads. */ +function loadSnippet(scriptUrl: string): void { + if (typeof window === "undefined") return; + const w = window as unknown as { posthog?: PostHogJs; document?: Document }; + if (w.posthog?.__loaded) return; + + const doc = w.document; + if (!doc) return; + if (doc.querySelector(`script[src="${scriptUrl}"]`)) return; + + const script = doc.createElement("script"); + script.async = true; + script.src = scriptUrl; + const first = doc.getElementsByTagName("script")[0]; + first?.parentNode?.insertBefore(script, first); +} + +/** + * Create a client-side (device-mode) PostHog destination. + * + * @example + * ```ts + * import { createPostHogWeb } from "@junctionjs/destination-posthog"; + * const dest = createPostHogWeb({ apiKey: "phc_...", sessionReplay: true }); + * ``` + */ +export function createPostHogWeb(config: PostHogWebConfig): Destination { + const apiHost = resolveHost(config.host); + const scriptUrl = config.scriptUrl ?? `${apiHost}/static/array.js`; + + return { + name: "posthog-web", + description: "PostHog (web / device-mode)", + version: "0.1.0", + consent: config.consent ?? ["analytics"], + runtime: "client", + + init() { + if (typeof window === "undefined") return; // SSR guard + if (!config.apiKey) { + throw new Error("[Junction:posthog-web] apiKey is required"); + } + if (config.loadScript !== false) { + loadSnippet(scriptUrl); + } + const posthog = getPostHog(); + posthog?.init(config.apiKey, { + api_host: apiHost, + autocapture: config.autocapture ?? false, + capture_pageview: config.capturePageview ?? false, + capture_pageleave: config.capturePageview ?? false, + disable_session_recording: !(config.sessionReplay ?? false), + }); + }, + + transform(event: JctEvent) { + return transformWebEvent(event, config); + }, + + async send(payload: unknown) { + const posthog = getPostHog(); + if (!posthog) return; // script not loaded yet / SSR + const p = payload as WebPayload; + if (p.type === "identify") { + posthog.identify(p.distinctId, p.traits ? { $set: p.traits } : undefined); + } else { + posthog.capture(p.name, p.properties); + } + }, + + onConsent(state: ConsentState) { + if (state.analytics === false) { + getPostHog()?.opt_out_capturing(); + } + }, + }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run packages/destination-posthog/src/web.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/destination-posthog/src/web.ts packages/destination-posthog/src/web.test.ts +git commit -m "feat(posthog): web loader, send routing, and consent opt-out" +``` + +--- + +## Task 6: Barrel export + full build/lint/typecheck verification + +**Files:** +- Create: `packages/destination-posthog/src/index.ts` +- Create: `packages/destination-posthog/src/index.test.ts` +- Create: `packages/destination-posthog/README.md` + +**Interfaces:** +- Consumes: `createPostHogServer`, `PostHogServerConfig` (Task 3); `createPostHogWeb`, `PostHogWebConfig` (Task 5); `PostHogBaseConfig` (Task 1). +- Produces: the package's public API surface. + +- [ ] **Step 1: Write the failing test** — `packages/destination-posthog/src/index.test.ts` + +```ts +import { describe, expect, it } from "vitest"; +import { createPostHogServer, createPostHogWeb } from "./index.js"; + +describe("index barrel", () => { + it("exports both factories", () => { + expect(typeof createPostHogWeb).toBe("function"); + expect(typeof createPostHogServer).toBe("function"); + expect(createPostHogWeb({ apiKey: "k" }).runtime).toBe("client"); + expect(createPostHogServer({ apiKey: "k" }).runtime).toBe("server"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run packages/destination-posthog/src/index.test.ts` +Expected: FAIL — cannot resolve `./index.js`. + +- [ ] **Step 3: Implement `packages/destination-posthog/src/index.ts`** + +```ts +/** + * @junctionjs/destination-posthog + * + * PostHog destination for Junction, offered as two tree-shakeable factories: + * - createPostHogWeb — device-mode: loads posthog-js, full browser signals + * - createPostHogServer — cloud-mode: forwards events to PostHog's HTTP API + * + * Import only the one you need; the other is eliminated by tree-shaking. + */ + +export { type PostHogBaseConfig, POSTHOG_DEFAULT_HOST } from "./shared.js"; +export { createPostHogServer, type PostHogServerConfig } from "./server.js"; +export { createPostHogWeb, type PostHogWebConfig } from "./web.js"; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run packages/destination-posthog/src/index.test.ts` +Expected: PASS. + +- [ ] **Step 5: Create `packages/destination-posthog/README.md`** + +````markdown +# @junctionjs/destination-posthog + +PostHog destination for Junction, offered as two tree-shakeable factories mirroring +the web (device-mode) vs server (cloud-mode) split. + +- **`createPostHogWeb`** — loads `posthog-js` on the page. Full library: sessionization, + person profiles, session replay, feature flags, browser-signal enrichment. Junction stays + the event source of truth (autocapture/pageview off by default). +- **`createPostHogServer`** — forwards events to PostHog's HTTP capture API. No replay, flags, + or client sessionization. The "just get events into my dataset" path. + +Import only the one you need — the other is eliminated by tree-shaking. `posthog-js` is loaded +from PostHog's CDN at runtime, never bundled. + +## Install + +```bash +npm install @junctionjs/destination-posthog +``` + +## Web (device-mode) + +```ts +import { createPostHogWeb } from "@junctionjs/destination-posthog"; + +const posthogWeb = createPostHogWeb({ + apiKey: "phc_...", + host: "https://us.i.posthog.com", // or eu.i.posthog.com / self-hosted + sessionReplay: false, // opt in when you want replay +}); +``` + +## Server (cloud-mode) + +```ts +import { createPostHogServer } from "@junctionjs/destination-posthog"; + +const posthogServer = createPostHogServer({ + apiKey: "phc_...", + batchSize: 20, // buffer then POST to /batch; 1 = per-event /capture + flushIntervalMs: 5000, + maxRetries: 3, +}); +``` + +## Configuration + +| Option | Web | Server | Default | Notes | +|---|---|---|---|---| +| `apiKey` | ✓ | ✓ | — | PostHog project API key (required) | +| `host` | ✓ | ✓ | `https://us.i.posthog.com` | EU cloud or self-hosted URL | +| `consent` | ✓ | ✓ | `["analytics"]` | Consent categories (AND logic) | +| `eventNameMap` | ✓ | ✓ | — | Override `entity:action` → PostHog event name | +| `loadScript` | ✓ | — | `true` | Inject the posthog-js snippet | +| `scriptUrl` | ✓ | — | `${host}/static/array.js` | Reverse-proxy / self-host override | +| `sessionReplay` | ✓ | — | `false` | Enable session recording | +| `autocapture` | ✓ | — | `false` | Let posthog-js autocapture on its own | +| `capturePageview` | ✓ | — | `false` | Junction owns `page:viewed` by default | +| `batchSize` | — | ✓ | `20` | Buffer size before flush to `/batch` | +| `flushIntervalMs` | — | ✓ | `5000` | Max time between flushes | +| `maxRetries` | — | ✓ | `3` | Retries with exponential backoff | +```` + +- [ ] **Step 6: Build the package** + +Run: `npm run build --workspace @junctionjs/destination-posthog` +Expected: tsup emits `dist/index.js` and `dist/index.d.ts` with no errors. + +- [ ] **Step 7: Typecheck, lint, and run the full suite** + +Run: `npm run typecheck && npm run lint && npm test` +Expected: typecheck clean; Biome reports no errors; all tests pass (the existing 179 plus the new PostHog tests). + +- [ ] **Step 8: Commit** + +```bash +git add packages/destination-posthog/src/index.ts packages/destination-posthog/src/index.test.ts \ + packages/destination-posthog/README.md +git commit -m "feat(posthog): barrel export, README, build verification" +``` + +--- + +## Task 7: Document the identity-projection convention + +**Files:** +- Modify: `.claude/rules/destinations.md` + +**Interfaces:** none (docs only). This is the spec's "convention introduced by this spec" deliverable. + +- [ ] **Step 1: Add an Identity section to `.claude/rules/destinations.md`** + +Insert this section immediately after the "## Event Name Mapping" section (before "## Script Loading"): + +```markdown +## Identity Projection + +Junction owns a canonical identity model; each destination projects it into the vendor's shape. + +Canonical fields on every event: +- `event.user.anonymousId` — first-party anonymous/device ID, always present +- `event.user.userId` — known ID, set after `collector.identify()` +- `event.user.traits` — persistent traits from `identify()` +- `event.id` — unique per event, used as a dedup key +- `user:identified` — lifecycle event emitted by `collector.identify()` + +| Canonical | GA4 | Amplitude | PostHog | +|---|---|---|---| +| `anonymousId` | `client_id` | `device_id` | `distinct_id` (while anonymous) | +| `userId` | `user_id` | `user_id` | `distinct_id` after merge + `$identify` | +| `traits` | `user_properties` | `user_properties` | person props via `$set` | +| `user:identified` | no-op (carries `user_id`) | no-op (carries `user_id`) | `posthog.identify()` / `$identify` w/ `$anon_distinct_id` | +| `event.id` | — | `insert_id` | `uuid` | + +**Every destination must decide how it handles `user:identified`.** Parallel-fields vendors +(GA4, Amplitude) carry `device_id` + `user_id` on every event and can no-op it. Merge/alias +vendors (PostHog) must act on it to stitch anonymous history to the known person. + +> **Known gap (tracked):** `UserIdentity` has no `sessionId`; GA4/Amplitude do not yet map +> `session_id`/`$session_id`. Cross-destination follow-up, not owned by any single destination. +``` + +- [ ] **Step 2: Commit** + +```bash +git add .claude/rules/destinations.md +git commit -m "docs(rules): add identity-projection convention for destinations" +``` + +--- + +## Task 8: Docs page, overview row, demo wiring, changeset + +**Files:** +- Create: `apps/docs/src/content/docs/destinations/posthog.mdx` +- Modify: `apps/docs/src/content/docs/destinations/overview.mdx` +- Modify: demo app destination registration (locate first — see Step 3) +- Create: `.changeset/posthog-destination.md` + +**Interfaces:** none (docs/demo/release). + +- [ ] **Step 1: Create the docs page** — `apps/docs/src/content/docs/destinations/posthog.mdx` + +First open `apps/docs/src/content/docs/destinations/plausible.mdx` to copy its frontmatter shape (title/description keys), then write: + +```mdx +--- +title: PostHog +description: Send Junction events to PostHog — web (device-mode) or server (cloud-mode). +--- + +`@junctionjs/destination-posthog` offers two tree-shakeable factories mirroring the +device-mode vs cloud-mode split. Import only the one you need. + +## Web (device-mode) + +Loads `posthog-js` on the page for the full library — sessionization, person profiles, +session replay, feature flags, browser-signal enrichment. Junction stays the event source of +truth: `posthog-js` autocapture and pageview capture are **off by default**. + +```ts +import { createPostHogWeb } from "@junctionjs/destination-posthog"; + +const posthogWeb = createPostHogWeb({ + apiKey: "phc_...", + host: "https://us.i.posthog.com", + sessionReplay: false, +}); +``` + +## Server (cloud-mode) + +Forwards events to PostHog's HTTP capture API. No replay, flags, or client sessionization — +the "just get events into my dataset" path. Buffers and flushes to `/batch` with retry. + +```ts +import { createPostHogServer } from "@junctionjs/destination-posthog"; + +const posthogServer = createPostHogServer({ + apiKey: "phc_...", + batchSize: 20, + maxRetries: 3, +}); +``` + +## Consent + +Both default to `consent: ["analytics"]` and only initialize after consent resolves — no +consent means `posthog-js` never loads and no cookies are set. Session replay stays under +`analytics`; treat it as sensitive. + +## Identity + +Junction's canonical identity maps onto PostHog's single `distinct_id`: anonymous events use +`anonymousId`; after `identify()` the destination emits `$identify` (server) or calls +`posthog.identify()` (web) to stitch prior anonymous history to the known person. +``` + +- [ ] **Step 2: Add a PostHog row to `overview.mdx`** + +Open `apps/docs/src/content/docs/destinations/overview.mdx`, find the list/table of +destinations, and add a PostHog entry consistent with the existing rows (link to +`/destinations/posthog`, note "web + server"). Match the surrounding markup exactly. + +- [ ] **Step 3: Wire the demo app** + +Run: `grep -rn "destination-plausible\|destination-amplitude\|createPostHog\|plausible(\|amplitude" apps/demo/src` +to find where destinations are registered in the demo. Add a `createPostHogServer` (and/or +`createPostHogWeb`) registration alongside the existing ones, following the same shape. Add +`"@junctionjs/destination-posthog": "*"` to `apps/demo/package.json` dependencies if the demo +imports by package name. If no destinations are registered in the demo yet, skip this step and +note it in the commit body. + +- [ ] **Step 4: Create the changeset** — `.changeset/posthog-destination.md` + +```markdown +--- +"@junctionjs/destination-posthog": minor +--- + +Add PostHog destination with web (device-mode) and server (cloud-mode) factories. + +`createPostHogWeb` loads posthog-js for full browser signals; `createPostHogServer` forwards +events to PostHog's HTTP capture API with batching and retry. Junction stays the event source +of truth (autocapture/pageview off by default). Establishes the canonical identity-projection +convention in the destination rules. +``` + +- [ ] **Step 5: Build docs + verify everything once more** + +Run: `npm run build && npm test` +Expected: docs site builds; all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add apps/docs/src/content/docs/destinations/posthog.mdx \ + apps/docs/src/content/docs/destinations/overview.mdx \ + apps/demo .changeset/posthog-destination.md +git commit -m "docs(posthog): destination page, overview row, demo wiring, changeset" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Two destinations, web + server → Tasks 4–5 (web), 2–3 (server) ✓ +- One package, two tree-shakeable factories → `sideEffects: false` (Task 1), barrel (Task 6) ✓ +- posthog-js via script snippet, not bundled → `loadSnippet` (Task 5), no `posthog-js` dependency in package.json (Task 1) ✓ +- Conservative init posture (autocapture/pageview/replay off) → Task 5 init + tests ✓ +- Identity projection + `user:identified` handling → server `$identify` (Task 2), web `identify` (Tasks 4–5), rule doc (Task 7) ✓ +- Config surface (base + web + server) → Tasks 1, 3, 4 ✓ +- Event-name mapping (3-tier, `$pageview` default) → Task 1 ✓ +- `_system` filtering → Tasks 2, 4 ✓ +- distinct_id precedence → Task 1 `resolveDistinctId`, tested in 2 & 4 ✓ +- Server batching (/capture vs /batch), retry/backoff → Task 3 ✓ +- Consent default `["analytics"]`, revocation opt-out → Tasks 3, 5 ✓ +- Error isolation (throw on failure, `[Junction:*]` prefix) → Tasks 3, 5 ✓ +- Testing (server fetch mock, web posthog stub, shared units) → Tasks 1–6 ✓ +- Deliverables: package + tests, docs page, demo wiring, rule convention, changeset → Tasks 1–8 ✓ +- Out-of-scope items (GA4/Amplitude retrofit, session-ID gap, npm-import mode, flag wrapper) → left out; session-ID gap noted in Task 7 rule doc ✓ + +**Placeholder scan:** No TBD/TODO/"handle edge cases"/"similar to Task N". Task 8 Step 3 (demo wiring) is a discovery step with an exact grep command and an explicit fallback, not a placeholder. + +**Type consistency:** `PostHogBaseConfig` (Task 1) extended by `PostHogServerConfig` (Task 2/3) and `PostHogWebConfig` (Task 4). `transformServerEvent`/`transformWebEvent` names consistent between definition and factory use. `resolveDistinctId`, `getEventName`, `buildEventProperties`, `resolveHost`, `isSystemEvent`, `isIdentifyEvent` defined in Task 1 and consumed with matching signatures in Tasks 2–5. `WebPayload` / `PostHogCaptureEvent` shapes consistent between transform (produce) and send (consume). +``` From d3a5bd4aeed3d4ee02550fa4cbf9548384ac8cc7 Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Thu, 9 Jul 2026 11:41:59 -0400 Subject: [PATCH 03/15] feat(posthog): scaffold package + shared helpers --- package-lock.json | 20 ++++ packages/destination-posthog/package.json | 45 ++++++++ .../destination-posthog/src/shared.test.ts | 101 ++++++++++++++++++ packages/destination-posthog/src/shared.ts | 65 +++++++++++ packages/destination-posthog/tsconfig.json | 8 ++ vitest.config.ts | 1 + 6 files changed, 240 insertions(+) create mode 100644 packages/destination-posthog/package.json create mode 100644 packages/destination-posthog/src/shared.test.ts create mode 100644 packages/destination-posthog/src/shared.ts create mode 100644 packages/destination-posthog/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 3183e0a..83f200b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2279,6 +2279,10 @@ "resolved": "packages/destination-plausible", "link": true }, + "node_modules/@junctionjs/destination-posthog": { + "resolved": "packages/destination-posthog", + "link": true + }, "node_modules/@junctionjs/docs": { "resolved": "apps/docs", "link": true @@ -12760,6 +12764,22 @@ "@junctionjs/core": "^0.3.0" } }, + "packages/destination-posthog": { + "name": "@junctionjs/destination-posthog", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@junctionjs/core": "*", + "tsup": "^8.0.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@junctionjs/core": "^0.3.0" + } + }, "packages/gateway": { "name": "@junctionjs/gateway", "version": "0.1.3", diff --git a/packages/destination-posthog/package.json b/packages/destination-posthog/package.json new file mode 100644 index 0000000..e5283a5 --- /dev/null +++ b/packages/destination-posthog/package.json @@ -0,0 +1,45 @@ +{ + "name": "@junctionjs/destination-posthog", + "version": "0.1.0", + "description": "PostHog destination for Junction — web (device-mode) and server (cloud-mode)", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsup src/index.ts --format esm --dts --sourcemap --target es2022 --no-config --external @junctionjs/core", + "dev": "tsup src/index.ts --format esm --dts --watch --no-config --external @junctionjs/core", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@junctionjs/core": "^0.3.0" + }, + "devDependencies": { + "@junctionjs/core": "*", + "tsup": "^8.0.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "registry": "https://registry.npmjs.org/" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/tyssejc/junction.git", + "directory": "packages/destination-posthog" + }, + "keywords": ["analytics", "posthog", "product-analytics", "junction-destination"] +} diff --git a/packages/destination-posthog/src/shared.test.ts b/packages/destination-posthog/src/shared.test.ts new file mode 100644 index 0000000..6152a96 --- /dev/null +++ b/packages/destination-posthog/src/shared.test.ts @@ -0,0 +1,101 @@ +import type { JctEvent } from "@junctionjs/core"; +import { describe, expect, it } from "vitest"; +import { + POSTHOG_DEFAULT_HOST, + buildEventProperties, + getEventName, + isIdentifyEvent, + isSystemEvent, + resolveDistinctId, + resolveHost, +} from "./shared.js"; + +function makeEvent(overrides?: Partial): JctEvent { + return { + entity: "page", + action: "viewed", + properties: {}, + context: { + page: { + url: "https://example.com/blog", + path: "/blog", + title: "Blog", + referrer: "https://google.com", + search: "", + hash: "", + }, + device: { type: "desktop", userAgent: "Mozilla/5.0 TestAgent", language: "en-US" }, + }, + user: { anonymousId: "anon-123" }, + timestamp: "2026-06-16T12:00:00.000Z", + id: "evt-001", + version: "1.0.0", + source: { type: "server", name: "test", version: "0.0.0" }, + ...overrides, + }; +} + +describe("shared", () => { + describe("getEventName (3-tier)", () => { + it("uses config override first", () => { + const name = getEventName(makeEvent({ entity: "product", action: "viewed" }), { + "product:viewed": "Custom Name", + }); + expect(name).toBe("Custom Name"); + }); + + it("falls back to DEFAULT_MAP ($pageview for page:viewed)", () => { + expect(getEventName(makeEvent(), undefined)).toBe("$pageview"); + }); + + it("generates entity_action when no map matches", () => { + expect(getEventName(makeEvent({ entity: "product", action: "added" }), undefined)).toBe("product_added"); + }); + }); + + describe("resolveDistinctId", () => { + it("prefers userId when present", () => { + expect(resolveDistinctId(makeEvent({ user: { anonymousId: "anon-1", userId: "user-9" } }))).toBe("user-9"); + }); + + it("falls back to anonymousId", () => { + expect(resolveDistinctId(makeEvent())).toBe("anon-123"); + }); + }); + + describe("isSystemEvent / isIdentifyEvent", () => { + it("detects _system entity", () => { + expect(isSystemEvent(makeEvent({ entity: "_system", action: "init" }))).toBe(true); + expect(isSystemEvent(makeEvent())).toBe(false); + }); + + it("detects user:identified", () => { + expect(isIdentifyEvent(makeEvent({ entity: "user", action: "identified" }))).toBe(true); + expect(isIdentifyEvent(makeEvent())).toBe(false); + }); + }); + + describe("resolveHost", () => { + it("defaults to PostHog US cloud", () => { + expect(resolveHost(undefined)).toBe(POSTHOG_DEFAULT_HOST); + }); + + it("strips trailing slash", () => { + expect(resolveHost("https://eu.i.posthog.com/")).toBe("https://eu.i.posthog.com"); + }); + }); + + describe("buildEventProperties", () => { + it("merges event properties with $current_url and $referrer from page context", () => { + const props = buildEventProperties(makeEvent({ properties: { plan: "pro" } })); + expect(props.plan).toBe("pro"); + expect(props.$current_url).toBe("https://example.com/blog"); + expect(props.$referrer).toBe("https://google.com"); + }); + + it("merges extra properties", () => { + const props = buildEventProperties(makeEvent(), { $lib: "junction-server" }); + expect(props.$lib).toBe("junction-server"); + }); + }); +}); diff --git a/packages/destination-posthog/src/shared.ts b/packages/destination-posthog/src/shared.ts new file mode 100644 index 0000000..aeddc97 --- /dev/null +++ b/packages/destination-posthog/src/shared.ts @@ -0,0 +1,65 @@ +/** + * @junctionjs/destination-posthog — shared helpers + * + * Pure functions shared by the web (device-mode) and server (cloud-mode) + * PostHog destinations. No side effects, no network, no DOM. + */ + +import type { ConsentCategory, JctEvent } from "@junctionjs/core"; + +export const POSTHOG_DEFAULT_HOST = "https://us.i.posthog.com"; + +export interface PostHogBaseConfig { + /** PostHog project API key */ + apiKey: string; + + /** PostHog host. Default US cloud; use https://eu.i.posthog.com or a self-hosted/proxy URL. */ + host?: string; + + /** Consent categories required (AND logic). Default ["analytics"]. */ + consent?: ConsentCategory[]; + + /** Override entity:action → PostHog event name. */ + eventNameMap?: Record; + + /** Verbose logging. */ + debug?: boolean; +} + +/** PostHog accepts arbitrary event names, so the default map is deliberately thin. */ +const POSTHOG_DEFAULT_MAP: Record = { + "page:viewed": "$pageview", +}; + +/** 3-tier event name resolution: config override → default map → generated entity_action. */ +export function getEventName(event: JctEvent, eventNameMap?: Record): string { + const key = `${event.entity}:${event.action}`; + return eventNameMap?.[key] ?? POSTHOG_DEFAULT_MAP[key] ?? `${event.entity}_${event.action}`; +} + +/** PostHog uses a single distinct_id that flips from anonymous to known on identify. */ +export function resolveDistinctId(event: JctEvent): string { + return event.user.userId ?? event.user.anonymousId; +} + +export function isSystemEvent(event: JctEvent): boolean { + return event.entity === "_system"; +} + +export function isIdentifyEvent(event: JctEvent): boolean { + return event.entity === "user" && event.action === "identified"; +} + +export function resolveHost(host?: string): string { + return (host ?? POSTHOG_DEFAULT_HOST).replace(/\/$/, ""); +} + +/** Build PostHog event properties from a Junction event, adding $-prefixed browser context. */ +export function buildEventProperties(event: JctEvent, extra?: Record): Record { + const props: Record = { ...event.properties, ...extra }; + if (event.context.page) { + props.$current_url = event.context.page.url; + if (event.context.page.referrer) props.$referrer = event.context.page.referrer; + } + return props; +} diff --git a/packages/destination-posthog/tsconfig.json b/packages/destination-posthog/tsconfig.json new file mode 100644 index 0000000..246146a --- /dev/null +++ b/packages/destination-posthog/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts index 1ae0868..df34493 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ "@junctionjs/debug": path.resolve(__dirname, "packages/debug/src"), "@junctionjs/destination-http": path.resolve(__dirname, "packages/destination-http/src"), "@junctionjs/destination-plausible": path.resolve(__dirname, "packages/destination-plausible/src"), + "@junctionjs/destination-posthog": path.resolve(__dirname, "packages/destination-posthog/src"), }, }, }); From 2a51c14d2a2a0872cdf3ad4e06d386337133beec Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Thu, 9 Jul 2026 11:46:29 -0400 Subject: [PATCH 04/15] feat(posthog): server transform (capture + $identify) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../destination-posthog/src/server.test.ts | 70 +++++++++++++++++++ packages/destination-posthog/src/server.ts | 63 +++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 packages/destination-posthog/src/server.test.ts create mode 100644 packages/destination-posthog/src/server.ts diff --git a/packages/destination-posthog/src/server.test.ts b/packages/destination-posthog/src/server.test.ts new file mode 100644 index 0000000..2d0b2c8 --- /dev/null +++ b/packages/destination-posthog/src/server.test.ts @@ -0,0 +1,70 @@ +import type { JctEvent } from "@junctionjs/core"; +import { describe, expect, it } from "vitest"; +import { transformServerEvent } from "./server.js"; + +function makeEvent(overrides?: Partial): JctEvent { + return { + entity: "page", + action: "viewed", + properties: {}, + context: { + page: { + url: "https://example.com/blog", + path: "/blog", + title: "Blog", + referrer: "https://google.com", + search: "", + hash: "", + }, + }, + user: { anonymousId: "anon-123" }, + timestamp: "2026-06-16T12:00:00.000Z", + id: "evt-001", + version: "1.0.0", + source: { type: "server", name: "test", version: "0.0.0" }, + ...overrides, + }; +} + +describe("server transform", () => { + it("returns null for _system events", () => { + expect(transformServerEvent(makeEvent({ entity: "_system", action: "init" }), { apiKey: "k" })).toBeNull(); + }); + + it("maps a capture event with distinct_id, uuid, timestamp", () => { + const result = transformServerEvent(makeEvent({ entity: "product", action: "added" }), { apiKey: "k" }); + expect(result).toEqual({ + event: "product_added", + distinct_id: "anon-123", + properties: expect.objectContaining({ $current_url: "https://example.com/blog" }), + timestamp: "2026-06-16T12:00:00.000Z", + uuid: "evt-001", + }); + }); + + it("prefers userId as distinct_id", () => { + const result = transformServerEvent(makeEvent({ user: { anonymousId: "a", userId: "u" } }), { apiKey: "k" }); + expect(result?.distinct_id).toBe("u"); + }); + + it("maps user:identified to a $identify event with $anon_distinct_id and $set", () => { + const event = makeEvent({ + entity: "user", + action: "identified", + user: { anonymousId: "anon-123", userId: "user-9", traits: { email: "a@b.co" } }, + }); + const result = transformServerEvent(event, { apiKey: "k" }); + expect(result?.event).toBe("$identify"); + expect(result?.distinct_id).toBe("user-9"); + expect(result?.properties.$anon_distinct_id).toBe("anon-123"); + expect(result?.properties.$set).toEqual({ email: "a@b.co" }); + }); + + it("applies eventNameMap override", () => { + const result = transformServerEvent(makeEvent({ entity: "order", action: "completed" }), { + apiKey: "k", + eventNameMap: { "order:completed": "purchase" }, + }); + expect(result?.event).toBe("purchase"); + }); +}); diff --git a/packages/destination-posthog/src/server.ts b/packages/destination-posthog/src/server.ts new file mode 100644 index 0000000..e2d9363 --- /dev/null +++ b/packages/destination-posthog/src/server.ts @@ -0,0 +1,63 @@ +/** + * @junctionjs/destination-posthog — server (cloud-mode) + * + * Forwards Junction events to PostHog's HTTP capture API. No browser + * signals, no session replay, no feature flags, no client sessionization. + * The "just get events into my PostHog dataset" path. + */ + +import type { JctEvent } from "@junctionjs/core"; +import { + type PostHogBaseConfig, + buildEventProperties, + getEventName, + isIdentifyEvent, + isSystemEvent, + resolveDistinctId, +} from "./shared.js"; + +export interface PostHogServerConfig extends PostHogBaseConfig { + /** Buffer up to this many events, then POST to /batch. 1 = send each event to /capture. Default 20. */ + batchSize?: number; + + /** Flush the buffer at least this often (ms). Default 5000. */ + flushIntervalMs?: number; + + /** Retry a failed flush this many times with exponential backoff. Default 3. */ + maxRetries?: number; +} + +export interface PostHogCaptureEvent { + event: string; + distinct_id: string; + properties: Record; + timestamp: string; + uuid: string; +} + +export function transformServerEvent(event: JctEvent, config: PostHogServerConfig): PostHogCaptureEvent | null { + if (isSystemEvent(event)) return null; + + const distinctId = resolveDistinctId(event); + + if (isIdentifyEvent(event)) { + return { + event: "$identify", + distinct_id: distinctId, + properties: { + $anon_distinct_id: event.user.anonymousId, + ...(event.user.traits ? { $set: event.user.traits } : {}), + }, + timestamp: event.timestamp, + uuid: event.id, + }; + } + + return { + event: getEventName(event, config.eventNameMap), + distinct_id: distinctId, + properties: buildEventProperties(event, { $lib: "junction-server" }), + timestamp: event.timestamp, + uuid: event.id, + }; +} From 5287f21cc147920d62efefacbb887f3a16c2dced Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Thu, 9 Jul 2026 11:50:28 -0400 Subject: [PATCH 05/15] feat(posthog): server send with batching, retry, and factory --- .../destination-posthog/src/server.test.ts | 99 ++++++++++++++++- packages/destination-posthog/src/server.ts | 105 +++++++++++++++++- 2 files changed, 201 insertions(+), 3 deletions(-) diff --git a/packages/destination-posthog/src/server.test.ts b/packages/destination-posthog/src/server.test.ts index 2d0b2c8..19784ce 100644 --- a/packages/destination-posthog/src/server.test.ts +++ b/packages/destination-posthog/src/server.test.ts @@ -1,6 +1,6 @@ import type { JctEvent } from "@junctionjs/core"; -import { describe, expect, it } from "vitest"; -import { transformServerEvent } from "./server.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPostHogServer, transformServerEvent } from "./server.js"; function makeEvent(overrides?: Partial): JctEvent { return { @@ -68,3 +68,98 @@ describe("server transform", () => { expect(result?.event).toBe("purchase"); }); }); + +describe("server factory", () => { + it("has correct defaults", () => { + const dest = createPostHogServer({ apiKey: "phc_test" }); + expect(dest.name).toBe("posthog-server"); + expect(dest.runtime).toBe("server"); + expect(dest.consent).toEqual(["analytics"]); + }); + + it("throws on init when apiKey missing", () => { + const dest = createPostHogServer({ apiKey: "" }); + expect(() => dest.init({} as any)).toThrow("apiKey is required"); + }); + + it("honors a custom consent config", () => { + const dest = createPostHogServer({ apiKey: "k", consent: ["analytics", "marketing"] }); + expect(dest.consent).toEqual(["analytics", "marketing"]); + }); +}); + +describe("server send", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("POSTs a single event to /capture when batchSize is 1", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "phc_test", batchSize: 1 }); + dest.init({} as any); + const payload = dest.transform(makeEvent({ entity: "product", action: "added" }), {} as any); + await dest.send(payload, {} as any); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe("https://us.i.posthog.com/capture/"); + const body = JSON.parse(options.body); + expect(body.api_key).toBe("phc_test"); + expect(body.event).toBe("product_added"); + }); + + it("buffers events and flushes to /batch when batchSize is reached", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "phc_test", batchSize: 2 }); + dest.init({} as any); + + await dest.send(dest.transform(makeEvent({ entity: "a", action: "x" }), {} as any), {} as any); + expect(mockFetch).not.toHaveBeenCalled(); // buffered, not yet flushed + + await dest.send(dest.transform(makeEvent({ entity: "b", action: "y" }), {} as any), {} as any); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe("https://us.i.posthog.com/batch/"); + const body = JSON.parse(options.body); + expect(body.batch).toHaveLength(2); + }); + + it("teardown flushes a partial buffer", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "phc_test", batchSize: 10 }); + dest.init({} as any); + await dest.send(dest.transform(makeEvent({ entity: "a", action: "x" }), {} as any), {} as any); + expect(mockFetch).not.toHaveBeenCalled(); + + await dest.teardown?.(); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(JSON.parse(mockFetch.mock.calls[0][1].body).batch).toHaveLength(1); + }); + + it("uses the EU host when configured", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "k", host: "https://eu.i.posthog.com", batchSize: 1 }); + dest.init({} as any); + await dest.send(dest.transform(makeEvent(), {} as any), {} as any); + expect(mockFetch.mock.calls[0][0]).toBe("https://eu.i.posthog.com/capture/"); + }); + + it("retries on failure then throws after maxRetries", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("boom") }); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "k", batchSize: 1, maxRetries: 2 }); + dest.init({} as any); + await expect(dest.send(dest.transform(makeEvent(), {} as any), {} as any)).rejects.toThrow("500"); + // initial attempt + 2 retries = 3 calls + expect(mockFetch).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/destination-posthog/src/server.ts b/packages/destination-posthog/src/server.ts index e2d9363..610b1b4 100644 --- a/packages/destination-posthog/src/server.ts +++ b/packages/destination-posthog/src/server.ts @@ -6,7 +6,7 @@ * The "just get events into my PostHog dataset" path. */ -import type { JctEvent } from "@junctionjs/core"; +import type { Destination, JctEvent } from "@junctionjs/core"; import { type PostHogBaseConfig, buildEventProperties, @@ -14,6 +14,7 @@ import { isIdentifyEvent, isSystemEvent, resolveDistinctId, + resolveHost, } from "./shared.js"; export interface PostHogServerConfig extends PostHogBaseConfig { @@ -61,3 +62,105 @@ export function transformServerEvent(event: JctEvent, config: PostHogServerConfi uuid: event.id, }; } + +const BACKOFF_BASE_MS = 100; + +async function postWithRetry( + url: string, + apiKey: string, + batch: PostHogCaptureEvent[], + single: boolean, + maxRetries: number, +): Promise { + const body = single ? JSON.stringify({ api_key: apiKey, ...batch[0] }) : JSON.stringify({ api_key: apiKey, batch }); + + let attempt = 0; + // total attempts = 1 + maxRetries + while (true) { + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + if (response.ok) return; + const text = await response.text(); + throw new Error(`[Junction:posthog-server] POST ${url} returned ${response.status}: ${text}`); + } catch (err) { + if (attempt >= maxRetries) throw err; + attempt += 1; + // exponential backoff: 100ms, 200ms, 400ms, ... + await new Promise((resolve) => setTimeout(resolve, BACKOFF_BASE_MS * 2 ** (attempt - 1))); + } + } +} + +/** + * Create a server-side (cloud-mode) PostHog destination. + * + * @example + * ```ts + * import { createPostHogServer } from "@junctionjs/destination-posthog"; + * const dest = createPostHogServer({ apiKey: "phc_...", host: "https://eu.i.posthog.com" }); + * ``` + */ +export function createPostHogServer(config: PostHogServerConfig): Destination { + const host = resolveHost(config.host); + const batchSize = config.batchSize ?? 20; + const flushIntervalMs = config.flushIntervalMs ?? 5000; + const maxRetries = config.maxRetries ?? 3; + + let buffer: PostHogCaptureEvent[] = []; + let timer: ReturnType | undefined; + + async function flush(): Promise { + if (buffer.length === 0) return; + const batch = buffer; + buffer = []; + const single = batchSize <= 1; + const url = single ? `${host}/capture/` : `${host}/batch/`; + await postWithRetry(url, config.apiKey, batch, single, maxRetries); + } + + return { + name: "posthog-server", + description: "PostHog (server / cloud-mode)", + version: "0.1.0", + consent: config.consent ?? ["analytics"], + runtime: "server", + + init() { + if (!config.apiKey) { + throw new Error("[Junction:posthog-server] apiKey is required"); + } + if (batchSize > 1 && !timer) { + timer = setInterval(() => { + void flush().catch((err) => { + if (config.debug) console.error("[Junction:posthog-server] scheduled flush failed:", err); + }); + }, flushIntervalMs); + // Do not keep the Node process alive solely for the flush timer. + (timer as { unref?: () => void }).unref?.(); + } + }, + + transform(event: JctEvent) { + return transformServerEvent(event, config); + }, + + async send(payload: unknown) { + buffer.push(payload as PostHogCaptureEvent); + if (buffer.length >= batchSize) { + await flush(); + } + }, + + async teardown() { + if (timer) { + clearInterval(timer); + timer = undefined; + } + await flush(); + }, + }; +} From 62ea65da13c52e8c2f06df1fdc247833a261e93c Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Fri, 4 Sep 2026 11:16:21 -0400 Subject: [PATCH 06/15] fix(posthog): harden server flush/retry (no silent data loss) 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 Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC --- .../plans/2026-06-16-posthog-destination.md | 16 ++++ .../destination-posthog/src/server.test.ts | 77 +++++++++++++++++++ packages/destination-posthog/src/server.ts | 42 +++++++++- 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-06-16-posthog-destination.md b/docs/superpowers/plans/2026-06-16-posthog-destination.md index 0ce68a9..4a8d1de 100644 --- a/docs/superpowers/plans/2026-06-16-posthog-destination.md +++ b/docs/superpowers/plans/2026-06-16-posthog-destination.md @@ -528,6 +528,22 @@ git commit -m "feat(posthog): server transform (capture + \$identify)" ## Task 3: Server destination — send, batching, retry, factory +> **Amendment (post-review, 2026-09-04):** The Task 3 reviewer flagged three +> reliability defects in the flush/retry design *as originally specified below*. +> Adjudicated with the maintainer → fix all three, so the code below is +> superseded on these points: +> 1. **No silent data loss.** `flush()` requeues its batch on an exhausted-retry +> failure instead of discarding it. A new `maxBufferSize` config (default 1000) +> bounds memory, dropping + logging oldest events past the cap — mirroring +> core's consent-queue guardrails. +> 2. **Timer-flush failures always log.** The `setInterval` flush no longer gates +> its error log behind `config.debug` (that path runs outside the collector's +> `send()` wrapper, so a swallowed error would be invisible). +> 3. **Non-retryable 4xx short-circuit.** `postWithRetry` retries only 429 + 5xx; +> a 401/400/422 throws immediately (`isRetryableStatus`). +> +> See `packages/destination-posthog/src/server.ts` for the shipped implementation. + **Files:** - Modify: `packages/destination-posthog/src/server.ts` - Test: `packages/destination-posthog/src/server.test.ts` (add describe blocks) diff --git a/packages/destination-posthog/src/server.test.ts b/packages/destination-posthog/src/server.test.ts index 19784ce..462604c 100644 --- a/packages/destination-posthog/src/server.test.ts +++ b/packages/destination-posthog/src/server.test.ts @@ -163,3 +163,80 @@ describe("server send", () => { expect(mockFetch).toHaveBeenCalledTimes(3); }); }); + +describe("server reliability", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("does not retry non-retryable 4xx responses", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("bad key") }); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "k", batchSize: 1, maxRetries: 3 }); + dest.init({} as any); + await expect(dest.send(dest.transform(makeEvent(), {} as any), {} as any)).rejects.toThrow("401"); + // 401 is terminal — one attempt, no retries + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("retries a 429 rate-limit response", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 429, text: () => Promise.resolve("slow down") }); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "k", batchSize: 1, maxRetries: 2 }); + dest.init({} as any); + await expect(dest.send(dest.transform(makeEvent(), {} as any), {} as any)).rejects.toThrow("429"); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it("requeues a failed batch instead of dropping it, and resends later", async () => { + // First flush fails on every attempt; the event must survive in the buffer. + const failing = vi.fn().mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("down") }); + vi.stubGlobal("fetch", failing); + + const dest = createPostHogServer({ apiKey: "k", batchSize: 1, maxRetries: 0 }); + dest.init({} as any); + await expect(dest.send(dest.transform(makeEvent({ id: "keep-me" }), {} as any), {} as any)).rejects.toThrow("500"); + + // PostHog recovers — teardown's flush should resend the requeued event. + const ok = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", ok); + await dest.teardown?.(); + + expect(ok).toHaveBeenCalledTimes(1); + const body = JSON.parse(ok.mock.calls[0][1].body); + expect(body.uuid).toBe("keep-me"); + }); + + it("drops oldest events and logs once the buffer exceeds maxBufferSize", async () => { + const failing = vi.fn().mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("down") }); + vi.stubGlobal("fetch", failing); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const dest = createPostHogServer({ apiKey: "k", batchSize: 1, maxRetries: 0, maxBufferSize: 1 }); + dest.init({} as any); + + await expect(dest.send(dest.transform(makeEvent({ id: "e1" }), {} as any), {} as any)).rejects.toThrow(); + await expect(dest.send(dest.transform(makeEvent({ id: "e2" }), {} as any), {} as any)).rejects.toThrow(); + + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("buffer exceeded 1; dropped 1")); + }); + + it("auto-flushes on the interval timer", async () => { + vi.useFakeTimers(); + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "k", batchSize: 5, flushIntervalMs: 1000 }); + dest.init({} as any); + await dest.send(dest.transform(makeEvent(), {} as any), {} as any); + expect(mockFetch).not.toHaveBeenCalled(); // buffered, below batchSize + + await vi.advanceTimersByTimeAsync(1000); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0][0]).toBe("https://us.i.posthog.com/batch/"); + + vi.useRealTimers(); + }); +}); diff --git a/packages/destination-posthog/src/server.ts b/packages/destination-posthog/src/server.ts index 610b1b4..f82a60b 100644 --- a/packages/destination-posthog/src/server.ts +++ b/packages/destination-posthog/src/server.ts @@ -26,6 +26,13 @@ export interface PostHogServerConfig extends PostHogBaseConfig { /** Retry a failed flush this many times with exponential backoff. Default 3. */ maxRetries?: number; + + /** + * Cap on events held in memory while flushes are failing. A failed flush + * requeues its batch rather than dropping it; once the buffer exceeds this, + * the oldest events are dropped (and logged) to bound memory. Default 1000. + */ + maxBufferSize?: number; } export interface PostHogCaptureEvent { @@ -65,6 +72,15 @@ export function transformServerEvent(event: JctEvent, config: PostHogServerConfi const BACKOFF_BASE_MS = 100; +/** + * Retry rate limits (429) and server errors (5xx); those are transient. A 4xx + * like 401 (bad key) or 400/422 (malformed) will fail identically on every + * retry, so surface it immediately instead of burning the retry budget. + */ +function isRetryableStatus(status: number): boolean { + return status === 429 || status >= 500; +} + async function postWithRetry( url: string, apiKey: string, @@ -77,6 +93,9 @@ async function postWithRetry( let attempt = 0; // total attempts = 1 + maxRetries while (true) { + // Network/transport errors (fetch throws) are transient → retryable. + // A non-ok response sets this from its status before we throw. + let retryable = true; try { const response = await fetch(url, { method: "POST", @@ -84,10 +103,11 @@ async function postWithRetry( body, }); if (response.ok) return; + retryable = isRetryableStatus(response.status); const text = await response.text(); throw new Error(`[Junction:posthog-server] POST ${url} returned ${response.status}: ${text}`); } catch (err) { - if (attempt >= maxRetries) throw err; + if (!retryable || attempt >= maxRetries) throw err; attempt += 1; // exponential backoff: 100ms, 200ms, 400ms, ... await new Promise((resolve) => setTimeout(resolve, BACKOFF_BASE_MS * 2 ** (attempt - 1))); @@ -109,6 +129,7 @@ export function createPostHogServer(config: PostHogServerConfig): Destination | undefined; @@ -119,7 +140,19 @@ export function createPostHogServer(config: PostHogServerConfig): Destination maxBufferSize) { + const dropped = buffer.length - maxBufferSize; + buffer = buffer.slice(dropped); // drop oldest to bound memory + console.error(`[Junction:posthog-server] buffer exceeded ${maxBufferSize}; dropped ${dropped} oldest event(s)`); + } + throw err; + } } return { @@ -135,8 +168,11 @@ export function createPostHogServer(config: PostHogServerConfig): Destination 1 && !timer) { timer = setInterval(() => { + // Always surface timer-driven flush failures. This path runs outside + // the collector's send() wrapper, so a swallowed error here would be + // completely invisible to operators. void flush().catch((err) => { - if (config.debug) console.error("[Junction:posthog-server] scheduled flush failed:", err); + console.error("[Junction:posthog-server] scheduled flush failed:", err); }); }, flushIntervalMs); // Do not keep the Node process alive solely for the flush timer. From 50a14a1a1ad9876270f75b3dc81ff1e61339d91a Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Fri, 4 Sep 2026 11:36:05 -0400 Subject: [PATCH 07/15] feat(posthog): web transform (capture + identify intents) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC --- packages/destination-posthog/src/web.test.ts | 52 ++++++++++++++++++++ packages/destination-posthog/src/web.ts | 52 ++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 packages/destination-posthog/src/web.test.ts create mode 100644 packages/destination-posthog/src/web.ts diff --git a/packages/destination-posthog/src/web.test.ts b/packages/destination-posthog/src/web.test.ts new file mode 100644 index 0000000..9cb9ffb --- /dev/null +++ b/packages/destination-posthog/src/web.test.ts @@ -0,0 +1,52 @@ +import type { JctEvent } from "@junctionjs/core"; +import { describe, expect, it } from "vitest"; +import { transformWebEvent } from "./web.js"; + +function makeEvent(overrides?: Partial): JctEvent { + return { + entity: "page", + action: "viewed", + properties: {}, + context: { + page: { + url: "https://example.com/blog", + path: "/blog", + title: "Blog", + referrer: "https://google.com", + search: "", + hash: "", + }, + }, + user: { anonymousId: "anon-123" }, + timestamp: "2026-06-16T12:00:00.000Z", + id: "evt-001", + version: "1.0.0", + source: { type: "client", name: "browser", version: "0.0.0" }, + ...overrides, + }; +} + +describe("web transform", () => { + it("returns null for _system events", () => { + expect(transformWebEvent(makeEvent({ entity: "_system", action: "init" }), { apiKey: "k" })).toBeNull(); + }); + + it("maps a capture event", () => { + const result = transformWebEvent(makeEvent({ entity: "product", action: "added" }), { apiKey: "k" }); + expect(result).toEqual({ + type: "capture", + name: "product_added", + properties: expect.objectContaining({ $current_url: "https://example.com/blog" }), + }); + }); + + it("maps user:identified to an identify payload", () => { + const event = makeEvent({ + entity: "user", + action: "identified", + user: { anonymousId: "anon-123", userId: "user-9", traits: { email: "a@b.co" } }, + }); + const result = transformWebEvent(event, { apiKey: "k" }); + expect(result).toEqual({ type: "identify", distinctId: "user-9", traits: { email: "a@b.co" } }); + }); +}); diff --git a/packages/destination-posthog/src/web.ts b/packages/destination-posthog/src/web.ts new file mode 100644 index 0000000..d995aec --- /dev/null +++ b/packages/destination-posthog/src/web.ts @@ -0,0 +1,52 @@ +/** + * @junctionjs/destination-posthog — web (device-mode) + * + * Loads posthog-js on the page and routes Junction's tracked events + * through posthog.capture(). Junction stays the event source of truth: + * posthog-js autocapture / capture_pageview are OFF by default. + */ + +import type { JctEvent } from "@junctionjs/core"; +import { + type PostHogBaseConfig, + buildEventProperties, + getEventName, + isIdentifyEvent, + isSystemEvent, + resolveDistinctId, +} from "./shared.js"; + +export interface PostHogWebConfig extends PostHogBaseConfig { + /** Inject the posthog-js snippet. Default true. */ + loadScript?: boolean; + + /** Override the snippet URL (reverse proxy / self-hosted). */ + scriptUrl?: string; + + /** Enable session replay. Default false (heavy + privacy-sensitive). */ + sessionReplay?: boolean; + + /** Let posthog-js autocapture clicks/inputs on its own. Default false. */ + autocapture?: boolean; + + /** Let posthog-js fire its own pageviews. Default false (Junction owns page:viewed). */ + capturePageview?: boolean; +} + +export type WebPayload = + | { type: "capture"; name: string; properties: Record } + | { type: "identify"; distinctId: string; traits?: Record }; + +export function transformWebEvent(event: JctEvent, config: PostHogWebConfig): WebPayload | null { + if (isSystemEvent(event)) return null; + + if (isIdentifyEvent(event)) { + return { type: "identify", distinctId: resolveDistinctId(event), traits: event.user.traits }; + } + + return { + type: "capture", + name: getEventName(event, config.eventNameMap), + properties: buildEventProperties(event, { $lib: "junction-web" }), + }; +} From fb1f604e7d6b0b00eeb3af8e5c2cccec96997179 Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Fri, 4 Sep 2026 11:38:28 -0400 Subject: [PATCH 08/15] feat(posthog): web loader, send routing, and consent opt-out Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC --- packages/destination-posthog/src/web.test.ts | 122 ++++++++++++++++++- packages/destination-posthog/src/web.ts | 94 +++++++++++++- 2 files changed, 213 insertions(+), 3 deletions(-) diff --git a/packages/destination-posthog/src/web.test.ts b/packages/destination-posthog/src/web.test.ts index 9cb9ffb..914178f 100644 --- a/packages/destination-posthog/src/web.test.ts +++ b/packages/destination-posthog/src/web.test.ts @@ -1,6 +1,6 @@ import type { JctEvent } from "@junctionjs/core"; -import { describe, expect, it } from "vitest"; -import { transformWebEvent } from "./web.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPostHogWeb, transformWebEvent } from "./web.js"; function makeEvent(overrides?: Partial): JctEvent { return { @@ -50,3 +50,121 @@ describe("web transform", () => { expect(result).toEqual({ type: "identify", distinctId: "user-9", traits: { email: "a@b.co" } }); }); }); + +interface FakePostHog { + init: ReturnType; + capture: ReturnType; + identify: ReturnType; + opt_out_capturing: ReturnType; + __loaded: boolean; +} + +function installFakePostHog(): FakePostHog { + const fake: FakePostHog = { + init: vi.fn(), + capture: vi.fn(), + identify: vi.fn(), + opt_out_capturing: vi.fn(), + __loaded: true, + }; + vi.stubGlobal("window", { posthog: fake }); + return fake; +} + +describe("web factory", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("has correct defaults", () => { + const dest = createPostHogWeb({ apiKey: "phc_test" }); + expect(dest.name).toBe("posthog-web"); + expect(dest.runtime).toBe("client"); + expect(dest.consent).toEqual(["analytics"]); + }); + + it("no-ops init in SSR (no window)", () => { + vi.stubGlobal("window", undefined); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + expect(() => dest.init({} as any)).not.toThrow(); + }); + + it("inits posthog with autocapture and pageview off by default", () => { + const fake = installFakePostHog(); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + dest.init({} as any); + expect(fake.init).toHaveBeenCalledWith( + "phc_test", + expect.objectContaining({ + api_host: "https://us.i.posthog.com", + autocapture: false, + capture_pageview: false, + }), + ); + }); + + it("enables autocapture/pageview/replay when opted in", () => { + const fake = installFakePostHog(); + const dest = createPostHogWeb({ + apiKey: "phc_test", + autocapture: true, + capturePageview: true, + sessionReplay: true, + }); + dest.init({} as any); + const opts = fake.init.mock.calls[0][1]; + expect(opts.autocapture).toBe(true); + expect(opts.capture_pageview).toBe(true); + expect(opts.disable_session_recording).toBe(false); + }); +}); + +describe("web send", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("routes a capture payload to posthog.capture", async () => { + const fake = installFakePostHog(); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + dest.init({} as any); + const payload = dest.transform(makeEvent({ entity: "product", action: "added" }), {} as any); + await dest.send(payload, {} as any); + expect(fake.capture).toHaveBeenCalledWith("product_added", expect.objectContaining({ $lib: "junction-web" })); + }); + + it("routes an identify payload to posthog.identify", async () => { + const fake = installFakePostHog(); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + dest.init({} as any); + const event = makeEvent({ + entity: "user", + action: "identified", + user: { anonymousId: "anon-1", userId: "user-9", traits: { email: "a@b.co" } }, + }); + await dest.send(dest.transform(event, {} as any), {} as any); + expect(fake.identify).toHaveBeenCalledWith("user-9", { $set: { email: "a@b.co" } }); + }); +}); + +describe("web consent", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("opts out of capturing when analytics consent is revoked", () => { + const fake = installFakePostHog(); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + dest.init({} as any); + dest.onConsent?.({ analytics: false } as any); + expect(fake.opt_out_capturing).toHaveBeenCalled(); + }); + + it("does not opt out while analytics consent is granted", () => { + const fake = installFakePostHog(); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + dest.init({} as any); + dest.onConsent?.({ analytics: true } as any); + expect(fake.opt_out_capturing).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/destination-posthog/src/web.ts b/packages/destination-posthog/src/web.ts index d995aec..266aa44 100644 --- a/packages/destination-posthog/src/web.ts +++ b/packages/destination-posthog/src/web.ts @@ -6,7 +6,7 @@ * posthog-js autocapture / capture_pageview are OFF by default. */ -import type { JctEvent } from "@junctionjs/core"; +import type { ConsentState, Destination, JctEvent } from "@junctionjs/core"; import { type PostHogBaseConfig, buildEventProperties, @@ -14,6 +14,7 @@ import { isIdentifyEvent, isSystemEvent, resolveDistinctId, + resolveHost, } from "./shared.js"; export interface PostHogWebConfig extends PostHogBaseConfig { @@ -50,3 +51,94 @@ export function transformWebEvent(event: JctEvent, config: PostHogWebConfig): We properties: buildEventProperties(event, { $lib: "junction-web" }), }; } + +interface PostHogJs { + init: (apiKey: string, options: Record) => void; + capture: (name: string, properties?: Record) => void; + identify: (distinctId: string, properties?: Record) => void; + opt_out_capturing: () => void; + __loaded?: boolean; +} + +function getPostHog(): PostHogJs | undefined { + if (typeof window === "undefined") return undefined; + return (window as unknown as { posthog?: PostHogJs }).posthog; +} + +/** Inject the posthog-js snippet with an array stub that queues calls until the script loads. */ +function loadSnippet(scriptUrl: string): void { + if (typeof window === "undefined") return; + const w = window as unknown as { posthog?: PostHogJs; document?: Document }; + if (w.posthog?.__loaded) return; + + const doc = w.document; + if (!doc) return; + if (doc.querySelector(`script[src="${scriptUrl}"]`)) return; + + const script = doc.createElement("script"); + script.async = true; + script.src = scriptUrl; + const first = doc.getElementsByTagName("script")[0]; + first?.parentNode?.insertBefore(script, first); +} + +/** + * Create a client-side (device-mode) PostHog destination. + * + * @example + * ```ts + * import { createPostHogWeb } from "@junctionjs/destination-posthog"; + * const dest = createPostHogWeb({ apiKey: "phc_...", sessionReplay: true }); + * ``` + */ +export function createPostHogWeb(config: PostHogWebConfig): Destination { + const apiHost = resolveHost(config.host); + const scriptUrl = config.scriptUrl ?? `${apiHost}/static/array.js`; + + return { + name: "posthog-web", + description: "PostHog (web / device-mode)", + version: "0.1.0", + consent: config.consent ?? ["analytics"], + runtime: "client", + + init() { + if (typeof window === "undefined") return; // SSR guard + if (!config.apiKey) { + throw new Error("[Junction:posthog-web] apiKey is required"); + } + if (config.loadScript !== false) { + loadSnippet(scriptUrl); + } + const posthog = getPostHog(); + posthog?.init(config.apiKey, { + api_host: apiHost, + autocapture: config.autocapture ?? false, + capture_pageview: config.capturePageview ?? false, + capture_pageleave: config.capturePageview ?? false, + disable_session_recording: !(config.sessionReplay ?? false), + }); + }, + + transform(event: JctEvent) { + return transformWebEvent(event, config); + }, + + async send(payload: unknown) { + const posthog = getPostHog(); + if (!posthog) return; // script not loaded yet / SSR + const p = payload as WebPayload; + if (p.type === "identify") { + posthog.identify(p.distinctId, p.traits ? { $set: p.traits } : undefined); + } else { + posthog.capture(p.name, p.properties); + } + }, + + onConsent(state: ConsentState) { + if (state.analytics === false) { + getPostHog()?.opt_out_capturing(); + } + }, + }; +} From d4487bda68fb28c7121e2cd152e6a190fba7c187 Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Fri, 4 Sep 2026 11:47:17 -0400 Subject: [PATCH 09/15] fix(posthog): web queue-before-load stub + consent re-grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC --- packages/destination-posthog/src/web.test.ts | 55 +++++++++++++++++++ packages/destination-posthog/src/web.ts | 56 ++++++++++++++++++-- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/packages/destination-posthog/src/web.test.ts b/packages/destination-posthog/src/web.test.ts index 914178f..9cc2224 100644 --- a/packages/destination-posthog/src/web.test.ts +++ b/packages/destination-posthog/src/web.test.ts @@ -56,6 +56,7 @@ interface FakePostHog { capture: ReturnType; identify: ReturnType; opt_out_capturing: ReturnType; + opt_in_capturing: ReturnType; __loaded: boolean; } @@ -65,6 +66,7 @@ function installFakePostHog(): FakePostHog { capture: vi.fn(), identify: vi.fn(), opt_out_capturing: vi.fn(), + opt_in_capturing: vi.fn(), __loaded: true, }; vi.stubGlobal("window", { posthog: fake }); @@ -167,4 +169,57 @@ describe("web consent", () => { dest.onConsent?.({ analytics: true } as any); expect(fake.opt_out_capturing).not.toHaveBeenCalled(); }); + + it("opts back in when analytics consent is re-granted", () => { + const fake = installFakePostHog(); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + dest.init({} as any); + dest.onConsent?.({ analytics: true } as any); + expect(fake.opt_in_capturing).toHaveBeenCalled(); + }); +}); + +describe("web loader (queue-before-load)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function installFakeDom() { + const created: Array> = []; + const firstScript = { parentNode: { insertBefore: vi.fn() } }; + const doc = { + querySelector: vi.fn(() => null), + createElement: vi.fn(() => { + const el: Record = {}; + created.push(el); + return el; + }), + getElementsByTagName: vi.fn(() => [firstScript]), + }; + vi.stubGlobal("window", { document: doc }); + return { doc, created }; + } + + it("installs a queuing stub and injects array.js when posthog isn't loaded", () => { + const { created } = installFakeDom(); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + dest.init({} as any); + + const ph = (window as unknown as { posthog: any }).posthog; + expect(ph).toBeDefined(); + expect(typeof ph.capture).toBe("function"); // stub method installed + expect(ph._i).toHaveLength(1); // init call queued for replay + expect(created[0].src).toContain("/static/array.js"); + }); + + it("queues capture calls fired before array.js loads instead of dropping them", () => { + installFakeDom(); + const dest = createPostHogWeb({ apiKey: "phc_test" }); + dest.init({} as any); + dest.send({ type: "capture", name: "product_added", properties: { $lib: "junction-web" } } as any, {} as any); + + const ph = (window as unknown as { posthog: any }).posthog; + const queued = (ph as unknown[]).find((e) => Array.isArray(e) && e[0] === "capture"); + expect(queued).toEqual(["capture", "product_added", { $lib: "junction-web" }]); + }); }); diff --git a/packages/destination-posthog/src/web.ts b/packages/destination-posthog/src/web.ts index 266aa44..9090487 100644 --- a/packages/destination-posthog/src/web.ts +++ b/packages/destination-posthog/src/web.ts @@ -57,6 +57,7 @@ interface PostHogJs { capture: (name: string, properties?: Record) => void; identify: (distinctId: string, properties?: Record) => void; opt_out_capturing: () => void; + opt_in_capturing: () => void; __loaded?: boolean; } @@ -65,16 +66,59 @@ function getPostHog(): PostHogJs | undefined { return (window as unknown as { posthog?: PostHogJs }).posthog; } -/** Inject the posthog-js snippet with an array stub that queues calls until the script loads. */ +// Method names PostHog's array.js expects on the stub so queued calls replay +// after the real SDK loads. Ported from PostHog's official loader snippet. +const POSTHOG_STUB_METHODS = + "capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset group".split( + " ", + ); + +/** + * Install PostHog's queuing stub on window.posthog, then inject array.js. This is + * the queue-before-load pattern required by .claude/rules/destinations.md: without + * the stub, window.posthog is undefined until the async array.js executes, so init() + * and every capture()/identify() fired before then — including the first page:viewed + * on load — are silently dropped. The stub queues those calls; array.js replays them. + */ function loadSnippet(scriptUrl: string): void { if (typeof window === "undefined") return; - const w = window as unknown as { posthog?: PostHogJs; document?: Document }; - if (w.posthog?.__loaded) return; + const w = window as unknown as { posthog?: any; document?: Document }; + // Already fully loaded, or the stub is already installed (__SV) — idempotent. + if (w.posthog?.__loaded || w.posthog?.__SV) return; const doc = w.document; if (!doc) return; if (doc.querySelector(`script[src="${scriptUrl}"]`)) return; + const stub: any = w.posthog || []; + w.posthog = stub; + stub._i = []; + stub.init = (apiKey: string, options?: Record, name?: string) => { + const queueMethod = (base: any, method: string) => { + let target = base; + let key = method; + const parts = method.split("."); + if (parts.length === 2) { + target = base[parts[0]]; + key = parts[1]; + } + target[key] = (...args: unknown[]) => { + target.push([key].concat(args)); + }; + }; + let u: any = stub; + const instanceName = typeof name !== "undefined" ? name : "posthog"; + if (typeof name !== "undefined") { + u = stub[name] = []; + } + u.people = u.people || []; + for (const method of POSTHOG_STUB_METHODS) { + queueMethod(u, method); + } + stub._i.push([apiKey, options, instanceName]); + }; + stub.__SV = 1; + const script = doc.createElement("script"); script.async = true; script.src = scriptUrl; @@ -136,8 +180,12 @@ export function createPostHogWeb(config: PostHogWebConfig): Destination Date: Fri, 4 Sep 2026 11:48:37 -0400 Subject: [PATCH 10/15] docs(posthog): amend plan Task 5 + ledger for reviewed fixes Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC --- .../plans/2026-06-16-posthog-destination.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/superpowers/plans/2026-06-16-posthog-destination.md b/docs/superpowers/plans/2026-06-16-posthog-destination.md index 4a8d1de..ae71de7 100644 --- a/docs/superpowers/plans/2026-06-16-posthog-destination.md +++ b/docs/superpowers/plans/2026-06-16-posthog-destination.md @@ -932,6 +932,22 @@ git commit -m "feat(posthog): web transform (capture + identify intents)" ## Task 5: Web destination — loader, send, consent, factory +> **Amendment (post-review, 2026-09-04):** Review of the implementation below +> surfaced two plan-mandated defects; adjudicated with the maintainer → fix both, +> so the code below is superseded on these points: +> 1. **`loadSnippet` never created `window.posthog`.** As written it only injects +> `array.js`, so `init()`'s `getPostHog()?.init(...)` short-circuits and posthog +> never initializes in a real browser — and every `capture`/`identify` before the +> async script loads (including the first `page:viewed`) is dropped. Fixed by +> installing PostHog's official queuing stub (the queue-before-load pattern +> `.claude/rules/destinations.md` requires); `array.js` replays the queue on load. +> 2. **`onConsent` only opted out.** Re-granting analytics consent was a no-op. +> Fixed to call `opt_in_capturing()` on `analytics === true` (consent-first). +> +> Deferred (not fixed): missing-`apiKey` validation is inside the `window` guard, +> so SSR misconfig no-ops silently until client mount — low value, folded into a +> later cross-destination validation pass. See `src/web.ts` for the shipped code. + **Files:** - Modify: `packages/destination-posthog/src/web.ts` - Test: `packages/destination-posthog/src/web.test.ts` (add describe blocks) From 4526e9986cd2184e7bd0f194c5d2fae26c5812b2 Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Fri, 4 Sep 2026 11:51:55 -0400 Subject: [PATCH 11/15] chore(biome): ignore generated .next and .astro output 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 Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC --- biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/biome.json b/biome.json index 65c05a7..ddef733 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,7 @@ { "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json", "files": { - "ignore": ["dist", "node_modules", ".turbo", ".changeset", "package-lock.json", "*.d.ts"] + "ignore": ["dist", "node_modules", ".turbo", ".next", ".astro", ".changeset", "package-lock.json", "*.d.ts"] }, "formatter": { "indentStyle": "space", From cf5d0f49f9c31227ce74271b1310f14e493f81d4 Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Fri, 4 Sep 2026 11:51:55 -0400 Subject: [PATCH 12/15] feat(posthog): barrel export, README, build verification 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 Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC --- packages/destination-posthog/README.md | 62 +++++++++++++++++++ .../destination-posthog/src/index.test.ts | 11 ++++ packages/destination-posthog/src/index.ts | 13 ++++ packages/destination-posthog/src/web.ts | 2 +- 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 packages/destination-posthog/README.md create mode 100644 packages/destination-posthog/src/index.test.ts create mode 100644 packages/destination-posthog/src/index.ts diff --git a/packages/destination-posthog/README.md b/packages/destination-posthog/README.md new file mode 100644 index 0000000..0da7e11 --- /dev/null +++ b/packages/destination-posthog/README.md @@ -0,0 +1,62 @@ +# @junctionjs/destination-posthog + +PostHog destination for Junction, offered as two tree-shakeable factories mirroring +the web (device-mode) vs server (cloud-mode) split. + +- **`createPostHogWeb`** — loads `posthog-js` on the page. Full library: sessionization, + person profiles, session replay, feature flags, browser-signal enrichment. Junction stays + the event source of truth (autocapture/pageview off by default). +- **`createPostHogServer`** — forwards events to PostHog's HTTP capture API. No replay, flags, + or client sessionization. The "just get events into my dataset" path. + +Import only the one you need — the other is eliminated by tree-shaking. `posthog-js` is loaded +from PostHog's CDN at runtime, never bundled. + +## Install + +```bash +npm install @junctionjs/destination-posthog +``` + +## Web (device-mode) + +```ts +import { createPostHogWeb } from "@junctionjs/destination-posthog"; + +const posthogWeb = createPostHogWeb({ + apiKey: "phc_...", + host: "https://us.i.posthog.com", // or eu.i.posthog.com / self-hosted + sessionReplay: false, // opt in when you want replay +}); +``` + +## Server (cloud-mode) + +```ts +import { createPostHogServer } from "@junctionjs/destination-posthog"; + +const posthogServer = createPostHogServer({ + apiKey: "phc_...", + batchSize: 20, // buffer then POST to /batch; 1 = per-event /capture + flushIntervalMs: 5000, + maxRetries: 3, +}); +``` + +## Configuration + +| Option | Web | Server | Default | Notes | +|---|---|---|---|---| +| `apiKey` | ✓ | ✓ | — | PostHog project API key (required) | +| `host` | ✓ | ✓ | `https://us.i.posthog.com` | EU cloud or self-hosted URL | +| `consent` | ✓ | ✓ | `["analytics"]` | Consent categories (AND logic) | +| `eventNameMap` | ✓ | ✓ | — | Override `entity:action` → PostHog event name | +| `loadScript` | ✓ | — | `true` | Inject the posthog-js snippet | +| `scriptUrl` | ✓ | — | `${host}/static/array.js` | Reverse-proxy / self-host override | +| `sessionReplay` | ✓ | — | `false` | Enable session recording | +| `autocapture` | ✓ | — | `false` | Let posthog-js autocapture on its own | +| `capturePageview` | ✓ | — | `false` | Junction owns `page:viewed` by default | +| `batchSize` | — | ✓ | `20` | Buffer size before flush to `/batch` | +| `flushIntervalMs` | — | ✓ | `5000` | Max time between flushes | +| `maxRetries` | — | ✓ | `3` | Retries with exponential backoff | +| `maxBufferSize` | — | ✓ | `1000` | Cap on buffered events while flushes fail; oldest dropped past it | diff --git a/packages/destination-posthog/src/index.test.ts b/packages/destination-posthog/src/index.test.ts new file mode 100644 index 0000000..e80f8c3 --- /dev/null +++ b/packages/destination-posthog/src/index.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { createPostHogServer, createPostHogWeb } from "./index.js"; + +describe("index barrel", () => { + it("exports both factories", () => { + expect(typeof createPostHogWeb).toBe("function"); + expect(typeof createPostHogServer).toBe("function"); + expect(createPostHogWeb({ apiKey: "k" }).runtime).toBe("client"); + expect(createPostHogServer({ apiKey: "k" }).runtime).toBe("server"); + }); +}); diff --git a/packages/destination-posthog/src/index.ts b/packages/destination-posthog/src/index.ts new file mode 100644 index 0000000..d55b497 --- /dev/null +++ b/packages/destination-posthog/src/index.ts @@ -0,0 +1,13 @@ +/** + * @junctionjs/destination-posthog + * + * PostHog destination for Junction, offered as two tree-shakeable factories: + * - createPostHogWeb — device-mode: loads posthog-js, full browser signals + * - createPostHogServer — cloud-mode: forwards events to PostHog's HTTP API + * + * Import only the one you need; the other is eliminated by tree-shaking. + */ + +export { POSTHOG_DEFAULT_HOST, type PostHogBaseConfig } from "./shared.js"; +export { createPostHogServer, type PostHogServerConfig } from "./server.js"; +export { createPostHogWeb, type PostHogWebConfig } from "./web.js"; diff --git a/packages/destination-posthog/src/web.ts b/packages/destination-posthog/src/web.ts index 9090487..a27c575 100644 --- a/packages/destination-posthog/src/web.ts +++ b/packages/destination-posthog/src/web.ts @@ -103,7 +103,7 @@ function loadSnippet(scriptUrl: string): void { key = parts[1]; } target[key] = (...args: unknown[]) => { - target.push([key].concat(args)); + target.push([key, ...args]); }; }; let u: any = stub; From f8ed9b8ac4a4978b35e53faa9a526a95f7b5f671 Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Fri, 4 Sep 2026 11:52:29 -0400 Subject: [PATCH 13/15] docs(rules): add identity-projection convention for destinations 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 Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC --- .claude/rules/destinations.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.claude/rules/destinations.md b/.claude/rules/destinations.md index 4d24e85..4e39a63 100644 --- a/.claude/rules/destinations.md +++ b/.claude/rules/destinations.md @@ -58,6 +58,32 @@ function getEventName(event: JctEvent, config: Config): string { | product:added | add_to_cart | Product Added | AddToCart | | order:completed | purchase | Order Completed | Purchase | +## Identity Projection + +Junction owns a canonical identity model; each destination projects it into the vendor's shape. + +Canonical fields on every event: +- `event.user.anonymousId` — first-party anonymous/device ID, always present +- `event.user.userId` — known ID, set after `collector.identify()` +- `event.user.traits` — persistent traits from `identify()` +- `event.id` — unique per event, used as a dedup key +- `user:identified` — lifecycle event emitted by `collector.identify()` + +| Canonical | GA4 | Amplitude | PostHog | +|---|---|---|---| +| `anonymousId` | `client_id` | `device_id` | `distinct_id` (while anonymous) | +| `userId` | `user_id` | `user_id` | `distinct_id` after merge + `$identify` | +| `traits` | `user_properties` | `user_properties` | person props via `$set` | +| `user:identified` | no-op (carries `user_id`) | no-op (carries `user_id`) | `posthog.identify()` / `$identify` w/ `$anon_distinct_id` | +| `event.id` | — | `insert_id` | `uuid` | + +**Every destination must decide how it handles `user:identified`.** Parallel-fields vendors +(GA4, Amplitude) carry `device_id` + `user_id` on every event and can no-op it. Merge/alias +vendors (PostHog) must act on it to stitch anonymous history to the known person. + +> **Known gap (tracked):** `UserIdentity` has no `sessionId`; GA4/Amplitude do not yet map +> `session_id`/`$session_id`. Cross-destination follow-up, not owned by any single destination. + ## Script Loading Client-side destinations that load vendor scripts use a queue-before-load pattern: From d84c3fd819b3f3c3b2ba6a098348832297e7c5f4 Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Fri, 4 Sep 2026 11:55:05 -0400 Subject: [PATCH 14/15] docs(posthog): destination page, overview row, demo wiring, changeset - 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 Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC --- .changeset/posthog-destination.md | 10 ++++ apps/demo/lib/junction-config.ts | 12 +++++ apps/demo/package.json | 1 + .../content/docs/destinations/overview.mdx | 1 + .../src/content/docs/destinations/posthog.mdx | 50 +++++++++++++++++++ package-lock.json | 1 + tsconfig.json | 1 + 7 files changed, 76 insertions(+) create mode 100644 .changeset/posthog-destination.md create mode 100644 apps/docs/src/content/docs/destinations/posthog.mdx diff --git a/.changeset/posthog-destination.md b/.changeset/posthog-destination.md new file mode 100644 index 0000000..220996a --- /dev/null +++ b/.changeset/posthog-destination.md @@ -0,0 +1,10 @@ +--- +"@junctionjs/destination-posthog": minor +--- + +Add PostHog destination with web (device-mode) and server (cloud-mode) factories. + +`createPostHogWeb` loads posthog-js for full browser signals; `createPostHogServer` forwards +events to PostHog's HTTP capture API with batching and retry. Junction stays the event source +of truth (autocapture/pageview off by default). Establishes the canonical identity-projection +convention in the destination rules. diff --git a/apps/demo/lib/junction-config.ts b/apps/demo/lib/junction-config.ts index 6cacdb2..ffb9fd0 100644 --- a/apps/demo/lib/junction-config.ts +++ b/apps/demo/lib/junction-config.ts @@ -1,6 +1,7 @@ import { readConsentCookie } from "@/components/consent/use-consent-cookie"; import type { CollectorConfig } from "@junctionjs/core"; import { ga4 } from "@junctionjs/destination-ga4"; +import { createPostHogWeb } from "@junctionjs/destination-posthog"; import { contracts } from "./contracts"; import { demoSink, simulatedAmplitude, simulatedMeta } from "./demo-sink"; @@ -49,6 +50,17 @@ export const junctionConfig: CollectorConfig = { }, ] : []), + // Real PostHog (device-mode) — gated on env var so the demo works without it. + ...(process.env.NEXT_PUBLIC_POSTHOG_KEY + ? [ + { + destination: createPostHogWeb({ apiKey: process.env.NEXT_PUBLIC_POSTHOG_KEY }), + config: {}, + consent: ["analytics"], + enabled: true, + }, + ] + : []), { destination: simulatedAmplitude, config: {}, enabled: true }, { destination: simulatedMeta, config: {}, enabled: true }, ], diff --git a/apps/demo/package.json b/apps/demo/package.json index 3cf6213..01a0418 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -15,6 +15,7 @@ "@junctionjs/destination-amplitude": "*", "@junctionjs/destination-ga4": "*", "@junctionjs/destination-meta": "*", + "@junctionjs/destination-posthog": "*", "@junctionjs/next": "*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/docs/src/content/docs/destinations/overview.mdx b/apps/docs/src/content/docs/destinations/overview.mdx index 741b73c..d01a59e 100644 --- a/apps/docs/src/content/docs/destinations/overview.mdx +++ b/apps/docs/src/content/docs/destinations/overview.mdx @@ -12,6 +12,7 @@ Destinations are where Junction sends your events. Each destination is a plain o | Google Analytics 4 | `@junctionjs/destination-ga4` | Client + Server | | Amplitude | `@junctionjs/destination-amplitude` | Client + Server | | Meta Pixel + CAPI | `@junctionjs/destination-meta` | Client + Server | +| PostHog | `@junctionjs/destination-posthog` | Client + Server | | Plausible | `@junctionjs/destination-plausible` | Client | | HTTP (Generic) | `@junctionjs/destination-http` | Server | diff --git a/apps/docs/src/content/docs/destinations/posthog.mdx b/apps/docs/src/content/docs/destinations/posthog.mdx new file mode 100644 index 0000000..e56b8c7 --- /dev/null +++ b/apps/docs/src/content/docs/destinations/posthog.mdx @@ -0,0 +1,50 @@ +--- +title: PostHog +description: Send Junction events to PostHog — web (device-mode) or server (cloud-mode). +--- + +`@junctionjs/destination-posthog` offers two tree-shakeable factories mirroring the +device-mode vs cloud-mode split. Import only the one you need. + +## Web (device-mode) + +Loads `posthog-js` on the page for the full library — sessionization, person profiles, +session replay, feature flags, browser-signal enrichment. Junction stays the event source of +truth: `posthog-js` autocapture and pageview capture are **off by default**. + +```ts +import { createPostHogWeb } from "@junctionjs/destination-posthog"; + +const posthogWeb = createPostHogWeb({ + apiKey: "phc_...", + host: "https://us.i.posthog.com", + sessionReplay: false, +}); +``` + +## Server (cloud-mode) + +Forwards events to PostHog's HTTP capture API. No replay, flags, or client sessionization — +the "just get events into my dataset" path. Buffers and flushes to `/batch` with retry. + +```ts +import { createPostHogServer } from "@junctionjs/destination-posthog"; + +const posthogServer = createPostHogServer({ + apiKey: "phc_...", + batchSize: 20, + maxRetries: 3, +}); +``` + +## Consent + +Both default to `consent: ["analytics"]` and only initialize after consent resolves — no +consent means `posthog-js` never loads and no cookies are set. Session replay stays under +`analytics`; treat it as sensitive. + +## Identity + +Junction's canonical identity maps onto PostHog's single `distinct_id`: anonymous events use +`anonymousId`; after `identify()` the destination emits `$identify` (server) or calls +`posthog.identify()` (web) to stitch prior anonymous history to the known person. diff --git a/package-lock.json b/package-lock.json index 83f200b..e7315d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "@junctionjs/destination-amplitude": "*", "@junctionjs/destination-ga4": "*", "@junctionjs/destination-meta": "*", + "@junctionjs/destination-posthog": "*", "@junctionjs/next": "*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/tsconfig.json b/tsconfig.json index d6e7795..f46776b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,7 @@ "@junctionjs/destination-meta": ["./packages/destination-meta/src"], "@junctionjs/destination-http": ["./packages/destination-http/src"], "@junctionjs/destination-plausible": ["./packages/destination-plausible/src"], + "@junctionjs/destination-posthog": ["./packages/destination-posthog/src"], "@junctionjs/cmp-onetrust": ["./packages/cmp-onetrust/src"], "@junctionjs/auto-collect": ["./packages/auto-collect/src"], "@junctionjs/next": ["./packages/next/src"], From 41bd04964a429fdf834701e7a8114e6cfe01b161 Mon Sep 17 00:00:00 2001 From: Charlie Tysse Date: Fri, 4 Sep 2026 14:36:48 -0400 Subject: [PATCH 15/15] fix(posthog): await in-flight flushes on teardown; server UA; web identity gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018LYiJTqGj9jPcivJbq3kMC --- .claude/rules/destinations.md | 8 +++- .../src/content/docs/destinations/posthog.mdx | 11 +++-- .../destination-posthog/src/server.test.ts | 42 +++++++++++++++++++ packages/destination-posthog/src/server.ts | 27 +++++++++--- 4 files changed, 79 insertions(+), 9 deletions(-) diff --git a/.claude/rules/destinations.md b/.claude/rules/destinations.md index 4e39a63..5401ae6 100644 --- a/.claude/rules/destinations.md +++ b/.claude/rules/destinations.md @@ -71,7 +71,7 @@ Canonical fields on every event: | Canonical | GA4 | Amplitude | PostHog | |---|---|---|---| -| `anonymousId` | `client_id` | `device_id` | `distinct_id` (while anonymous) | +| `anonymousId` | `client_id` | `device_id` | `distinct_id` (server; web uses posthog-js's own ID — see gap) | | `userId` | `user_id` | `user_id` | `distinct_id` after merge + `$identify` | | `traits` | `user_properties` | `user_properties` | person props via `$set` | | `user:identified` | no-op (carries `user_id`) | no-op (carries `user_id`) | `posthog.identify()` / `$identify` w/ `$anon_distinct_id` | @@ -84,6 +84,12 @@ vendors (PostHog) must act on it to stitch anonymous history to the known person > **Known gap (tracked):** `UserIdentity` has no `sessionId`; GA4/Amplitude do not yet map > `session_id`/`$session_id`. Cross-destination follow-up, not owned by any single destination. +> **Known gap (tracked):** web-mode (`createPostHogWeb`) delegates the anonymous +> `distinct_id` to posthog-js's own cookie-managed ID rather than binding Junction's +> `anonymousId` (posthog-js needs the ID at `init()`, before any event is seen), so +> pre-identify web events won't correlate with server-mode events by `anonymousId`. +> Reconcile via `bootstrap.distinctID` once the collector exposes `anonymousId` at init. + ## Script Loading Client-side destinations that load vendor scripts use a queue-before-load pattern: diff --git a/apps/docs/src/content/docs/destinations/posthog.mdx b/apps/docs/src/content/docs/destinations/posthog.mdx index e56b8c7..c246a5f 100644 --- a/apps/docs/src/content/docs/destinations/posthog.mdx +++ b/apps/docs/src/content/docs/destinations/posthog.mdx @@ -45,6 +45,11 @@ consent means `posthog-js` never loads and no cookies are set. Session replay st ## Identity -Junction's canonical identity maps onto PostHog's single `distinct_id`: anonymous events use -`anonymousId`; after `identify()` the destination emits `$identify` (server) or calls -`posthog.identify()` (web) to stitch prior anonymous history to the known person. +In **server mode**, Junction's canonical identity maps onto PostHog's single `distinct_id`: +anonymous events use `anonymousId`, and after `identify()` the destination emits `$identify` +with `$anon_distinct_id` to stitch prior anonymous history to the known person. + +In **web mode**, `posthog-js` manages its own cookie-based anonymous `distinct_id`; `identify()` +calls `posthog.identify()` to attach the known ID. Junction's `anonymousId` is not yet bound to +posthog-js's anonymous ID (a tracked gap), so pre-identify web events are keyed by posthog-js's +own ID rather than Junction's `anonymousId`. diff --git a/packages/destination-posthog/src/server.test.ts b/packages/destination-posthog/src/server.test.ts index 462604c..b1e02ad 100644 --- a/packages/destination-posthog/src/server.test.ts +++ b/packages/destination-posthog/src/server.test.ts @@ -67,6 +67,18 @@ describe("server transform", () => { }); expect(result?.event).toBe("purchase"); }); + + it("forwards device.userAgent as $raw_user_agent", () => { + const result = transformServerEvent( + makeEvent({ + entity: "product", + action: "added", + context: { device: { userAgent: "Mozilla/5.0 (Test)" } } as JctEvent["context"], + }), + { apiKey: "k" }, + ); + expect(result?.properties.$raw_user_agent).toBe("Mozilla/5.0 (Test)"); + }); }); describe("server factory", () => { @@ -239,4 +251,34 @@ describe("server reliability", () => { vi.useRealTimers(); }); + + it("teardown awaits an in-flight flush from an un-awaited send", async () => { + // Controllable fetch: stays pending until we resolve it. + let resolveFetch!: (v: unknown) => void; + const fetchGate = new Promise((r) => { + resolveFetch = r; + }); + const mockFetch = vi.fn().mockReturnValue(fetchGate); + vi.stubGlobal("fetch", mockFetch); + + const dest = createPostHogServer({ apiKey: "k", batchSize: 1 }); + dest.init({} as any); + + // Fire-and-forget, exactly like the collector's dispatch — do NOT await. + const sendPromise = dest.send(dest.transform(makeEvent(), {} as any), {} as any); + expect(mockFetch).toHaveBeenCalledTimes(1); // flush started, fetch in flight + + let torn = false; + const teardownPromise = (dest.teardown?.() as Promise).then(() => { + torn = true; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(torn).toBe(false); // teardown must still be waiting on the in-flight fetch + + resolveFetch({ ok: true }); + await teardownPromise; + await sendPromise.catch(() => {}); + expect(torn).toBe(true); + }); }); diff --git a/packages/destination-posthog/src/server.ts b/packages/destination-posthog/src/server.ts index f82a60b..d4f8b53 100644 --- a/packages/destination-posthog/src/server.ts +++ b/packages/destination-posthog/src/server.ts @@ -61,10 +61,16 @@ export function transformServerEvent(event: JctEvent, config: PostHogServerConfi }; } + const properties = buildEventProperties(event, { $lib: "junction-server" }); + // Server capture: PostHog derives browser/OS/device from $raw_user_agent, not + // from our outbound request's UA. Forward the visitor's UA when present. + const userAgent = event.context.device?.userAgent; + if (userAgent) properties.$raw_user_agent = userAgent; + return { event: getEventName(event, config.eventNameMap), distinct_id: distinctId, - properties: buildEventProperties(event, { $lib: "junction-server" }), + properties, timestamp: event.timestamp, uuid: event.id, }; @@ -133,6 +139,7 @@ export function createPostHogServer(config: PostHogServerConfig): Destination | undefined; + const pending = new Set>(); async function flush(): Promise { if (buffer.length === 0) return; @@ -140,11 +147,13 @@ export function createPostHogServer(config: PostHogServerConfig): Destination maxBufferSize) { const dropped = buffer.length - maxBufferSize; @@ -152,6 +161,8 @@ export function createPostHogServer(config: PostHogServerConfig): Destination { + console.error("[Junction:posthog-server] teardown flush failed:", err); + }); + await Promise.allSettled([...pending]); }, }; }