Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 7a4f19a

Browse files
authored
fix(agent): detect feature flags product across tool-name variants (#2508)
1 parent a2582f5 commit 7a4f19a

2 files changed

Lines changed: 172 additions & 7 deletions

File tree

packages/agent/src/posthog-products.test.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
classifyPostHogSqlQuery,
55
classifyPostHogSubTool,
66
POSTHOG_PRODUCTS,
7+
type PostHogProductId,
78
} from "./posthog-products";
89

910
describe("classifyPostHogSubTool", () => {
@@ -46,6 +47,21 @@ describe("classifyPostHogSubTool", () => {
4647
},
4748
);
4849

50+
// Plural and verb-first tool names resolve to the same domain product as the
51+
// canonical singular form — no per-variant entry required.
52+
it.each([
53+
["feature-flags-activity-retrieve", "feature_flags"],
54+
["feature-flags-status-retrieve", "feature_flags"],
55+
["create-feature-flag", "feature_flags"],
56+
["update-feature-flag", "feature_flags"],
57+
["delete-feature-flag", "feature_flags"],
58+
// The fix is general, not flag-specific.
59+
["create-survey", "surveys"],
60+
["create-experiment", "experiments"],
61+
])("matches plural/verb-first variant %s to %s", (subTool, product) => {
62+
expect(classifyPostHogSubTool(subTool)).toBe(product);
63+
});
64+
4965
it.each(["project-get", "activity-log-list", "docs-search", "tasks-list"])(
5066
"returns null for admin/meta/introspection domain %s",
5167
(subTool) => {
@@ -162,7 +178,49 @@ describe("classifyPostHogExecCall", () => {
162178
expect(classifyPostHogExecCall("experiment-get")).toEqual(["experiments"]);
163179
});
164180

165-
it("returns an empty array for admin/meta sub-tools", () => {
166-
expect(classifyPostHogExecCall("project-get")).toEqual([]);
181+
it.each<[string, string | undefined, PostHogProductId[]]>([
182+
// Plural tool name resolves via the pattern matcher.
183+
[
184+
"feature-flags-activity-retrieve",
185+
'call feature-flags-activity-retrieve {"id":1}',
186+
["feature_flags"],
187+
],
188+
// Activity-log reads are attributed by their scope...
189+
[
190+
"activity-log-list",
191+
'call activity-log-list {"scope":"FeatureFlag"}',
192+
["feature_flags"],
193+
],
194+
// ...including web analytics, only reachable from the log via this scope.
195+
[
196+
"activity-log-list",
197+
'call activity-log-list {"scope":"WebAnalyticsFilterPreset"}',
198+
["web_analytics"],
199+
],
200+
// Multiple scopes are collected and deduped.
201+
[
202+
"advanced-activity-logs-list",
203+
'call advanced-activity-logs-list {"scopes":["FeatureFlag","Insight"]}',
204+
["feature_flags", "product_analytics"],
205+
],
206+
])("attributes %s to its scope/domain product", (subTool, cmd, expected) => {
207+
expect(classifyPostHogExecCall(subTool, cmd)).toEqual(expected);
208+
});
209+
210+
it.each<[string, string | undefined]>([
211+
// Admin/meta sub-tool.
212+
["project-get", undefined],
213+
// Unscoped or empty-scope activity-log reads.
214+
["activity-log-list", 'call activity-log-list {"page":1}'],
215+
[
216+
"advanced-activity-logs-list",
217+
'call advanced-activity-logs-list {"scopes":[]}',
218+
],
219+
// Admin/meta scope is not surfaced.
220+
["activity-log-list", 'call activity-log-list {"scope":"Team"}'],
221+
// No command text to read a scope from.
222+
["activity-log-list", undefined],
223+
])("returns nothing for %s", (subTool, cmd) => {
224+
expect(classifyPostHogExecCall(subTool, cmd)).toEqual([]);
167225
});
168226
});

packages/agent/src/posthog-products.ts

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,23 @@ const DOMAIN_PRODUCT: Record<string, PostHogProductId | null> = {
118118

119119
const KNOWN_DOMAINS = Object.keys(DOMAIN_PRODUCT);
120120

121+
const escapeRe = (s: string): string =>
122+
s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
123+
124+
/**
125+
* One regex per domain: the domain must appear as a complete hyphen-delimited
126+
* token run anywhere in the sub-tool name, tolerating a plural trailing "s".
127+
* A single pattern covers `feature-flag-get` (prefix), `create-feature-flag`
128+
* (verb-first) and `feature-flags-status-retrieve` (plural) — so a new
129+
* `feature-flags-*` tool needs no entry here. The `(^|-)…($|-)` boundaries keep
130+
* a domain from matching a partial token, and `s?` matches zero for
131+
* already-plural domains like `external-data-sources`.
132+
*/
133+
const DOMAIN_PATTERNS: ReadonlyArray<readonly [string, RegExp]> =
134+
KNOWN_DOMAINS.map(
135+
(d) => [d, new RegExp(`(^|-)${escapeRe(d)}s?($|-)`)] as const,
136+
);
137+
121138
/**
122139
* HogQL/PostHog table name → product. Lets an `execute-sql` call be attributed
123140
* to the product whose data it reads (e.g. `SELECT count() FROM feature_flags`
@@ -198,14 +215,97 @@ export function classifyPostHogSqlQuery(sql: string): PostHogProductId[] {
198215
return [...products];
199216
}
200217

218+
/**
219+
* Activity-log `scope` value → product. The activity-log tools are generic
220+
* audit readers (their own domain is suppressed), but a call scoped to a
221+
* specific entity type is really about that entity's product. Keys are the
222+
* PascalCase scope enum values; only scopes that map to a surfaced product are
223+
* listed — admin/meta scopes (Team, Project, User, Role, Comment, Tag, …) are
224+
* omitted so a scoped audit read of them surfaces nothing, as before.
225+
*/
226+
const ACTIVITY_SCOPE_PRODUCT: Record<string, PostHogProductId> = {
227+
FeatureFlag: "feature_flags",
228+
EarlyAccessFeature: "feature_flags",
229+
Experiment: "experiments",
230+
ExperimentHoldout: "experiments",
231+
ExperimentSavedMetric: "experiments",
232+
ErrorTrackingIssue: "error_tracking",
233+
Replay: "session_replay",
234+
SessionRecordingPlaylist: "session_replay",
235+
Survey: "surveys",
236+
LLMTrace: "llm_analytics",
237+
Evaluation: "llm_analytics",
238+
DataWarehouseSavedQuery: "data_warehouse",
239+
ExternalDataSource: "data_warehouse",
240+
ExternalDataSchema: "data_warehouse",
241+
BatchExport: "data_warehouse",
242+
BatchImport: "data_warehouse",
243+
HogFunction: "cdp",
244+
HogFlow: "cdp",
245+
Log: "logs",
246+
LogsAlertConfiguration: "logs",
247+
LogsExclusionRule: "logs",
248+
WebAnalyticsFilterPreset: "web_analytics",
249+
Insight: "product_analytics",
250+
Dashboard: "product_analytics",
251+
DashboardWidget: "product_analytics",
252+
Cohort: "product_analytics",
253+
Person: "product_analytics",
254+
Group: "product_analytics",
255+
Notebook: "product_analytics",
256+
Action: "product_analytics",
257+
EventDefinition: "product_analytics",
258+
PropertyDefinition: "product_analytics",
259+
Annotation: "product_analytics",
260+
Endpoint: "product_analytics",
261+
EndpointVersion: "product_analytics",
262+
Subscription: "product_analytics",
263+
AlertConfiguration: "product_analytics",
264+
Threshold: "product_analytics",
265+
AlertSubscription: "product_analytics",
266+
};
267+
268+
/**
269+
* Attribute an activity-log call to the product(s) of its `scope`/`scopes`
270+
* argument. Returns an empty array when the body has no recognized scope (so an
271+
* unscoped or admin-scoped audit read still surfaces nothing).
272+
*/
273+
function classifyPostHogActivityLog(commandText: string): PostHogProductId[] {
274+
const start = commandText.indexOf("{");
275+
const end = commandText.lastIndexOf("}");
276+
if (start === -1 || end <= start) return [];
277+
let body: unknown;
278+
try {
279+
body = JSON.parse(commandText.slice(start, end + 1));
280+
} catch {
281+
return [];
282+
}
283+
if (!body || typeof body !== "object") return [];
284+
const scopesField = (body as { scopes?: unknown }).scopes;
285+
const raw: unknown[] = [
286+
(body as { scope?: unknown }).scope,
287+
...(Array.isArray(scopesField) ? scopesField : []),
288+
];
289+
const products = new Set<PostHogProductId>();
290+
for (const s of raw) {
291+
if (typeof s === "string") {
292+
const product = ACTIVITY_SCOPE_PRODUCT[s];
293+
if (product) products.add(product);
294+
}
295+
}
296+
return [...products];
297+
}
298+
201299
/**
202300
* Classify an executed MCP exec `call` into the products it touched. For
203301
* `execute-sql` the query text is inspected so the call is attributed to the
204302
* product whose tables it reads (e.g. Feature flags), falling back to the
205-
* generic "sql" product only when no table maps. All other sub-tools resolve
206-
* to their single domain product (or none, for admin/meta domains).
303+
* generic "sql" product only when no table maps. Activity-log reads are
304+
* attributed by their `scope` argument. All other sub-tools resolve to their
305+
* single domain product (or none, for admin/meta domains).
207306
*
208-
* `commandText` is the raw exec command (which embeds the SQL for execute-sql).
307+
* `commandText` is the raw exec command (which embeds the SQL for execute-sql
308+
* and the scope JSON for activity-log reads).
209309
*/
210310
export function classifyPostHogExecCall(
211311
subTool: string,
@@ -216,6 +316,13 @@ export function classifyPostHogExecCall(
216316
const fromTables = commandText ? classifyPostHogSqlQuery(commandText) : [];
217317
return fromTables.length > 0 ? fromTables : ["sql"];
218318
}
319+
if (
320+
name === "activity-log" ||
321+
name.startsWith("activity-log-") ||
322+
name.startsWith("advanced-activity-logs")
323+
) {
324+
return commandText ? classifyPostHogActivityLog(commandText) : [];
325+
}
219326
const product = classifyPostHogSubTool(subTool);
220327
return product ? [product] : [];
221328
}
@@ -249,8 +356,8 @@ export function classifyPostHogSubTool(
249356
// Longest matching domain wins so `feature-flag` beats a hypothetical
250357
// `feature` and multi-word domains aren't shadowed by shorter prefixes.
251358
let best: string | null = null;
252-
for (const domain of KNOWN_DOMAINS) {
253-
if (name === domain || name.startsWith(`${domain}-`)) {
359+
for (const [domain, re] of DOMAIN_PATTERNS) {
360+
if (re.test(name)) {
254361
if (best === null || domain.length > best.length) best = domain;
255362
}
256363
}

0 commit comments

Comments
 (0)