Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions packages/enricher/src/apm-breakdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
APM_STATS_WINDOW,
type SymbolStatsPeriod,
type SymbolStatsRow,
} from "@posthog/shared";
import { describe, expect, it } from "vitest";
import {
buildSymbolStatsQuery,
mapSymbolStatsResults,
} from "./apm-breakdown.js";

describe("buildSymbolStatsQuery", () => {
it("builds a line-mode query (no symbols) with the file path and default window", () => {
const q = buildSymbolStatsQuery("src/flags/flag_matching.rs");
expect(q.kind).toBe("TraceSpansSymbolStatsQuery");
expect(q.filePath).toBe("src/flags/flag_matching.rs");
expect(q.dateRange.date_from).toBe("-24h");
expect(q.symbols).toBeUndefined();
});

it("includes symbols when supplied and honors a window override", () => {
const q = buildSymbolStatsQuery("a/b.rs", {
dateFrom: "-7d",
symbols: [{ name: "f", startLine: 1, endLine: 9 }],
});
expect(q.dateRange.date_from).toBe("-7d");
expect(q.symbols).toEqual([{ name: "f", startLine: 1, endLine: 9 }]);
});

it("defaults the window to the shared APM_STATS_WINDOW (single source)", () => {
expect(buildSymbolStatsQuery("a.rs").dateRange.date_from).toBe(
APM_STATS_WINDOW.dateFrom,
);
});

it("treats an empty symbols array as line mode (omits symbols)", () => {
expect(
buildSymbolStatsQuery("a.rs", { symbols: [] }).symbols,
).toBeUndefined();
});
});

function period(): SymbolStatsPeriod {
return {
count: 0,
error_count: 0,
sum_duration_nano: 0,
p50_duration_nano: 0,
p95_duration_nano: 0,
p99_duration_nano: 0,
busy_count: 0,
p50_busy_nano: 0,
p95_busy_nano: 0,
p99_busy_nano: 0,
};
}

function row(overrides: Partial<SymbolStatsRow>): SymbolStatsRow {
return {
...period(),
line: 0,
count_pct_change: null,
p50_duration_pct_change: null,
p95_duration_pct_change: null,
p99_duration_pct_change: null,
error_rate_pct_change: null,
...overrides,
};
}

describe("mapSymbolStatsResults", () => {
it("maps rows to per-line stats, converting ns → ms", () => {
const rows: SymbolStatsRow[] = [
row({
line: 459,
count: 25941,
error_count: 12,
p50_duration_nano: 1_695_000,
p95_duration_nano: 7_153_900,
}),
];
expect(mapSymbolStatsResults(rows)).toEqual([
{
line: 459,
count: 25941,
errorCount: 12,
p50Ms: 1.695,
p95Ms: 7.1539,
p99Ms: 0,
countPctChange: null,
p50PctChange: null,
p95PctChange: null,
p99PctChange: null,
errorRatePctChange: null,
},
]);
});

it("passes the server's per-metric deltas straight through", () => {
const [s] = mapSymbolStatsResults([
row({
line: 1,
count_pct_change: 40,
p50_duration_pct_change: 12,
p95_duration_pct_change: 180,
p99_duration_pct_change: -5,
error_rate_pct_change: 100,
}),
]);
expect(s.countPctChange).toBe(40);
expect(s.p50PctChange).toBe(12);
expect(s.p95PctChange).toBe(180);
expect(s.p99PctChange).toBe(-5);
expect(s.errorRatePctChange).toBe(100);
});

it("preserves the server's line ordering", () => {
const rows = [row({ line: 12 }), row({ line: 3 })];
expect(mapSymbolStatsResults(rows).map((s) => s.line)).toEqual([12, 3]);
});

it("returns an empty array for no rows (the no-data path)", () => {
expect(mapSymbolStatsResults([])).toEqual([]);
});

it("converts sub-millisecond durations (ns → ms)", () => {
const [s] = mapSymbolStatsResults([
row({ line: 1, p50_duration_nano: 500_000, p99_duration_nano: 30_000 }),
]);
expect(s.p50Ms).toBe(0.5);
expect(s.p99Ms).toBe(0.03);
});
});
63 changes: 63 additions & 0 deletions packages/enricher/src/apm-breakdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {
APM_STATS_WINDOW,
type SourceSymbol,
type SpanLineStat,
type SymbolStatsRow,
} from "@posthog/shared";

/**
* Request body for `POST …/tracing/spans/symbol-stats/`. The server owns OTel
* attribute resolution, path matching, and aggregation, so the client only names
* the file; omit `symbols` for per-line stats, supply them for per-symbol rollup.
*/
export interface SymbolStatsQueryNode {
kind: "TraceSpansSymbolStatsQuery";
dateRange: { date_from: string };
filePath: string;
symbols?: SourceSymbol[];
}

interface BuildOptions {
dateFrom?: string;
symbols?: SourceSymbol[];
}

/**
* Repo-relative `filePath` is suffix-matched server-side against the recorded
* `code.file.path`. Defaults the window to `APM_STATS_WINDOW` (single source).
*/
export function buildSymbolStatsQuery(
filePath: string,
opts: BuildOptions = {},
): SymbolStatsQueryNode {
const node: SymbolStatsQueryNode = {
kind: "TraceSpansSymbolStatsQuery",
dateRange: { date_from: opts.dateFrom ?? APM_STATS_WINDOW.dateFrom },
filePath,
};
if (opts.symbols && opts.symbols.length > 0) {
node.symbols = opts.symbols;
}
return node;
}

