Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/posthog-destination.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions .claude/rules/destinations.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,38 @@ 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` (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` |
| `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.

> **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:
Expand Down
12 changes: 12 additions & 0 deletions apps/demo/lib/junction-config.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 },
],
Expand Down
1 change: 1 addition & 0 deletions apps/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/docs/src/content/docs/destinations/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
55 changes: 55 additions & 0 deletions apps/docs/src/content/docs/destinations/posthog.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
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

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`.
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading