diff --git a/packages/enricher/README.md b/packages/enricher/README.md new file mode 100644 index 000000000..342cdfd42 --- /dev/null +++ b/packages/enricher/README.md @@ -0,0 +1,191 @@ +# @posthog/enricher + +Detect and enrich PostHog SDK usage in source code. Uses tree-sitter AST analysis to find `capture()` calls, feature flag checks, `init()` calls, and variant branches across JavaScript, TypeScript, Python, Go, and Ruby. + +## Quick start + +```typescript +import { PostHogEnricher } from "@posthog/enricher"; + +const enricher = new PostHogEnricher(); +await enricher.initialize("/path/to/grammars"); + +const result = await enricher.parse(sourceCode, "typescript"); + +result.events; // [{ name: "purchase", line: 5, dynamic: false }] +result.flagChecks; // [{ method: "getFeatureFlag", flagKey: "new-checkout", line: 8 }] +result.flagKeys; // ["new-checkout"] +result.eventNames; // ["purchase"] +result.toList(); // [{ type: "event", line: 5, name: "purchase", method: "capture" }, ...] +``` + +## Enriching from the PostHog API + +Let the enricher fetch everything it needs based on what `parse()` found — feature flags, experiments, event definitions, and event volume/user stats: + +```typescript +const result = await enricher.parse(sourceCode, "typescript"); +const enriched = await result.enrichFromApi({ + apiKey: "phx_...", + host: "https://us.posthog.com", + projectId: 12345, +}); + +// Flags with staleness, rollout, experiment info +enriched.enrichedFlags; +// [{ flagKey: "new-checkout", flagType: "boolean", staleness: "fully_rolled_out", +// rollout: 100, experiment: { name: "Checkout v2", ... }, ... }] + +// Events with definition, volume, unique users +enriched.enrichedEvents; +// [{ eventName: "purchase", verified: true, lastSeenAt: "2025-04-01", +// tags: ["revenue"], stats: { volume: 12500, uniqueUsers: 3200 }, ... }] + +// Flat list combining both +enriched.toList(); +// [{ type: "event", name: "purchase", verified: true, volume: 12500, ... }, +// { type: "flag", name: "new-checkout", flagType: "boolean", staleness: "fully_rolled_out", ... }] + +// Source code with inline annotation comments +enriched.toComments(); +// // [PostHog] Event: "purchase" (verified) — 12,500 events — 3,200 users +// posthog.capture("purchase", { amount: 99 }); +// +// // [PostHog] Flag: "new-checkout" — boolean — 100% rolled out — STALE (fully_rolled_out) +// const flag = posthog.getFeatureFlag("new-checkout"); +``` + +## Supported languages + +| Language | ID | Capture | Flags | Init | Variants | +|---|---|---|---|---|---| +| JavaScript | `javascript` | yes | yes | yes | yes | +| TypeScript | `typescript` | yes | yes | yes | yes | +| JSX | `javascriptreact` | yes | yes | yes | yes | +| TSX | `typescriptreact` | yes | yes | yes | yes | +| Python | `python` | yes | yes | yes | yes | +| Go | `go` | yes | yes | yes | yes | +| Ruby | `ruby` | yes | yes | yes | yes | + +## API reference + +### `PostHogEnricher` + +Main entry point. Owns the tree-sitter parser lifecycle. + +```typescript +const enricher = new PostHogEnricher(); +await enricher.initialize(wasmDir); +const result = await enricher.parse(source, languageId); +enricher.dispose(); +``` + +### `ParseResult` + +Returned by `enricher.parse()`. Contains all detected PostHog SDK usage. + +| Property / Method | Type | Description | +|---|---|---| +| `calls` | `PostHogCall[]` | All detected SDK method calls | +| `initCalls` | `PostHogInitCall[]` | `posthog.init()` and constructor calls | +| `flagAssignments` | `FlagAssignment[]` | Flag result variable assignments | +| `variantBranches` | `VariantBranch[]` | If/switch branches on flag values | +| `functions` | `FunctionInfo[]` | Function definitions in the file | +| `events` | `CapturedEvent[]` | Capture calls only | +| `flagChecks` | `FlagCheck[]` | Flag method calls only | +| `flagKeys` | `string[]` | Unique flag keys | +| `eventNames` | `string[]` | Unique event names | +| `toList()` | `ListItem[]` | Flat sorted list of all SDK usage | +| `enrichFromApi(config)` | `Promise` | Fetch from PostHog API and enrich | + +### `EnrichedResult` + +Returned by `enrich()` or `enrichFromApi()`. Detection combined with PostHog context. + +| Property / Method | Type | Description | +|---|---|---| +| `enrichedFlags` | `EnrichedFlag[]` | Flags grouped by key with type, staleness, rollout, experiment | +| `enrichedEvents` | `EnrichedEvent[]` | Events grouped by name with definition, stats, tags | +| `toList()` | `EnrichedListItem[]` | Flat list with all metadata | +| `toComments()` | `string` | Source code with inline annotation comments | + +### `EnricherApiConfig` + +```typescript +interface EnricherApiConfig { + apiKey: string; + host: string; // e.g. "https://us.posthog.com" + projectId: number; +} +``` + +### `EnrichedFlag` + +```typescript +interface EnrichedFlag { + flagKey: string; + flagType: "boolean" | "multivariate" | "remote_config"; + staleness: StalenessReason | null; + rollout: number | null; + variants: { key: string; rollout_percentage: number }[]; + flag: FeatureFlag | undefined; + experiment: Experiment | undefined; + occurrences: FlagCheck[]; +} +``` + +### `EnrichedEvent` + +```typescript +interface EnrichedEvent { + eventName: string; + verified: boolean; + lastSeenAt: string | null; + tags: string[]; + stats: { volume?: number; uniqueUsers?: number } | undefined; + definition: EventDefinition | undefined; + occurrences: CapturedEvent[]; +} +``` + +## Detection API + +The lower-level detection API is also exported for direct use (this is the same API used by the PostHog VSCode extension): + +```typescript +import { PostHogDetector } from "@posthog/enricher"; + +const detector = new PostHogDetector(); +await detector.initialize(wasmDir); + +const calls = await detector.findPostHogCalls(source, "typescript"); +const initCalls = await detector.findInitCalls(source, "typescript"); +const branches = await detector.findVariantBranches(source, "typescript"); +const assignments = await detector.findFlagAssignments(source, "typescript"); +const functions = await detector.findFunctions(source, "typescript"); + +detector.dispose(); +``` + +### Flag classification utilities + +```typescript +import { classifyFlagType, classifyStaleness } from "@posthog/enricher"; + +classifyFlagType(flag); // "boolean" | "multivariate" | "remote_config" +classifyStaleness(key, flag, experiments, opts); // StalenessReason | null +``` + +## Logging + +Warnings are silenced by default. To receive them: + +```typescript +import { setLogger } from "@posthog/enricher"; + +setLogger({ warn: console.warn }); +``` + +## Setup + +The package requires pre-built tree-sitter WASM grammar files. Run `pnpm fetch-grammars` to build them, or place pre-built `.wasm` files in the `grammars/` directory. diff --git a/packages/enricher/package.json b/packages/enricher/package.json index 208b86fda..6c3632692 100644 --- a/packages/enricher/package.json +++ b/packages/enricher/package.json @@ -7,18 +7,6 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - }, - "./classification": { - "types": "./dist/flag-classification.d.ts", - "import": "./dist/flag-classification.js" - }, - "./stale-flags": { - "types": "./dist/stale-flags.d.ts", - "import": "./dist/stale-flags.js" - }, - "./types": { - "types": "./dist/types.d.ts", - "import": "./dist/types.js" } }, "scripts": { diff --git a/packages/enricher/src/comment-formatter.ts b/packages/enricher/src/comment-formatter.ts new file mode 100644 index 000000000..cb41506b5 --- /dev/null +++ b/packages/enricher/src/comment-formatter.ts @@ -0,0 +1,96 @@ +import type { EnrichedEvent, EnrichedFlag, EnrichedListItem } from "./types.js"; + +function commentPrefix(languageId: string): string { + if (languageId === "python" || languageId === "ruby") { + return "#"; + } + return "//"; +} + +function formatFlagComment(flag: EnrichedFlag): string { + const parts: string[] = [`Flag: "${flag.flagKey}"`]; + + if (flag.flag) { + parts.push(flag.flagType); + if (flag.rollout !== null) { + parts.push(`${flag.rollout}% rolled out`); + } + if (flag.experiment) { + const status = flag.experiment.end_date ? "complete" : "running"; + parts.push(`Experiment: "${flag.experiment.name}" (${status})`); + } + if (flag.staleness) { + parts.push(`STALE (${flag.staleness})`); + } + } + + return parts.join(" \u2014 "); +} + +function formatEventComment(event: EnrichedEvent): string { + const parts: string[] = [`Event: "${event.eventName}"`]; + if (event.verified) { + parts.push("(verified)"); + } + if (event.stats?.volume !== undefined) { + parts.push(`${event.stats.volume.toLocaleString()} events`); + } + if (event.stats?.uniqueUsers !== undefined) { + parts.push(`${event.stats.uniqueUsers.toLocaleString()} users`); + } + if (event.definition?.description) { + parts.push(event.definition.description); + } + return parts.join(" \u2014 "); +} + +export function formatComments( + source: string, + languageId: string, + items: EnrichedListItem[], + enrichedFlags: Map, + enrichedEvents: Map, +): string { + const prefix = commentPrefix(languageId); + const lines = source.split("\n"); + const sorted = [...items].sort((a, b) => a.line - b.line); + + let offset = 0; + // One comment per original source line — if multiple detections share a line, + // only the first (by sort order) gets an annotation to keep output readable. + const annotatedLines = new Set(); + + for (const item of sorted) { + const targetLine = item.line + offset; + if (annotatedLines.has(item.line)) { + continue; + } + annotatedLines.add(item.line); + + let comment: string | null = null; + + if (item.type === "flag") { + const flag = enrichedFlags.get(item.name); + if (flag) { + comment = `${prefix} [PostHog] ${formatFlagComment(flag)}`; + } + } else if (item.type === "event") { + const event = enrichedEvents.get(item.name); + if (event) { + comment = `${prefix} [PostHog] ${formatEventComment(event)}`; + } else if (item.detail) { + comment = `${prefix} [PostHog] Event: ${item.detail}`; + } + } else if (item.type === "init") { + comment = `${prefix} [PostHog] Init: token "${item.name}"`; + } + + if (comment) { + const indent = lines[targetLine]?.match(/^(\s*)/)?.[1] ?? ""; + lines.splice(targetLine, 0, `${indent}${comment}`); + offset++; + } + } + + return lines.join("\n"); +} diff --git a/packages/enricher/src/enriched-result.ts b/packages/enricher/src/enriched-result.ts new file mode 100644 index 000000000..b297ebe53 --- /dev/null +++ b/packages/enricher/src/enriched-result.ts @@ -0,0 +1,165 @@ +import { formatComments } from "./comment-formatter.js"; +import { + classifyFlagType, + extractRollout, + extractVariants, +} from "./flag-classification.js"; +import type { ParseResult } from "./parse-result.js"; +import { classifyStaleness } from "./stale-flags.js"; +import type { + EnrichedEvent, + EnrichedFlag, + EnrichedListItem, + EnrichmentContext, +} from "./types.js"; + +export class EnrichedResult { + private readonly parsed: ParseResult; + private readonly context: EnrichmentContext; + private cachedFlags: EnrichedFlag[] | null = null; + private cachedEvents: EnrichedEvent[] | null = null; + + constructor(parsed: ParseResult, context: EnrichmentContext) { + this.parsed = parsed; + this.context = context; + } + + get enrichedFlags(): EnrichedFlag[] { + if (this.cachedFlags) { + return this.cachedFlags; + } + + const flagMap = new Map(); + const checks = this.parsed.flagChecks; + const experiments = this.context.experiments ?? []; + + for (const check of checks) { + let entry = flagMap.get(check.flagKey); + if (!entry) { + const flag = this.context.flags?.get(check.flagKey); + entry = { + flagKey: check.flagKey, + occurrences: [], + flag, + flagType: classifyFlagType(flag), + staleness: classifyStaleness( + check.flagKey, + flag, + experiments, + this.context.stalenessOptions, + ), + rollout: flag ? extractRollout(flag) : null, + variants: flag ? extractVariants(flag) : [], + experiment: experiments.find( + (e) => e.feature_flag_key === check.flagKey, + ), + }; + flagMap.set(check.flagKey, entry); + } + entry.occurrences.push(check); + } + + this.cachedFlags = [...flagMap.values()]; + return this.cachedFlags; + } + + get enrichedEvents(): EnrichedEvent[] { + if (this.cachedEvents) { + return this.cachedEvents; + } + + const eventMap = new Map(); + const events = this.parsed.events; + + for (const event of events) { + if (event.dynamic) { + continue; + } + let entry = eventMap.get(event.name); + if (!entry) { + const definition = this.context.eventDefinitions?.get(event.name); + const stats = this.context.eventStats?.get(event.name); + entry = { + eventName: event.name, + occurrences: [], + definition, + verified: definition?.verified ?? false, + lastSeenAt: stats?.lastSeenAt ?? definition?.last_seen_at ?? null, + tags: definition?.tags ?? [], + stats, + }; + eventMap.set(event.name, entry); + } + entry.occurrences.push(event); + } + + this.cachedEvents = [...eventMap.values()]; + return this.cachedEvents; + } + + toList(): EnrichedListItem[] { + const baseList = this.parsed.toList(); + const _experiments = this.context.experiments ?? []; + + const flagLookup = new Map(); + for (const f of this.enrichedFlags) { + flagLookup.set(f.flagKey, f); + } + + const eventLookup = new Map(); + for (const e of this.enrichedEvents) { + eventLookup.set(e.eventName, e); + } + + return baseList.map((item) => { + const enriched: EnrichedListItem = { ...item }; + + if (item.type === "flag") { + const flag = flagLookup.get(item.name); + if (flag) { + enriched.flagType = flag.flagType; + enriched.staleness = flag.staleness; + enriched.rollout = flag.rollout; + if (flag.experiment) { + enriched.experimentName = flag.experiment.name; + enriched.experimentStatus = flag.experiment.end_date + ? "complete" + : "running"; + } + } + } else if (item.type === "event") { + const event = eventLookup.get(item.name); + if (event) { + enriched.verified = event.verified; + enriched.description = event.definition?.description ?? null; + enriched.lastSeenAt = event.lastSeenAt; + enriched.tags = event.tags; + enriched.volume = event.stats?.volume; + enriched.uniqueUsers = event.stats?.uniqueUsers; + } + } + + return enriched; + }); + } + + toComments(): string { + const flagLookup = new Map(); + for (const f of this.enrichedFlags) { + flagLookup.set(f.flagKey, f); + } + + const eventLookup = new Map(); + for (const e of this.enrichedEvents) { + eventLookup.set(e.eventName, e); + } + + return formatComments( + this.parsed.source, + this.parsed.languageId, + this.toList(), + flagLookup, + eventLookup, + ); + } +} diff --git a/packages/enricher/src/enricher.test.ts b/packages/enricher/src/enricher.test.ts new file mode 100644 index 000000000..465310ab9 --- /dev/null +++ b/packages/enricher/src/enricher.test.ts @@ -0,0 +1,430 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { PostHogEnricher } from "./enricher.js"; +import type { + EnricherApiConfig, + EventDefinition, + Experiment, + FeatureFlag, +} from "./types.js"; + +const GRAMMARS_DIR = path.join(__dirname, "..", "grammars"); +const hasGrammars = fs.existsSync( + path.join(GRAMMARS_DIR, "tree-sitter-javascript.wasm"), +); + +const describeWithGrammars = hasGrammars ? describe : describe.skip; + +const API_CONFIG: EnricherApiConfig = { + apiKey: "phx_test", + host: "https://test.posthog.com", + projectId: 1, +}; + +const makeFlag = ( + key: string, + overrides: Partial = {}, +): FeatureFlag => ({ + id: 1, + key, + name: key, + active: true, + filters: {}, + created_at: "2024-01-01T00:00:00Z", + created_by: null, + deleted: false, + ...overrides, +}); + +const makeExperiment = ( + flagKey: string, + overrides: Partial = {}, +): Experiment => ({ + id: 1, + name: `Experiment for ${flagKey}`, + description: null, + start_date: "2024-01-01", + end_date: null, + feature_flag_key: flagKey, + created_at: "2024-01-01T00:00:00Z", + created_by: null, + ...overrides, +}); + +const makeEventDef = ( + name: string, + overrides: Partial = {}, +): EventDefinition => ({ + id: "1", + name, + description: null, + tags: [], + last_seen_at: null, + verified: false, + hidden: false, + ...overrides, +}); + +function mockApiResponses(opts: { + flags?: FeatureFlag[]; + experiments?: Experiment[]; + eventDefs?: EventDefinition[]; + eventStats?: [string, number, number, string][]; +}): void { + const mockFetch = vi.fn(async (url: string, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : String(url); + + if (urlStr.includes("/feature_flags/")) { + return Response.json({ results: opts.flags ?? [] }); + } + if (urlStr.includes("/experiments/")) { + return Response.json({ results: opts.experiments ?? [] }); + } + if (urlStr.includes("/event_definitions/")) { + return Response.json({ results: opts.eventDefs ?? [] }); + } + if (urlStr.includes("/query/") && init?.method === "POST") { + return Response.json({ results: opts.eventStats ?? [] }); + } + return Response.json({}); + }); + + vi.stubGlobal("fetch", mockFetch); +} + +describeWithGrammars("PostHogEnricher", () => { + let enricher: PostHogEnricher; + + beforeAll(async () => { + enricher = new PostHogEnricher(); + await enricher.initialize(GRAMMARS_DIR); + }); + + // ── ParseResult ── + + describe("parse → ParseResult", () => { + test("returns events and flagChecks", async () => { + const code = [ + `posthog.capture('purchase');`, + `const f = posthog.getFeatureFlag('my-flag');`, + ].join("\n"); + + const result = await enricher.parse(code, "javascript"); + expect(result.events).toHaveLength(1); + expect(result.events[0].name).toBe("purchase"); + expect(result.flagChecks).toHaveLength(1); + expect(result.flagChecks[0].flagKey).toBe("my-flag"); + }); + + test("flagKeys returns unique keys", async () => { + const code = [ + `posthog.getFeatureFlag('flag-a');`, + `posthog.isFeatureEnabled('flag-a');`, + `posthog.getFeatureFlag('flag-b');`, + ].join("\n"); + + const result = await enricher.parse(code, "javascript"); + expect(result.flagKeys).toEqual(["flag-a", "flag-b"]); + }); + + test("eventNames returns unique non-dynamic names", async () => { + const code = [ + `posthog.capture('purchase');`, + `posthog.capture('signup');`, + `posthog.capture('purchase');`, + ].join("\n"); + + const result = await enricher.parse(code, "javascript"); + expect(result.eventNames).toEqual(["purchase", "signup"]); + }); + + test("toList returns sorted items", async () => { + const code = [ + `posthog.getFeatureFlag('flag');`, + `posthog.capture('event');`, + ].join("\n"); + + const result = await enricher.parse(code, "javascript"); + const list = result.toList(); + expect(list).toHaveLength(2); + expect(list[0].type).toBe("flag"); + expect(list[1].type).toBe("event"); + }); + }); + + // ── EnrichedResult via API ── + + describe("enrichFromApi → EnrichedResult", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("enrichedFlags includes flag metadata", async () => { + const code = `posthog.getFeatureFlag('my-flag');`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({ flags: [makeFlag("my-flag")] }); + const enriched = await result.enrichFromApi(API_CONFIG); + + expect(enriched.enrichedFlags).toHaveLength(1); + expect(enriched.enrichedFlags[0].flagKey).toBe("my-flag"); + expect(enriched.enrichedFlags[0].flagType).toBe("boolean"); + }); + + test("enrichedFlags detects staleness", async () => { + const code = `posthog.getFeatureFlag('stale-flag');`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({ flags: [makeFlag("stale-flag", { active: false })] }); + const enriched = await result.enrichFromApi(API_CONFIG); + + expect(enriched.enrichedFlags[0].staleness).toBe("inactive"); + }); + + test("enrichedFlags links experiment", async () => { + const code = `posthog.getFeatureFlag('exp-flag');`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({ + flags: [makeFlag("exp-flag")], + experiments: [makeExperiment("exp-flag")], + }); + const enriched = await result.enrichFromApi(API_CONFIG); + + expect(enriched.enrichedFlags[0].experiment?.name).toBe( + "Experiment for exp-flag", + ); + }); + + test("enrichedEvents includes definition", async () => { + const code = `posthog.capture('purchase');`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({ + eventDefs: [ + makeEventDef("purchase", { + verified: true, + description: "User bought something", + }), + ], + }); + const enriched = await result.enrichFromApi(API_CONFIG); + + expect(enriched.enrichedEvents).toHaveLength(1); + expect(enriched.enrichedEvents[0].verified).toBe(true); + }); + + test("toList returns enriched items", async () => { + const code = [ + `posthog.capture('purchase');`, + `posthog.getFeatureFlag('my-flag');`, + ].join("\n"); + + const result = await enricher.parse(code, "javascript"); + mockApiResponses({ + flags: [makeFlag("my-flag")], + eventDefs: [makeEventDef("purchase", { verified: true })], + }); + const enriched = await result.enrichFromApi(API_CONFIG); + + const list = enriched.toList(); + expect(list).toHaveLength(2); + + const eventItem = list.find((i) => i.type === "event"); + expect(eventItem?.verified).toBe(true); + + const flagItem = list.find((i) => i.type === "flag"); + expect(flagItem?.flagType).toBe("boolean"); + }); + + test("toComments inserts annotations", async () => { + const code = [ + `posthog.capture('purchase');`, + `posthog.getFeatureFlag('my-flag');`, + ].join("\n"); + + const result = await enricher.parse(code, "javascript"); + mockApiResponses({ + flags: [makeFlag("my-flag", { active: false })], + eventDefs: [makeEventDef("purchase", { verified: true })], + }); + const enriched = await result.enrichFromApi(API_CONFIG); + + const annotated = enriched.toComments(); + expect(annotated).toContain("// [PostHog]"); + expect(annotated).toContain("purchase"); + expect(annotated).toContain("my-flag"); + }); + + test("toComments uses # for Python", async () => { + const code = `posthog.get_feature_flag('my-flag')`; + const result = await enricher.parse(code, "python"); + + mockApiResponses({ flags: [makeFlag("my-flag")] }); + const enriched = await result.enrichFromApi(API_CONFIG); + + const annotated = enriched.toComments(); + expect(annotated).toContain("# [PostHog]"); + }); + + test("enrichedEvents surfaces stats, lastSeenAt, and tags", async () => { + const code = `posthog.capture('purchase');`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({ + eventDefs: [ + makeEventDef("purchase", { + verified: true, + tags: ["revenue", "checkout"], + last_seen_at: "2025-03-01T00:00:00Z", + }), + ], + eventStats: [["purchase", 12500, 3200, "2025-04-01T00:00:00Z"]], + }); + const enriched = await result.enrichFromApi(API_CONFIG); + + const event = enriched.enrichedEvents[0]; + expect(event.verified).toBe(true); + expect(event.tags).toEqual(["revenue", "checkout"]); + expect(event.stats?.volume).toBe(12500); + expect(event.stats?.uniqueUsers).toBe(3200); + + const list = enriched.toList(); + const item = list.find((i) => i.type === "event"); + expect(item?.volume).toBe(12500); + expect(item?.uniqueUsers).toBe(3200); + expect(item?.tags).toEqual(["revenue", "checkout"]); + }); + + test("toComments includes volume when available", async () => { + const code = `posthog.capture('purchase');`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({ + eventStats: [["purchase", 5000, 1200, "2025-04-01"]], + }); + const enriched = await result.enrichFromApi(API_CONFIG); + + const annotated = enriched.toComments(); + expect(annotated).toContain("5,000 events"); + expect(annotated).toContain("1,200 users"); + }); + + test("enrichFromApi with no detected usage returns empty enrichment", async () => { + const code = `const x = 1;`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({}); + const enriched = await result.enrichFromApi(API_CONFIG); + + expect(enriched.toList()).toHaveLength(0); + expect(enriched.enrichedFlags).toHaveLength(0); + expect(enriched.enrichedEvents).toHaveLength(0); + }); + + test("only fetches flags when flags are detected", async () => { + const code = `posthog.capture('purchase');`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({ + eventDefs: [makeEventDef("purchase")], + }); + await result.enrichFromApi(API_CONFIG); + + const calls = vi.mocked(fetch).mock.calls; + const urls = calls.map(([url]) => String(url)); + expect(urls.some((u) => u.includes("/feature_flags/"))).toBe(false); + expect(urls.some((u) => u.includes("/experiments/"))).toBe(false); + expect(urls.some((u) => u.includes("/event_definitions/"))).toBe(true); + }); + }); + + // ── API error handling ── + + describe("enrichFromApi error handling", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("rejects on 401 unauthorized", async () => { + const code = `posthog.getFeatureFlag('my-flag');`; + const result = await enricher.parse(code, "javascript"); + + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("Unauthorized", { status: 401 })), + ); + + await expect(result.enrichFromApi(API_CONFIG)).rejects.toThrow( + /PostHog API error: 401/, + ); + }); + + test("rejects on 500 server error", async () => { + const code = `posthog.getFeatureFlag('my-flag');`; + const result = await enricher.parse(code, "javascript"); + + vi.stubGlobal( + "fetch", + vi.fn( + async () => new Response("Internal Server Error", { status: 500 }), + ), + ); + + await expect(result.enrichFromApi(API_CONFIG)).rejects.toThrow( + /PostHog API error: 500/, + ); + }); + + test("rejects on network failure", async () => { + const code = `posthog.getFeatureFlag('my-flag');`; + const result = await enricher.parse(code, "javascript"); + + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("fetch failed"); + }), + ); + + await expect(result.enrichFromApi(API_CONFIG)).rejects.toThrow( + "fetch failed", + ); + }); + + test("rejects on malformed JSON response", async () => { + const code = `posthog.getFeatureFlag('my-flag');`; + const result = await enricher.parse(code, "javascript"); + + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("not json", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }), + ), + ); + + await expect(result.enrichFromApi(API_CONFIG)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/enricher/src/enricher.ts b/packages/enricher/src/enricher.ts new file mode 100644 index 000000000..296ddc4c7 --- /dev/null +++ b/packages/enricher/src/enricher.ts @@ -0,0 +1,63 @@ +import { PostHogDetector } from "./detector.js"; +import { warn } from "./log.js"; +import { ParseResult } from "./parse-result.js"; +import type { DetectionConfig } from "./types.js"; + +export class PostHogEnricher { + private detector = new PostHogDetector(); + + async initialize(wasmDir: string): Promise { + return this.detector.initialize(wasmDir); + } + + updateConfig(config: DetectionConfig): void { + this.detector.updateConfig(config); + } + + isSupported(langId: string): boolean { + return this.detector.isSupported(langId); + } + + get supportedLanguages(): string[] { + return this.detector.supportedLanguages; + } + + async parse(source: string, languageId: string): Promise { + const results = await Promise.allSettled([ + this.detector.findPostHogCalls(source, languageId), + this.detector.findInitCalls(source, languageId), + this.detector.findFlagAssignments(source, languageId), + this.detector.findVariantBranches(source, languageId), + this.detector.findFunctions(source, languageId), + ]); + + const settled = results.map((r, i) => { + if (r.status === "fulfilled") { + return r.value; + } + const labels = [ + "calls", + "initCalls", + "flagAssignments", + "variantBranches", + "functions", + ]; + warn(`enricher: ${labels[i]} detection failed`, r.reason); + return []; + }); + + return new ParseResult( + source, + languageId, + settled[0] as Awaited>, + settled[1] as Awaited>, + settled[2] as Awaited>, + settled[3] as Awaited>, + settled[4] as Awaited>, + ); + } + + dispose(): void { + this.detector.dispose(); + } +} diff --git a/packages/enricher/src/flag-classification.test.ts b/packages/enricher/src/flag-classification.test.ts index 4486d40ee..ec2f0417e 100644 --- a/packages/enricher/src/flag-classification.test.ts +++ b/packages/enricher/src/flag-classification.test.ts @@ -15,7 +15,6 @@ function makeFlag(overrides: Partial = {}): FeatureFlag { name: "Test", active: true, filters: {}, - rollout_percentage: null, created_at: "2024-01-01", created_by: null, deleted: false, @@ -149,15 +148,18 @@ describe("isFullyRolledOut", () => { expect(isFullyRolledOut(flag)).toBe(false); }); - test("top-level rollout_percentage 100 with no groups returns true", () => { - const flag = makeFlag({ rollout_percentage: 100, filters: {} }); - expect(isFullyRolledOut(flag)).toBe(true); + test("no groups and no multivariate returns false", () => { + const flag = makeFlag({ filters: {} }); + expect(isFullyRolledOut(flag)).toBe(false); }); }); describe("extractRollout", () => { - test("top-level rollout returns it", () => { - expect(extractRollout(makeFlag({ rollout_percentage: 75 }))).toBe(75); + test("rollout from groups returns it", () => { + const flag = makeFlag({ + filters: { groups: [{ rollout_percentage: 75 }] }, + }); + expect(extractRollout(flag)).toBe(75); }); test("rollout in groups returns it", () => { @@ -171,16 +173,18 @@ describe("extractRollout", () => { expect(extractRollout(makeFlag({ filters: {} }))).toBe(null); }); - test("null top-level falls through to groups", () => { + test("first group rollout is returned", () => { const flag = makeFlag({ - rollout_percentage: null, filters: { groups: [{ rollout_percentage: 60 }] }, }); expect(extractRollout(flag)).toBe(60); }); test("rollout 0 returns 0 (not null)", () => { - expect(extractRollout(makeFlag({ rollout_percentage: 0 }))).toBe(0); + const flag = makeFlag({ + filters: { groups: [{ rollout_percentage: 0 }] }, + }); + expect(extractRollout(flag)).toBe(0); }); }); diff --git a/packages/enricher/src/flag-classification.ts b/packages/enricher/src/flag-classification.ts index 88e71b942..f93d17dd2 100644 --- a/packages/enricher/src/flag-classification.ts +++ b/packages/enricher/src/flag-classification.ts @@ -48,20 +48,11 @@ export function isFullyRolledOut(flag: FeatureFlag): boolean { }); } - if (flag.rollout_percentage === 100) { - return true; - } return false; } /** Extract rollout percentage from a flag's filters */ export function extractRollout(flag: FeatureFlag): number | null { - if ( - flag.rollout_percentage !== null && - flag.rollout_percentage !== undefined - ) { - return flag.rollout_percentage; - } const filters = flag.filters as Record | undefined; if (filters?.groups && Array.isArray(filters.groups)) { for (const group of filters.groups) { diff --git a/packages/enricher/src/index.ts b/packages/enricher/src/index.ts index 589616013..7b4bf86fb 100644 --- a/packages/enricher/src/index.ts +++ b/packages/enricher/src/index.ts @@ -1,3 +1,5 @@ +// ── Detection API (replaces posthog-vscode tree-sitter service) ── + export { PostHogDetector } from "./detector.js"; export { classifyFlagType, @@ -10,7 +12,6 @@ export type { LangFamily, QueryStrings } from "./languages.js"; export { ALL_FLAG_METHODS, CLIENT_NAMES, LANG_FAMILIES } from "./languages.js"; export type { DetectorLogger } from "./log.js"; export { setLogger } from "./log.js"; -export type { StalenessCheckOptions } from "./stale-flags.js"; export { classifyStaleness, STALENESS_ORDER, @@ -27,8 +28,27 @@ export type { FunctionInfo, PostHogCall, PostHogInitCall, + StalenessCheckOptions, StalenessReason, SupportedLanguage, VariantBranch, } from "./types.js"; export { DEFAULT_CONFIG } from "./types.js"; + +// ── Enricher API ── + +export { EnrichedResult } from "./enriched-result.js"; +export { PostHogEnricher } from "./enricher.js"; +export { ParseResult } from "./parse-result.js"; +export { PostHogApi } from "./posthog-api.js"; + +export type { + CapturedEvent, + EnrichedEvent, + EnrichedFlag, + EnrichedListItem, + EnricherApiConfig, + EventStats, + FlagCheck, + ListItem, +} from "./types.js"; diff --git a/packages/enricher/src/parse-result.ts b/packages/enricher/src/parse-result.ts new file mode 100644 index 000000000..858d41031 --- /dev/null +++ b/packages/enricher/src/parse-result.ts @@ -0,0 +1,139 @@ +import { EnrichedResult } from "./enriched-result.js"; +import { PostHogApi } from "./posthog-api.js"; +import type { + CapturedEvent, + EnricherApiConfig, + FlagAssignment, + FlagCheck, + FunctionInfo, + ListItem, + PostHogCall, + PostHogInitCall, + VariantBranch, +} from "./types.js"; + +const CAPTURE_METHODS = new Set(["capture", "Enqueue"]); + +export class ParseResult { + readonly source: string; + readonly languageId: string; + readonly calls: readonly PostHogCall[]; + readonly initCalls: readonly PostHogInitCall[]; + readonly flagAssignments: readonly FlagAssignment[]; + readonly variantBranches: readonly VariantBranch[]; + readonly functions: readonly FunctionInfo[]; + + constructor( + source: string, + languageId: string, + calls: PostHogCall[], + initCalls: PostHogInitCall[], + flagAssignments: FlagAssignment[], + variantBranches: VariantBranch[], + functions: FunctionInfo[], + ) { + this.source = source; + this.languageId = languageId; + this.calls = calls; + this.initCalls = initCalls; + this.flagAssignments = flagAssignments; + this.variantBranches = variantBranches; + this.functions = functions; + } + + get events(): CapturedEvent[] { + return this.calls + .filter((c) => CAPTURE_METHODS.has(c.method)) + .map((c) => ({ + name: c.key, + line: c.line, + dynamic: c.dynamic ?? false, + })); + } + + get flagChecks(): FlagCheck[] { + return this.calls + .filter((c) => !CAPTURE_METHODS.has(c.method)) + .map((c) => ({ + method: c.method, + flagKey: c.key, + line: c.line, + })); + } + + get flagKeys(): string[] { + return [...new Set(this.flagChecks.map((c) => c.flagKey))]; + } + + get eventNames(): string[] { + return [ + ...new Set(this.events.filter((e) => !e.dynamic).map((e) => e.name)), + ]; + } + + toList(): ListItem[] { + const items: ListItem[] = []; + + for (const init of this.initCalls) { + items.push({ + type: "init", + line: init.tokenLine, + name: init.token, + method: "init", + }); + } + + for (const call of this.calls) { + const isEvent = CAPTURE_METHODS.has(call.method); + items.push({ + type: isEvent ? "event" : "flag", + line: call.line, + name: call.key, + method: call.method, + detail: call.dynamic ? "dynamic event name" : undefined, + }); + } + + return items.sort((a, b) => a.line - b.line); + } + + async enrichFromApi(config: EnricherApiConfig): Promise { + const api = new PostHogApi(config); + const flagKeys = this.flagKeys; + const eventNames = this.eventNames; + + const [allFlags, allExperiments, allEventDefs, eventStats] = + await Promise.all([ + flagKeys.length > 0 ? api.getFeatureFlags() : Promise.resolve([]), + flagKeys.length > 0 ? api.getExperiments() : Promise.resolve([]), + eventNames.length > 0 + ? api.getEventDefinitions(eventNames) + : Promise.resolve([]), + eventNames.length > 0 + ? api.getEventStats(eventNames) + : Promise.resolve(new Map()), + ]); + + const flagKeySet = new Set(flagKeys); + const flags = new Map( + allFlags.filter((f) => flagKeySet.has(f.key)).map((f) => [f.key, f]), + ); + + const experiments = allExperiments.filter((e) => + flagKeySet.has(e.feature_flag_key), + ); + + const eventDefinitions = new Map( + allEventDefs + .filter((d) => eventNames.includes(d.name)) + .map((d) => [d.name, d]), + ); + + return new EnrichedResult(this, { + flags, + experiments, + eventDefinitions, + eventStats, + }); + } +} diff --git a/packages/enricher/src/posthog-api.ts b/packages/enricher/src/posthog-api.ts new file mode 100644 index 000000000..b81b6704b --- /dev/null +++ b/packages/enricher/src/posthog-api.ts @@ -0,0 +1,122 @@ +import type { + EnricherApiConfig, + EventDefinition, + EventStats, + Experiment, + FeatureFlag, +} from "./types.js"; + +export class PostHogApi { + private config: EnricherApiConfig; + + constructor(config: EnricherApiConfig) { + this.config = config; + } + + private get baseUrl(): string { + const host = this.config.host.replace(/\/$/, ""); + return `${host}/api/projects/${this.config.projectId}`; + } + + private get signal(): AbortSignal { + return AbortSignal.timeout(this.config.timeoutMs ?? 10_000); + } + + private async get(path: string): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + "Content-Type": "application/json", + }, + signal: this.signal, + }); + if (!res.ok) { + throw new Error( + `PostHog API error: ${res.status} ${res.statusText} on GET ${path}`, + ); + } + return res.json() as Promise; + } + + private async post(path: string, body: unknown): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: this.signal, + }); + if (!res.ok) { + throw new Error( + `PostHog API error: ${res.status} ${res.statusText} on POST ${path}`, + ); + } + return res.json() as Promise; + } + + async getFeatureFlags(): Promise { + const data = await this.get<{ results: FeatureFlag[] }>( + "/feature_flags/?limit=500", + ); + return data.results.filter((f) => !f.deleted); + } + + async getExperiments(): Promise { + const data = await this.get<{ results: Experiment[] }>( + "/experiments/?limit=500", + ); + return data.results; + } + + async getEventDefinitions(names?: string[]): Promise { + let path = "/event_definitions/?limit=500"; + if (names && names.length > 0) { + path += `&search=${encodeURIComponent(names.join(","))}`; + } + const data = await this.get<{ results: EventDefinition[] }>(path); + return data.results; + } + + async getEventStats( + eventNames: string[], + daysBack = 30, + ): Promise> { + if (eventNames.length === 0) { + return new Map(); + } + + const query = ` + SELECT + event, + count() AS volume, + count(DISTINCT person_id) AS unique_users, + max(timestamp) AS last_seen + FROM events + WHERE event IN ({eventNames:Array(String)}) + AND timestamp >= now() - INTERVAL {daysBack:Int32} DAY + GROUP BY event + `; + + const data = await this.post<{ + results: [string, number, number, string][]; + }>("/query/", { + query: { + kind: "HogQLQuery", + query, + values: { eventNames, daysBack }, + }, + }); + + const stats = new Map(); + for (const [event, volume, uniqueUsers, lastSeen] of data.results) { + stats.set(event, { + volume, + uniqueUsers, + lastSeenAt: lastSeen || null, + }); + } + return stats; + } +} diff --git a/packages/enricher/src/stale-flags.test.ts b/packages/enricher/src/stale-flags.test.ts index 89072fd6e..68de33a17 100644 --- a/packages/enricher/src/stale-flags.test.ts +++ b/packages/enricher/src/stale-flags.test.ts @@ -9,7 +9,6 @@ function makeFlag(overrides: Partial = {}): FeatureFlag { name: "Test", active: true, filters: {}, - rollout_percentage: null, created_at: "2024-01-01", created_by: null, deleted: false, diff --git a/packages/enricher/src/stale-flags.ts b/packages/enricher/src/stale-flags.ts index db8b2b7c3..1a8549b9f 100644 --- a/packages/enricher/src/stale-flags.ts +++ b/packages/enricher/src/stale-flags.ts @@ -1,10 +1,10 @@ import { isFullyRolledOut } from "./flag-classification.js"; -import type { Experiment, FeatureFlag, StalenessReason } from "./types.js"; - -export interface StalenessCheckOptions { - /** Minimum age in days before a fully-rolled-out flag is considered stale. Default: 30 */ - staleFlagAgeDays?: number; -} +import type { + Experiment, + FeatureFlag, + StalenessCheckOptions, + StalenessReason, +} from "./types.js"; /** Classify why a flag key is stale, or return null if it's not stale. */ export function classifyStaleness( diff --git a/packages/enricher/src/types.ts b/packages/enricher/src/types.ts index 44e703b33..ef8fc46aa 100644 --- a/packages/enricher/src/types.ts +++ b/packages/enricher/src/types.ts @@ -78,7 +78,6 @@ export interface FeatureFlag { name: string; active: boolean; filters: Record; - rollout_percentage: number | null; created_at: string; created_by: { email: string; first_name: string } | null; deleted: boolean; @@ -99,7 +98,13 @@ export interface Experiment { feature_flag_variants?: { key: string; rollout_percentage: number }[]; recommended_sample_size?: number; }; - conclusion?: "won" | "lost" | null; + conclusion?: + | "won" + | "lost" + | "inconclusive" + | "stopped_early" + | "invalid" + | null; conclusion_comment?: string | null; } @@ -113,11 +118,11 @@ export interface ExperimentMetric { export interface EventDefinition { id: string; name: string; - description: string | null; + description?: string | null; tags: string[]; last_seen_at: string | null; - verified: boolean; - hidden: boolean; + verified?: boolean; + hidden?: boolean; } // ── Stale flag types ── @@ -129,3 +134,88 @@ export type StalenessReason = | "experiment_complete"; export type FlagType = "boolean" | "multivariate" | "remote_config"; + +// ── Enricher types ── + +export interface CapturedEvent { + name: string; + line: number; + dynamic: boolean; +} + +export interface FlagCheck { + method: string; + flagKey: string; + line: number; +} + +export interface ListItem { + type: "event" | "flag" | "init"; + line: number; + name: string; + method: string; + detail?: string; +} + +export interface EnrichedListItem extends ListItem { + flagType?: FlagType; + staleness?: StalenessReason | null; + rollout?: number | null; + experimentName?: string | null; + experimentStatus?: "running" | "complete" | null; + verified?: boolean; + description?: string | null; + volume?: number; + uniqueUsers?: number; + lastSeenAt?: string | null; + tags?: string[]; +} + +export interface EventStats { + volume?: number; + uniqueUsers?: number; + lastSeenAt?: string | null; +} + +export interface EnrichmentContext { + flags?: Map; + experiments?: Experiment[]; + eventDefinitions?: Map; + eventStats?: Map; + stalenessOptions?: StalenessCheckOptions; +} + +export interface StalenessCheckOptions { + staleFlagAgeDays?: number; +} + +export interface EnrichedFlag { + flagKey: string; + occurrences: FlagCheck[]; + flag: FeatureFlag | undefined; + flagType: FlagType; + staleness: StalenessReason | null; + rollout: number | null; + variants: { key: string; rollout_percentage: number }[]; + experiment: Experiment | undefined; +} + +export interface EnrichedEvent { + eventName: string; + occurrences: CapturedEvent[]; + definition: EventDefinition | undefined; + verified: boolean; + lastSeenAt: string | null; + tags: string[]; + stats: EventStats | undefined; +} + +// ── API configuration ── + +export interface EnricherApiConfig { + apiKey: string; + host: string; + projectId: number; + /** Timeout in ms for each API request (default: 10 000). */ + timeoutMs?: number; +} diff --git a/packages/enricher/tsup.config.ts b/packages/enricher/tsup.config.ts index 8dec083f2..1465ea247 100644 --- a/packages/enricher/tsup.config.ts +++ b/packages/enricher/tsup.config.ts @@ -1,12 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: [ - "src/index.ts", - "src/flag-classification.ts", - "src/stale-flags.ts", - "src/types.ts", - ], + entry: ["src/index.ts"], format: ["esm"], dts: true, sourcemap: true,