From 8ffd7e0e958753dbc61004ad6dfaeff95e586b65 Mon Sep 17 00:00:00 2001 From: Jon McCallum Date: Mon, 22 Jun 2026 15:28:47 +0100 Subject: [PATCH 1/2] feat(apm): editor latency gutter markers + popover --- .../apmEnrichmentEligibility.test.ts | 24 +++ .../code-editor/apmEnrichmentEligibility.ts | 10 + .../code-editor/buildApmLineMarkers.test.ts | 49 +++++ .../src/code-editor/buildApmLineMarkers.ts | 42 ++++ .../code-editor/enrichmentPresenters.test.ts | 22 +++ .../src/code-editor/enrichmentPresenters.ts | 5 + .../components/ApmEnrichmentPopover.tsx | 185 ++++++++++++++++++ .../components/CodeEditorPanel.tsx | 18 +- .../components/CodeMirrorEditor.tsx | 25 ++- .../extensions/postHogApmEnrichment.ts | 139 +++++++++++++ .../code-editor/hooks/useEditorExtensions.ts | 5 +- .../code-editor/hooks/useFileApmEnrichment.ts | 49 +++++ .../code-editor/stores/apmPopoverStore.ts | 46 +++++ 13 files changed, 615 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/code-editor/apmEnrichmentEligibility.test.ts create mode 100644 packages/core/src/code-editor/apmEnrichmentEligibility.ts create mode 100644 packages/core/src/code-editor/buildApmLineMarkers.test.ts create mode 100644 packages/core/src/code-editor/buildApmLineMarkers.ts create mode 100644 packages/core/src/code-editor/enrichmentPresenters.test.ts create mode 100644 packages/ui/src/features/code-editor/components/ApmEnrichmentPopover.tsx create mode 100644 packages/ui/src/features/code-editor/extensions/postHogApmEnrichment.ts create mode 100644 packages/ui/src/features/code-editor/hooks/useFileApmEnrichment.ts create mode 100644 packages/ui/src/features/code-editor/stores/apmPopoverStore.ts diff --git a/packages/core/src/code-editor/apmEnrichmentEligibility.test.ts b/packages/core/src/code-editor/apmEnrichmentEligibility.test.ts new file mode 100644 index 0000000000..a08118c1b4 --- /dev/null +++ b/packages/core/src/code-editor/apmEnrichmentEligibility.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { isApmEnrichmentEligible } from "./apmEnrichmentEligibility"; + +describe("isApmEnrichmentEligible", () => { + it("accepts Rust files (the feature-flags service is Rust)", () => { + expect( + isApmEnrichmentEligible("rust/feature-flags/src/flags/flag_matching.rs"), + ).toBe(true); + }); + + it.each(["a/b/handler.go", "svc/main.py", "src/index.ts", "app/Foo.java"])( + "accepts source file %s", + (path) => { + expect(isApmEnrichmentEligible(path)).toBe(true); + }, + ); + + it.each(["README.md", "config.json", "styles.css", "image.png"])( + "rejects non-source file %s", + (path) => { + expect(isApmEnrichmentEligible(path)).toBe(false); + }, + ); +}); diff --git a/packages/core/src/code-editor/apmEnrichmentEligibility.ts b/packages/core/src/code-editor/apmEnrichmentEligibility.ts new file mode 100644 index 0000000000..cc5896dac8 --- /dev/null +++ b/packages/core/src/code-editor/apmEnrichmentEligibility.ts @@ -0,0 +1,10 @@ +import { apmLangForFile } from "@posthog/shared"; + +/** + * Whether a file is eligible for APM line enrichment, decided purely by its + * extension. Derives from {@link APM_LANG_BY_EXT} (in `@posthog/shared`) so the + * editor and agent paths share one supported-language list. + */ +export function isApmEnrichmentEligible(filePath: string): boolean { + return apmLangForFile(filePath) !== null; +} diff --git a/packages/core/src/code-editor/buildApmLineMarkers.test.ts b/packages/core/src/code-editor/buildApmLineMarkers.test.ts new file mode 100644 index 0000000000..645b9f141a --- /dev/null +++ b/packages/core/src/code-editor/buildApmLineMarkers.test.ts @@ -0,0 +1,49 @@ +import type { SerializedApmEnrichment, SpanLineStat } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { buildApmLineMarkers } from "./buildApmLineMarkers"; + +function stat(overrides: Partial): SpanLineStat { + return { + line: 1, + count: 10, + errorCount: 0, + p50Ms: 1, + p95Ms: 2, + ...overrides, + }; +} + +function enrichment(stats: SpanLineStat[]): SerializedApmEnrichment { + return { filePath: "x.rs", stats, tracingUrl: "https://us.posthog.com/x" }; +} + +describe("buildApmLineMarkers", () => { + it("returns no markers for null enrichment", () => { + expect(buildApmLineMarkers(null)).toEqual([]); + }); + + it("produces one marker per line stat", () => { + const markers = buildApmLineMarkers( + enrichment([ + stat({ line: 459, p95Ms: 4.8 }), + stat({ line: 900, p95Ms: 1.3 }), + ]), + ); + expect(markers.map((m) => m.line)).toEqual([459, 900]); + }); + + it("carries the underlying stat through to each marker", () => { + const [marker] = buildApmLineMarkers( + enrichment([stat({ line: 42, p95Ms: 4, count: 7 })]), + ); + expect(marker.stat).toMatchObject({ line: 42, p95Ms: 4, count: 7 }); + }); + + it("summarizes p95/p50 latency and span count for the tooltip", () => { + const [marker] = buildApmLineMarkers( + enrichment([stat({ p95Ms: 4.8, p50Ms: 1.7, count: 1240 })]), + ); + expect(marker.summary).toContain("p95 4.8"); + expect(marker.summary).toContain("1240 spans"); + }); +}); diff --git a/packages/core/src/code-editor/buildApmLineMarkers.ts b/packages/core/src/code-editor/buildApmLineMarkers.ts new file mode 100644 index 0000000000..a5e5be52a1 --- /dev/null +++ b/packages/core/src/code-editor/buildApmLineMarkers.ts @@ -0,0 +1,42 @@ +import { + formatMs, + type SerializedApmEnrichment, + type SpanLineStat, +} from "@posthog/shared"; + +export interface ApmLineMarker { + /** 1-based line number (span code.lineno is already 1-based — no offset). */ + line: number; + /** The underlying per-line stats, surfaced in the popover. */ + stat: SpanLineStat; + /** Single-line tooltip summary shown on gutter hover. */ + summary: string; +} + +function summarize(stat: SpanLineStat): string { + const parts = [ + `p95 ${formatMs(stat.p95Ms)}`, + `p50 ${formatMs(stat.p50Ms)}`, + `${stat.count} spans`, + ]; + if (stat.errorCount > 0) parts.push(`${stat.errorCount} err`); + return parts.join(" · "); +} + +/** + * Build per-line gutter markers from APM enrichment. Each instrumented line gets + * one presence marker; the gutter renders a single fixed colour ("PostHog has + * data on this line") rather than a severity gradient — latency has no inherent + * good/bad without a threshold, so the numbers live in the popover instead. + */ +export function buildApmLineMarkers( + enrichment: SerializedApmEnrichment | null, +): ApmLineMarker[] { + if (!enrichment || enrichment.stats.length === 0) return []; + + return enrichment.stats.map((stat) => ({ + line: stat.line, + stat, + summary: summarize(stat), + })); +} diff --git a/packages/core/src/code-editor/enrichmentPresenters.test.ts b/packages/core/src/code-editor/enrichmentPresenters.test.ts new file mode 100644 index 0000000000..f7b70e0f63 --- /dev/null +++ b/packages/core/src/code-editor/enrichmentPresenters.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { formatPercentDelta } from "./enrichmentPresenters"; + +describe("formatPercentDelta", () => { + it("returns null when there is no delta (absent / no baseline)", () => { + expect(formatPercentDelta(null)).toBeNull(); + expect(formatPercentDelta(undefined)).toBeNull(); + }); + + it("signs and rounds a meaningful delta", () => { + expect(formatPercentDelta(186.2)).toBe("+186%"); + expect(formatPercentDelta(-5.2)).toBe("-5%"); + expect(formatPercentDelta(1.43)).toBe("+1%"); + expect(formatPercentDelta(-0.9)).toBe("-1%"); + }); + + it("hides sub-1% noise rather than rendering a meaningless +0%/-0%", () => { + expect(formatPercentDelta(0)).toBeNull(); + expect(formatPercentDelta(0.4)).toBeNull(); + expect(formatPercentDelta(-0.4)).toBeNull(); + }); +}); diff --git a/packages/core/src/code-editor/enrichmentPresenters.ts b/packages/core/src/code-editor/enrichmentPresenters.ts index 55544cfd31..0596886a47 100644 --- a/packages/core/src/code-editor/enrichmentPresenters.ts +++ b/packages/core/src/code-editor/enrichmentPresenters.ts @@ -1,5 +1,10 @@ import type { SerializedFlag } from "@posthog/shared"; +// formatPercentDelta lives in @posthog/shared so the agent inline comments (in +// @posthog/enricher, which can't import core) round deltas identically. Re- +// exported here to keep the editor popover's existing import path stable. +export { formatPercentDelta } from "@posthog/shared"; + export function compactNumber(n: number): string { if (n < 1000) return `${n}`; if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`; diff --git a/packages/ui/src/features/code-editor/components/ApmEnrichmentPopover.tsx b/packages/ui/src/features/code-editor/components/ApmEnrichmentPopover.tsx new file mode 100644 index 0000000000..65b5387669 --- /dev/null +++ b/packages/ui/src/features/code-editor/components/ApmEnrichmentPopover.tsx @@ -0,0 +1,185 @@ +import { + compactNumber, + formatPercentDelta, +} from "@posthog/core/code-editor/enrichmentPresenters"; +import { Badge, Card } from "@posthog/quill"; +import { APM_STATS_WINDOW, formatMs, getFileName } from "@posthog/shared"; +import { openExternalUrl } from "@posthog/ui/shell/openExternal"; +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { useApmPopoverStore } from "../stores/apmPopoverStore"; + +const POPOVER_WIDTH = 280; +const GAP = 8; + +function Metric({ + label, + value, + delta, + worseWhenUp, +}: { + label: string; + value: string; + /** % change vs the prior window; null/undefined hides it. */ + delta?: number | null; + /** When true, an increase is bad (latency) → red up / green down. */ + worseWhenUp?: boolean; +}) { + const deltaText = formatPercentDelta(delta); + const deltaClass = worseWhenUp + ? (delta ?? 0) > 0 + ? "text-(--red-11)" + : "text-(--green-11)" + : "text-muted-foreground"; + return ( +
+
{label}
+
+ {value} + {deltaText && ( + {deltaText} + )} +
+
+ ); +} + +export function ApmEnrichmentPopover() { + const open = useApmPopoverStore((s) => s.open); + const marker = useApmPopoverStore((s) => s.marker); + const anchorRect = useApmPopoverStore((s) => s.anchorRect); + const filePath = useApmPopoverStore((s) => s.filePath); + const tracingUrl = useApmPopoverStore((s) => s.tracingUrl); + const close = useApmPopoverStore((s) => s.close); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (!ref.current) return; + if (!ref.current.contains(e.target as Node)) close(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey); + }; + }, [open, close]); + + if (!open || !marker || !anchorRect) return null; + + const { stat } = marker; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const preferredLeft = anchorRect.right + GAP; + const fitsRight = preferredLeft + POPOVER_WIDTH + 8 <= viewportWidth; + const left = fitsRight + ? preferredLeft + : Math.max(8, anchorRect.left - POPOVER_WIDTH - GAP); + const top = Math.max(8, Math.min(anchorRect.top, viewportHeight - 260)); + + const errorRate = stat.count > 0 ? (stat.errorCount / stat.count) * 100 : 0; + const file = filePath ? getFileName(filePath) : null; + const hasDelta = [ + stat.p50PctChange, + stat.p95PctChange, + stat.p99PctChange, + stat.countPctChange, + stat.errorRatePctChange, + ].some((d) => formatPercentDelta(d) != null); + + return createPortal( +
+ +
+
+
+ APM +
+
Production latency
+
+ {APM_STATS_WINDOW.label} · PostHog tracing +
+
+
+ {stat.errorCount > 0 && ( + {stat.errorCount} err + )} +
+ +
+ + + {stat.p99Ms != null && ( + + )} +
+ +
+ + +
+ + {hasDelta && ( +
+ Δ {APM_STATS_WINDOW.comparisonLabel} +
+ )} + +
+ + {file ? `${file}:${stat.line}` : `line ${stat.line}`} + + {tracingUrl && ( + + )} +
+
+
+
, + document.body, + ); +} diff --git a/packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx b/packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx index 624e4d1161..6d319c4f8a 100644 --- a/packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx +++ b/packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx @@ -13,7 +13,7 @@ import { } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -26,12 +26,15 @@ import { useFileTreeStore } from "../../right-sidebar/fileTreeStore"; import { useCwd } from "../../sidebar/useCwd"; import { useIsWorkspaceCloudRun } from "../../workspace/useWorkspace"; import { useCloudFileContent } from "../hooks/useCloudFileContent"; +import { useFileApmEnrichment } from "../hooks/useFileApmEnrichment"; import { useAbsoluteFileContent, useFileAsBase64, useRepoFileContent, } from "../hooks/useFileContent"; import { useFileEnrichment } from "../hooks/useFileEnrichment"; +import { useApmPopoverStore } from "../stores/apmPopoverStore"; +import { ApmEnrichmentPopover } from "./ApmEnrichmentPopover"; import { CodeMirrorEditor } from "./CodeMirrorEditor"; import { EnrichmentPopover } from "./EnrichmentPopover"; @@ -161,6 +164,17 @@ export function CodeEditorPanel({ content: isImage ? null : fileContent, }); + // Repo-relative path: the server suffix-matches it against the recorded OTel + // `code.file.path` (which carries the crate/workspace prefix). An absolute + // local path would never match. + const apmEnrichment = useFileApmEnrichment({ filePath }); + + // Dismiss any open APM popover when switching files; the global store would + // otherwise keep the previous file's stats visible over the new one. + useEffect(() => { + if (filePath) useApmPopoverStore.getState().close(); + }, [filePath]); + const dataUrlImage = useMemo( () => isImage || fileContent == null ? null : parseImageDataUrl(fileContent), @@ -293,8 +307,10 @@ export function CodeEditorPanel({ relativePath={filePath} readOnly enrichment={enrichment} + apmEnrichment={apmEnrichment} /> + ); } diff --git a/packages/ui/src/features/code-editor/components/CodeMirrorEditor.tsx b/packages/ui/src/features/code-editor/components/CodeMirrorEditor.tsx index d5ea22a1af..d92ff7a37c 100644 --- a/packages/ui/src/features/code-editor/components/CodeMirrorEditor.tsx +++ b/packages/ui/src/features/code-editor/components/CodeMirrorEditor.tsx @@ -1,8 +1,12 @@ import { openSearchPanel } from "@codemirror/search"; import { EditorView } from "@codemirror/view"; -import type { SerializedEnrichment } from "@posthog/shared"; +import type { + SerializedApmEnrichment, + SerializedEnrichment, +} from "@posthog/shared"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useEffect, useMemo } from "react"; +import { setApmEnrichmentEffect } from "../extensions/postHogApmEnrichment"; import { setEnrichmentEffect } from "../extensions/postHogEnrichment"; import { useCodeMirror } from "../hooks/useCodeMirror"; import { useEditorExtensions } from "../hooks/useEditorExtensions"; @@ -14,6 +18,7 @@ interface CodeMirrorEditorProps { relativePath?: string; readOnly?: boolean; enrichment?: SerializedEnrichment | null; + apmEnrichment?: SerializedApmEnrichment | null; } export function CodeMirrorEditor({ @@ -22,9 +27,16 @@ export function CodeMirrorEditor({ relativePath, readOnly = false, enrichment, + apmEnrichment, }: CodeMirrorEditorProps) { const enrichmentEnabled = enrichment !== undefined; - const extensions = useEditorExtensions(filePath, readOnly, enrichmentEnabled); + const apmEnrichmentEnabled = apmEnrichment !== undefined; + const extensions = useEditorExtensions( + filePath, + readOnly, + enrichmentEnabled, + apmEnrichmentEnabled, + ); const options = useMemo( () => ({ doc: content, extensions, filePath }), [content, extensions, filePath], @@ -40,6 +52,15 @@ export function CodeMirrorEditor({ }); }, [enrichment, enrichmentEnabled, instanceRef]); + useEffect(() => { + if (!apmEnrichmentEnabled) return; + const view = instanceRef.current; + if (!view) return; + view.dispatch({ + effects: setApmEnrichmentEffect.of(apmEnrichment ?? null), + }); + }, [apmEnrichment, apmEnrichmentEnabled, instanceRef]); + useEffect(() => { if (!filePath) return; const scrollToLine = () => { diff --git a/packages/ui/src/features/code-editor/extensions/postHogApmEnrichment.ts b/packages/ui/src/features/code-editor/extensions/postHogApmEnrichment.ts new file mode 100644 index 0000000000..2da867e2d5 --- /dev/null +++ b/packages/ui/src/features/code-editor/extensions/postHogApmEnrichment.ts @@ -0,0 +1,139 @@ +import { + type Extension, + RangeSet, + StateEffect, + StateField, + type Text, +} from "@codemirror/state"; +import { EditorView, GutterMarker, gutter } from "@codemirror/view"; +import { + type ApmLineMarker, + buildApmLineMarkers, +} from "@posthog/core/code-editor/buildApmLineMarkers"; +import type { SerializedApmEnrichment } from "@posthog/shared"; +import { useApmPopoverStore } from "../stores/apmPopoverStore"; + +export const setApmEnrichmentEffect = + StateEffect.define(); + +interface ApmFieldState { + /** File the stats were matched against; shown in the popover footer. */ + filePath: string | null; + /** Deep link to the PostHog tracing explorer for the popover's link. */ + tracingUrl: string | null; + /** One presence marker per instrumented line, keyed by line number. */ + markers: Map; + /** Gutter marker set, cached so it isn't rebuilt on every view update. */ + rangeSet: RangeSet; +} + +// Markers render at the span's raw `code.lineno` — the exact instrumentation +// site the OTel SDK reported (e.g. a Rust `#[instrument]` attribute or a Python +// decorator, which may sit just above the function). This is correct in every +// language with no per-language heuristics; the breakdown already yields one +// row per line, so there's never more than one marker per line. +const apmMarkerField = StateField.define({ + create: () => ({ + filePath: null, + tracingUrl: null, + markers: new Map(), + rangeSet: RangeSet.empty, + }), + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(setApmEnrichmentEffect)) { + const enrichment = effect.value; + const markers = new Map(); + for (const m of buildApmLineMarkers(enrichment)) markers.set(m.line, m); + return { + filePath: enrichment?.filePath ?? null, + tracingUrl: enrichment?.tracingUrl ?? null, + markers, + rangeSet: markersToRangeSet(markers, tr.state.doc), + }; + } + } + // Re-anchor cached markers when the doc shifts line offsets (read-only + // today, but keeps positions correct if the view becomes editable). + if (tr.docChanged && value.markers.size > 0) { + return { + ...value, + rangeSet: markersToRangeSet(value.markers, tr.state.doc), + }; + } + return value; + }, +}); + +// One fixed colour: the gutter signals "PostHog has data on this line", not a +// severity. The numbers (and any errors) live in the popover. +class ApmPresenceMarker extends GutterMarker { + constructor(private readonly marker: ApmLineMarker) { + super(); + } + toDOM(): HTMLElement { + const el = document.createElement("div"); + el.className = "cm-apm-marker"; + el.title = this.marker.summary; + el.dataset.apmLine = String(this.marker.line); + return el; + } +} + +const markerTheme = EditorView.baseTheme({ + ".cm-apm-gutter": { width: "6px", paddingLeft: "2px" }, + ".cm-apm-marker": { + width: "4px", + height: "100%", + minHeight: "1em", + borderRadius: "2px", + cursor: "pointer", + backgroundColor: "var(--purple-9, #8b5cf6)", + opacity: "0.85", + }, + ".cm-apm-marker:hover": { opacity: "1" }, +}); + +function markersToRangeSet( + markers: Map, + doc: Text, +): RangeSet { + const ranges = [...markers.values()] + .filter((m) => m.line >= 1 && m.line <= doc.lines) + .sort((a, b) => a.line - b.line) + .map((m) => new ApmPresenceMarker(m).range(doc.line(m.line).from)); + return RangeSet.of(ranges); +} + +function openApmPopover(view: EditorView, lineNo: number, event: MouseEvent) { + const state = view.state.field(apmMarkerField); + const marker = state.markers.get(lineNo); + if (!marker) return; + useApmPopoverStore.getState().show( + { + top: event.clientY, + bottom: event.clientY, + left: event.clientX, + right: event.clientX, + }, + marker, + { filePath: state.filePath, tracingUrl: state.tracingUrl }, + ); +} + +const apmGutter = gutter({ + class: "cm-apm-gutter", + markers: (view) => view.state.field(apmMarkerField).rangeSet, + domEventHandlers: { + click(view, line, event) { + const lineNo = view.state.doc.lineAt(line.from).number; + if (!view.state.field(apmMarkerField).markers.has(lineNo)) return false; + openApmPopover(view, lineNo, event as MouseEvent); + return true; + }, + }, +}); + +export function postHogApmEnrichmentExtension(): Extension { + return [apmMarkerField, apmGutter, markerTheme]; +} diff --git a/packages/ui/src/features/code-editor/hooks/useEditorExtensions.ts b/packages/ui/src/features/code-editor/hooks/useEditorExtensions.ts index eff8b0b69f..befbb447b9 100644 --- a/packages/ui/src/features/code-editor/hooks/useEditorExtensions.ts +++ b/packages/ui/src/features/code-editor/hooks/useEditorExtensions.ts @@ -17,12 +17,14 @@ import { import { getLanguageExtension } from "@posthog/ui/features/code-editor/utils/languages"; import { useThemeStore } from "@posthog/ui/shell/themeStore"; import { useMemo } from "react"; +import { postHogApmEnrichmentExtension } from "../extensions/postHogApmEnrichment"; import { postHogEnrichmentExtension } from "../extensions/postHogEnrichment"; export function useEditorExtensions( filePath?: string, readOnly = false, enableEnrichment = false, + enableApmEnrichment = false, ) { const isDarkMode = useThemeStore((state) => state.isDarkMode); @@ -42,6 +44,7 @@ export function useEditorExtensions( ...(readOnly ? [EditorState.readOnly.of(true)] : []), ...(languageExtension ? [languageExtension] : []), ...(enableEnrichment ? [postHogEnrichmentExtension()] : []), + ...(enableApmEnrichment ? [postHogApmEnrichmentExtension()] : []), ]; - }, [filePath, isDarkMode, readOnly, enableEnrichment]); + }, [filePath, isDarkMode, readOnly, enableEnrichment, enableApmEnrichment]); } diff --git a/packages/ui/src/features/code-editor/hooks/useFileApmEnrichment.ts b/packages/ui/src/features/code-editor/hooks/useFileApmEnrichment.ts new file mode 100644 index 0000000000..bf920a6509 --- /dev/null +++ b/packages/ui/src/features/code-editor/hooks/useFileApmEnrichment.ts @@ -0,0 +1,49 @@ +import { isApmEnrichmentEligible } from "@posthog/core/code-editor/apmEnrichmentEligibility"; +import { useHostTRPC } from "@posthog/host-router/react"; +import type { SerializedApmEnrichment } from "@posthog/shared"; +import { APM_ENRICHMENT_FLAG } from "@posthog/shared/constants"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useQuery } from "@tanstack/react-query"; +import { useAuthStateValue } from "../../auth/store"; + +interface UseFileApmEnrichmentOptions { + /** + * Repo-relative path of the open file. The server suffix-matches it against + * the recorded OTel `code.file.path` (which may carry an extra crate/workspace + * prefix), so `src/flags/flag_matching.rs` matches a recorded + * `feature-flags/src/flags/flag_matching.rs`. + */ + filePath: string; +} + +export function useFileApmEnrichment({ + filePath, +}: UseFileApmEnrichmentOptions): SerializedApmEnrichment | null | undefined { + const trpc = useHostTRPC(); + const isAuthenticated = useAuthStateValue( + (s) => s.status === "authenticated", + ); + // Gated behind a PostHog flag for safe rollout; on automatically in dev. + const flagEnabled = + useFeatureFlag(APM_ENRICHMENT_FLAG) || import.meta.env.DEV; + + // Eligibility by file type; not gated on `isInsideRepo` since spans match by + // path suffix regardless of the task's repo root. + const eligible = isApmEnrichmentEligible(filePath); + const enabled = flagEnabled && eligible && isAuthenticated; + + const query = useQuery( + trpc.apmEnrichment.enrichFile.queryOptions( + { filePath }, + { + enabled, + staleTime: Number.POSITIVE_INFINITY, + }, + ), + ); + + // `undefined` when the query can't run (flag off, ineligible file, or signed + // out) so the editor skips the gutter extension entirely; `null` means + // "active, no data yet". + return enabled ? (query.data ?? null) : undefined; +} diff --git a/packages/ui/src/features/code-editor/stores/apmPopoverStore.ts b/packages/ui/src/features/code-editor/stores/apmPopoverStore.ts new file mode 100644 index 0000000000..47b90b56be --- /dev/null +++ b/packages/ui/src/features/code-editor/stores/apmPopoverStore.ts @@ -0,0 +1,46 @@ +import type { ApmLineMarker } from "@posthog/core/code-editor/buildApmLineMarkers"; +import { create } from "zustand"; +import type { PopoverAnchorRect } from "./enrichmentPopoverStore"; + +interface ApmPopoverMeta { + /** File the stats belong to; the popover shows its basename in the footer. */ + filePath: string | null; + /** Deep link to the PostHog tracing explorer for "View in PostHog". */ + tracingUrl: string | null; +} + +interface ApmPopoverState extends ApmPopoverMeta { + open: boolean; + anchorRect: PopoverAnchorRect | null; + marker: ApmLineMarker | null; + show: ( + rect: PopoverAnchorRect, + marker: ApmLineMarker, + meta: ApmPopoverMeta, + ) => void; + close: () => void; +} + +export const useApmPopoverStore = create((set) => ({ + open: false, + anchorRect: null, + marker: null, + filePath: null, + tracingUrl: null, + show: (rect, marker, meta) => + set({ + open: true, + anchorRect: rect, + marker, + filePath: meta.filePath, + tracingUrl: meta.tracingUrl, + }), + close: () => + set({ + open: false, + marker: null, + anchorRect: null, + filePath: null, + tracingUrl: null, + }), +})); From d3667afcea2d213df6f99539eb119b4da4f31511 Mon Sep 17 00:00:00 2001 From: Jon McCallum <66999846+jonmcwest@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:40:40 +0100 Subject: [PATCH 2/2] refactor(apm): address review feedback on enrichment tests and stale time - Convert formatPercentDelta tests to it.each parameterised rows per team preference - Bound APM enrichment query staleTime to 5 minutes so latency numbers refresh after deploys instead of caching for the whole session Generated-By: PostHog Code Task-Id: de3222cb-d87d-4d5e-a110-72b304dbd505 --- .../code-editor/enrichmentPresenters.test.ts | 33 +++++++++++-------- .../code-editor/hooks/useFileApmEnrichment.ts | 5 ++- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/core/src/code-editor/enrichmentPresenters.test.ts b/packages/core/src/code-editor/enrichmentPresenters.test.ts index f7b70e0f63..73bcce62b7 100644 --- a/packages/core/src/code-editor/enrichmentPresenters.test.ts +++ b/packages/core/src/code-editor/enrichmentPresenters.test.ts @@ -2,21 +2,26 @@ import { describe, expect, it } from "vitest"; import { formatPercentDelta } from "./enrichmentPresenters"; describe("formatPercentDelta", () => { - it("returns null when there is no delta (absent / no baseline)", () => { - expect(formatPercentDelta(null)).toBeNull(); - expect(formatPercentDelta(undefined)).toBeNull(); - }); + it.each([null, undefined])( + "returns null when there is no delta (absent / no baseline): %s", + (input) => { + expect(formatPercentDelta(input)).toBeNull(); + }, + ); - it("signs and rounds a meaningful delta", () => { - expect(formatPercentDelta(186.2)).toBe("+186%"); - expect(formatPercentDelta(-5.2)).toBe("-5%"); - expect(formatPercentDelta(1.43)).toBe("+1%"); - expect(formatPercentDelta(-0.9)).toBe("-1%"); + it.each([ + [186.2, "+186%"], + [-5.2, "-5%"], + [1.43, "+1%"], + [-0.9, "-1%"], + ] as const)("signs and rounds %s → %s", (input, expected) => { + expect(formatPercentDelta(input)).toBe(expected); }); - it("hides sub-1% noise rather than rendering a meaningless +0%/-0%", () => { - expect(formatPercentDelta(0)).toBeNull(); - expect(formatPercentDelta(0.4)).toBeNull(); - expect(formatPercentDelta(-0.4)).toBeNull(); - }); + it.each([0, 0.4, -0.4])( + "hides sub-1%% noise rather than rendering a meaningless +0%%/-0%%: %s", + (input) => { + expect(formatPercentDelta(input)).toBeNull(); + }, + ); }); diff --git a/packages/ui/src/features/code-editor/hooks/useFileApmEnrichment.ts b/packages/ui/src/features/code-editor/hooks/useFileApmEnrichment.ts index bf920a6509..7d3e5c02ba 100644 --- a/packages/ui/src/features/code-editor/hooks/useFileApmEnrichment.ts +++ b/packages/ui/src/features/code-editor/hooks/useFileApmEnrichment.ts @@ -37,7 +37,10 @@ export function useFileApmEnrichment({ { filePath }, { enabled, - staleTime: Number.POSITIVE_INFINITY, + // APM data is cheap to refresh; a bounded stale time lets React Query + // silently background-refresh so latency numbers don't go stale after a + // deploy, without causing visible loading flickers. + staleTime: 5 * 60 * 1000, }, ), );