From f0a6a13f3b864642e6101c7246b97f3fc983e319 Mon Sep 17 00:00:00 2001 From: Jon McCallum Date: Fri, 19 Jun 2026 12:28:52 +0100 Subject: [PATCH] =?UTF-8?q?feat(apm):=20enrichment=20engine=20=E2=80=94=20?= =?UTF-8?q?shared=20primitives=20+=20enricher=20query/format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/enricher/src/apm-breakdown.test.ts | 133 ++++++++++++++ packages/enricher/src/apm-breakdown.ts | 63 +++++++ .../src/apm-comment-formatter.test.ts | 143 +++++++++++++++ .../enricher/src/apm-comment-formatter.ts | 80 +++++++++ packages/enricher/src/comment-formatter.ts | 8 +- packages/enricher/src/comment-style.ts | 8 + packages/enricher/src/index.ts | 4 + packages/enricher/src/posthog-api.ts | 18 ++ packages/shared/src/apm-enrichment.test.ts | 38 ++++ packages/shared/src/apm-enrichment.ts | 166 ++++++++++++++++++ packages/shared/src/constants.ts | 1 + packages/shared/src/flags.ts | 1 + packages/shared/src/index.ts | 1 + 13 files changed, 657 insertions(+), 7 deletions(-) create mode 100644 packages/enricher/src/apm-breakdown.test.ts create mode 100644 packages/enricher/src/apm-breakdown.ts create mode 100644 packages/enricher/src/apm-comment-formatter.test.ts create mode 100644 packages/enricher/src/apm-comment-formatter.ts create mode 100644 packages/enricher/src/comment-style.ts create mode 100644 packages/shared/src/apm-enrichment.test.ts create mode 100644 packages/shared/src/apm-enrichment.ts diff --git a/packages/enricher/src/apm-breakdown.test.ts b/packages/enricher/src/apm-breakdown.test.ts new file mode 100644 index 0000000000..03329c0011 --- /dev/null +++ b/packages/enricher/src/apm-breakdown.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/enricher/src/apm-breakdown.ts b/packages/enricher/src/apm-breakdown.ts new file mode 100644 index 0000000000..18deab2989 --- /dev/null +++ b/packages/enricher/src/apm-breakdown.ts @@ -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, + })); +} diff --git a/packages/enricher/src/apm-comment-formatter.test.ts b/packages/enricher/src/apm-comment-formatter.test.ts new file mode 100644 index 0000000000..db97f06abf --- /dev/null +++ b/packages/enricher/src/apm-comment-formatter.test.ts @@ -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 { + 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() {}"); + }); +}); diff --git a/packages/enricher/src/apm-comment-formatter.ts b/packages/enricher/src/apm-comment-formatter.ts new file mode 100644 index 0000000000..0206ba66de --- /dev/null +++ b/packages/enricher/src/apm-comment-formatter.ts @@ -0,0 +1,80 @@ +import { + APM_STATS_WINDOW, + formatMs, + formatPercentDelta, + type SpanLineStat, +} from "@posthog/shared"; +import { commentPrefix } from "./comment-style.js"; + +function formatCount(n: number): string { + if (n < 1_000) return String(n); + // Promote to the next unit when rounding to one decimal would otherwise + // overflow it: 999_999 must read "1.0M", not "1000.0k". + if (n < 999_950) return `${(n / 1_000).toFixed(1)}k`; + return `${(n / 1_000_000).toFixed(1)}M`; +} + +function withDelta(label: string, pct: number | null | undefined): string { + const delta = formatPercentDelta(pct); + return delta ? `${label} (${delta})` : label; +} + +function formatApmComment(s: SpanLineStat, filePath: string): string { + const parts = [ + "APM", + withDelta(`p50 ${formatMs(s.p50Ms)}`, s.p50PctChange), + withDelta(`p95 ${formatMs(s.p95Ms)}`, s.p95PctChange), + ]; + if (s.p99Ms != null) { + parts.push(withDelta(`p99 ${formatMs(s.p99Ms)}`, s.p99PctChange)); + } + parts.push( + withDelta( + `${formatCount(s.count)} spans/${APM_STATS_WINDOW.short}`, + s.countPctChange, + ), + ); + if (s.errorCount > 0) { + parts.push(withDelta(`${s.errorCount} errors`, s.errorRatePctChange)); + } + // Self-contained drill-in: the agent reads file windows, not the whole file, + // so the MCP query lives on each annotated line rather than in a file header. + parts.push( + `dig in: query-apm-spans code.filepath~"${filePath}" code.lineno=${s.line}`, + ); + return parts.join(" — "); +} + +/** Stat lines are 1-based (`code.lineno`); the source line array is 0-based. */ +export function formatApmInlineComments( + source: string, + languageId: string, + stats: SpanLineStat[], + filePath: string, +): string { + const prefix = commentPrefix(languageId); + const lines = source.split("\n"); + + const byLine = new Map(); + for (const s of stats) { + const idx = s.line - 1; + if (idx < 0 || idx >= lines.length) continue; + const list = byLine.get(idx) ?? []; + list.push(s); + byLine.set(idx, list); + } + + for (const [idx, lineStats] of byLine) { + const body = lineStats + .map((s) => formatApmComment(s, filePath)) + .join(" | "); + const line = lines[idx]; + // Keep a trailing CRLF intact: insert the suffix before the "\r" so the + // carriage return stays at end-of-line instead of landing mid-comment. + const cr = line.endsWith("\r") ? "\r" : ""; + const text = cr ? line.slice(0, -1) : line; + lines[idx] = `${text} ${prefix} [PostHog] ${body}${cr}`; + } + + return lines.join("\n"); +} diff --git a/packages/enricher/src/comment-formatter.ts b/packages/enricher/src/comment-formatter.ts index 35c23724b2..f938502646 100644 --- a/packages/enricher/src/comment-formatter.ts +++ b/packages/enricher/src/comment-formatter.ts @@ -1,12 +1,6 @@ +import { commentPrefix } from "./comment-style.js"; 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}"`]; diff --git a/packages/enricher/src/comment-style.ts b/packages/enricher/src/comment-style.ts new file mode 100644 index 0000000000..92e4422a40 --- /dev/null +++ b/packages/enricher/src/comment-style.ts @@ -0,0 +1,8 @@ +// Shared by the enricher's inline-comment formatters (event/flag + APM) so the +// `#`-vs-`//` rule lives in one place. + +const HASH_COMMENT_LANGS = new Set(["python", "ruby"]); + +export function commentPrefix(languageId: string): string { + return HASH_COMMENT_LANGS.has(languageId) ? "#" : "//"; +} diff --git a/packages/enricher/src/index.ts b/packages/enricher/src/index.ts index a2a29bcb64..9efaf7920a 100644 --- a/packages/enricher/src/index.ts +++ b/packages/enricher/src/index.ts @@ -70,6 +70,10 @@ export type { } from "./enrich-source.js"; export { enrichSource } from "./enrich-source.js"; +// ── APM (tracing-span) enrichment ── + +export { formatApmInlineComments } from "./apm-comment-formatter.js"; + // ── Serialisation (tRPC/IPC boundary) ── export type { diff --git a/packages/enricher/src/posthog-api.ts b/packages/enricher/src/posthog-api.ts index 626cf91ae3..4aa7c1d620 100644 --- a/packages/enricher/src/posthog-api.ts +++ b/packages/enricher/src/posthog-api.ts @@ -1,3 +1,8 @@ +import type { SpanLineStat, SymbolStatsRow } from "@posthog/shared"; +import { + buildSymbolStatsQuery, + mapSymbolStatsResults, +} from "./apm-breakdown.js"; import type { EnricherApiConfig, EventDefinition, @@ -199,4 +204,17 @@ export class PostHogApi { } return stats; } + + async getApmLineStats( + filePath: string, + opts?: { dateFrom?: string }, + ): Promise { + const data = await this.post<{ results?: unknown }>( + "/tracing/spans/symbol-stats/", + { query: buildSymbolStatsQuery(filePath, opts) }, + ); + return mapSymbolStatsResults( + Array.isArray(data.results) ? (data.results as SymbolStatsRow[]) : [], + ); + } } diff --git a/packages/shared/src/apm-enrichment.test.ts b/packages/shared/src/apm-enrichment.test.ts new file mode 100644 index 0000000000..4ba0a40c66 --- /dev/null +++ b/packages/shared/src/apm-enrichment.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { formatMs, formatPercentDelta } from "./apm-enrichment"; + +describe("formatMs", () => { + it.each([ + [0, "0ms"], + // Sub-0.05ms rounds to "0ms" — acceptable for latency display (a function + // this fast isn't what APM surfaces); pinned so the rounding is intentional. + [0.03, "0ms"], + [1.5, "1.5ms"], + [7, "7ms"], + [9.94, "9.9ms"], + [10, "10ms"], + [13.4, "13ms"], + [726.6, "727ms"], + ])("formats %dms as %s", (ms, expected) => { + expect(formatMs(ms)).toBe(expected); + }); +}); + +describe("formatPercentDelta", () => { + const cases: Array<[number | null | undefined, string | null]> = [ + [null, null], // no baseline + [undefined, null], + [0.4, null], // sub-1% noise suppressed (avoids +0% / -0%) + [-0.4, null], + [0.5, "+1%"], // equal-magnitude deltas round symmetrically (no -0 suppression) + [-0.5, "-1%"], + [186.2, "+186%"], + [-5.2, "-5%"], + [Number.NaN, null], // non-finite guarded + [Number.POSITIVE_INFINITY, null], + [Number.NEGATIVE_INFINITY, null], + ]; + it.each(cases)("formatPercentDelta(%p) → %p", (input, expected) => { + expect(formatPercentDelta(input)).toBe(expected); + }); +}); diff --git a/packages/shared/src/apm-enrichment.ts b/packages/shared/src/apm-enrichment.ts new file mode 100644 index 0000000000..a22db4683e --- /dev/null +++ b/packages/shared/src/apm-enrichment.ts @@ -0,0 +1,166 @@ +// PostHog APM enrichment boundary types: per-line production-latency stats shown +// in the editor and agent comments. In @posthog/shared so both ui and +// workspace-server can import them without crossing layer boundaries. + +import { getFileExtension } from "./path"; + +export interface SpanLineStat { + /** 1-based line (the span's code.lineno). */ + line: number; + count: number; + errorCount: number; + /** Latency in milliseconds. */ + p50Ms: number; + p95Ms: number; + p99Ms?: number; + /** % change vs the previous equal window (180 = +180%); null when no baseline. */ + countPctChange?: number | null; + p50PctChange?: number | null; + p95PctChange?: number | null; + p99PctChange?: number | null; + errorRatePctChange?: number | null; +} + +/** The forms of an APM stats window, all derived from one value like "24h". */ +export interface ApmWindow { + value: string; + dateFrom: string; + /** e.g. the "24h" in "spans/24h" */ + short: string; + /** e.g. "last 24h" */ + label: string; + /** e.g. "vs previous 24h" */ + comparisonLabel: string; +} + +export function apmWindow(value: string): ApmWindow { + return { + value, + dateFrom: `-${value}`, + short: value, + label: `last ${value}`, + comparisonLabel: `vs previous ${value}`, + }; +} + +/** + * The window APM line stats use (editor + agent comments) — one source of truth: + * change the value and the query window plus every label follow. 24h gives a + * stable day-over-day comparison and returns in ~8s server-side. A future user + * setting just calls `apmWindow(value)`. + */ +export const APM_STATS_WINDOW = apmWindow("24h"); + +export interface SerializedApmEnrichment { + /** Repo-relative path these stats were matched against. */ + filePath: string; + stats: SpanLineStat[]; + /** Deep link to the PostHog tracing explorer; built host-side. */ + tracingUrl: string; +} + +/** + * A source symbol (function) to request latency for, by declaration line range. + * The client supplies these from its editor parse; the server attributes spans + * to the smallest enclosing range. `name` is echoed back on the result row. + */ +export interface SourceSymbol { + name?: string; + /** 1-based, inclusive. */ + startLine: number; + endLine: number; +} + +/** Aggregated metrics for one bucket over a single period. Durations in nanoseconds. */ +export interface SymbolStatsPeriod { + count: number; + error_count: number; + sum_duration_nano: number; + p50_duration_nano: number; + p95_duration_nano: number; + p99_duration_nano: number; + /** Spans with an active/busy-time attribute. 0 ⇒ busy_* are not meaningful. */ + busy_count: number; + p50_busy_nano: number; + p95_busy_nano: number; + p99_busy_nano: number; +} + +/** + * One bucket of the symbol-stats response (line mode): a source line's current + * period plus the server-computed % deltas vs the previous equal window + * (180 = +180%; null when no baseline). + */ +export interface SymbolStatsRow extends SymbolStatsPeriod { + line: number; + count_pct_change: number | null; + p50_duration_pct_change: number | null; + p95_duration_pct_change: number | null; + p99_duration_pct_change: number | null; + error_rate_pct_change: number | null; +} + +export type SymbolStatsGranularity = "line" | "symbol"; + +export function formatMs(ms: number): string { + return ms < 10 ? `${Math.round(ms * 10) / 10}ms` : `${Math.round(ms)}ms`; +} + +/** + * Format a period-over-period % change ("+186%", "-5%"), or null when there's no + * baseline or it rounds to sub-1% noise (avoids a meaningless "+0%" / "-0%"). + */ +export function formatPercentDelta( + pct: number | null | undefined, +): string | null { + if (pct == null || !Number.isFinite(pct)) return null; + // Round half away from zero so equal-magnitude deltas render symmetrically: + // Math.round sends -0.5 to -0 (suppressed) while +0.5 becomes +1 (shown). + const rounded = pct < 0 ? -Math.round(-pct) : Math.round(pct); + if (rounded === 0) return null; + return `${rounded > 0 ? "+" : ""}${rounded}%`; +} + +/** + * Single source of truth for which files get APM enrichment: lowercased ext → + * comment-style language id. Broader than event/flag enrichment (e.g. `.rs`) + * because APM joins on span attributes, not parsed SDK calls; the id only needs + * to distinguish `#` from `//`. + */ +export const APM_LANG_BY_EXT: Record = { + ".rs": "rust", + ".go": "go", + ".py": "python", + ".rb": "ruby", + ".java": "java", + ".kt": "kotlin", + ".kts": "kotlin", + ".cs": "csharp", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".c": "c", + ".h": "c", + ".hpp": "cpp", + ".ts": "typescript", + ".tsx": "typescript", + ".js": "javascript", + ".jsx": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".php": "php", + ".scala": "scala", + ".swift": "swift", +}; + +/** The lowercased file extension (with leading dot), or `null` if none. */ +export function fileExtension(filePath: string): string | null { + const ext = getFileExtension(filePath); + return ext ? `.${ext}` : null; +} + +/** The APM comment-style language id for a file, or `null` if not eligible. */ +export function apmLangForFile(filePath: string): string | null { + const ext = fileExtension(filePath); + return ext ? (APM_LANG_BY_EXT[ext] ?? null) : null; +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b0a3336e29..6353281a01 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,4 +1,5 @@ export { + APM_ENRICHMENT_FLAG, BILLING_FLAG, DISCOVERY_RUN_FLAG, EXPERIMENT_SUGGESTIONS_FLAG, diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index b42d0979fa..93ae63c890 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -1,3 +1,4 @@ +export const APM_ENRICHMENT_FLAG = "posthog-code-apm-enrichment"; export const BILLING_FLAG = "posthog-code-billing"; export const EXPERIMENT_SUGGESTIONS_FLAG = "posthog-code-experiment-suggestions"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8ecefc4d5e..62cdfbee22 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export * from "./analytics-events"; +export * from "./apm-enrichment"; export { type ArchivedTask, archivedTaskSchema } from "./archive-domain"; export { withTimeout } from "./async"; export {