function nsToMs(ns: number): number {
return ns / 1_000_000;
}

/** Server row → client shape; deltas are server-computed, not derived here. */
export function mapSymbolStatsResults(rows: SymbolStatsRow[]): SpanLineStat[] {
return rows.map((r) => ({
line: r.line,
count: r.count,
errorCount: r.error_count,
p50Ms: nsToMs(r.p50_duration_nano),
p95Ms: nsToMs(r.p95_duration_nano),
p99Ms: nsToMs(r.p99_duration_nano),
countPctChange: r.count_pct_change,
p50PctChange: r.p50_duration_pct_change,
p95PctChange: r.p95_duration_pct_change,
p99PctChange: r.p99_duration_pct_change,
errorRatePctChange: r.error_rate_pct_change,
}));
}
143 changes: 143 additions & 0 deletions packages/enricher/src/apm-comment-formatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { APM_STATS_WINDOW, type SpanLineStat } from "@posthog/shared";
import { describe, expect, it } from "vitest";
import { formatApmInlineComments } from "./apm-comment-formatter.js";

function stat(overrides: Partial<SpanLineStat>): SpanLineStat {
return {
line: 1,
count: 100,
errorCount: 0,
p50Ms: 1,
p95Ms: 2,
...overrides,
};
}

describe("formatApmInlineComments", () => {
const src = ["fn a() {}", "fn b() {}", "fn c() {}"].join("\n");
const FILE = "rust/feature-flags/src/flags/flag_matching.rs";

it("appends an APM suffix to the line the stat points at (1-based)", () => {
const out = formatApmInlineComments(
src,
"rust",
[stat({ line: 2, p95Ms: 4.8 })],
FILE,
);
const lines = out.split("\n");
expect(lines[0]).toBe("fn a() {}");
expect(lines[1]).toContain("fn b() {}");
expect(lines[1]).toContain("[PostHog] APM");
expect(lines[1]).toContain("4.8");
expect(lines[2]).toBe("fn c() {}");
});

it("includes a self-contained, line-specific query-apm-spans drill-in hint", () => {
const out = formatApmInlineComments(src, "rust", [stat({ line: 2 })], FILE);
const line = out.split("\n")[1];
expect(line).toContain("query-apm-spans");
expect(line).toContain(`code.filepath~"${FILE}"`);
expect(line).toContain("code.lineno=2");
});

it("uses # comments for python/ruby", () => {
const out = formatApmInlineComments(
"def a():\n pass",
"python",
[stat({ line: 1 })],
"svc/main.py",
);
expect(out.split("\n")[0]).toMatch(/# \[PostHog\] APM/);
});

it("surfaces error count only when there are errors", () => {
const withErr = formatApmInlineComments(
src,
"rust",
[stat({ line: 1, errorCount: 3, count: 100 })],
FILE,
);
expect(withErr.split("\n")[0]).toContain("3 errors");

const noErr = formatApmInlineComments(
src,
"rust",
[stat({ line: 1, errorCount: 0 })],
FILE,
);
// "errors" must not appear; the hint uses no such word, so this is safe.
expect(noErr.split("\n")[0]).not.toContain("errors");
});

it("ignores stats whose line is out of range", () => {
expect(
formatApmInlineComments(src, "rust", [stat({ line: 99 })], FILE),
).toBe(src);
});

it("promotes a count that rounds up to the next unit (no '1000.0k')", () => {
const out = formatApmInlineComments(
src,
"rust",
[stat({ line: 1, count: 999_999 })],
FILE,
);
const line = out.split("\n")[0];
expect(line).toContain("1.0M");
expect(line).not.toContain("1000.0k");
});

it("includes p99 and the window-labelled span count", () => {
const out = formatApmInlineComments(
src,
"rust",
[stat({ line: 1, count: 26_200, p99Ms: 12 })],
FILE,
);
const line = out.split("\n")[0];
expect(line).toContain("p99 12ms");
expect(line).toContain(`spans/${APM_STATS_WINDOW.short}`);
});

it("appends period-over-period deltas to the metrics that changed", () => {
const out = formatApmInlineComments(
src,
"rust",
[
stat({
line: 1,
count: 1000,
p50Ms: 1.5,
p95Ms: 7,
p99Ms: 13,
p50PctChange: 12,
p95PctChange: 180,
p99PctChange: null,
countPctChange: 40,
}),
],
FILE,
);
const line = out.split("\n")[0];
expect(line).toContain("p50 1.5ms (+12%)");
expect(line).toContain("p95 7ms (+180%)");
expect(line).toContain(`spans/${APM_STATS_WINDOW.short} (+40%)`);
// p99 had no baseline (null) → no delta token on it.
expect(line).not.toContain("p99 13ms (");
});

it("keeps CRLF line endings intact (comment before the carriage return)", () => {
const crlf = ["fn a() {}", "fn b() {}"].join("\r\n");
const out = formatApmInlineComments(
crlf,
"rust",
[stat({ line: 1 })],
FILE,
);
const outLines = out.split("\r\n");
expect(outLines).toHaveLength(2);
expect(outLines[0]).toContain("[PostHog] APM");
expect(outLines[0]).not.toContain("\r");
expect(outLines[1]).toBe("fn b() {}");
});
});
Loading
Loading