Skip to content

Commit f2d7d0d

Browse files
authored
1 parent 22a47a4 commit f2d7d0d

File tree

11 files changed

+1139
-479
lines changed

11 files changed

+1139
-479
lines changed

.changeset/purple-emus-jump.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@spotlightjs/overlay": minor
3+
---
4+
5+
- Added support to visualize Profile in specific Trace

packages/overlay/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"eslint-plugin-react-refresh": "^0.4.12",
5555
"happy-dom": "^15.10.2",
5656
"magic-string": "^0.30.11",
57+
"nanovis": "^0.1.3",
5758
"platformicons": "^7.0.4",
5859
"react": "^18.3.1",
5960
"react-diff-viewer-continued": "^3.4.0",

packages/overlay/src/index.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
--color-primary-800: #3730a3;
1515
--color-primary-900: #312e81;
1616
--color-primary-950: #1e1b4b;
17+
18+
--color-frame-application: #d0d1f5;
19+
--color-frame-system: #ffe0e4;
20+
--color-frame-unknown: #f3f4f6;
1721
}
1822

1923
.spotlight-fullscreen-blur {
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import type { TreeNode } from "nanovis";
2+
import { useEffect, useRef, useState } from "react";
3+
import { FRAME_TYPES } from "~/integrations/sentry/constants/profile";
4+
import useMouseTracking from "~/integrations/sentry/hooks/useMouseTracking";
5+
import type { NanovisTreeNode } from "~/integrations/sentry/types";
6+
import { cn } from "~/lib/cn";
7+
import type { SentryProfileWithTraceMeta } from "../../../../store/types";
8+
import { convertSentryProfileToNormalizedTree } from "../../../../utils/profileTree";
9+
10+
interface NanovisVisualization {
11+
el: HTMLElement;
12+
events: {
13+
on(event: "select" | "hover", callback: (node: NanovisTreeNode) => void): void;
14+
};
15+
}
16+
17+
type VisualizationType = "flame" | "treemap" | "sunburst";
18+
19+
interface TraceProfileTreeProps {
20+
profile: SentryProfileWithTraceMeta;
21+
}
22+
23+
const FlamegraphLegend = () => {
24+
return (
25+
<div className="flex items-center gap-4">
26+
{FRAME_TYPES.map(({ label, color }) => (
27+
<span key={label} className="flex items-center gap-1 text-sm">
28+
<span className={cn("inline-block size-4 rounded-xs", color)} />
29+
{label}
30+
</span>
31+
))}
32+
</div>
33+
);
34+
};
35+
36+
export default function TraceProfileTree({ profile }: TraceProfileTreeProps) {
37+
const containerRef = useRef<HTMLDivElement>(null);
38+
const visualizationRef = useRef<NanovisVisualization | null>(null);
39+
const [visualizationType, setVisualizationType] = useState<VisualizationType>("flame");
40+
const [hoveredNode, setHoveredNode] = useState<NanovisTreeNode | null>(null);
41+
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
42+
43+
useEffect(() => {
44+
const handleMouseMove = (e: MouseEvent) => {
45+
setMousePosition({ x: e.clientX, y: e.clientY });
46+
};
47+
48+
(async () => {
49+
if (!containerRef.current || !profile) return;
50+
51+
const tree = await convertSentryProfileToNormalizedTree(profile);
52+
53+
const nanovisModule = await import("nanovis");
54+
const { Flamegraph, Treemap, Sunburst } = nanovisModule;
55+
56+
if (visualizationRef.current) {
57+
visualizationRef.current.el.remove();
58+
visualizationRef.current = null;
59+
}
60+
61+
const options = {
62+
getColor: (node: TreeNode<unknown>) => node.color,
63+
};
64+
65+
let visualization: NanovisVisualization;
66+
switch (visualizationType) {
67+
case "treemap":
68+
visualization = new Treemap(tree, options) as NanovisVisualization;
69+
break;
70+
case "sunburst":
71+
visualization = new Sunburst(tree, options) as NanovisVisualization;
72+
break;
73+
default:
74+
visualization = new Flamegraph(tree, options) as NanovisVisualization;
75+
break;
76+
}
77+
78+
visualizationRef.current = visualization;
79+
80+
visualization.events.on("select", (node: NanovisTreeNode) => {
81+
console.log("Selected node:", node);
82+
});
83+
84+
visualization.events.on("hover", (node: NanovisTreeNode | null) => {
85+
setHoveredNode(node);
86+
});
87+
88+
if (containerRef.current) {
89+
containerRef.current.appendChild(visualization.el);
90+
}
91+
window.addEventListener("mousemove", handleMouseMove);
92+
})();
93+
94+
return () => {
95+
if (visualizationRef.current) {
96+
visualizationRef.current.el.remove();
97+
visualizationRef.current = null;
98+
}
99+
window.removeEventListener("mousemove", handleMouseMove);
100+
};
101+
}, [profile, visualizationType]);
102+
103+
if (!profile) {
104+
return <div className="text-primary-300 px-6 py-4">No profile data available</div>;
105+
}
106+
107+
const getVisualizationName = (type: VisualizationType): string => {
108+
switch (type) {
109+
case "flame":
110+
return "Flamegraph";
111+
case "treemap":
112+
return "Treemap";
113+
case "sunburst":
114+
return "Sunburst";
115+
default:
116+
return "Flamegraph";
117+
}
118+
};
119+
120+
const mouseTrackingProps = useMouseTracking({
121+
elem: containerRef,
122+
onPositionChange: args => {
123+
if (args) {
124+
const { left, top } = args;
125+
setMousePosition({ x: left, y: top });
126+
}
127+
},
128+
});
129+
130+
return (
131+
<div className="w-full h-full relative p-4">
132+
<div className="mb-4">
133+
<div className="flex items-center justify-between mb-2">
134+
<h3 className="text-lg font-semibold text-primary-200">Profile</h3>
135+
<div className="flex items-center space-x-2">
136+
<span className="text-sm text-primary-400">View:</span>
137+
<div className="flex bg-primary-800 rounded-md p-1">
138+
{(["flame", "treemap", "sunburst"] as VisualizationType[]).map(type => (
139+
<button
140+
key={type}
141+
type="button"
142+
onClick={() => setVisualizationType(type)}
143+
className={`px-3 py-1 text-xs rounded-md transition-colors ${
144+
visualizationType === type
145+
? "bg-primary-600 text-white"
146+
: "text-primary-300 hover:text-primary-200 hover:bg-primary-700"
147+
}`}
148+
>
149+
{getVisualizationName(type)}
150+
</button>
151+
))}
152+
</div>
153+
</div>
154+
</div>
155+
<p className="text-sm text-primary-400">
156+
Visual representation of profile using {getVisualizationName(visualizationType).toLowerCase()}
157+
</p>
158+
</div>
159+
<FlamegraphLegend />
160+
<div onMouseLeave={() => setHoveredNode(null)}>
161+
<div
162+
ref={containerRef}
163+
className="w-full border border-primary-700 rounded-md overflow-auto p-2 my-4"
164+
{...mouseTrackingProps}
165+
>
166+
{hoveredNode && (
167+
<div
168+
className="bg-primary-900 border-primary-400 absolute flex flex-col min-w-[200px] rounded-lg border p-3 shadow-lg z-50"
169+
style={{
170+
left: mousePosition.x + 12,
171+
top: mousePosition.y + 12,
172+
pointerEvents: "none",
173+
}}
174+
>
175+
<span className="text-primary-200 font-semibold">{hoveredNode.text}</span>
176+
<span className="text-primary-400 text-xs">{hoveredNode.subtext}</span>
177+
<span className="text-primary-400 text-xs">Total Time: {hoveredNode.size}</span>
178+
</div>
179+
)}
180+
</div>
181+
</div>
182+
</div>
183+
);
184+
}

packages/overlay/src/integrations/sentry/components/traces/TraceDetails/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createTab } from "~/integrations/sentry/utils/tabs";
55

