Skip to content

Commit af75b2b

Browse files
feat: browser events in trace waterfall (#3443)
Closes HDX-5774 Stacks on #3440 ## Summary Newer OTel browser SDKs no longer put network timing on fetch and XHR spans. They send one `browser.resource_timing` log per request instead, and the click that started the request is a separate log with no trace id. This draws the timing logs as dots on their spans, like the old SDK's span events, and adds a "Triggered by" row for the click. Old-SDK and backend traces are unchanged. ### Changes - **Gate** (`hooks/useBrowserTraceEvents.ts`, `utils/browserTraceContext.ts`): both queries run only for traces with a `browser.document.url.full` span, when the correlated log source has Event Name, Event Attributes, Trace Id and Span Id expressions. They start after the waterfall loads and fail quietly. - **Timing dots** (`utils/resourceTimingMarkers.ts`): `browser.resource_timing` logs are read by TraceId and drawn on their span through the existing span event markers, named like sdk-trace-web's events. The last dot's tooltip shows duration, sizes and protocol, since new-SDK spans don't carry the response size. Dots follow the **Span events** chip. - **Timing rows**: once the dots load, the log rows they replace leave the waterfall and the log count, so they show for about one round trip first. They stay when the dots aren't visible (**Spans** or **Span events** off, a logs filter) or when the row is selected. - **Triggered by** (`utils/traceTriggerClick.ts`): the latest `browser.user_action.click` with the same `session.id` and service in the 250 ms before the first browser span, shown first as "Triggered by: Clicked … · 3ms before". Clicking it opens the click log. It doesn't match on page URL: the SDK records the click's URL before the handler changes the route or query string. - **Shared code**: E2's session log query parts move to `getSessionLogEventQueryParts` (`utils/sessionLogEvents.ts`) for the trigger query. E2's SQL is unchanged. ### Screenshots Example of a browser event triggering this trace, along with timing events on the span: <img width="1418" height="411" alt="Example of a browser event triggering this trace, along with timing events on the span" src="https://github.com/user-attachments/assets/3433d341-2b0c-4fc6-b3dd-cf06ad8f9585" />
1 parent 253a690 commit af75b2b

20 files changed

Lines changed: 2272 additions & 89 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/app': patch
3+
---
4+
5+
feat: Trace waterfall shows OpenTelemetry browser SDK request timings as dots on fetch and XHR spans, and the click that triggered a browser trace

‎packages/app/src/components/DBTraceWaterfallChart.tsx‎

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
IconChevronsDown,
3636
IconChevronsRight,
3737
IconLogs,
38+
IconPointer,
3839
} from '@tabler/icons-react';
3940

4041
import { ContactSupportText } from '@/components/ContactSupportText';
@@ -51,6 +52,7 @@ import {
5152
TRACE_WATERFALL_FETCH_LIMIT,
5253
TRACE_WATERFALL_ROW_LIMIT,
5354
} from '@/components/traceWaterfallLimits';
55+
import { useBrowserTraceEvents } from '@/hooks/useBrowserTraceEvents';
5456
import { useColumns } from '@/hooks/useMetadata';
5557
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
5658
import {
@@ -107,6 +109,7 @@ export type SpanRow = {
107109
Attributes: Record<string, any>;
108110
}>;
109111
__hdx_hidden?: boolean | 1 | 0;
112+
triggeredBy?: string;
110113
};
111114

