diff --git a/README.md b/README.md index 9f5228c7..18ac67a8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,13 @@ All services start with sensible defaults. No config file needed: - **Slack** on `http://localhost:4003` - **Apple** on `http://localhost:4004` - **Microsoft** on `http://localhost:4005` -- **AWS** on `http://localhost:4006` +- **Okta** on `http://localhost:4006` +- **AWS** on `http://localhost:4007` +- **Resend** on `http://localhost:4008` +- **Stripe** on `http://localhost:4009` +- **MongoDB Atlas** on `http://localhost:4010` +- **Clerk** on `http://localhost:4011` +- **PostHog** on `http://localhost:4012` ## CLI @@ -141,7 +147,7 @@ afterAll(() => Promise.all([github.close(), vercel.close()])) | Option | Default | Description | |--------|---------|-------------| -| `service` | *(required)* | Service name: `'vercel'`, `'github'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, or `'aws'` | +| `service` | *(required)* | Service name: `'vercel'`, `'github'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, `'okta'`, `'aws'`, `'resend'`, `'stripe'`, `'mongoatlas'`, `'clerk'`, or `'posthog'` | | `port` | `4000` | Port for the HTTP server | | `seed` | none | Inline seed data (same shape as YAML config) | | `baseUrl` | none | Override advertised base URL. Per-service `baseUrl` in seed config takes highest priority, then this option, then `EMULATE_BASE_URL` env var (supports `{service}`), then `PORTLESS_URL` (supports `{service}`, automatically set by the `portless` CLI wrapper), then `http://localhost:`. | @@ -299,6 +305,22 @@ aws: roles: - role_name: lambda-execution-role description: Role for Lambda function execution + +posthog: + projects: + - id: 1 + api_token: phc_test + feature_flags: + - key: new-checkout + project_id: 1 + default: false + conditions: + - property: email + operator: icontains + value: "@acme.com" + variant: true + overrides: + user-123: true ``` ## OAuth & Integrations @@ -690,6 +712,18 @@ All operations via `POST /iam/` with `Action` parameter: All operations via `POST /sts/` with `Action` parameter: - `GetCallerIdentity`, `AssumeRole` +## PostHog + +Product analytics capture and feature flag decision emulation for local event assertions and SDK tests. + +- `POST /capture/` - capture a single event with `api_key` +- `POST /batch/` - capture multiple events with `api_key` +- `POST /e/` and `POST /track/` - capture aliases +- `POST /decide/` - evaluate seeded feature flags with `token`, `distinct_id`, and `person_properties` +- `GET /_inspector` - inspect captured events and feature flags + +Capture accepts JSON, form encoded `data=`, and `text/plain` sendBeacon payloads. Feature flags support distinct ID overrides and simple person property conditions. Session replay, insights, cohorts, surveys, and the admin REST API are not implemented. + ## Next.js Integration Embed emulators directly in your Next.js app so they run on the same origin. This solves the Vercel preview deployment problem where OAuth callback URLs change with every deployment. @@ -816,7 +850,13 @@ packages/ slack/ # Slack Web API, OAuth v2, incoming webhooks apple/ # Apple Sign In / OIDC microsoft/ # Microsoft Entra ID OAuth 2.0 / OIDC + Graph /me + okta/ # Okta OAuth 2.0 / OIDC + management API aws/ # AWS S3, SQS, IAM, STS + resend/ # Resend email API + stripe/ # Stripe payments API + mongoatlas/ # MongoDB Atlas Admin API and Data API + clerk/ # Clerk auth and user management + posthog/ # PostHog capture and feature flags apps/ web/ # Documentation site (Next.js) ``` @@ -840,3 +880,5 @@ Tokens are configured in the seed config and map to users. Pass them as `Authori **Microsoft**: OIDC authorization code flow with PKCE support. Also supports client credentials grants. Microsoft Graph `/v1.0/me` available. **AWS**: Bearer tokens or IAM access key credentials. Default key pair always seeded: `AKIAIOSFODNN7EXAMPLE` / `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`. + +**PostHog**: Capture and decide routes use body-token auth. Send `api_key` to capture routes and `token` to `/decide/`. diff --git a/packages/@emulators/posthog/README.md b/packages/@emulators/posthog/README.md new file mode 100644 index 00000000..acd09654 --- /dev/null +++ b/packages/@emulators/posthog/README.md @@ -0,0 +1,66 @@ +# @emulators/posthog + +Local PostHog API emulator for product analytics events and feature flag decisions. + +## Usage + +```typescript +import { createEmulator } from 'emulate' + +const posthog = await createEmulator({ + service: 'posthog', + port: 4000, + seed: { + posthog: { + projects: [{ id: 1, api_token: 'phc_test' }], + feature_flags: [ + { + key: 'new-checkout', + project_id: 1, + default: false, + conditions: [ + { property: 'email', operator: 'icontains', value: '@acme.com', variant: true }, + ], + }, + ], + }, + }, +}) +``` + +Point PostHog SDKs at `posthog.url` as the host. Capture and decide routes authenticate with the body token fields used by PostHog: `api_key` for capture routes and `token` for decide. + +## Routes + +- `POST /capture/` +- `POST /batch/` +- `POST /e/` +- `POST /track/` +- `POST /decide/` +- `GET /_inspector` + +Capture routes accept JSON, `application/x-www-form-urlencoded` with a `data` field, and `text/plain` sendBeacon payloads. Stored events are visible in the inspector. + +## Feature Flags + +Feature flags support exact distinct ID overrides and simple person property conditions: + +```yaml +posthog: + projects: + - id: 1 + api_token: phc_test + feature_flags: + - key: new-checkout + project_id: 1 + default: false + conditions: + - property: email + operator: icontains + value: "@acme.com" + variant: true + overrides: + user-123: true +``` + +Evaluation order is override, then conditions, then default. Cohorts, percentage rollouts, group property evaluation, surveys, insights, and session replay are not implemented. diff --git a/packages/@emulators/posthog/package.json b/packages/@emulators/posthog/package.json new file mode 100644 index 00000000..5658a385 --- /dev/null +++ b/packages/@emulators/posthog/package.json @@ -0,0 +1,46 @@ +{ + "name": "@emulators/posthog", + "version": "0.5.0", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "homepage": "https://emulate.dev", + "repository": { + "type": "git", + "url": "https://github.com/vercel-labs/emulate.git", + "directory": "packages/@emulators/posthog" + }, + "bugs": { + "url": "https://github.com/vercel-labs/emulate/issues" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup --clean", + "dev": "tsup --watch", + "test": "vitest run", + "clean": "rm -rf dist .turbo", + "type-check": "tsc --noEmit", + "lint": "eslint src" + }, + "dependencies": { + "@emulators/core": "workspace:*", + "hono": "^4" + }, + "devDependencies": { + "tsup": "^8", + "typescript": "^5.7", + "vitest": "^4.1.0" + } +} diff --git a/packages/@emulators/posthog/src/__tests__/posthog.test.ts b/packages/@emulators/posthog/src/__tests__/posthog.test.ts new file mode 100644 index 00000000..c865f1c6 --- /dev/null +++ b/packages/@emulators/posthog/src/__tests__/posthog.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { gzipSync } from "node:zlib"; +import { Hono } from "hono"; +import { + Store, + WebhookDispatcher, + authMiddleware, + createApiErrorHandler, + createErrorHandler, + type TokenMap, +} from "@emulators/core"; +import { getPostHogStore, posthogPlugin, seedFromConfig } from "../index.js"; + +const base = "http://localhost:4000"; + +function createTestApp() { + const store = new Store(); + const webhooks = new WebhookDispatcher(); + const tokenMap: TokenMap = new Map(); + const app = new Hono(); + + app.onError(createApiErrorHandler()); + app.use("*", createErrorHandler()); + app.use("*", authMiddleware(tokenMap)); + posthogPlugin.register(app as any, store, webhooks, base, tokenMap); + posthogPlugin.seed?.(store, base); + seedFromConfig(store, base, { + projects: [ + { id: 1, api_token: "phc_project_a", name: "Project A" }, + { id: 2, api_token: "phc_project_b", name: "Project B" }, + { id: 3, api_token: "phc_test", name: "Browser SDK Project" }, + ], + feature_flags: [ + { + key: "new-checkout", + project_id: 1, + default: false, + conditions: [{ property: "email", operator: "icontains", value: "@acme.com", variant: true }], + overrides: { "user-123": true }, + }, + { + key: "pricing-experiment", + project_id: 1, + default: "control", + variants: ["control", "treatment"], + overrides: { "user-456": "treatment" }, + }, + { + key: "project-b-flag", + project_id: 2, + default: true, + }, + ], + }); + + return { app, store, webhooks, tokenMap }; +} + +function jsonHeaders(): Record { + return { "Content-Type": "application/json" }; +} + +describe("PostHog plugin", () => { + let app: Hono; + let store: Store; + + beforeEach(() => { + const test = createTestApp(); + app = test.app; + store = test.store; + }); + + it("POST /capture/ stores a single event", async () => { + const res = await app.request(`${base}/capture/`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + api_key: "phc_project_a", + event: "user_signed_up", + distinct_id: "user-1", + properties: { plan: "pro" }, + }), + }); + + expect(res.status).toBe(200); + const actual = getPostHogStore(store).events.all(); + expect(actual).toHaveLength(1); + expect(actual[0].event).toBe("user_signed_up"); + expect(actual[0].project_id).toBe(1); + expect(actual[0].properties).toEqual({ plan: "pro" }); + }); + + it("POST /e/ accepts api_key inside properties.token (browser SDK)", async () => { + const res = await app.request(`${base}/e/`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + event: "$pageview", + distinct_id: "browser-user", + properties: { token: "phc_test", current_url: "https://example.com" }, + }), + }); + + expect(res.status).toBe(200); + const actual = getPostHogStore(store).events.all(); + expect(actual).toHaveLength(1); + expect(actual[0].event).toBe("$pageview"); + expect(actual[0].project_id).toBe(3); + expect(actual[0].properties).toEqual({ token: "phc_test", current_url: "https://example.com" }); + }); + + it("POST /batch/ stores multiple events with the same project", async () => { + const res = await app.request(`${base}/batch/`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + api_key: "phc_project_a", + batch: [ + { event: "one", distinct_id: "user-1" }, + { event: "two", distinct_id: "user-2" }, + { event: "three", distinct_id: "user-3" }, + ], + }), + }); + + expect(res.status).toBe(200); + const actual = getPostHogStore(store).events.all(); + expect(actual).toHaveLength(3); + expect(actual.map((event) => event.project_id)).toEqual([1, 1, 1]); + }); + + it("POST /batch/ decompresses Content-Encoding: gzip", async () => { + const compressed = gzipSync( + JSON.stringify({ + api_key: "phc_project_a", + batch: [ + { event: "gzip_one", distinct_id: "user-1" }, + { event: "gzip_two", distinct_id: "user-2" }, + ], + }), + ); + + const res = await app.request(`${base}/batch/`, { + method: "POST", + headers: { + "Content-Encoding": "gzip", + "Content-Type": "application/json", + }, + body: compressed, + }); + + expect(res.status).toBe(200); + const actual = getPostHogStore(store).events.all(); + expect(actual).toHaveLength(2); + expect(actual.map((event) => event.event)).toEqual(["gzip_one", "gzip_two"]); + expect(actual.map((event) => event.project_id)).toEqual([1, 1]); + }); + + it("POST /capture/?compression=gzip-js with text/plain body decompresses (browser SDK)", async () => { + const compressed = gzipSync( + JSON.stringify({ + api_key: "phc_project_a", + event: "browser_event", + distinct_id: "user-browser", + }), + ); + + const res = await app.request(`${base}/capture/?compression=gzip-js`, { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: compressed, + }); + + expect(res.status).toBe(200); + const actual = getPostHogStore(store).events.all()[0]; + expect(actual.event).toBe("browser_event"); + expect(actual.distinct_id).toBe("user-browser"); + }); + + it("POST /capture/ rejects a bad api_key", async () => { + const res = await app.request(`${base}/capture/`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ api_key: "phc_bad", event: "bad_auth", distinct_id: "user-1" }), + }); + + expect(res.status).toBe(401); + const actual = await res.text(); + expect(actual).toBe(""); + }); + + it("POST /capture/ accepts form encoded data", async () => { + const payload = { + api_key: "phc_project_a", + event: "form_event", + distinct_id: "user-form", + properties: { source: "beacon" }, + }; + const res = await app.request(`${base}/capture/`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ data: JSON.stringify(payload) }).toString(), + }); + + expect(res.status).toBe(200); + const actual = getPostHogStore(store).events.all()[0]; + expect(actual.event).toBe("form_event"); + expect(actual.distinct_id).toBe("user-form"); + }); + + it("POST /capture/ accepts text/plain JSON", async () => { + const res = await app.request(`${base}/capture/`, { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: JSON.stringify({ + api_key: "phc_project_a", + event: "plain_event", + distinct_id: "user-plain", + }), + }); + + expect(res.status).toBe(200); + const actual = getPostHogStore(store).events.all()[0]; + expect(actual.event).toBe("plain_event"); + expect(actual.distinct_id).toBe("user-plain"); + }); + + it("keeps inspector events isolated by project", async () => { + await app.request(`${base}/capture/`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ api_key: "phc_project_a", event: "project_a_event", distinct_id: "a" }), + }); + await app.request(`${base}/capture/`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ api_key: "phc_project_b", event: "project_b_event", distinct_id: "b" }), + }); + + const res = await app.request(`${base}/_inspector?tab=events&project_id=2`); + expect(res.status).toBe(200); + const actual = await res.text(); + expect(actual).toContain("project_b_event"); + expect(actual).not.toContain("project_a_event"); + }); + + it("POST /decide/ returns the default flag value", async () => { + const res = await app.request(`${base}/decide/`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ token: "phc_project_a", distinct_id: "unknown-user" }), + }); + + expect(res.status).toBe(200); + const actual = (await res.json()) as { featureFlags: Record }; + expect(actual.featureFlags["new-checkout"]).toBe(false); + }); + + it("POST /decide/ returns a distinct_id override", async () => { + const res = await app.request(`${base}/decide/`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ token: "phc_project_a", distinct_id: "user-123" }), + }); + + expect(res.status).toBe(200); + const actual = (await res.json()) as { featureFlags: Record }; + expect(actual.featureFlags["new-checkout"]).toBe(true); + }); + + it("POST /decide/ returns a property condition match", async () => { + const res = await app.request(`${base}/decide/`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ + token: "phc_project_a", + distinct_id: "user-789", + person_properties: { email: "alice@acme.com" }, + }), + }); + + expect(res.status).toBe(200); + const actual = (await res.json()) as { featureFlags: Record }; + expect(actual.featureFlags["new-checkout"]).toBe(true); + }); + + it("POST /decide/ returns safe defaults for SDK config", async () => { + const res = await app.request(`${base}/decide/`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ token: "phc_project_a", distinct_id: "user-1" }), + }); + + expect(res.status).toBe(200); + const actual = (await res.json()) as Record; + expect(actual.sessionRecording).toBe(false); + expect(actual.supportedCompression).toEqual([]); + expect(actual.siteApps).toEqual([]); + expect(actual.capturePerformance).toBe(false); + expect(actual.autocapture_opt_out).toBe(true); + expect(actual.surveys).toBe(false); + }); + + it("POST /flags/?v=2 returns same shape as /decide/", async () => { + const body = JSON.stringify({ token: "phc_project_a", distinct_id: "user-123" }); + const decideRes = await app.request(`${base}/decide/`, { + method: "POST", + headers: jsonHeaders(), + body, + }); + const flagsRes = await app.request(`${base}/flags/?v=2`, { + method: "POST", + headers: jsonHeaders(), + body, + }); + + expect(decideRes.status).toBe(200); + expect(flagsRes.status).toBe(200); + const decide = (await decideRes.json()) as Record; + const flags = (await flagsRes.json()) as Record; + expect(flags.featureFlags).toEqual(decide.featureFlags); + expect(flags.featureFlagPayloads).toEqual(decide.featureFlagPayloads); + expect(flags.errorsWhileComputingFlags).toBe(decide.errorsWhileComputingFlags); + expect(flags.config).toEqual(decide.config); + expect(flags.sessionRecording).toBe(decide.sessionRecording); + expect(flags.supportedCompression).toEqual(decide.supportedCompression); + expect(flags.siteApps).toEqual(decide.siteApps); + expect(flags.capturePerformance).toBe(decide.capturePerformance); + expect(flags.autocapture_opt_out).toBe(decide.autocapture_opt_out); + expect(flags.surveys).toBe(decide.surveys); + }); + + it("GET /_inspector returns events and feature flags tables", async () => { + await app.request(`${base}/capture/`, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ api_key: "phc_project_a", event: "inspected_event", distinct_id: "user-1" }), + }); + + const eventsRes = await app.request(`${base}/_inspector?tab=events`); + const flagsRes = await app.request(`${base}/_inspector?tab=flags`); + + expect(eventsRes.status).toBe(200); + expect(flagsRes.status).toBe(200); + const eventsHtml = await eventsRes.text(); + const flagsHtml = await flagsRes.text(); + expect(eventsHtml).toContain("Events (1)"); + expect(eventsHtml).toContain("inspected_event"); + expect(flagsHtml).toContain("Feature Flags (3)"); + expect(flagsHtml).toContain("new-checkout"); + }); +}); diff --git a/packages/@emulators/posthog/src/entities.ts b/packages/@emulators/posthog/src/entities.ts new file mode 100644 index 00000000..997e0211 --- /dev/null +++ b/packages/@emulators/posthog/src/entities.ts @@ -0,0 +1,34 @@ +import type { Entity } from "@emulators/core"; + +export type FeatureFlagValue = boolean | string; + +export interface FlagCondition { + property: string; + operator: "exact" | "is_set" | "icontains" | "regex"; + value?: string | number | boolean; + variant: FeatureFlagValue; +} + +export interface PostHogProject extends Entity { + project_id: number; + api_token: string; + name: string | null; +} + +export interface PostHogEvent extends Entity { + uuid: string; + project_id: number; + event: string; + distinct_id: string | null; + properties: Record; + timestamp: string; +} + +export interface PostHogFeatureFlag extends Entity { + key: string; + project_id: number; + default: FeatureFlagValue; + variants: string[]; + conditions: FlagCondition[]; + overrides: Record; +} diff --git a/packages/@emulators/posthog/src/flag-eval.ts b/packages/@emulators/posthog/src/flag-eval.ts new file mode 100644 index 00000000..fd6a3959 --- /dev/null +++ b/packages/@emulators/posthog/src/flag-eval.ts @@ -0,0 +1,58 @@ +import type { FeatureFlagValue, PostHogFeatureFlag } from "./entities.js"; +import { asRecord, asString } from "./helpers.js"; + +export interface FeatureFlagContext { + distinct_id: string | null; + person_properties?: Record; + groups?: Record; + group_properties?: Record; +} + +function conditionMatches(operator: string, actual: unknown, expected: unknown): boolean { + if (operator === "is_set") { + return actual !== undefined && actual !== null && actual !== ""; + } + + if (actual === undefined || actual === null) { + return false; + } + + if (operator === "exact") { + return actual === expected || String(actual) === String(expected); + } + + if (operator === "icontains") { + return String(actual) + .toLowerCase() + .includes(String(expected ?? "").toLowerCase()); + } + + if (operator === "regex") { + try { + return new RegExp(String(expected ?? "")).test(String(actual)); + } catch { + return false; + } + } + + return false; +} + +export function evaluateFeatureFlag(flag: PostHogFeatureFlag, context: FeatureFlagContext): FeatureFlagValue { + const distinctId = asString(context.distinct_id); + if (distinctId && Object.prototype.hasOwnProperty.call(flag.overrides, distinctId)) { + return flag.overrides[distinctId]; + } + + const personProperties = asRecord(context.person_properties); + + // This first version intentionally evaluates person properties only. Group properties, + // cohorts, and percentage rollouts can be layered in here without changing route shape. + for (const condition of flag.conditions) { + if (conditionMatches(condition.operator, personProperties[condition.property], condition.value)) { + return condition.variant; + } + } + + return flag.default; +} diff --git a/packages/@emulators/posthog/src/helpers.ts b/packages/@emulators/posthog/src/helpers.ts new file mode 100644 index 00000000..cc52af82 --- /dev/null +++ b/packages/@emulators/posthog/src/helpers.ts @@ -0,0 +1,105 @@ +import { randomUUID } from "crypto"; +import { gunzipSync } from "node:zlib"; +import type { Context } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +export function generateUuid(): string { + return randomUUID(); +} + +export function posthogError(c: Context, statusCode: number, detail: string) { + return c.json({ type: "validation_error", code: "invalid_request", detail }, statusCode as ContentfulStatusCode); +} + +function tryJson(text: string): Record { + try { + const body = JSON.parse(text); + if (body && typeof body === "object" && !Array.isArray(body)) { + return body as Record; + } + } catch { + return {}; + } + return {}; +} + +function parseUrlEncoded(text: string): Record { + const params = new URLSearchParams(text); + const data = params.get("data"); + if (data) { + return tryJson(data); + } + + const result: Record = {}; + for (const [key, value] of params.entries()) { + result[key] = value; + } + return result; +} + +function parseCaptureText(text: string, contentType: string): Record { + if (contentType.includes("application/x-www-form-urlencoded")) { + return parseUrlEncoded(text); + } + + if (contentType.includes("text/plain") && text.startsWith("data=")) { + return parseUrlEncoded(text); + } + + return tryJson(text); +} + +export async function parseCaptureBody(c: Context): Promise> { + const contentType = c.req.header("content-type") ?? ""; + const contentEncoding = c.req.header("content-encoding") ?? ""; + const compressionParam = c.req.query("compression") ?? ""; + const isGzipped = + contentEncoding.startsWith("gzip") || + contentEncoding === "x-gzip" || + compressionParam === "gzip-js" || + compressionParam === "gzip"; + + if (isGzipped) { + try { + const buffer = Buffer.from(await c.req.arrayBuffer()); + return parseCaptureText(gunzipSync(buffer).toString("utf8"), contentType); + } catch { + return {}; + } + } + + if (contentType.includes("application/x-www-form-urlencoded")) { + return parseUrlEncoded(await c.req.text()); + } + + if (contentType.includes("text/plain")) { + const text = await c.req.text(); + if (text.startsWith("data=")) { + return parseUrlEncoded(text); + } + return tryJson(text); + } + + try { + const body = await c.req.json(); + if (body && typeof body === "object" && !Array.isArray(body)) { + return body as Record; + } + return {}; + } catch { + return {}; + } +} + +export function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +export function asString(value: unknown): string | null { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return null; +} diff --git a/packages/@emulators/posthog/src/index.ts b/packages/@emulators/posthog/src/index.ts new file mode 100644 index 00000000..13e9227a --- /dev/null +++ b/packages/@emulators/posthog/src/index.ts @@ -0,0 +1,104 @@ +import type { Hono } from "hono"; +import type { AppEnv, RouteContext, ServicePlugin, Store, TokenMap, WebhookDispatcher } from "@emulators/core"; +import type { FeatureFlagValue, FlagCondition } from "./entities.js"; +import { getPostHogStore } from "./store.js"; +import { captureRoutes } from "./routes/capture.js"; +import { decideRoutes } from "./routes/decide.js"; +import { inspectorRoutes } from "./routes/inspector.js"; + +export { getPostHogStore, type PostHogStore } from "./store.js"; +export * from "./entities.js"; + +export interface PostHogSeedConfig { + port?: number; + projects?: Array<{ + id: number; + api_token: string; + name?: string; + }>; + feature_flags?: Array<{ + key: string; + project_id: number; + default: FeatureFlagValue; + variants?: string[]; + conditions?: FlagCondition[]; + overrides?: Record; + }>; +} + +function seedDefaults(store: Store): void { + const ph = getPostHogStore(store); + const existing = ph.projects.findOneBy("api_token", "phc_test"); + if (existing) return; + + ph.projects.insert({ + project_id: 1, + api_token: "phc_test", + name: "Default Project", + }); +} + +export function seedFromConfig(store: Store, _baseUrl: string, config: PostHogSeedConfig): void { + const ph = getPostHogStore(store); + + if (config.projects) { + for (const project of config.projects) { + const existingById = ph.projects.findOneBy("project_id", project.id); + if (existingById) { + ph.projects.update(existingById.id, { + api_token: project.api_token, + name: project.name ?? existingById.name, + }); + continue; + } + + const existingByToken = ph.projects.findOneBy("api_token", project.api_token); + if (existingByToken) { + ph.projects.update(existingByToken.id, { + project_id: project.id, + name: project.name ?? existingByToken.name, + }); + continue; + } + + ph.projects.insert({ + project_id: project.id, + api_token: project.api_token, + name: project.name ?? null, + }); + } + } + + if (config.feature_flags) { + for (const flag of config.feature_flags) { + const existing = ph.featureFlags + .findBy("key", flag.key) + .find((candidate) => candidate.project_id === flag.project_id); + if (existing) continue; + + ph.featureFlags.insert({ + key: flag.key, + project_id: flag.project_id, + default: flag.default, + variants: flag.variants ?? [], + conditions: flag.conditions ?? [], + overrides: flag.overrides ?? {}, + }); + } + } +} + +export const posthogPlugin: ServicePlugin = { + name: "posthog", + register(app: Hono, store: Store, webhooks: WebhookDispatcher, baseUrl: string, tokenMap?: TokenMap): void { + const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap }; + captureRoutes(ctx); + decideRoutes(ctx); + inspectorRoutes(ctx); + }, + seed(store: Store): void { + seedDefaults(store); + }, +}; + +export default posthogPlugin; diff --git a/packages/@emulators/posthog/src/routes/capture.ts b/packages/@emulators/posthog/src/routes/capture.ts new file mode 100644 index 00000000..21f8f5c4 --- /dev/null +++ b/packages/@emulators/posthog/src/routes/capture.ts @@ -0,0 +1,68 @@ +import type { RouteContext } from "@emulators/core"; +import type { Context } from "hono"; +import { asRecord, asString, generateUuid, parseCaptureBody, posthogError } from "../helpers.js"; +import { getPostHogStore } from "../store.js"; + +function normalizeEvent(input: Record, projectId: number) { + const properties = asRecord(input.properties); + const event = asString(input.event); + + if (!event) { + return null; + } + + const distinctId = asString(input.distinct_id) ?? asString(properties.distinct_id); + const timestamp = asString(input.timestamp) ?? asString(properties.timestamp) ?? new Date().toISOString(); + + return { + uuid: generateUuid(), + project_id: projectId, + event, + distinct_id: distinctId, + properties, + timestamp, + }; +} + +function extractApiKey(input: Record): string | null { + const properties = asRecord(input.properties); + return asString(input.api_key) ?? asString(input.token) ?? asString(properties.token); +} + +export function captureRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + const ph = () => getPostHogStore(store); + + const handler = async (c: Context) => { + const body = await parseCaptureBody(c); + const rawBatch = Array.isArray(body.batch) ? body.batch : null; + const items = rawBatch ? rawBatch.map(asRecord) : [body]; + const fallbackApiKey = rawBatch ? extractApiKey(body) : null; + const events = []; + + for (const item of items) { + const apiKey = extractApiKey(item) ?? fallbackApiKey; + const project = apiKey ? ph().projects.findOneBy("api_token", apiKey) : undefined; + + if (!project) { + return c.body(null, 401); + } + + events.push(normalizeEvent(item, project.project_id)); + } + + if (events.some((event) => event === null)) { + return posthogError(c, 400, "event is required"); + } + + for (const event of events) { + ph().events.insert(event!); + } + + return c.json({ status: 1 }); + }; + + for (const path of ["/capture", "/capture/", "/batch", "/batch/", "/e", "/e/", "/track", "/track/"]) { + app.post(path, handler); + } +} diff --git a/packages/@emulators/posthog/src/routes/decide.ts b/packages/@emulators/posthog/src/routes/decide.ts new file mode 100644 index 00000000..18b98932 --- /dev/null +++ b/packages/@emulators/posthog/src/routes/decide.ts @@ -0,0 +1,53 @@ +import type { RouteContext } from "@emulators/core"; +import type { Context } from "hono"; +import { evaluateFeatureFlag } from "../flag-eval.js"; +import { asRecord, asString, parseCaptureBody } from "../helpers.js"; +import { getPostHogStore } from "../store.js"; + +export function decideRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + const ph = () => getPostHogStore(store); + + const handler = async (c: Context) => { + const body = await parseCaptureBody(c); + const token = asString(body.token); + const project = token ? ph().projects.findOneBy("api_token", token) : undefined; + + if (!project) { + return c.body(null, 401); + } + + const distinctId = asString(body.distinct_id); + const flags = ph().featureFlags.findBy("project_id", project.project_id); + const featureFlags: Record = {}; + + for (const flag of flags) { + featureFlags[flag.key] = evaluateFeatureFlag(flag, { + distinct_id: distinctId, + person_properties: asRecord(body.person_properties), + groups: asRecord(body.$groups), + group_properties: asRecord(body.group_properties), + }); + } + + return c.json({ + featureFlags, + featureFlagPayloads: {}, + errorsWhileComputingFlags: false, + config: { enable_collect_everything: true }, + sessionRecording: false, + supportedCompression: [], + siteApps: [], + capturePerformance: false, + autocapture_opt_out: true, + surveys: false, + toolbarParams: {}, + isAuthenticated: false, + editorParams: {}, + }); + }; + + for (const path of ["/decide", "/decide/", "/flags", "/flags/"]) { + app.post(path, handler); + } +} diff --git a/packages/@emulators/posthog/src/routes/inspector.ts b/packages/@emulators/posthog/src/routes/inspector.ts new file mode 100644 index 00000000..8289584a --- /dev/null +++ b/packages/@emulators/posthog/src/routes/inspector.ts @@ -0,0 +1,82 @@ +import type { InspectorTab, RouteContext } from "@emulators/core"; +import { escapeHtml, renderInspectorPage } from "@emulators/core"; +import { getPostHogStore } from "../store.js"; + +const SERVICE_LABEL = "PostHog"; + +const TABS: InspectorTab[] = [ + { id: "events", label: "Events", href: "/_inspector?tab=events" }, + { id: "flags", label: "Feature Flags", href: "/_inspector?tab=flags" }, +]; + +function summarizeProperties(properties: Record): string { + const text = JSON.stringify(properties); + if (text.length <= 80) return text; + return `${text.slice(0, 77)}...`; +} + +export function inspectorRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + const ph = () => getPostHogStore(store); + + app.get("/_inspector", (c) => { + const tab = c.req.query("tab") ?? "events"; + const projectIdParam = c.req.query("project_id"); + const projectId = projectIdParam ? Number(projectIdParam) : null; + + let contentHtml = ""; + + if (tab === "flags") { + const flags = ph() + .featureFlags.all() + .filter((flag) => projectId === null || flag.project_id === projectId); + const rows = flags + .map( + (flag) => ` + ${escapeHtml(flag.key)} + ${flag.project_id} + ${escapeHtml(String(flag.default))} + ${Object.keys(flag.overrides).length} + ${flag.conditions.length} + `, + ) + .join("\n"); + + contentHtml = ` +
+

