From 12b0c4c1f63602914979b34c942df69ae24c4759 Mon Sep 17 00:00:00 2001 From: Spencer Torres Date: Wed, 21 Oct 2026 22:40:15 -0700 Subject: [PATCH] feat: browser events in session replay --- .../session-replay-browser-log-events.md | 5 + packages/app/src/Playbar.tsx | 9 +- packages/app/src/SessionEventList.tsx | 22 +- packages/app/src/SessionEventSourcePanel.tsx | 55 ++++ packages/app/src/SessionSidePanel.tsx | 20 +- packages/app/src/SessionSubpanel.tsx | 18 +- .../app/src/components/DBRowSidePanel.tsx | 29 ++- .../app/src/components/DBSessionPanel.tsx | 4 +- .../DBRowSidePanel.traceLogsTab.test.tsx | 60 ++++- .../__tests__/useSessionLogEvents.test.tsx | 213 ++++++++++++++++ packages/app/src/hooks/useSessionLogEvents.ts | 203 +++++++++++++++ .../src/utils/__tests__/browserEvents.test.ts | 124 +++++++++ .../utils/__tests__/sessionLogEvents.test.ts | 162 ++++++++++++ packages/app/src/utils/browserEvents.ts | 187 ++++++++++++++ packages/app/src/utils/sessionLogEvents.ts | 239 ++++++++++++++++++ .../app/tests/e2e/features/sessions.spec.ts | 62 +++++ .../app/tests/e2e/fixtures/e2e-fixtures.json | 1 + packages/app/tests/e2e/seed-clickhouse.ts | 10 + packages/app/tests/e2e/seed-otel-sessions.ts | 17 +- 19 files changed, 1417 insertions(+), 23 deletions(-) create mode 100644 .changeset/session-replay-browser-log-events.md create mode 100644 packages/app/src/SessionEventSourcePanel.tsx create mode 100644 packages/app/src/hooks/__tests__/useSessionLogEvents.test.tsx create mode 100644 packages/app/src/hooks/useSessionLogEvents.ts create mode 100644 packages/app/src/utils/__tests__/browserEvents.test.ts create mode 100644 packages/app/src/utils/__tests__/sessionLogEvents.test.ts create mode 100644 packages/app/src/utils/browserEvents.ts create mode 100644 packages/app/src/utils/sessionLogEvents.ts diff --git a/.changeset/session-replay-browser-log-events.md b/.changeset/session-replay-browser-log-events.md new file mode 100644 index 0000000000..e2a720082e --- /dev/null +++ b/.changeset/session-replay-browser-log-events.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +feat: Session replay shows clicks, navigations, console output and exceptions from OpenTelemetry browser SDKs diff --git a/packages/app/src/Playbar.tsx b/packages/app/src/Playbar.tsx index d84694c7c2..9fc34b7e82 100644 --- a/packages/app/src/Playbar.tsx +++ b/packages/app/src/Playbar.tsx @@ -5,6 +5,7 @@ import { keepPreviousData } from '@tanstack/react-query'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; +import { mergeLogMarkers, SessionLogEvent } from './utils/sessionLogEvents'; import { getSessionRowHttp, SessionRow, @@ -21,6 +22,7 @@ export default function Playbar({ playbackRange, focus, queriedConfig, + logEvents, }: { playerState: 'playing' | 'paused'; setPlayerState: (playerState: 'playing' | 'paused') => void; @@ -28,6 +30,7 @@ export default function Playbar({ setFocus: (focus: { ts: number; setBy: string }) => void; playbackRange: [Date, Date]; queriedConfig: ChartConfigWithOptDateRange; + logEvents: SessionLogEvent[]; }) { // might be outdated? state update or something? that's why the max slider val can be wrong? const minTs = playbackRange[0].getTime(); @@ -49,7 +52,7 @@ export default function Playbar({ [data?.data], ); - const markers = useMemo(() => { + const spanMarkers = useMemo(() => { return uniqBy( events .map(event => { @@ -111,6 +114,10 @@ export default function Playbar({ 'percentage', ); }, [events, maxTs, minTs]); + const markers = useMemo( + () => mergeLogMarkers(spanMarkers, logEvents, minTs, maxTs), + [spanMarkers, logEvents, minTs, maxTs], + ); return ( ; + logEvents: SessionLogEvents; // highlightedResultId: string | undefined; focus: { ts: number; setBy: string } | undefined; minTs: number; @@ -130,7 +137,7 @@ export const SessionEventList = ({ const getRowWhere = useRowWhere({ meta: data?.meta, aliasMap }); - const rows = React.useMemo(() => { + const spanRows = React.useMemo(() => { return ( events.map(event => { const { timestamp, durationInMs } = event; @@ -167,7 +174,7 @@ export const SessionEventList = ({ return { id: event.id, - row: event, + open: { kind: 'span', row: event }, isError, isSuccess, eventSource: isNavigation @@ -216,6 +223,7 @@ export const SessionEventList = ({ }) ?? [] ); }, [events, showRelativeTime, minTs, formatTime]); + const rows = useLogEventRows(spanRows, logEvents, showRelativeTime, minTs); const parentRef = React.useRef(null); @@ -244,7 +252,7 @@ export const SessionEventList = ({ } }, [currentEventIndex, eventsFollowPlayerPosition, rowVirtualizer]); - if (isLoading) { + if (isLoading || (logEvents.isLoading && spanRows.length === 0)) { return ( @@ -285,7 +293,11 @@ export const SessionEventList = ({ dataIndex={virtualItem.index} isHighlighted={currentEventIndex === virtualItem.index} ref={rowVirtualizer.measureElement} - onClick={() => onClick(getRowWhere(row.row))} + onClick={() => + row.open.kind === 'span' + ? onClick(getRowWhere(row.open.row)) + : logEvents.open(row.open.event) + } onTimeClick={() => onTimeClick(row.timestamp.getTime())} /> diff --git a/packages/app/src/SessionEventSourcePanel.tsx b/packages/app/src/SessionEventSourcePanel.tsx new file mode 100644 index 0000000000..ae95621a3a --- /dev/null +++ b/packages/app/src/SessionEventSourcePanel.tsx @@ -0,0 +1,55 @@ +import { ComponentProps, use, useEffect } from 'react'; +import { SourceKind } from '@hyperdx/common-utils/dist/types'; +import { Loader } from '@mantine/core'; + +import { + DBRowSidePanelInner, + RowSidePanelContext, +} from '@/components/DBRowSidePanel'; +import { useSource } from '@/source'; + +type EventPanelProps = ComponentProps; + +/** + * The session drawer's event view. A browser log event opens on its log + * source; everything else on the trace source passed in `source`. + */ +export default function SessionEventSourcePanel({ + sourceId, + ...props +}: EventPanelProps & { sourceId?: string }) { + return sourceId ? ( + + ) : ( + + ); +} + +function LogSourceEventPanel({ + sourceId, + ...props +}: EventPanelProps & { sourceId: string }) { + const { data: source, isLoading } = useSource({ + id: sourceId, + kinds: [SourceKind.Log], + }); + const context = use(RowSidePanelContext); + const { onNavigateToParent } = props; + const isMissing = !isLoading && source == null; + + // A link to a deleted source falls back to the session. + useEffect(() => { + if (isMissing) { + onNavigateToParent?.(); + } + }, [isMissing, onNavigateToParent]); + + if (source == null) { + return ; + } + return ( + + + + ); +} diff --git a/packages/app/src/SessionSidePanel.tsx b/packages/app/src/SessionSidePanel.tsx index 943c947feb..69e2bcd05f 100644 --- a/packages/app/src/SessionSidePanel.tsx +++ b/packages/app/src/SessionSidePanel.tsx @@ -26,7 +26,6 @@ import { notifications } from '@mantine/notifications'; import { IconLink, IconX } from '@tabler/icons-react'; import { - DBRowSidePanelInner, RowSidePanelContext, SidePanelErrorFallback, } from '@/components/DBRowSidePanel'; @@ -46,9 +45,11 @@ import { copyTextToClipboard, } from '@/utils/clipboard'; import { parseAsJsonEncoded } from '@/utils/queryParsers'; +import type { SessionEventNavigate } from '@/utils/sessionLogEvents'; import { ZIndexContext } from '@/zIndex'; import useSidePanelStack from './hooks/useSidePanelStack'; +import SessionEventSourcePanel from './SessionEventSourcePanel'; import { Session } from './sessions'; import SessionSubpanel from './SessionSubpanel'; import { formatDistanceToNowStrictShort } from './utils'; @@ -59,12 +60,15 @@ type SelectedEvent = { rowId: string; aliasWith: WithClause[]; sessionId: string; + /** Set for log events; spans live on the trace source. */ + sourceId?: string; }; const sessionEventSchema = z.object({ rowId: z.string(), aliasWith: z.array(WithClauseSchema), sessionId: z.string(), + sourceId: z.string().optional(), }); const sessionEventParser = parseAsJsonEncoded( @@ -137,11 +141,16 @@ export default function SessionSidePanel({ sidePanelStack.clearTrail(); }, [setSelectedEvent, sidePanelStack]); - const handleEventNavigate = useCallback( - (rowId: string, aliasWith: WithClause[]) => { + const handleEventNavigate = useCallback( + (rowId, aliasWith, target) => { clearSpanSelection(); sidePanelStack.clearTrail(); - setSelectedEvent({ rowId, aliasWith, sessionId }); + setSelectedEvent({ + rowId, + aliasWith, + sessionId, + sourceId: target?.sourceId, + }); }, [clearSpanSelection, setSelectedEvent, sidePanelStack, sessionId], ); @@ -229,7 +238,8 @@ export default function SessionSidePanel({ /> )} > - void; - onEventNavigate?: (rowId: string, aliasWith: WithClause[]) => void; + onEventNavigate?: SessionEventNavigate; }) { const [tsQuery, setTsQuery] = useQueryState( 'ts', @@ -365,6 +367,16 @@ export default function SessionSubpanel({ end, tab, }); + const logEvents = useSessionLogEvents({ + traceSource, + sessionId: rumSessionId, + sessionIdKey: session.sessionIdKey, + serviceName: session.serviceName, + dateRange: eventsDateRange, + tab, + searchedQuery, + onEventNavigate, + }); const [playerFullWidth, setPlayerFullWidth] = useState(false); @@ -485,6 +497,7 @@ export default function SessionSubpanel({ eventsFollowPlayerPosition={eventsFollowPlayerPosition} aliasMap={aliasMap} queriedConfig={eventsConfig} + logEvents={logEvents} onClick={onSessionEventClick} focus={focus} onTimeClick={onSessionEventTimeClick} @@ -528,6 +541,7 @@ export default function SessionSubpanel({ setFocus={setFocus} playbackRange={playbackRange} queriedConfig={eventsConfig} + logEvents={logEvents.events} /> )} diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index a43536865d..2e08b309ad 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -57,6 +57,7 @@ import { getRowLookupWindow, resolveRowTimestampAnchor, } from '@/utils/rowTimestamps'; +import type { SessionEventNavigate } from '@/utils/sessionLogEvents'; import { useZIndex, ZIndexContext } from '@/zIndex'; import ServiceMapSidePanel from './ServiceMap/ServiceMapSidePanel'; @@ -618,19 +619,37 @@ export const DBRowSidePanelInner = ({ return clauses.length > 0 ? clauses.join(' AND ') : undefined; }, [traceIdExpression, traceId, spanIdExpression, spanId]); - const handleSessionEventNavigate = useCallback( - (rowId: string, aliasWith: WithClause[]) => { - if (traceSourceData) { + const handleSessionEventNavigate = useCallback( + (rowId, aliasWith, target) => { + const label = mainContent || 'Session Replay'; + if (target?.sourceId === source.id) { + handleNavigateToRow(rowId, aliasWith, label, SourceKind.Log); + } else if (target != null) { + handleSourceStackPush({ + sourceId: target.sourceId, + rowId, + aliasWith, + label, + sourceKind: SourceKind.Log, + focusTimestamp: target.focusTimestamp, + }); + } else if (traceSourceData) { handleSourceStackPush({ sourceId: traceSourceData.id, rowId, aliasWith, - label: mainContent || 'Session Replay', + label, sourceKind: traceSourceData.kind as SourceKind, }); } }, - [traceSourceData, handleSourceStackPush, mainContent], + [ + traceSourceData, + source.id, + handleNavigateToRow, + handleSourceStackPush, + mainContent, + ], ); const handleTraceLogNavigate = useCallback( diff --git a/packages/app/src/components/DBSessionPanel.tsx b/packages/app/src/components/DBSessionPanel.tsx index 48f9d433ff..724d2dfa42 100644 --- a/packages/app/src/components/DBSessionPanel.tsx +++ b/packages/app/src/components/DBSessionPanel.tsx @@ -4,10 +4,10 @@ import { isTraceSource, SourceKind } from '@hyperdx/common-utils/dist/types'; import { Loader } from '@mantine/core'; import useFieldExpressionGenerator from '@/hooks/useFieldExpressionGenerator'; -import { WithClause } from '@/hooks/useRowWhere'; import SessionSubpanel from '@/SessionSubpanel'; import { useSource } from '@/source'; import { SessionIdKey, traceSessionIdColumns } from '@/utils/sessionId'; +import type { SessionEventNavigate } from '@/utils/sessionLogEvents'; import { useEventsData } from './DBTraceWaterfallChart'; @@ -124,7 +124,7 @@ export const DBSessionPanel = ({ dateRange: [Date, Date]; focusDate: Date; serviceName?: string; - onEventNavigate?: (rowId: string, aliasWith: WithClause[]) => void; + onEventNavigate?: SessionEventNavigate; }) => { const { data: traceSource } = useSource({ id: traceSourceId, diff --git a/packages/app/src/components/__tests__/DBRowSidePanel.traceLogsTab.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanel.traceLogsTab.test.tsx index a6a4da0a6f..8a8d6f3e2a 100644 --- a/packages/app/src/components/__tests__/DBRowSidePanel.traceLogsTab.test.tsx +++ b/packages/app/src/components/__tests__/DBRowSidePanel.traceLogsTab.test.tsx @@ -8,6 +8,8 @@ import { import { MantineProvider } from '@mantine/core'; import { act, render } from '@testing-library/react'; +import type { SessionEventNavigate } from '@/utils/sessionLogEvents'; + // In-memory nuqs so the active tab can be seeded and the stack setters // inspected. Prefixed with `mock` so jest.mock factories may reference them. const mockQueryStore: Record = {}; @@ -132,10 +134,20 @@ jest.mock('../TraceLogsPanel', () => ({ }, })); +const mockSessionPanel: { + sessionId?: string; + onEventNavigate?: SessionEventNavigate; +} = {}; jest.mock('../DBSessionPanel', () => ({ __esModule: true, - useSessionId: () => ({ rumSessionId: undefined, rumServiceName: undefined }), - DBSessionPanel: () => null, + useSessionId: () => ({ + rumSessionId: mockSessionPanel.sessionId, + rumServiceName: undefined, + }), + DBSessionPanel: (props: { onEventNavigate?: SessionEventNavigate }) => { + mockSessionPanel.onEventNavigate = props.onEventNavigate; + return null; + }, })); jest.mock('@/utils/highlightedAttributes', () => ({ __esModule: true, @@ -326,3 +338,47 @@ describe('DBRowSidePanelInner — trace logs tab', () => { expect(setterFor('sidePanelTab')).toHaveBeenCalledWith(Tab.Overview); }); }); + +describe('DBRowSidePanelInner — log events from the session replay tab', () => { + const LOG_ROW_ID = "Timestamp=parseDateTime64BestEffort('x', 9)"; + const target = { sourceId: 'log-src', focusTimestamp: TIMESTAMP_VALUE }; + + beforeEach(() => { + resetQueryState(); + localStorage.clear(); + mockUseRowData.mockReset(); + mockUseRowData.mockReturnValue(rowResult(ROW_WITH_TRACE)); + mockSessionPanel.sessionId = 'session-1'; + mockQueryStore.sidePanelTab = Tab.Replay; + }); + + afterEach(() => { + mockSessionPanel.sessionId = undefined; + }); + + it('pushes a frame on the log source from a span', () => { + renderPanel(TRACE_SOURCE_WITH_LOGS); + + act(() => mockSessionPanel.onEventNavigate?.(LOG_ROW_ID, [], target)); + + expect(setterFor('sidePanelSourceStack').mock.calls[0][0][0]).toMatchObject( + { + ...target, + rowId: LOG_ROW_ID, + aliasWith: [], + sourceKind: 'log', + }, + ); + }); + + it('stays in the same source from a log on that source', () => { + renderPanel(LOG_SOURCE); + + act(() => mockSessionPanel.onEventNavigate?.(LOG_ROW_ID, [], target)); + + expect(setterFor('sidePanelNavStack').mock.calls[0][0][0]).toMatchObject({ + rowId: LOG_ROW_ID, + sourceKind: 'log', + }); + }); +}); diff --git a/packages/app/src/hooks/__tests__/useSessionLogEvents.test.tsx b/packages/app/src/hooks/__tests__/useSessionLogEvents.test.tsx new file mode 100644 index 0000000000..b52ed99de6 --- /dev/null +++ b/packages/app/src/hooks/__tests__/useSessionLogEvents.test.tsx @@ -0,0 +1,213 @@ +import { + SourceKind, + TLogSource, + TTraceSource, +} from '@hyperdx/common-utils/dist/types'; +import { renderHook } from '@testing-library/react'; + +import { + useLogEventRows, + useSessionLogEvents, +} from '@/hooks/useSessionLogEvents'; +import type { SessionIdKey } from '@/utils/sessionId'; + +const mockUseSource = jest.fn(); +const mockUseQueriedChartConfig = jest.fn(); +const mockUseFieldExpressionGenerator = jest.fn(); + +jest.mock('@/source', () => ({ + ...jest.requireActual('@/source'), + useSource: (args: unknown) => mockUseSource(args), +})); +jest.mock('@/hooks/useChartConfig', () => ({ + useQueriedChartConfig: (...args: unknown[]) => + mockUseQueriedChartConfig(...args), +})); +jest.mock('@/hooks/useFieldExpressionGenerator', () => ({ + __esModule: true, + default: (source?: TLogSource) => { + mockUseFieldExpressionGenerator(source); + return { + isLoading: false, + getFieldExpression: source + ? (column: string, key: string) => `${column}['${key}']` + : undefined, + }; + }, +})); +jest.mock('@/useFormatTime', () => ({ + useFormatTime: () => (ts: number) => `t${ts}`, +})); + +const traceSource: TTraceSource = { + id: 'traces', + kind: SourceKind.Trace, + name: 'Traces', + connection: 'conn', + from: { databaseName: 'default', tableName: 'otel_traces' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, SpanName', + durationExpression: 'Duration', + durationPrecision: 9, + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', + parentSpanIdExpression: 'ParentSpanId', + spanNameExpression: 'SpanName', + spanKindExpression: 'SpanKind', + logSourceId: 'logs', +}; +const logSource: TLogSource = { + id: 'logs', + kind: SourceKind.Log, + name: 'Logs', + connection: 'conn', + from: { databaseName: 'default', tableName: 'otel_logs' }, + timestampValueExpression: 'TimestampTime', + displayedTimestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, Body', + implicitColumnExpression: 'Body', + eventAttributesExpression: 'LogAttributes', + eventNameExpression: 'EventName', +}; + +const TIMESTAMP = '2026-10-22T02:17:49.873000000Z'; +const queryResult = { + data: [ + { + timestamp: TIMESTAMP, + timestamp_key: '2026-10-22T02:17:49Z', + event_name: 'browser.user_action.click', + session_id: 's1', + body: '', + attributes: { 'browser.tag_name': 'BUTTON' }, + }, + ], + meta: [ + { name: 'timestamp', type: 'DateTime64(9)' }, + { name: 'timestamp_key', type: 'DateTime' }, + { name: 'event_name', type: 'String' }, + { name: 'session_id', type: 'String' }, + { name: 'body', type: 'String' }, + { name: 'attributes', type: 'Map(LowCardinality(String), String)' }, + ], +}; + +function renderLogEvents(sessionIdKey: SessionIdKey, searchedQuery = '') { + const onEventNavigate = jest.fn(); + const dateRange: [Date, Date] = [new Date(0), new Date(1)]; + const hook = renderHook(() => + useSessionLogEvents({ + traceSource, + sessionId: 's1', + sessionIdKey, + serviceName: 'web', + dateRange, + tab: 'events', + searchedQuery, + onEventNavigate, + }), + ); + return { ...hook, onEventNavigate }; +} + +describe('useSessionLogEvents', () => { + beforeEach(() => { + mockUseSource.mockImplementation(({ id }: { id: string | null }) => ({ + data: id === 'logs' ? logSource : undefined, + isLoading: false, + })); + mockUseQueriedChartConfig.mockImplementation( + (_config: unknown, { enabled }: { enabled: boolean }) => ({ + data: enabled ? queryResult : undefined, + isLoading: false, + isError: false, + }), + ); + }); + + it('sends nothing for a legacy session', () => { + const { result, rerender } = renderLogEvents('rum.sessionId'); + const { events } = result.current; + rerender(); + + expect(mockUseSource).toHaveBeenCalledWith( + expect.objectContaining({ id: null }), + ); + expect(mockUseQueriedChartConfig).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ enabled: false }), + ); + expect(result.current.isLoading).toBe(false); + expect(result.current.events).toHaveLength(0); + expect(result.current.events).toBe(events); + }); + + it('skips the metadata lookup while a search is active', () => { + const { result } = renderLogEvents('session.id', 'SpanName:foo'); + + expect(mockUseFieldExpressionGenerator).toHaveBeenLastCalledWith(undefined); + expect(mockUseQueriedChartConfig).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ enabled: false }), + ); + expect(result.current.isLoading).toBe(false); + }); + + it('hides log events when the query fails', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: queryResult, + isLoading: false, + isError: true, + }); + const { result } = renderLogEvents('session.id'); + + expect(result.current.events).toHaveLength(0); + expect(result.current.isLoading).toBe(false); + }); + + it('opens an event on the log source by its key columns', () => { + const { result, onEventNavigate } = renderLogEvents('session.id'); + + expect(result.current.events[0].title).toBe('Clicked button'); + result.current.open(result.current.events[0]); + + expect(onEventNavigate).toHaveBeenCalledWith( + `Timestamp=parseDateTime64BestEffort('${TIMESTAMP}', 9) AND TimestampTime=parseDateTime64BestEffort('2026-10-22T02:17:49Z', 9) AND toString(EventName)='browser.user_action.click' AND LogAttributes['session.id']='s1' AND Body=''`, + [], + { sourceId: 'logs', focusTimestamp: '2026-10-22T02:17:49.873Z' }, + ); + }); +}); + +describe('useLogEventRows', () => { + it('merges log events into span rows by time, log events first on ties', () => { + const spanRows = [ + { id: 'a', timestamp: new Date(1000) }, + { id: 'b', timestamp: new Date(3000) }, + ]; + const noLogs = { events: [], isLoading: false, open: jest.fn() }; + const logs = { + ...noLogs, + events: [1000, 2000].map(ts => ({ + id: `log-${ts}`, + timestamp: new Date(ts), + title: 'Clicked button', + description: '', + timelineText: '', + isError: false, + eventSource: 'custom' as const, + row: {}, + })), + }; + + expect( + renderHook(() => useLogEventRows(spanRows, noLogs, false, 0)).result + .current, + ).toBe(spanRows); + expect( + renderHook(() => + useLogEventRows(spanRows, logs, false, 0), + ).result.current.map(row => row.id), + ).toEqual(['log-1000', 'a', 'log-2000', 'b']); + }); +}); diff --git a/packages/app/src/hooks/useSessionLogEvents.ts b/packages/app/src/hooks/useSessionLogEvents.ts new file mode 100644 index 0000000000..7247e2f610 --- /dev/null +++ b/packages/app/src/hooks/useSessionLogEvents.ts @@ -0,0 +1,203 @@ +import { useCallback, useMemo } from 'react'; +import sortBy from 'lodash/sortBy'; +import { + BuilderChartConfigWithDateRange, + SourceKind, + TTraceSource, +} from '@hyperdx/common-utils/dist/types'; +import { keepPreviousData } from '@tanstack/react-query'; + +import { useQueriedChartConfig } from '@/hooks/useChartConfig'; +import useFieldExpressionGenerator from '@/hooks/useFieldExpressionGenerator'; +import useRowWhere from '@/hooks/useRowWhere'; +import { useSource } from '@/source'; +import { useFormatTime } from '@/useFormatTime'; +import { formatmmss } from '@/utils'; +import { CONSOLE_METHOD_ATTRIBUTE } from '@/utils/browserEvents'; +import type { SessionIdKey } from '@/utils/sessionId'; +import { + buildSessionLogEventsQuery, + SESSION_LOG_EVENT_KEY_COLUMNS, + SessionEventNavigate, + SessionLogEvent, + SessionLogEvents, + toSessionLogEvents, +} from '@/utils/sessionLogEvents'; + +// Stable, so the event list and the timeline keep their memoized rows. +const NO_LOG_EVENTS: SessionLogEvent[] = []; + +// Twin of linkedSpans' usePlaceholderConfig, without a source because the log +// source may not resolve. Never fetched: the query is disabled while it is used. +const PLACEHOLDER_CONFIG: BuilderChartConfigWithDateRange = { + connection: '', + from: { databaseName: '', tableName: '' }, + select: [{ valueExpression: '1' }], + where: '0=1', + whereLanguage: 'sql', + timestampValueExpression: '', + dateRange: [new Date(0), new Date(0)], + limit: { limit: 0 }, +}; + +/** + * Clicks, navigations, console calls and exceptions that OTel browser SDKs + * record as log events, read from the trace source's log source. Legacy SDK + * sessions send no queries, metadata lookups included. + */ +export function useSessionLogEvents({ + traceSource, + sessionId, + sessionIdKey, + serviceName, + dateRange, + tab, + searchedQuery, + onEventNavigate, +}: { + traceSource: TTraceSource; + sessionId: string; + sessionIdKey: SessionIdKey | undefined; + serviceName: string; + dateRange: [Date, Date]; + tab: string; + searchedQuery: string; + onEventNavigate: SessionEventNavigate | undefined; +}): SessionLogEvents { + const isOtelSession = sessionIdKey === 'session.id'; + const { data: logSource, isLoading: isSourceLoading } = useSource({ + id: isOtelSession ? traceSource.logSourceId : null, + kinds: [SourceKind.Log], + }); + // A search disables the query, so it skips the metadata lookup too. + const attrs = + searchedQuery === '' && logSource?.eventNameExpression + ? logSource.eventAttributesExpression + : undefined; + const { getFieldExpression, isLoading: isFieldExpressionLoading } = + useFieldExpressionGenerator(attrs ? logSource : undefined); + // Strings, not the generator: the generator is a new function each render. + const sessionIdExpression = + attrs && getFieldExpression?.(attrs, 'session.id'); + const consoleMethodExpression = + attrs && getFieldExpression?.(attrs, CONSOLE_METHOD_ATTRIBUTE); + + const query = useMemo( + () => + buildSessionLogEventsQuery({ + logSource, + sessionId, + sessionIdKey, + serviceName, + searchedQuery, + dateRange, + tab, + sessionIdExpression, + consoleMethodExpression, + }), + [ + logSource, + sessionId, + sessionIdKey, + serviceName, + searchedQuery, + dateRange, + tab, + sessionIdExpression, + consoleMethodExpression, + ], + ); + + // Keeps the last tab's rows until the next tab's land, as the span queries + // do. They can't show while gated off: `data` is ignored without a query. + const { data, isLoading, isError } = useQueriedChartConfig( + query?.config ?? PLACEHOLDER_CONFIG, + { + enabled: query != null, + queryKeyPrefix: 'SessionLogEvents', + placeholderData: keepPreviousData, + }, + ); + // A failed query hides log events; the spans still show. + const result = query != null && !isError ? data : undefined; + const rows = result?.data; + const events = useMemo( + () => (rows?.length ? toSessionLogEvents(rows) : NO_LOG_EVENTS), + [rows], + ); + + const getRowWhere = useRowWhere({ + meta: result?.meta, + aliasMap: query?.aliasMap, + primaryKeyColumns: SESSION_LOG_EVENT_KEY_COLUMNS, + }); + const open = useCallback( + (event: SessionLogEvent) => { + if (logSource == null) { + return; + } + // Built on click: it throws on a column without meta, which must not + // take down the player and the list. + let rowId: string; + try { + rowId = getRowWhere(event.row).where; + } catch (e) { + console.error(e); + return; + } + // The row id is made of raw expressions, so it needs no WITH clauses. + onEventNavigate?.(rowId, [], { + sourceId: logSource.id, + focusTimestamp: event.timestamp.toISOString(), + }); + }, + [getRowWhere, logSource, onEventNavigate], + ); + + const isPreparingQuery = + isOtelSession && + searchedQuery === '' && + (isSourceLoading || isFieldExpressionLoading); + return { + events, + isLoading: query != null ? isLoading : isPreparingQuery, + open, + }; +} + +/** + * The span rows with the log events merged in by time. Log events come first + * on ties: a click and the request it starts usually share a millisecond. + * Without log events the span rows come back as they are. + */ +export function useLogEventRows( + spanRows: T[], + { events }: SessionLogEvents, + showRelativeTime: boolean, + minTs: number, +) { + const formatTime = useFormatTime(); + return useMemo(() => { + if (events.length === 0) { + return spanRows; + } + const logRows = events.map(event => { + const ts = event.timestamp.getTime(); + return { + id: event.id, + open: { kind: 'log' as const, event }, + isError: event.isError, + isSuccess: false, + eventSource: event.eventSource, + title: event.title, + description: event.description, + timestamp: event.timestamp, + formattedTimestamp: showRelativeTime + ? formatmmss(ts - minTs) + : formatTime(ts, { format: 'time' }), + duration: 0, + }; + }); + return sortBy([...logRows, ...spanRows], row => row.timestamp.getTime()); + }, [spanRows, events, showRelativeTime, minTs, formatTime]); +} diff --git a/packages/app/src/utils/__tests__/browserEvents.test.ts b/packages/app/src/utils/__tests__/browserEvents.test.ts new file mode 100644 index 0000000000..0c5b12a991 --- /dev/null +++ b/packages/app/src/utils/__tests__/browserEvents.test.ts @@ -0,0 +1,124 @@ +import { + BROWSER_EVENT_NAMES, + getBrowserEventLabel, +} from '@/utils/browserEvents'; + +const { CLICK, NAVIGATION, CONSOLE, EXCEPTION } = BROWSER_EVENT_NAMES; + +describe('getBrowserEventLabel', () => { + it.each([ + [ + '#root > main.app > section.card.p-4:nth-child(3) > table.w-full > tbody > tr.row.hover\\:bg-gray-50:nth-child(14) > td:nth-child(1) > button.btn.btn-primary', + 'tr:nth-child(14) > td:nth-child(1) > button', + ], + [ + '#checkout-form > div.actions > button.btn', + '#checkout-form > div > button', + ], + ])('describes a click on %s by its selector tail', (selector, tail) => { + expect( + getBrowserEventLabel({ + eventName: CLICK, + body: '', + attributes: { + 'browser.tag_name': 'BUTTON', + 'browser.css_selector': selector, + }, + }), + ).toEqual({ + title: 'Clicked button', + description: tail, + timelineText: `Clicked ${tail}`, + isError: false, + eventSource: 'custom', + }); + }); + + it.each([ + [ + 'flat keys', + { + 'browser.element.attributes.action': 'buy', + 'browser.element.attributes.sku': '42', + }, + ], + [ + 'a JSON string', + { 'browser.element.attributes': '{"action":"buy","sku":"42"}' }, + ], + [ + 'a JSON column', + { browser: { element: { attributes: { action: 'buy', sku: 42 } } } }, + ], + ])( + 'describes a click by data-otel attributes as %s', + (_shape, attributes) => { + expect( + getBrowserEventLabel({ + eventName: CLICK, + body: '', + attributes: { + 'browser.css_selector': '#root > button.buy', + ...attributes, + }, + }).description, + ).toBe('action=buy sku=42'); + }, + ); + + it('describes a navigation by its short URL', () => { + expect( + getBrowserEventLabel({ + eventName: NAVIGATION, + body: '', + attributes: { + 'url.full': 'http://localhost:5173/cart/checkout?step=2', + 'browser.document.url.full': 'http://localhost:5173/', + }, + }), + ).toMatchObject({ + title: 'Navigated', + description: '/cart/checkout', + timelineText: 'Navigated to /cart/checkout', + eventSource: 'navigation', + }); + }); + + it.each([ + ['error', true], + ['log', false], + ])('labels console.%s by its first line', (method, isError) => { + expect( + getBrowserEventLabel({ + eventName: CONSOLE, + body: 'Error: boom\n at onClick (app.js:1:1)', + attributes: { 'browser.console.method': method }, + }), + ).toMatchObject({ + title: `console.${method}`, + description: 'Error: boom', + timelineText: `console.${method}: Error: boom`, + isError, + }); + }); + + it.each([ + [ + '', + { 'exception.type': 'TypeError', 'exception.message': 'x is undefined' }, + ['TypeError', 'x is undefined', 'TypeError: x is undefined'], + ], + [ + 'Error: Checkout failed\n at submit (app.js:2:2)', + {}, + ['Error', 'Error: Checkout failed', 'Error: Checkout failed'], + ], + ])( + 'labels an exception with body %p and %p as an error', + (body, attributes, [title, description, timelineText]) => { + expect( + getBrowserEventLabel({ eventName: EXCEPTION, body, attributes }), + ).toMatchObject({ title, description, timelineText, isError: true }); + }, + ); +}); diff --git a/packages/app/src/utils/__tests__/sessionLogEvents.test.ts b/packages/app/src/utils/__tests__/sessionLogEvents.test.ts new file mode 100644 index 0000000000..056c583965 --- /dev/null +++ b/packages/app/src/utils/__tests__/sessionLogEvents.test.ts @@ -0,0 +1,162 @@ +import { SourceKind, TLogSource } from '@hyperdx/common-utils/dist/types'; + +import { + buildSessionLogEventsQuery, + mergeLogMarkers, + SessionLogEvent, +} from '@/utils/sessionLogEvents'; + +const logSource: TLogSource = { + id: 'logs', + kind: SourceKind.Log, + name: 'Logs', + connection: 'conn', + from: { databaseName: 'default', tableName: 'otel_logs' }, + timestampValueExpression: 'TimestampTime, Timestamp', + displayedTimestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, Body', + implicitColumnExpression: 'Body', + serviceNameExpression: 'ServiceName', + resourceAttributesExpression: 'ResourceAttributes', + eventAttributesExpression: 'LogAttributes', + eventNameExpression: 'EventName', +}; + +const dateRange: [Date, Date] = [ + new Date('2026-10-22T00:00:00Z'), + new Date('2026-10-22T08:00:00Z'), +]; + +const args = { + logSource, + sessionId: 's1', + sessionIdKey: 'session.id' as const, + serviceName: 'web', + searchedQuery: '', + dateRange, + tab: 'highlighted', + sessionIdExpression: "LogAttributes['session.id']", + consoleMethodExpression: "LogAttributes['browser.console.method']", +}; + +describe('buildSessionLogEventsQuery', () => { + it.each([ + ['a legacy session', { sessionIdKey: 'rum.sessionId' as const }], + ['no log source', { logSource: undefined }], + [ + 'no event name expression', + { logSource: { ...logSource, eventNameExpression: undefined } }, + ], + [ + 'no event attributes expression', + { logSource: { ...logSource, eventAttributesExpression: undefined } }, + ], + ['an active search', { searchedQuery: 'SpanName:foo' }], + ])('builds nothing for %s', (_case, override) => { + expect(buildSessionLogEventsQuery({ ...args, ...override })).toBeNull(); + }); + + it('looks up the session on the log source, newest last', () => { + const { config, aliasMap } = buildSessionLogEventsQuery(args)!; + + expect(aliasMap).toEqual({ + timestamp: 'Timestamp', + timestamp_key: 'TimestampTime', + event_name: 'toString(EventName)', + session_id: "LogAttributes['session.id']", + body: 'Body', + attributes: 'LogAttributes', + }); + expect(config).toMatchObject({ + source: 'logs', + from: logSource.from, + connection: 'conn', + timestampValueExpression: 'TimestampTime, Timestamp', + dateRange, + orderBy: 'Timestamp ASC', + limit: { limit: 4000, offset: 0 }, + filters: [ + { type: 'lucene', condition: 'LogAttributes.session.id:"s1"' }, + expect.anything(), + { type: 'sql', condition: "ServiceName = 'web'" }, + ], + }); + }); + + it.each([ + [ + 'highlighted', + "toString(EventName) IN ('browser.navigation', 'exception') OR (toString(EventName) = 'browser.console' AND LogAttributes['browser.console.method'] = 'error')", + ], + [ + 'events', + "toString(EventName) IN ('browser.user_action.click', 'browser.navigation', 'exception', 'browser.console')", + ], + ])('filters event names for the %s tab', (tab, condition) => { + expect( + buildSessionLogEventsQuery({ ...args, tab })!.config.filters?.[1], + ).toEqual({ type: 'sql', condition }); + }); + + it('skips the service filter when the session has no service', () => { + expect( + buildSessionLogEventsQuery({ ...args, serviceName: '' })!.config.filters, + ).toHaveLength(2); + }); + + it('wraps a custom event name expression verbatim', () => { + const { config } = buildSessionLogEventsQuery({ + ...args, + tab: 'events', + logSource: { + ...logSource, + eventNameExpression: "LogAttributes['event.name?']", + }, + })!; + + expect(config.filters?.[1]).toMatchObject({ + condition: expect.stringMatching( + /^toString\(LogAttributes\['event\.name\?'\]\) IN \('browser/, + ), + }); + }); +}); + +describe('mergeLogMarkers', () => { + const spanMarker = { + id: 'span', + ts: 1000, + description: '500 GET /api', + isError: true, + isSuccess: false, + }; + const logEvent = (ts: number, id: string): SessionLogEvent => ({ + id, + timestamp: new Date(ts), + title: 'Clicked button', + description: '', + timelineText: 'Clicked button', + isError: false, + eventSource: 'custom', + row: {}, + }); + + it('returns the span markers themselves when no log event is in range', () => { + const spanMarkers = [spanMarker]; + + expect( + mergeLogMarkers(spanMarkers, [logEvent(5000, 'late')], 0, 2000), + ).toBe(spanMarkers); + }); + + it('merges by time and keeps the earliest marker per percent, spans first', () => { + expect( + mergeLogMarkers( + [spanMarker], + [logEvent(1000, 'tie'), logEvent(500, 'early'), logEvent(1500, 'late')], + 0, + 2000, + ).map(marker => marker.id), + ).toEqual(['early', 'span', 'late']); + }); +}); diff --git a/packages/app/src/utils/browserEvents.ts b/packages/app/src/utils/browserEvents.ts new file mode 100644 index 0000000000..f60e6a6d27 --- /dev/null +++ b/packages/app/src/utils/browserEvents.ts @@ -0,0 +1,187 @@ +import { flatten } from 'flat'; +import { parseJSON } from '@hyperdx/common-utils/dist/core/utils'; + +import { isRecord } from '@/llm/lib/attributeUtils'; +import { getShortUrl } from '@/utils'; +import { + Attributes, + firstSetString, + isSet, +} from '@/utils/spanAttributeReaders'; + +// Event names from upstream's experimental @opentelemetry/browser-instrumentation. +// Browser semconv is still in Development, so they live in one place. +export const BROWSER_EVENT_NAMES = { + CLICK: 'browser.user_action.click', + NAVIGATION: 'browser.navigation', + CONSOLE: 'browser.console', + EXCEPTION: 'exception', +} as const; + +export const CONSOLE_METHOD_ATTRIBUTE = 'browser.console.method'; + +const ELEMENT_ATTRIBUTES_KEY = 'browser.element.attributes'; +const SHORT_SELECTOR_SEGMENTS = 3; + +export type BrowserEventLabel = { + title: string; + description: string; + /** Tooltip text for the timeline marker. */ + timelineText: string; + isError: boolean; + /** Picks the event list icon. */ + eventSource: 'navigation' | 'custom' | 'log'; +}; + +function joinWords(...words: Array): string { + return words.filter(Boolean).join(' '); +} + +function firstLine(text: string): string { + return text.split('\n', 1)[0].trim(); +} + +function setPairs(entries: [string, unknown][]): [string, string][] { + return entries + .filter(([, value]) => isSet(value)) + .map(([key, value]) => [key, String(value)]); +} + +// The SDK sends data-otel-* attributes, prefix removed, as one map. The +// collector flattens it into `browser.element.attributes.` keys; without +// that step a Map column holds it as a JSON string. +function getElementAttributes(attributes: Attributes): [string, string][] { + const prefix = `${ELEMENT_ATTRIBUTES_KEY}.`; + const flatPairs = setPairs( + Object.entries(attributes) + .filter(([key]) => key.startsWith(prefix)) + .map(([key, value]) => [key.slice(prefix.length), value]), + ); + if (flatPairs.length > 0) { + return flatPairs; + } + const raw = attributes[ELEMENT_ATTRIBUTES_KEY]; + const parsed = typeof raw === 'string' ? parseJSON(raw) : undefined; + return isRecord(parsed) ? setPairs(Object.entries(parsed)) : []; +} + +// Upstream's getElementCSSSelector joins segments with ' > '. A segment is a +// unique `#id`, where the selector then starts, or the tag followed by every +// class name and `:nth-child(n)` when siblings share them. Class names can be +// hundreds of characters long (utility CSS), so the label drops them and keeps +// the tail, which names the element. +function getShortSelector(selector: string): string { + const segments = selector.split(' > ').slice(-SHORT_SELECTOR_SEGMENTS); + const idIndex = segments.findLastIndex(segment => segment.startsWith('#')); + return segments + .slice(Math.max(idIndex, 0)) + .map(segment => { + if (segment.startsWith('#')) { + return segment; + } + const nthChild = segment.match(/:nth-child\(\d+\)$/)?.[0] ?? ''; + const tagAndClasses = segment.slice(0, segment.length - nthChild.length); + return `${tagAndClasses.split('.', 1)[0]}${nthChild}`; + }) + .join(' > '); +} + +function getClickLabel(attributes: Attributes): BrowserEventLabel { + const tag = firstSetString(attributes, ['browser.tag_name'])?.toLowerCase(); + const selector = firstSetString(attributes, ['browser.css_selector']); + const description = + getElementAttributes(attributes) + .map(([name, value]) => `${name}=${value}`) + .join(' ') || (selector ? getShortSelector(selector) : ''); + return { + title: joinWords('Clicked', tag), + description, + timelineText: joinWords('Clicked', description || tag), + isError: false, + eventSource: 'custom', + }; +} + +function getNavigationLabel(attributes: Attributes): BrowserEventLabel { + const url = firstSetString(attributes, [ + 'url.full', + 'browser.document.url.full', + ]); + const shortUrl = url != null ? getShortUrl(url) : ''; + return { + title: 'Navigated', + description: shortUrl, + timelineText: joinWords('Navigated to', shortUrl), + isError: false, + eventSource: 'navigation', + }; +} + +function getConsoleLabel( + attributes: Attributes, + body: string, +): BrowserEventLabel { + const method = firstSetString(attributes, [CONSOLE_METHOD_ATTRIBUTE]); + const title = method != null ? `console.${method}` : 'console'; + const line = firstLine(body); + return { + title, + description: line, + timelineText: line ? `${title}: ${line}` : title, + isError: method === 'error', + eventSource: 'log', + }; +} + +function getExceptionLabel( + attributes: Attributes, + body: string, +): BrowserEventLabel { + const type = firstSetString(attributes, ['exception.type']); + // Without a message attribute the body reads like `Error: `. + const message = firstLine( + firstSetString(attributes, ['exception.message']) ?? body, + ); + return { + title: type ?? 'Error', + description: message, + timelineText: + type != null && message ? `${type}: ${message}` : message || 'Error', + isError: true, + eventSource: 'log', + }; +} + +export function getBrowserEventLabel({ + eventName, + body, + attributes, +}: { + eventName: string; + body: unknown; + /** Flat keys from a Map column or nested objects from a JSON column. */ + attributes: unknown; +}): BrowserEventLabel { + const flat: Attributes = isRecord(attributes) + ? flatten(attributes) + : {}; + const text = typeof body === 'string' ? body : ''; + switch (eventName) { + case BROWSER_EVENT_NAMES.CLICK: + return getClickLabel(flat); + case BROWSER_EVENT_NAMES.NAVIGATION: + return getNavigationLabel(flat); + case BROWSER_EVENT_NAMES.CONSOLE: + return getConsoleLabel(flat, text); + case BROWSER_EVENT_NAMES.EXCEPTION: + return getExceptionLabel(flat, text); + default: + return { + title: eventName, + description: firstLine(text), + timelineText: eventName, + isError: false, + eventSource: 'log', + }; + } +} diff --git a/packages/app/src/utils/sessionLogEvents.ts b/packages/app/src/utils/sessionLogEvents.ts new file mode 100644 index 0000000000..0d557c1a00 --- /dev/null +++ b/packages/app/src/utils/sessionLogEvents.ts @@ -0,0 +1,239 @@ +import sortBy from 'lodash/sortBy'; +import uniqBy from 'lodash/uniqBy'; +import SqlString from 'sqlstring'; +import type { + BuilderChartConfigWithDateRange, + Filter, + TLogSource, +} from '@hyperdx/common-utils/dist/types'; + +import type { WithClause } from '@/hooks/useRowWhere'; +import type { PlaybarMarker } from '@/PlaybarSlider'; +import { + getDisplayedTimestampValueExpression, + getEventBody, + getFirstTimestampValueExpression, +} from '@/source'; +import { + BROWSER_EVENT_NAMES, + BrowserEventLabel, + getBrowserEventLabel, +} from '@/utils/browserEvents'; +import { sessionIdFilter, SessionIdKey } from '@/utils/sessionId'; +import type { SessionRow } from '@/utils/sessions'; + +export type SessionLogEvent = BrowserEventLabel & { + id: string; + timestamp: Date; + /** The result row, which keeps the nanosecond timestamp for the row id. */ + row: Record; +}; + +export type SessionLogEvents = { + events: SessionLogEvent[]; + isLoading: boolean; + open: (event: SessionLogEvent) => void; +}; + +/** What a click on an event list row opens. */ +export type SessionEventOpen = + | { kind: 'span'; row: SessionRow } + | { kind: 'log'; event: SessionLogEvent }; + +/** Set when the event lives on a log source rather than the trace source. */ +type SessionLogEventTarget = { + sourceId: string; + focusTimestamp: string; +}; + +export type SessionEventNavigate = ( + rowId: string, + aliasWith: WithClause[], + target?: SessionLogEventTarget, +) => void; + +// Click and navigation rows have an empty Body, SpanId and SeverityText, so the +// event name and session id keep row ids unique. Body splits console calls +// made in the same millisecond. timestamp_key lets the lookup prune on tables +// keyed by another time column than the displayed one (TimestampTime). +export const SESSION_LOG_EVENT_KEY_COLUMNS = new Set([ + 'timestamp', + 'timestamp_key', + 'event_name', + 'session_id', + 'body', +]); + +const { CLICK, NAVIGATION, CONSOLE, EXCEPTION } = BROWSER_EVENT_NAMES; + +/** + * Highlighted keeps what the legacy SDK's tab showed: navigations, exceptions + * and console errors. Filtered on the server, as spans are, so a console flood + * can't push them past the row limit. + */ +function eventNameCondition( + eventName: string, + consoleMethodExpression: string, + highlighted: boolean, +): string { + return highlighted + ? SqlString.format('? IN (?) OR (? = ? AND ? = ?)', [ + SqlString.raw(eventName), + [NAVIGATION, EXCEPTION], + SqlString.raw(eventName), + CONSOLE, + SqlString.raw(consoleMethodExpression), + 'error', + ]) + : SqlString.format('? IN (?)', [ + SqlString.raw(eventName), + [CLICK, NAVIGATION, EXCEPTION, CONSOLE], + ]); +} + +/** Null when the session has no browser log events to query. */ +export function buildSessionLogEventsQuery({ + logSource, + sessionId, + sessionIdKey, + serviceName, + searchedQuery, + dateRange, + tab, + sessionIdExpression, + consoleMethodExpression, +}: { + logSource: TLogSource | undefined; + sessionId: string; + sessionIdKey: SessionIdKey | undefined; + serviceName: string; + searchedQuery: string; + dateRange: [Date, Date]; + tab: string; + sessionIdExpression: string | undefined; + consoleMethodExpression: string | undefined; +}): { + config: BuilderChartConfigWithDateRange; + aliasMap: Record; +} | null { + const attrs = logSource?.eventAttributesExpression; + // Only OTel browser SDKs write these events, and the search box is written + // against trace fields. + if ( + sessionIdKey !== 'session.id' || + searchedQuery !== '' || + !logSource?.eventNameExpression || + !attrs || + !sessionIdExpression || + !consoleMethodExpression + ) { + return null; + } + // toString: a JSON path is Dynamic, which IN rejects. + const eventName = `toString(${logSource.eventNameExpression})`; + const timestamp = getDisplayedTimestampValueExpression(logSource); + const timestampKey = getFirstTimestampValueExpression( + logSource.timestampValueExpression, + ); + const body = getEventBody(logSource); + const aliasMap: Record = { + timestamp, + ...(timestampKey !== timestamp ? { timestamp_key: timestampKey } : {}), + event_name: eventName, + session_id: sessionIdExpression, + ...(body ? { body } : {}), + attributes: attrs, + }; + const filters: Filter[] = [ + { + type: 'lucene', + condition: sessionIdFilter( + { + resource: + logSource.resourceAttributesExpression ?? 'ResourceAttributes', + attrs, + }, + sessionId, + 'session.id', + ), + }, + { + type: 'sql', + condition: eventNameCondition( + eventName, + consoleMethodExpression, + tab === 'highlighted', + ), + }, + ]; + // Matches the replay query, and keeps backend exceptions that carry a + // propagated session.id off the browser timeline. + if (logSource.serviceNameExpression && serviceName) { + filters.push({ + type: 'sql', + condition: SqlString.format('? = ?', [ + SqlString.raw(logSource.serviceNameExpression), + serviceName, + ]), + }); + } + return { + aliasMap, + config: { + source: logSource.id, + select: Object.entries(aliasMap).map(([alias, valueExpression]) => ({ + valueExpression, + alias, + })), + from: logSource.from, + connection: logSource.connection, + timestampValueExpression: logSource.timestampValueExpression, + dateRange, + where: '', + whereLanguage: 'sql', + filters, + orderBy: `${timestamp} ASC`, + limit: { limit: 4000, offset: 0 }, + }, + }; +} + +export function toSessionLogEvents( + rows: Record[], +): SessionLogEvent[] { + return rows.map((row, index) => ({ + ...getBrowserEventLabel({ + eventName: String(row.event_name ?? ''), + body: row.body, + attributes: row.attributes, + }), + id: `log-${index}`, + timestamp: new Date(String(row.timestamp)), + row, + })); +} + +/** Returns `spanMarkers` itself when no log event falls in the range. */ +export function mergeLogMarkers( + spanMarkers: PlaybarMarker[], + logEvents: SessionLogEvent[], + minTs: number, + maxTs: number, +): PlaybarMarker[] { + const logMarkers = logEvents + .map(event => ({ + id: event.id, + ts: event.timestamp.getTime(), + description: event.timelineText, + isError: event.isError, + isSuccess: false, + })) + .filter(({ ts }) => ts >= minTs && ts <= maxTs); + if (logMarkers.length === 0) { + return spanMarkers; + } + // Same bucketing as the span markers, which are already one per percent. + return uniqBy(sortBy([...spanMarkers, ...logMarkers], 'ts'), ({ ts }) => + Math.round(((ts - minTs) / (maxTs - minTs)) * 100), + ); +} diff --git a/packages/app/tests/e2e/features/sessions.spec.ts b/packages/app/tests/e2e/features/sessions.spec.ts index d51932f288..54f89ac6ce 100644 --- a/packages/app/tests/e2e/features/sessions.spec.ts +++ b/packages/app/tests/e2e/features/sessions.spec.ts @@ -68,6 +68,68 @@ test.describe('Client Sessions Functionality', { tag: ['@sessions'] }, () => { ).toBeVisible({ timeout: 15000 }); }); + test('shows browser log events from the OpenTelemetry SDK and opens them', async ({ + page, + }) => { + const panel = sessionsPage.sessionSidePanel; + const rows = sessionsPage.getSessionEventRows(); + + await test.step('Highlighted shows the console error but not the click', async () => { + await page.goto('/search'); + await sessionsPage.goto(); + await sessionsPage.selectDataSource(); + await switchWhereToLucene(sessionsPage.whereLanguageSwitch); + await sessionsPage.search( + `SpanAttributes.session.id:"${OTEL_SESSIONS.newSdk}"`, + ); + await page.getByTestId(`session-card-${OTEL_SESSIONS.newSdk}`).click(); + await expect(rows.filter({ hasText: 'console.error' })).toBeVisible({ + timeout: 15000, + }); + await expect(rows.filter({ hasText: 'Clicked button' })).toHaveCount(0); + }); + + await test.step('All events shows the click, which opens its log row', async () => { + await panel.getByText('All Events').click(); + const click = rows.filter({ hasText: 'Clicked button' }); + await expect(click).toContainText('testid=checkout'); + await click.getByText('Clicked button').click(); + await expect(panel.getByTestId('side-panel-tab-overview')).toBeVisible(); + await expect(page.getByTestId('row-side-panel')).toHaveCount(0); + }); + + await test.step('Escape returns to the event list', async () => { + await page.keyboard.press('Escape'); + await expect(rows.first()).toBeVisible(); + }); + + await test.step('A session keyed on rum.sessionId shows no log events', async () => { + const logQueries: string[] = []; + // query_id marks ClickHouse queries; the app's own telemetry exports + // can carry the event name in captured URLs. + page.on('request', request => { + const body = request.postData() ?? ''; + if ( + new URL(request.url()).searchParams.has('query_id') && + body.includes('browser.user_action.click') + ) { + logQueries.push(body); + } + }); + await page.keyboard.press('Escape'); + await sessionsPage.search( + `SpanAttributes.session.id:"${OTEL_SESSIONS.bothKeys}"`, + ); + await page.getByTestId(`session-card-${OTEL_SESSIONS.bothKeys}`).click(); + await panel.getByText('All Events').click(); + // A log query would go out with the span query, so by the time the span + // row shows it would have been sent. + await expect(rows.first()).toBeVisible({ timeout: 15000 }); + await expect(rows.filter({ hasText: 'Clicked' })).toHaveCount(0); + expect(logQueries).toHaveLength(0); + }); + }); + test('shows one card for a session that carries both session id keys', async ({ page, }) => { diff --git a/packages/app/tests/e2e/fixtures/e2e-fixtures.json b/packages/app/tests/e2e/fixtures/e2e-fixtures.json index fa6681e1dc..b8c3b3179c 100644 --- a/packages/app/tests/e2e/fixtures/e2e-fixtures.json +++ b/packages/app/tests/e2e/fixtures/e2e-fixtures.json @@ -20,6 +20,7 @@ "serviceNameExpression": "ServiceName", "severityTextExpression": "SeverityText", "eventAttributesExpression": "LogAttributes", + "eventNameExpression": "EventName", "resourceAttributesExpression": "ResourceAttributes", "traceIdExpression": "TraceId", "spanIdExpression": "SpanId", diff --git a/packages/app/tests/e2e/seed-clickhouse.ts b/packages/app/tests/e2e/seed-clickhouse.ts index 5c4f25c48c..c101df91c6 100644 --- a/packages/app/tests/e2e/seed-clickhouse.ts +++ b/packages/app/tests/e2e/seed-clickhouse.ts @@ -30,6 +30,7 @@ import { E2E_TRACES_TABLE, } from './utils/constants'; import { + generateOtelSessionLogEvents, generateOtelSessionReplayRows, generateOtelSessionSpans, } from './seed-otel-sessions'; @@ -1013,6 +1014,15 @@ export async function seedClickHouse(): Promise { `); console.log(` Inserted ${numDataPoints} session trace data`); + console.log(' Inserting OTel browser log events...'); + await client.query(` + INSERT INTO ${E2E_CLICKHOUSE_DATABASE}.${E2E_LOGS_TABLE} ( + Timestamp, TraceId, SpanId, TraceFlags, SeverityText, SeverityNumber, + ServiceName, Body, ResourceSchemaUrl, ResourceAttributes, ScopeSchemaUrl, + ScopeName, ScopeVersion, ScopeAttributes, LogAttributes, EventName + ) VALUES ${generateOtelSessionLogEvents(seedRef)} + `); + // Insert session data console.log(' Inserting session data...'); await client.query(` diff --git a/packages/app/tests/e2e/seed-otel-sessions.ts b/packages/app/tests/e2e/seed-otel-sessions.ts index 4035cfc041..c1c6a0da15 100644 --- a/packages/app/tests/e2e/seed-otel-sessions.ts +++ b/packages/app/tests/e2e/seed-otel-sessions.ts @@ -56,6 +56,21 @@ export function generateOtelSessionSpans(seedRef: number): string { const { newSdk, bothKeys } = OTEL_SESSIONS; return [ `('${ns}', '${newSdk}-trace', '${newSdk}-span', '', '', 'GET', 'SPAN_KIND_CLIENT', '${SERVICE}', {'service.name':'${SERVICE}'}, '@opentelemetry/instrumentation-fetch', '', {'session.id':'${newSdk}','browser.document.url.full':'${PAGE_URL}','url.full':'${PAGE_URL}api/error','http.request.method':'GET','http.response.status_code':'500','error.type':'500'}, 12000000, 'Error', '', [], [], [], [], [], [], [])`, - `('${ns}', '${bothKeys}-trace', '${bothKeys}-span', '', '', 'documentFetch', 'SPAN_KIND_INTERNAL', '${SERVICE}', {'rum.sessionId':'${bothKeys}','service.name':'${SERVICE}'}, '', '', {'session.id':'${bothKeys}','component':'document-load'}, 3000000, 'Unset', '', [], [], [], [], [], [], [])`, + // url.full, which document-load sets, lists the span on the All tab. + `('${ns}', '${bothKeys}-trace', '${bothKeys}-span', '', '', 'documentFetch', 'SPAN_KIND_INTERNAL', '${SERVICE}', {'rum.sessionId':'${bothKeys}','service.name':'${SERVICE}'}, '', '', {'session.id':'${bothKeys}','component':'document-load','url.full':'${PAGE_URL}'}, 3000000, 'Unset', '', [], [], [], [], [], [], [])`, + ].join(',\n'); +} + +/** Browser events the OTel SDK records as log events, after the +500 ms span. */ +export function generateOtelSessionLogEvents(seedRef: number): string { + const ns = (offsetMs: number) => + (seedRef - 5 * 60 * 1000 + offsetMs) * 1_000_000; + const resource = `{'service.name':'${SERVICE}'}`; + const click = (sessionId: string) => + `('${ns(600)}', '', '', 0, '', 9, '${SERVICE}', '', '', ${resource}, '', '', '', {}, {'session.id':'${sessionId}','browser.tag_name':'BUTTON','browser.css_selector':'#root > main > button.checkout','browser.document.url.full':'${PAGE_URL}','browser.element.attributes.testid':'checkout'}, 'browser.user_action.click')`; + return [ + click(OTEL_SESSIONS.newSdk), + `('${ns(800)}', '', '', 0, 'error', 17, '${SERVICE}', 'Error: checkout failed', '', ${resource}, '', '', '', {}, {'session.id':'${OTEL_SESSIONS.newSdk}','browser.console.method':'error','browser.document.url.full':'${PAGE_URL}'}, 'browser.console')`, + click(OTEL_SESSIONS.bothKeys), ].join(',\n'); }