112115
type TimestampedRow = {
@@ -785,11 +788,12 @@ export function DBTraceWaterfallChartContainer({
785788
logColumns?.some(column => column.name === 'SeverityNumber') ?? false;
786789

787790
const {
788-
rows: logRowsData,
791+
rows: fetchedLogRows,
789792
isFetching: logIsFetching,
790793
meta: logRowsMeta,
791794
filterError: logFilterError,
792795
fatalError: logFatalError,
796+
isTruncated: logIsTruncated,
793797
} = useFilteredEventsAroundFocus({
794798
// search data if logTableModel exist
795799
// search invalid date range if no logTableModel(react hook need execute no matter what)
@@ -816,8 +820,33 @@ export function DBTraceWaterfallChartContainer({
816820
// shown), otherwise the fatal error (no logs could be loaded at all).
817821
const logError = logFilterError ?? logFatalError;
818822

823+
const [showSpanEvents, setShowSpanEvents] = useState(true);
824+
const [showSpans, setShowSpans] = useState(true);
825+
const [showLogs, setShowLogs] = useState(true);
826+
827+
const {
828+
logRows: logRowsData,
829+
resourceTimingBySpanId,
830+
triggerRow,
831+
} = useBrowserTraceEvents({
832+
traceRows: traceRowsData,
833+
logRows: fetchedLogRows,
834+
logSource: logTableSource,
835+
traceId,
836+
highlightedRowWhere,
837+
initialRowHighlightHint,
838+
// Only while the dots show. A log filter asks for the rows it matches. A
839+
// capped list can keep a log that shares a timing log's key and drop the
840+
// timing log, and the fold would then hide that other log.
841+
foldTimingRows: showSpans && showSpanEvents && !logWhere && !logIsTruncated,
842+
enabled: !traceIsFetching,
843+
});
844+
819845
const rows: any[] = useMemo(() => {
820-
const nextRows: Array<(typeof traceRowsData)[number] & TimestampedRow> = [
846+
// The trigger row goes first so a click with the same timestamp as the
847+
// root span sorts before it (the sort is stable).
848+
const nextRows: TimestampedRow[] = [
849+
...(triggerRow ? [triggerRow] : []),
821850
...traceRowsData,
822851
...logRowsData,
823852
];
@@ -833,7 +862,7 @@ export function DBTraceWaterfallChartContainer({
833862
});
834863

835864
return nextRows;
836-
}, [traceRowsData, logRowsData]);
865+
}, [triggerRow, traceRowsData, logRowsData]);
837866

838867
const validSpanIDs = useMemo(() => {
839868
return new Set(
@@ -983,9 +1012,6 @@ export function DBTraceWaterfallChartContainer({
9831012
};
9841013

9851014
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
986-
const [showSpanEvents, setShowSpanEvents] = useState(true);
987-
const [showSpans, setShowSpans] = useState(true);
988-
const [showLogs, setShowLogs] = useState(true);
9891015

9901016
const { nodesMap, flattenedNodes, parentIdsByLevel } = useMemo(() => {
9911017
const rootNodes: Node[] = [];
@@ -1128,6 +1154,8 @@ export function DBTraceWaterfallChartContainer({
11281154
const visibleNodes = useMemo(() => {
11291155
if (showSpans && showLogs) return flattenedNodes;
11301156
return flattenedNodes.filter(node => {
1157+
// The click belongs with the spans it started, not with the logs.
1158+
if (node.triggeredBy) return showSpans;
11311159
if (node.type === SourceKind.Log) return showLogs;
11321160
return showSpans;
11331161
});
@@ -1137,7 +1165,7 @@ export function DBTraceWaterfallChartContainer({
11371165
node => node.type !== SourceKind.Log,
11381166
).length;
11391167
const logCount = visibleNodes.filter(
1140-
node => node.type === SourceKind.Log,
1168+
node => node.type === SourceKind.Log && !node.triggeredBy,
11411169
).length;
11421170
const errorCount = visibleNodes.filter(
11431171
node => getRowSeverity(node) === 'error',
@@ -1178,6 +1206,7 @@ export function DBTraceWaterfallChartContainer({
11781206
id,
11791207
type,
11801208
aliasWith,
1209+
triggeredBy,
11811210
} = result;
11821211
let body = `${_body}`;
11831212
try {
@@ -1193,21 +1222,34 @@ export function DBTraceWaterfallChartContainer({
11931222
const httpUrl = isRpcSpan(spanAttributes)
11941223
? undefined
11951224
: getHttpUrl(spanAttributes);
1196-
const displayText = httpUrl ? `${body} ${httpUrl}` : body;
1225+
const displayText =
1226+
triggeredBy ?? (httpUrl ? `${body} ${httpUrl}` : body);
1227+
const startedAt = !isNaN(startOffset)
1228+
? ` | Started at ${formatTime(new Date(startOffset), { format: 'withMs' })}`
1229+
: '';
11971230

11981231
// Process span events into markers (only if showSpanEvents is enabled)
1199-
const markers =
1200-
showSpanEvents && result.SpanEvents
1201-
? result.SpanEvents.map(spanEvent => {
1232+
const markers = showSpanEvents
1233+
? [
1234+
...(result.SpanEvents ?? []).map(spanEvent => {
12021235
const epochMs = parseTimestampToMs(spanEvent.Timestamp);
12031236
return {
12041237
timestamp: epochMs - minOffset,
12051238
epochMs,
12061239
name: spanEvent.Name,
12071240
attributes: spanEvent.Attributes || {},
12081241
};
1209-
})
1210-
: [];
1242+
}),
1243+
// Log rows carry their parent span's SpanId.
1244+
...(type !== SourceKind.Log
1245+
? (resourceTimingBySpanId.get(result.SpanId) ?? [])
1246+
: []
1247+
).map(marker => ({
1248+
...marker,
1249+
timestamp: marker.epochMs - minOffset,
1250+
})),
1251+
]
1252+
: [];
12111253

12121254
const severity = getRowSeverity(result);
12131255
const isError = severity === 'error';
@@ -1316,7 +1358,13 @@ export function DBTraceWaterfallChartContainer({
13161358
)}
13171359

13181360
<Group gap={0} wrap="nowrap">
1319-
{type === SourceKind.Log ? (
1361+
{triggeredBy ? (
1362+
<IconPointer
1363+
size={14}
1364+
className="align-middle me-2"
1365+
aria-label="Triggering click"
1366+
/>
1367+
) : type === SourceKind.Log ? (
13201368
<IconLogs
13211369
size={14}
13221370
className="align-middle me-2"
@@ -1327,7 +1375,10 @@ export function DBTraceWaterfallChartContainer({
13271375
size="xxs"
13281376
truncate="end"
13291377
span
1330-
title={`${serviceName}${httpUrl ? ` ${displayText}` : ''}`}
1378+
title={
1379+
triggeredBy ??
1380+
`${serviceName}${httpUrl ? ` ${displayText}` : ''}`
1381+
}
13311382
role="button"
13321383
>
13331384
{serviceName && <>{serviceName} </>}
@@ -1347,7 +1398,9 @@ export function DBTraceWaterfallChartContainer({
13471398
aliasWith,
13481399
start,
13491400
end,
1350-
tooltip: `${displayText} ${tookMs >= 0 ? `took ${tookMs.toFixed(4)}ms` : ''} ${status ? `| Status: ${status}` : ''}${!isNaN(startOffset) ? ` | Started at ${formatTime(new Date(startOffset), { format: 'withMs' })}` : ''}`,
1401+
tooltip: triggeredBy
1402+
? `${triggeredBy}${startedAt}`
1403+
: `${displayText} ${tookMs >= 0 ? `took ${tookMs.toFixed(4)}ms` : ''} ${status ? `| Status: ${status}` : ''}${startedAt}`,
13511404
color: 'var(--color-text-inverted)',
13521405
backgroundColor: barColor({
13531406
isHighlighted,
@@ -1373,6 +1426,7 @@ export function DBTraceWaterfallChartContainer({
13731426
highlightedRowWhere,
13741427
minOffset,
13751428
onClick,
1429+
resourceTimingBySpanId,
13761430
serviceColorMap,
13771431
showSpanEvents,
13781432
toggleCollapse,

‎packages/app/src/components/TimelineChart/TimelineSpanEventMarker.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export const TimelineSpanEventMarker = memo(function ({
8181
}}
8282
>
8383
<div
84+
data-testid="span-event-marker"
8485
style={{
8586
position: 'absolute',
8687
left: `${markerPosition.toFixed(6)}%`,

0 commit comments

Comments
 (0)