66
import { useSentryEvents } from "~/integrations/sentry/data/useSentryEvents";
77
import { isLocalTrace } from "~/integrations/sentry/store/helpers";
8+
import useSentryStore from "~/integrations/sentry/store/store";
89
import type { Trace } from "~/integrations/sentry/types";
910
import { getFormattedDuration } from "~/integrations/sentry/utils/duration";
1011
import { isErrorEvent } from "~/integrations/sentry/utils/sentry";
@@ -14,6 +15,7 @@ import AITraceSplitView from "../../insights/aiTraces/AITraceSplitView";
1415
import { hasAISpans } from "../../insights/aiTraces/sdks/aiLibraries";
1516
import LogsList from "../../log/LogsList";
1617
import DateTime from "../../shared/DateTime";
18+
import TraceProfileTree from "./components/TraceProfileTree";
1719

1820
type TraceDetailsProps = {
1921
trace: Trace;
@@ -66,6 +68,7 @@ export default function TraceDetails({ trace, aiConfig }: TraceDetailsProps) {
6668
}
6769

6870
const events = useSentryEvents(trace.trace_id);
71+
const profile = useSentryStore.getState().getProfileByTraceId(trace.trace_id);
6972
const errorCount = useMemo(
7073
() =>
7174
events.filter(
@@ -86,6 +89,10 @@ export default function TraceDetails({ trace, aiConfig }: TraceDetailsProps) {
8689
}),
8790
];
8891

92+
if (profile) {
93+
tabs.push(createTab("profileTree", "Profile"));
94+
}
95+
8996
return (
9097
<div className="flex h-full flex-col">
9198
{aiConfig.mode && hasAI ? (
@@ -99,6 +106,7 @@ export default function TraceDetails({ trace, aiConfig }: TraceDetailsProps) {
99106
<Route path="errors" element={<EventList traceId={trace.trace_id} />} />
100107
<Route path="logs" element={<LogsList traceId={trace.trace_id} />} />
101108
<Route path="logs/:id" element={<LogsList traceId={trace.trace_id} />} />
109+
{profile && <Route path="profileTree" element={<TraceProfileTree profile={profile} />} />}
102110
{/* Default tab */}
103111
<Route path="*" element={<Navigate to="context" replace />} />
104112
</Routes>

packages/overlay/src/integrations/sentry/constants.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { NanovisTreeNode } from "./types";
2+
13
export const DB_SPAN_REGEX = /^db(?:\.[A-Za-z]+)*$/;
24

35
export const AGGREGATE_CALL_PROFILES_SORT_KEYS = {
@@ -356,3 +358,15 @@ export const LOGS_HEADERS = [
356358
align: "right",
357359
},
358360
];
361+
362+
export const SAMPLE_EMPTY_PROFILE_FRAME: NanovisTreeNode = Object.freeze({
363+
id: "empty",
364+
text: "No profile data",
365+
subtext: "",
366+
sizeSelf: 0,
367+
size: 0,
368+
children: [],
369+
color: "#6b7280",
370+
frameId: -1,
371+
sampleCount: 0,
372+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const FRAME_COLOR = {
2+
application: "#d0d1f5",
3+
system: "#ffe0e4",
4+
unknown: "#f3f4f6",
5+
};
6+
7+
export const FRAME_TYPES = [
8+
{ label: "Application Frame", color: "bg-frame-application" },
9+
{ label: "System Frame", color: "bg-frame-system" },
10+
];

packages/overlay/src/integrations/sentry/types.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { EventEnvelopeHeaders, Measurements, SerializedLog } from "@sentry/core";
2+
import type { ColorValue } from "nanovis";
23

34
export type TraceId = string;
45
export type SpanId = string;
@@ -369,3 +370,66 @@ export type AILibraryHandler = {
369370
getTypeBadge: (trace: SpotlightAITrace) => string;
370371
getTokensDisplay: (trace: SpotlightAITrace) => string;
371372
};
373+
374+
/**
375+
* A generic tree node used to represent hierarchical performance data
376+
* for visualizations like flamegraphs, sunbursts, and treemaps.
377+
*
378+
* Each node corresponds to a stack frame in a profiling trace and includes
379+
* metrics like self time, total time, and sample count to support rendering
380+
* and interactive analysis.
381+
*/
382+
export type NanovisTreeNode = {
383+
/**
384+
* Unique identifier for the node, typically derived from frame ID and depth.
385+
*/
386+
id: string;
387+
388+
/**
389+
* Display text (label) for this node — usually the function name.
390+
*/
391+
text: string;
392+
393+
/**
394+
* Additional text shown under the label — e.g., file path, line number.
395+
*/
396+
subtext: string;
397+
398+
/**
399+
* Number of samples where this frame was the leaf (exclusive time).
400+
* Represents how much time this function spent doing its own work.
401+
*/
402+
sizeSelf: number;
403+
404+
/**
405+
* Total number of samples passing through this frame, including children.
406+
* This is the inclusive time (self + all descendants).
407+
*/
408+
size: number;
409+
410+
/**
411+
* Child nodes (functions called by this frame).
412+
*/
413+
children: NanovisTreeNode[];
414+
415+
/**
416+
* Color used for rendering this node in the flamegraph.
417+
*/
418+
color: ColorValue;
419+
420+
/**
421+
* Metadata about the frame (function name, file, etc.).
422+
* Optional: may be undefined for root or placeholder nodes.
423+
*/
424+
frame?: EventFrame;
425+
426+
/**
427+
* ID of the frame this node represents. Useful for lookups and deduplication.
428+
*/
429+
frameId: number;
430+
431+
/**
432+
* Total number of samples that included this frame.
433+
*/
434+
sampleCount: number;
435+
};

0 commit comments

Comments
 (0)