diff --git a/packages/enricher/src/comment-formatter.ts b/packages/enricher/src/comment-formatter.ts index 0bffa67ee..42809dd74 100644 --- a/packages/enricher/src/comment-formatter.ts +++ b/packages/enricher/src/comment-formatter.ts @@ -10,18 +10,30 @@ function commentPrefix(languageId: string): string { 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})`); - } + if (!flag.flag) { + parts.push("not in PostHog"); + return parts.join(" \u2014 "); + } + + parts.push(flag.flagType); + parts.push(flag.flag.active ? "active" : "inactive"); + if (flag.rollout !== null) { + parts.push(`${flag.rollout}% rolled out`); + } + if (flag.evaluationStats) { + const evals = flag.evaluationStats.evaluations.toLocaleString(); + const users = flag.evaluationStats.uniqueUsers.toLocaleString(); + parts.push(`${evals} evals / ${users} users (7d)`); + } + 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})`); + } + if (flag.url) { + parts.push(flag.url); } return parts.join(" \u2014 "); diff --git a/packages/enricher/src/enriched-result.ts b/packages/enricher/src/enriched-result.ts index 7a75193e2..553bb2a4b 100644 --- a/packages/enricher/src/enriched-result.ts +++ b/packages/enricher/src/enriched-result.ts @@ -5,6 +5,7 @@ import { extractVariants, } from "./flag-classification.js"; import type { ParseResult } from "./parse-result.js"; +import { buildFlagUrl } from "./posthog-api.js"; import { classifyStaleness } from "./stale-flags.js"; import type { EnrichedEvent, @@ -33,10 +34,15 @@ export class EnrichedResult { const checks = this.parsed.flagChecks; const experiments = this.context.experiments ?? []; + const { host, projectId } = this.context; for (const check of checks) { let entry = flagMap.get(check.flagKey); if (!entry) { const flag = this.context.flags?.get(check.flagKey); + const url = + flag && host && projectId !== undefined + ? buildFlagUrl(host, projectId, flag.id) + : null; entry = { flagKey: check.flagKey, occurrences: [], @@ -53,6 +59,8 @@ export class EnrichedResult { experiment: experiments.find( (e) => e.feature_flag_key === check.flagKey, ), + url, + evaluationStats: this.context.flagEvaluationStats?.get(check.flagKey), }; flagMap.set(check.flagKey, entry); } @@ -120,6 +128,10 @@ export class EnrichedResult { enriched.flagType = flag.flagType; enriched.staleness = flag.staleness; enriched.rollout = flag.rollout; + enriched.active = flag.flag?.active; + enriched.url = flag.url; + enriched.evaluations = flag.evaluationStats?.evaluations; + enriched.evaluationUsers = flag.evaluationStats?.uniqueUsers; if (flag.experiment) { enriched.experimentName = flag.experiment.name; enriched.experimentStatus = flag.experiment.end_date diff --git a/packages/enricher/src/enricher.test.ts b/packages/enricher/src/enricher.test.ts index af656d1f0..adade2b6e 100644 --- a/packages/enricher/src/enricher.test.ts +++ b/packages/enricher/src/enricher.test.ts @@ -82,6 +82,7 @@ function mockApiResponses(opts: { experiments?: Experiment[]; eventDefs?: EventDefinition[]; eventStats?: [string, number, number, string][]; + flagEvalStats?: [string, number, number][]; }): void { const mockFetch = vi.fn(async (url: string, init?: RequestInit) => { const urlStr = typeof url === "string" ? url : String(url); @@ -96,6 +97,15 @@ function mockApiResponses(opts: { return Response.json({ results: opts.eventDefs ?? [] }); } if (urlStr.includes("/query/") && init?.method === "POST") { + const body = + typeof init.body === "string" + ? init.body + : init.body instanceof Uint8Array + ? new TextDecoder().decode(init.body) + : ""; + if (body.includes("$feature_flag_called")) { + return Response.json({ results: opts.flagEvalStats ?? [] }); + } return Response.json({ results: opts.eventStats ?? [] }); } return Response.json({}); @@ -383,6 +393,109 @@ describeWithGrammars("PostHogEnricher", () => { expect(annotated).toContain("1,200 users"); }); + test("enrichedFlags includes url and evaluation stats", async () => { + const code = `posthog.getFeatureFlag('my-flag');`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({ + flags: [makeFlag("my-flag", { id: 42 })], + flagEvalStats: [["my-flag", 1240, 230]], + }); + const enriched = await result.enrichFromApi(API_CONFIG); + + expect(enriched.flags[0].url).toBe( + "https://test.posthog.com/project/1/feature_flags/42", + ); + expect(enriched.flags[0].evaluationStats).toEqual({ + evaluations: 1240, + uniqueUsers: 230, + }); + }); + + test("toComments renders rich flag line with url, active, rollout, evals", async () => { + const code = `posthog.getFeatureFlag('my-flag');`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({ + flags: [ + makeFlag("my-flag", { + id: 42, + filters: { groups: [{ rollout_percentage: 60, properties: [] }] }, + }), + ], + flagEvalStats: [["my-flag", 1240, 230]], + }); + const enriched = await result.enrichFromApi(API_CONFIG); + + const annotated = enriched.toComments(); + expect(annotated).toContain(`Flag: "my-flag"`); + expect(annotated).toContain("active"); + expect(annotated).toContain("60% rolled out"); + expect(annotated).toContain("1,240 evals / 230 users (7d)"); + expect(annotated).toContain( + "https://test.posthog.com/project/1/feature_flags/42", + ); + }); + + test("toComments marks inactive flags explicitly", async () => { + const code = `posthog.getFeatureFlag('off-flag');`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({ flags: [makeFlag("off-flag", { active: false })] }); + const enriched = await result.enrichFromApi(API_CONFIG); + + const annotated = enriched.toComments(); + expect(annotated).toContain("inactive"); + expect(annotated).toContain("STALE (inactive)"); + }); + + test("toComments handles flag not in PostHog", async () => { + const code = `posthog.getFeatureFlag('ghost-flag');`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({ flags: [] }); + const enriched = await result.enrichFromApi(API_CONFIG); + + const annotated = enriched.toComments(); + expect(annotated).toContain(`Flag: "ghost-flag" \u2014 not in PostHog`); + }); + + test("toComments omits evaluation segment when stats missing", async () => { + const code = `posthog.getFeatureFlag('quiet-flag');`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({ flags: [makeFlag("quiet-flag", { id: 7 })] }); + const enriched = await result.enrichFromApi(API_CONFIG); + + const annotated = enriched.toComments(); + expect(annotated).toContain(`Flag: "quiet-flag"`); + expect(annotated).not.toContain("evals /"); + expect(annotated).toContain( + "https://test.posthog.com/project/1/feature_flags/7", + ); + }); + + test("getFlagEvaluationStats is called with detected flag keys", async () => { + const code = `posthog.getFeatureFlag('tracked-flag');`; + const result = await enricher.parse(code, "javascript"); + + mockApiResponses({ + flags: [makeFlag("tracked-flag")], + flagEvalStats: [["tracked-flag", 10, 5]], + }); + await result.enrichFromApi(API_CONFIG); + + const calls = vi.mocked(fetch).mock.calls; + const queryPost = calls.find( + ([url, init]) => + String(url).includes("/query/") && init?.method === "POST", + ); + expect(queryPost).toBeDefined(); + const body = String(queryPost?.[1]?.body ?? ""); + expect(body).toContain("$feature_flag_called"); + expect(body).toContain("tracked-flag"); + }); + test("enrichFromApi with no detected usage returns empty enrichment", async () => { const code = `const x = 1;`; const result = await enricher.parse(code, "javascript"); diff --git a/packages/enricher/src/parse-result.ts b/packages/enricher/src/parse-result.ts index 5a14f396b..a3ea89fc8 100644 --- a/packages/enricher/src/parse-result.ts +++ b/packages/enricher/src/parse-result.ts @@ -7,6 +7,7 @@ import type { EventStats, FlagAssignment, FlagCheck, + FlagEvaluationStats, FunctionInfo, ListItem, PostHogCall, @@ -113,16 +114,25 @@ export class ParseResult { eventNames.length > 0 ? api.getEventStats(eventNames) : Promise.resolve(new Map()), + flagKeys.length > 0 + ? api.getFlagEvaluationStats(flagKeys, 7) + : Promise.resolve(new Map()), ]); - const [flagsResult, experimentsResult, eventDefsResult, eventStatsResult] = - settled; + const [ + flagsResult, + experimentsResult, + eventDefsResult, + eventStatsResult, + flagEvalStatsResult, + ] = settled; const labels = [ "getFeatureFlags", "getExperiments", "getEventDefinitions", "getEventStats", + "getFlagEvaluationStats", ]; settled.forEach((r, i) => { if (r.status === "rejected") { @@ -140,6 +150,10 @@ export class ParseResult { eventStatsResult.status === "fulfilled" ? eventStatsResult.value : new Map(); + const flagEvaluationStats = + flagEvalStatsResult.status === "fulfilled" + ? flagEvalStatsResult.value + : new Map(); const flagKeySet = new Set(flagKeys); const flags = new Map( @@ -161,6 +175,9 @@ export class ParseResult { experiments, eventDefinitions, eventStats, + flagEvaluationStats, + host: config.host, + projectId: config.projectId, }); } } diff --git a/packages/enricher/src/posthog-api.ts b/packages/enricher/src/posthog-api.ts index d34b38ab6..b2ebd2330 100644 --- a/packages/enricher/src/posthog-api.ts +++ b/packages/enricher/src/posthog-api.ts @@ -4,8 +4,17 @@ import type { EventStats, Experiment, FeatureFlag, + FlagEvaluationStats, } from "./types.js"; +export function buildFlagUrl( + host: string, + projectId: number, + flagId: number, +): string { + return `${host.replace(/\/$/, "")}/project/${projectId}/feature_flags/${flagId}`; +} + export class PostHogApi { private config: EnricherApiConfig; @@ -122,4 +131,42 @@ export class PostHogApi { } return stats; } + + async getFlagEvaluationStats( + flagKeys: string[], + daysBack = 7, + ): Promise> { + if (flagKeys.length === 0) { + return new Map(); + } + + const days = Math.max(1, Math.min(365, Math.floor(daysBack))); + const query = ` + SELECT + properties.$feature_flag AS flag_key, + count() AS evaluations, + count(DISTINCT person_id) AS unique_users + FROM events + WHERE event = '$feature_flag_called' + AND properties.$feature_flag IN {flagKeys} + AND timestamp >= now() - INTERVAL ${days} DAY + GROUP BY flag_key + `; + + const data = await this.post<{ + results: [string, number, number][]; + }>("/query/", { + query: { + kind: "HogQLQuery", + query, + values: { flagKeys }, + }, + }); + + const stats = new Map(); + for (const [flagKey, evaluations, uniqueUsers] of data.results) { + stats.set(flagKey, { evaluations, uniqueUsers }); + } + return stats; + } } diff --git a/packages/enricher/src/types.ts b/packages/enricher/src/types.ts index ef8fc46aa..cd3408f80 100644 --- a/packages/enricher/src/types.ts +++ b/packages/enricher/src/types.ts @@ -161,6 +161,10 @@ export interface EnrichedListItem extends ListItem { flagType?: FlagType; staleness?: StalenessReason | null; rollout?: number | null; + active?: boolean; + url?: string | null; + evaluations?: number; + evaluationUsers?: number; experimentName?: string | null; experimentStatus?: "running" | "complete" | null; verified?: boolean; @@ -177,12 +181,20 @@ export interface EventStats { lastSeenAt?: string | null; } +export interface FlagEvaluationStats { + evaluations: number; + uniqueUsers: number; +} + export interface EnrichmentContext { flags?: Map; experiments?: Experiment[]; eventDefinitions?: Map; eventStats?: Map; + flagEvaluationStats?: Map; stalenessOptions?: StalenessCheckOptions; + host?: string; + projectId?: number; } export interface StalenessCheckOptions { @@ -198,6 +210,8 @@ export interface EnrichedFlag { rollout: number | null; variants: { key: string; rollout_percentage: number }[]; experiment: Experiment | undefined; + url: string | null; + evaluationStats: FlagEvaluationStats | undefined; } export interface EnrichedEvent {