Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/trace-waterfall-browser-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': patch
---

feat: Trace waterfall shows OpenTelemetry browser SDK request timings as dots on fetch and XHR spans, and the click that triggered a browser trace
86 changes: 70 additions & 16 deletions packages/app/src/components/DBTraceWaterfallChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
IconChevronsDown,
IconChevronsRight,
IconLogs,
IconPointer,
} from '@tabler/icons-react';

import { ContactSupportText } from '@/components/ContactSupportText';
Expand All @@ -51,6 +52,7 @@ import {
TRACE_WATERFALL_FETCH_LIMIT,
TRACE_WATERFALL_ROW_LIMIT,
} from '@/components/traceWaterfallLimits';
import { useBrowserTraceEvents } from '@/hooks/useBrowserTraceEvents';
import { useColumns } from '@/hooks/useMetadata';
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
import {
Expand Down Expand Up @@ -107,6 +109,7 @@ export type SpanRow = {
Attributes: Record<string, any>;
}>;
__hdx_hidden?: boolean | 1 | 0;
triggeredBy?: string;
};

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

const {
rows: logRowsData,
rows: fetchedLogRows,
isFetching: logIsFetching,
meta: logRowsMeta,
filterError: logFilterError,
fatalError: logFatalError,
isTruncated: logIsTruncated,
} = useFilteredEventsAroundFocus({
// search data if logTableModel exist
// search invalid date range if no logTableModel(react hook need execute no matter what)
Expand All @@ -816,8 +820,33 @@ export function DBTraceWaterfallChartContainer({
// shown), otherwise the fatal error (no logs could be loaded at all).
const logError = logFilterError ?? logFatalError;

const [showSpanEvents, setShowSpanEvents] = useState(true);
const [showSpans, setShowSpans] = useState(true);
const [showLogs, setShowLogs] = useState(true);

const {
logRows: logRowsData,
resourceTimingBySpanId,
triggerRow,
} = useBrowserTraceEvents({
traceRows: traceRowsData,
logRows: fetchedLogRows,
logSource: logTableSource,
traceId,
highlightedRowWhere,
initialRowHighlightHint,
// Only while the dots show. A log filter asks for the rows it matches. A
// capped list can keep a log that shares a timing log's key and drop the
// timing log, and the fold would then hide that other log.
foldTimingRows: showSpans && showSpanEvents && !logWhere && !logIsTruncated,
enabled: !traceIsFetching,
});

const rows: any[] = useMemo(() => {
const nextRows: Array<(typeof traceRowsData)[number] & TimestampedRow> = [
// The trigger row goes first so a click with the same timestamp as the
// root span sorts before it (the sort is stable).
const nextRows: TimestampedRow[] = [
...(triggerRow ? [triggerRow] : []),
...traceRowsData,
...logRowsData,
];
Expand All @@ -833,7 +862,7 @@ export function DBTraceWaterfallChartContainer({
});

return nextRows;
}, [traceRowsData, logRowsData]);
}, [triggerRow, traceRowsData, logRowsData]);

const validSpanIDs = useMemo(() => {
return new Set(
Expand Down Expand Up @@ -983,9 +1012,6 @@ export function DBTraceWaterfallChartContainer({
};

const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
const [showSpanEvents, setShowSpanEvents] = useState(true);
const [showSpans, setShowSpans] = useState(true);
const [showLogs, setShowLogs] = useState(true);

const { nodesMap, flattenedNodes, parentIdsByLevel } = useMemo(() => {
const rootNodes: Node[] = [];
Expand Down Expand Up @@ -1128,6 +1154,8 @@ export function DBTraceWaterfallChartContainer({
const visibleNodes = useMemo(() => {
if (showSpans && showLogs) return flattenedNodes;
return flattenedNodes.filter(node => {
// The click belongs with the spans it started, not with the logs.
if (node.triggeredBy) return showSpans;
if (node.type === SourceKind.Log) return showLogs;
return showSpans;
});
Expand All @@ -1137,7 +1165,7 @@ export function DBTraceWaterfallChartContainer({
node => node.type !== SourceKind.Log,
).length;
const logCount = visibleNodes.filter(
node => node.type === SourceKind.Log,
node => node.type === SourceKind.Log && !node.triggeredBy,
).length;
const errorCount = visibleNodes.filter(
node => getRowSeverity(node) === 'error',
Expand Down Expand Up @@ -1178,6 +1206,7 @@ export function DBTraceWaterfallChartContainer({
id,
type,
aliasWith,
triggeredBy,
} = result;
let body = `${_body}`;
try {
Expand All @@ -1193,21 +1222,34 @@ export function DBTraceWaterfallChartContainer({
const httpUrl = isRpcSpan(spanAttributes)
? undefined
: getHttpUrl(spanAttributes);
const displayText = httpUrl ? `${body} ${httpUrl}` : body;
const displayText =
triggeredBy ?? (httpUrl ? `${body} ${httpUrl}` : body);
const startedAt = !isNaN(startOffset)
? ` | Started at ${formatTime(new Date(startOffset), { format: 'withMs' })}`
: '';

// Process span events into markers (only if showSpanEvents is enabled)
const markers =
showSpanEvents && result.SpanEvents
? result.SpanEvents.map(spanEvent => {
const markers = showSpanEvents
? [
...(result.SpanEvents ?? []).map(spanEvent => {
const epochMs = parseTimestampToMs(spanEvent.Timestamp);
return {
timestamp: epochMs - minOffset,
epochMs,
name: spanEvent.Name,
attributes: spanEvent.Attributes || {},
};
})
: [];
}),
// Log rows carry their parent span's SpanId.
...(type !== SourceKind.Log
? (resourceTimingBySpanId.get(result.SpanId) ?? [])
: []
).map(marker => ({
...marker,
timestamp: marker.epochMs - minOffset,
})),
]
: [];

const severity = getRowSeverity(result);
const isError = severity === 'error';
Expand Down Expand Up @@ -1316,7 +1358,13 @@ export function DBTraceWaterfallChartContainer({
)}

<Group gap={0} wrap="nowrap">
{type === SourceKind.Log ? (
{triggeredBy ? (
<IconPointer
size={14}
className="align-middle me-2"
aria-label="Triggering click"
/>
) : type === SourceKind.Log ? (
<IconLogs
size={14}
className="align-middle me-2"
Expand All @@ -1327,7 +1375,10 @@ export function DBTraceWaterfallChartContainer({
size="xxs"
truncate="end"
span
title={`${serviceName}${httpUrl ? ` ${displayText}` : ''}`}
title={
triggeredBy ??
`${serviceName}${httpUrl ? ` ${displayText}` : ''}`
}
role="button"
>
{serviceName && <>{serviceName} </>}
Expand All @@ -1347,7 +1398,9 @@ export function DBTraceWaterfallChartContainer({
aliasWith,
start,
end,
tooltip: `${displayText} ${tookMs >= 0 ? `took ${tookMs.toFixed(4)}ms` : ''} ${status ? `| Status: ${status}` : ''}${!isNaN(startOffset) ? ` | Started at ${formatTime(new Date(startOffset), { format: 'withMs' })}` : ''}`,
tooltip: triggeredBy
? `${triggeredBy}${startedAt}`
: `${displayText} ${tookMs >= 0 ? `took ${tookMs.toFixed(4)}ms` : ''} ${status ? `| Status: ${status}` : ''}${startedAt}`,
color: 'var(--color-text-inverted)',
backgroundColor: barColor({
isHighlighted,
Expand All @@ -1373,6 +1426,7 @@ export function DBTraceWaterfallChartContainer({
highlightedRowWhere,
minOffset,
onClick,
resourceTimingBySpanId,
serviceColorMap,
showSpanEvents,
toggleCollapse,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const TimelineSpanEventMarker = memo(function ({
}}
>
<div
data-testid="span-event-marker"
style={{
position: 'absolute',
left: `${markerPosition.toFixed(6)}%`,
Expand Down
Loading
Loading