Feature Flags (${flags.length})

+ + + ${rows || ``} +
KeyProjectDefaultOverridesConditions
No feature flags
+
`; + } else { + const events = ph() + .events.all() + .filter((event) => projectId === null || event.project_id === projectId) + .reverse(); + const rows = events + .map( + (event) => ` + ${escapeHtml(event.distinct_id ?? "")} + ${escapeHtml(event.event)} + ${escapeHtml(event.timestamp)} + ${event.project_id} + ${escapeHtml(summarizeProperties(event.properties))} + `, + ) + .join("\n"); + + contentHtml = ` +
+

Events (${events.length})

+ + + ${rows || ``} +
Distinct IDEventTimestampProjectProperties
No events
+
`; + } + + return c.html(renderInspectorPage("Inspector", TABS, tab, contentHtml, SERVICE_LABEL)); + }); +} diff --git a/packages/@emulators/posthog/src/store.ts b/packages/@emulators/posthog/src/store.ts new file mode 100644 index 00000000..f86e83d6 --- /dev/null +++ b/packages/@emulators/posthog/src/store.ts @@ -0,0 +1,16 @@ +import { Store, type Collection } from "@emulators/core"; +import type { PostHogEvent, PostHogFeatureFlag, PostHogProject } from "./entities.js"; + +export interface PostHogStore { + events: Collection; + featureFlags: Collection; + projects: Collection; +} + +export function getPostHogStore(store: Store): PostHogStore { + return { + events: store.collection("posthog.events", ["uuid", "project_id"]), + featureFlags: store.collection("posthog.feature_flags", ["key", "project_id"]), + projects: store.collection("posthog.projects", ["project_id", "api_token"]), + }; +} diff --git a/packages/@emulators/posthog/tsconfig.json b/packages/@emulators/posthog/tsconfig.json new file mode 100644 index 00000000..c8c92cbd --- /dev/null +++ b/packages/@emulators/posthog/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/@emulators/posthog/tsup.config.ts b/packages/@emulators/posthog/tsup.config.ts new file mode 100644 index 00000000..59a7354c --- /dev/null +++ b/packages/@emulators/posthog/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsup"; +import { cpSync, mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +const copyFonts = async () => { + const src = resolve(__dirname, "../core/src/fonts"); + const dest = resolve(__dirname, "dist/fonts"); + mkdirSync(dest, { recursive: true }); + cpSync(src, dest, { recursive: true }); +}; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + noExternal: [/^@emulators\/core/], + onSuccess: copyFonts, +}); diff --git a/packages/@emulators/posthog/vitest.config.ts b/packages/@emulators/posthog/vitest.config.ts new file mode 100644 index 00000000..e2ec3329 --- /dev/null +++ b/packages/@emulators/posthog/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + }, +}); diff --git a/packages/emulate/package.json b/packages/emulate/package.json index c2cd741d..1d4bf0d7 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -68,6 +68,7 @@ "@emulators/mongoatlas": "workspace:*", "@emulators/slack": "workspace:*", "@emulators/vercel": "workspace:*", + "@emulators/posthog": "workspace:*", "@emulators/resend": "workspace:*", "@emulators/stripe": "workspace:*", "@emulators/clerk": "workspace:*", diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index c73674a1..41614275 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -27,6 +27,7 @@ const SERVICE_NAME_LIST = [ "stripe", "mongoatlas", "clerk", + "posthog", ] as const; export type ServiceName = (typeof SERVICE_NAME_LIST)[number]; export const SERVICE_NAMES: readonly ServiceName[] = SERVICE_NAME_LIST; @@ -450,6 +451,31 @@ export const SERVICE_REGISTRY: Record = { }, }, }, + posthog: { + label: "PostHog analytics emulator", + endpoints: "event capture, batch capture, feature flag decide, inspector UI", + async load() { + const mod = await import("@emulators/posthog"); + return { plugin: mod.posthogPlugin, seedFromConfig: mod.seedFromConfig }; + }, + defaultFallback() { + return { login: "phc_test_admin", id: 1, scopes: [] }; + }, + initConfig: { + posthog: { + projects: [{ id: 1, api_token: "phc_test" }], + feature_flags: [ + { + key: "new-checkout", + project_id: 1, + default: false, + conditions: [{ property: "email", operator: "icontains", value: "@acme.com", variant: true }], + overrides: { "user-123": true }, + }, + ], + }, + }, + }, }; export const DEFAULT_TOKENS = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c94ce844..f1dcb1ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: dependencies: '@ai-sdk/react': specifier: ^3.0.118 - version: 3.0.153(react@19.2.4)(zod@3.25.76) + version: 3.0.153(react@19.2.4)(zod@4.3.6) '@mdx-js/loader': specifier: ^3.1.1 version: 3.1.1 @@ -55,10 +55,10 @@ importers: version: 1.37.0 ai: specifier: ^6.0.116 - version: 6.0.151(zod@3.25.76) + version: 6.0.151(zod@4.3.6) bash-tool: specifier: ^1.3.15 - version: 1.3.16(ai@6.0.151(zod@3.25.76))(just-bash@2.14.0) + version: 1.3.16(ai@6.0.151(zod@4.3.6))(just-bash@2.14.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -588,6 +588,25 @@ importers: specifier: ^4.1.0 version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + packages/@emulators/posthog: + dependencies: + '@emulators/core': + specifier: workspace:* + version: link:../core + hono: + specifier: ^4 + version: 4.12.12 + devDependencies: + tsup: + specifier: ^8 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + typescript: + specifier: ^5.7 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + packages/@emulators/resend: dependencies: '@emulators/core': @@ -706,6 +725,9 @@ importers: '@emulators/okta': specifier: workspace:* version: link:../@emulators/okta + '@emulators/posthog': + specifier: workspace:* + version: link:../@emulators/posthog '@emulators/resend': specifier: workspace:* version: link:../@emulators/resend @@ -6800,28 +6822,28 @@ packages: snapshots: - '@ai-sdk/gateway@3.0.93(zod@3.25.76)': + '@ai-sdk/gateway@3.0.93(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) + '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) '@vercel/oidc': 3.1.0 - zod: 3.25.76 + zod: 4.3.6 - '@ai-sdk/provider-utils@4.0.23(zod@3.25.76)': + '@ai-sdk/provider-utils@4.0.23(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 - zod: 3.25.76 + zod: 4.3.6 '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 - '@ai-sdk/react@3.0.153(react@19.2.4)(zod@3.25.76)': + '@ai-sdk/react@3.0.153(react@19.2.4)(zod@4.3.6)': dependencies: - '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) - ai: 6.0.151(zod@3.25.76) + '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) + ai: 6.0.151(zod@4.3.6) react: 19.2.4 swr: 2.4.1(react@19.2.4) throttleit: 2.1.0 @@ -9864,13 +9886,13 @@ snapshots: agent-base@7.1.4: {} - ai@6.0.151(zod@3.25.76): + ai@6.0.151(zod@4.3.6): dependencies: - '@ai-sdk/gateway': 3.0.93(zod@3.25.76) + '@ai-sdk/gateway': 3.0.93(zod@4.3.6) '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) + '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) '@opentelemetry/api': 1.9.0 - zod: 3.25.76 + zod: 4.3.6 ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: @@ -10008,9 +10030,9 @@ snapshots: baseline-browser-mapping@2.10.9: {} - bash-tool@1.3.16(ai@6.0.151(zod@3.25.76))(just-bash@2.14.0): + bash-tool@1.3.16(ai@6.0.151(zod@4.3.6))(just-bash@2.14.0): dependencies: - ai: 6.0.151(zod@3.25.76) + ai: 6.0.151(zod@4.3.6) fast-glob: 3.3.3 yaml: 2.8.3 zod: 3.25.76 @@ -10730,8 +10752,8 @@ snapshots: '@next/eslint-plugin-next': 16.2.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -10757,7 +10779,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -10768,22 +10790,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10794,7 +10816,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 diff --git a/skills/posthog/SKILL.md b/skills/posthog/SKILL.md new file mode 100644 index 00000000..d93337ff --- /dev/null +++ b/skills/posthog/SKILL.md @@ -0,0 +1,143 @@ +--- +name: posthog +description: Emulated PostHog analytics and feature flag API for local development and testing. Use when the user needs to capture product analytics events locally, verify event payloads in tests, exercise feature flag decisions, test PostHog SDK integration without network access, or work with POSTHOG_HOST, NEXT_PUBLIC_POSTHOG_HOST, capture, batch, or decide endpoints. +allowed-tools: Bash(npx emulate:*), Bash(curl:*) +--- + +# PostHog Analytics Emulator + +Stateful PostHog capture and decide API emulation. Events persist in memory and feature flags evaluate from seeded project configuration. + +No real analytics data is sent. Every call to the capture routes stores events locally so you can inspect them programmatically or in the browser. + +## Start + +```bash +# PostHog only +npx emulate --service posthog + +# Default port when run alone +# http://localhost:4000 +``` + +Or programmatically: + +```typescript +import { createEmulator } from 'emulate' + +const posthog = await createEmulator({ service: 'posthog', port: 4000 }) +// posthog.url === 'http://localhost:4000' +``` + +## Auth + +PostHog uses body-token auth for the supported SDK routes. Capture requests pass `api_key`; decide requests pass `token`. + +```bash +curl -X POST http://localhost:4000/capture/ \ + -H "Content-Type: application/json" \ + -d '{"api_key": "phc_test", "event": "signup", "distinct_id": "user-1"}' +``` + +Bearer tokens are not required for capture or decide. + +## Pointing Your App at the Emulator + +Set the SDK host to the emulator URL: + +```bash +POSTHOG_HOST=http://localhost:4000 +NEXT_PUBLIC_POSTHOG_HOST=http://localhost:4000 +``` + +```typescript +import { PostHog } from 'posthog-node' + +const posthog = new PostHog('phc_test', { + host: process.env.POSTHOG_HOST, +}) + +await posthog.capture({ + distinctId: 'user-1', + event: 'signup', + properties: { plan: 'pro' }, +}) +``` + +For browser SDK tests, configure the host the same way you configure production PostHog, but use the emulator URL and a seeded project token. + +## Seed Config + +```yaml +posthog: + projects: + - id: 1 + api_token: phc_test + feature_flags: + - key: new-checkout + project_id: 1 + default: false + conditions: + - property: email + operator: icontains + value: "@acme.com" + variant: true + overrides: + user-123: true + - key: pricing-experiment + project_id: 1 + default: control + variants: [control, treatment] + overrides: + user-456: treatment +``` + +## Inspecting Events + +Browse captured events and configured flags: + +``` +http://localhost:4000/_inspector +``` + +Filter inspector data by project: + +``` +http://localhost:4000/_inspector?tab=events&project_id=1 +``` + +## API Endpoints + +### Capture + +```bash +curl -X POST http://localhost:4000/capture/ \ + -H "Content-Type: application/json" \ + -d '{"api_key": "phc_test", "event": "signup", "distinct_id": "user-1", "properties": {"plan": "pro"}}' +``` + +Batch capture: + +```bash +curl -X POST http://localhost:4000/batch/ \ + -H "Content-Type: application/json" \ + -d '{"api_key": "phc_test", "batch": [{"event": "signup", "distinct_id": "user-1"}]}' +``` + +The capture parser also accepts `application/x-www-form-urlencoded` with `data=` and `text/plain` JSON bodies used by sendBeacon. + +### Decide + +```bash +curl -X POST http://localhost:4000/decide/ \ + -H "Content-Type: application/json" \ + -d '{"token": "phc_test", "distinct_id": "user-1", "person_properties": {"email": "alice@acme.com"}}' +``` + +Decide returns `featureFlags`, `featureFlagPayloads`, and SDK config defaults that disable unsupported PostHog features cleanly. + +## Limitations + +Implemented: event capture, batch capture, feature flag defaults, distinct ID overrides, person property conditions, and inspector UI. + +Not implemented: session replay, insights, cohorts, percentage rollouts, surveys, admin REST API, and group property evaluation.