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

Commit de9f457

Browse files
committed
feat(apm): enrichment engine — shared primitives + enricher query/format
1 parent 97d7a4b commit de9f457

13 files changed

Lines changed: 674 additions & 7 deletions
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import {
2+
APM_STATS_WINDOW,
3+
type SymbolStatsPeriod,
4+
type SymbolStatsRow,
5+
} from "@posthog/shared";
6+
import { describe, expect, it } from "vitest";
7+
import {
8+
buildSymbolStatsQuery,
9+
mapSymbolStatsResults,
10+
} from "./apm-breakdown.js";
11+
12+
describe("buildSymbolStatsQuery", () => {
13+
it("builds a line-mode query (no symbols) with the file path and default window", () => {
14+
const q = buildSymbolStatsQuery("src/flags/flag_matching.rs");
15+
expect(q.kind).toBe("TraceSpansSymbolStatsQuery");
16+
expect(q.filePath).toBe("src/flags/flag_matching.rs");
17+
expect(q.dateRange.date_from).toBe("-24h");
18+
expect(q.symbols).toBeUndefined();
19+
});
20+
21+
it("includes symbols when supplied and honors a window override", () => {
22+
const q = buildSymbolStatsQuery("a/b.rs", {
23+
dateFrom: "-7d",
24+
symbols: [{ name: "f", startLine: 1, endLine: 9 }],
25+
});
26+
expect(q.dateRange.date_from).toBe("-7d");
27+
expect(q.symbols).toEqual([{ name: "f", startLine: 1, endLine: 9 }]);
28+
});
29+
30+
it("defaults the window to the shared APM_STATS_WINDOW (single source)", () => {
31+
expect(buildSymbolStatsQuery("a.rs").dateRange.date_from).toBe(
32+
APM_STATS_WINDOW.dateFrom,
33+
);
34+
});
35+
36+
it("treats an empty symbols array as line mode (omits symbols)", () => {
37+
expect(
38+
buildSymbolStatsQuery("a.rs", { symbols: [] }).symbols,
39+
).toBeUndefined();
40+
});
41+
});
42+
43+
function period(): SymbolStatsPeriod {
44+
return {
45+
count: 0,
46+
error_count: 0,
47+
sum_duration_nano: 0,
48+
p50_duration_nano: 0,
49+
p95_duration_nano: 0,
50+
p99_duration_nano: 0,
51+
busy_count: 0,
52+
p50_busy_nano: 0,
53+
p95_busy_nano: 0,
54+
p99_busy_nano: 0,
55+
};
56+
}
57+
58+
function row(overrides: Partial<SymbolStatsRow>): SymbolStatsRow {
59+
return {
60+
...period(),
61+
line: 0,
62+
previous: period(),
63+
count_pct_change: null,
64+
p50_duration_pct_change: null,
65+
p95_duration_pct_change: null,
66+
p99_duration_pct_change: null,
67+
error_rate_pct_change: null,
68+
...overrides,
69+
};
70+
}
71+
72+
describe("mapSymbolStatsResults", () => {
73+
it("maps rows to per-line stats, converting ns → ms", () => {
74+
const rows: SymbolStatsRow[] = [
75+
row({
76+
line: 459,
77+
count: 25941,
78+
error_count: 12,
79+
p50_duration_nano: 1_695_000,
80+
p95_duration_nano: 7_153_900,
81+
}),
82+
];
83+
expect(mapSymbolStatsResults(rows)).toEqual([
84+
{
85+
line: 459,
86+
count: 25941,
87+
errorCount: 12,
88+
p50Ms: 1.695,
89+
p95Ms: 7.1539,
90+
p99Ms: 0,
91+
countPctChange: null,
92+
p50PctChange: null,
93+
p95PctChange: null,
94+
p99PctChange: null,
95+
errorRatePctChange: null,
96+
},
97+
]);
98+
});
99+
100+
it("passes the server's per-metric deltas straight through", () => {
101+
const [s] = mapSymbolStatsResults([
102+
row({
103+
line: 1,
104+
count_pct_change: 40,
105+
p50_duration_pct_change: 12,
106+
p95_duration_pct_change: 180,
107+
p99_duration_pct_change: -5,
108+
error_rate_pct_change: 100,
109+
}),
110+
]);
111+
expect(s.countPctChange).toBe(40);
112+
expect(s.p50PctChange).toBe(12);
113+
expect(s.p95PctChange).toBe(180);
114+
expect(s.p99PctChange).toBe(-5);
115+
expect(s.errorRatePctChange).toBe(100);
116+
});
117+
118+
it("preserves the server's line ordering", () => {
119+
const rows = [row({ line: 12 }), row({ line: 3 })];
120+
expect(mapSymbolStatsResults(rows).map((s) => s.line)).toEqual([12, 3]);
121+
});
122+
123+
it("returns an empty array for no rows (the no-data path)", () => {
124+
expect(mapSymbolStatsResults([])).toEqual([]);
125+
});
126+
127+
it("converts sub-millisecond durations (ns → ms)", () => {
128+
const [s] = mapSymbolStatsResults([
129+
row({ line: 1, p50_duration_nano: 500_000, p99_duration_nano: 30_000 }),
130+
]);
131+
expect(s.p50Ms).toBe(0.5);
132+
expect(s.p99Ms).toBe(0.03);
133+
});
134+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import {
2+
APM_STATS_WINDOW,
3+
type SourceSymbol,
4+
type SpanLineStat,
5+
type SymbolStatsRow,
6+
} from "@posthog/shared";
7+
8+
/**
9+
* Request body for `POST …/tracing/spans/symbol-stats/`. The server owns OTel
10+
* attribute resolution, path matching, and aggregation, so the client only names
11+
* the file; omit `symbols` for per-line stats, supply them for per-symbol rollup.
12+
*/
13+
export interface SymbolStatsQueryNode {
14+
kind: "TraceSpansSymbolStatsQuery";
15+
dateRange: { date_from: string };
16+
filePath: string;
17+
symbols?: SourceSymbol[];
18+
}
19+
20+
interface BuildOptions {
21+
dateFrom?: string;
22+
symbols?: SourceSymbol[];
23+
}
24+
25+
/**
26+
* Repo-relative `filePath` is suffix-matched server-side against the recorded
27+
* `code.file.path`. Defaults the window to `APM_STATS_WINDOW` (single source).
28+
*/
29+
export function buildSymbolStatsQuery(
30+
filePath: string,
31+
opts: BuildOptions = {},
32+
): SymbolStatsQueryNode {
33+
const node: SymbolStatsQueryNode = {
34+
kind: "TraceSpansSymbolStatsQuery",
35+
dateRange: { date_from: opts.dateFrom ?? APM_STATS_WINDOW.dateFrom },
36+
filePath,
37+
};
38+
if (opts.symbols && opts.symbols.length > 0) {
39+
node.symbols = opts.symbols;
40+
}
41+
return node;
42+
}
43+
44+
function nsToMs(ns: number): number {
45+
return ns / 1_000_000;
46+
}
47+
48+
/** Server row → client shape; deltas are server-computed, not derived here. */
49+
export function mapSymbolStatsResults(rows: SymbolStatsRow[]): SpanLineStat[] {
50+
return rows.map((r) => ({
51+
line: r.line,
52+
count: r.count,
53+
errorCount: r.error_count,
54+
p50Ms: nsToMs(r.p50_duration_nano),
55+
p95Ms: nsToMs(r.p95_duration_nano),
56+
p99Ms: nsToMs(r.p99_duration_nano),
57+
countPctChange: r.count_pct_change,
58+
p50PctChange: r.p50_duration_pct_change,
59+
p95PctChange: r.p95_duration_pct_change,
60+
p99PctChange: r.p99_duration_pct_change,
61+
errorRatePctChange: r.error_rate_pct_change,
62+
}));
63+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { APM_STATS_WINDOW, type SpanLineStat } from "@posthog/shared";
2+
import { describe, expect, it } from "vitest";
3+
import { formatApmInlineComments } from "./apm-comment-formatter.js";
4+
5+
function stat(overrides: Partial<SpanLineStat>): SpanLineStat {
6+
return {
7+
line: 1,
8+
count: 100,
9+
errorCount: 0,
10+
p50Ms: 1,
11+
p95Ms: 2,
12+
...overrides,
13+
};
14+
}
15+
16+
describe("formatApmInlineComments", () => {
17+
const src = ["fn a() {}", "fn b() {}", "fn c() {}"].join("\n");
18+
const FILE = "rust/feature-flags/src/flags/flag_matching.rs";
19+
20+
it("appends an APM suffix to the line the stat points at (1-based)", () => {
21+
const out = formatApmInlineComments(
22+
src,
23+
"rust",
24+
[stat({ line: 2, p95Ms: 4.8 })],
25+
FILE,
26+
);
27+
const lines = out.split("\n");
28+
expect(lines[0]).toBe("fn a() {}");
29+
expect(lines[1]).toContain("fn b() {}");
30+
expect(lines[1]).toContain("[PostHog] APM");
31+
expect(lines[1]).toContain("4.8");
32+
expect(lines[2]).toBe("fn c() {}");
33+
});
34+
35+
it("includes a self-contained, line-specific query-apm-spans drill-in hint", () => {
36+
const out = formatApmInlineComments(src, "rust", [stat({ line: 2 })], FILE);
37+
const line = out.split("\n")[1];
38+
expect(line).toContain("query-apm-spans");
39+
expect(line).toContain('code.filepath~"flag_matching.rs"');
40+
expect(line).toContain("code.lineno=2");
41+
});
42+
43+
it("uses # comments for python/ruby", () => {
44+
const out = formatApmInlineComments(
45+
"def a():\n pass",
46+
"python",
47+
[stat({ line: 1 })],
48+
"svc/main.py",
49+
);
50+
expect(out.split("\n")[0]).toMatch(/# \[PostHog\] APM/);
51+
});
52+
53+
it("surfaces error count only when there are errors", () => {
54+
const withErr = formatApmInlineComments(
55+
src,
56+
"rust",
57+
[stat({ line: 1, errorCount: 3, count: 100 })],
58+
FILE,
59+
);
60+
expect(withErr.split("\n")[0]).toContain("3 errors");
61+
62+
const noErr = formatApmInlineComments(
63+
src,
64+
"rust",
65+
[stat({ line: 1, errorCount: 0 })],
66+
FILE,
67+
);
68+
// "errors" must not appear; the hint uses no such word, so this is safe.
69+
expect(noErr.split("\n")[0]).not.toContain("errors");
70+
});
71+
72+
it("ignores stats whose line is out of range", () => {
73+
expect(
74+
formatApmInlineComments(src, "rust", [stat({ line: 99 })], FILE),
75+
).toBe(src);
76+
});
77+
78+
it("promotes a count that rounds up to the next unit (no '1000.0k')", () => {
79+
const out = formatApmInlineComments(
80+
src,
81+
"rust",
82+
[stat({ line: 1, count: 999_999 })],
83+
FILE,
84+
);
85+
const line = out.split("\n")[0];
86+
expect(line).toContain("1.0M");
87+
expect(line).not.toContain("1000.0k");
88+
});
89+
90+
it("includes p99 and the window-labelled span count", () => {
91+
const out = formatApmInlineComments(
92+
src,
93+
"rust",
94+
[stat({ line: 1, count: 26_200, p99Ms: 12 })],
95+
FILE,
96+
);
97+
const line = out.split("\n")[0];
98+
expect(line).toContain("p99 12ms");
99+
expect(line).toContain(`spans/${APM_STATS_WINDOW.short}`);
100+
});
101+
102+
it("appends period-over-period deltas to the metrics that changed", () => {
103+
const out = formatApmInlineComments(
104+
src,
105+
"rust",
106+
[
107+
stat({
108+
line: 1,
109+
count: 1000,
110+
p50Ms: 1.5,
111+
p95Ms: 7,
112+
p99Ms: 13,
113+
p50PctChange: 12,
114+
p95PctChange: 180,
115+
p99PctChange: null,
116+
countPctChange: 40,
117+
}),
118+
],
119+
FILE,
120+
);
121+
const line = out.split("\n")[0];
122+
expect(line).toContain("p50 1.5ms (+12%)");
123+
expect(line).toContain("p95 7ms (+180%)");
124+
expect(line).toContain(`spans/${APM_STATS_WINDOW.short} (+40%)`);
125+
// p99 had no baseline (null) → no delta token on it.
126+
expect(line).not.toContain("p99 13ms (");
127+
});
128+
129+
it("keeps CRLF line endings intact (comment before the carriage return)", () => {
130+
const crlf = ["fn a() {}", "fn b() {}"].join("\r\n");
131+
const out = formatApmInlineComments(
132+
crlf,
133+
"rust",
134+
[stat({ line: 1 })],
135+
FILE,
136+
);
137+
const outLines = out.split("\r\n");
138+
expect(outLines).toHaveLength(2);
139+
expect(outLines[0]).toContain("[PostHog] APM");
140+
expect(outLines[0]).not.toContain("\r");
141+
expect(outLines[1]).toBe("fn b() {}");
142+
});
143+
});

0 commit comments

Comments
 (0)