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/session-replay-browser-log-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': patch
---

feat: Session replay shows clicks, navigations, console output and exceptions from OpenTelemetry browser SDKs
9 changes: 8 additions & 1 deletion packages/app/src/Playbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { keepPreviousData } from '@tanstack/react-query';

import { useQueriedChartConfig } from '@/hooks/useChartConfig';

import { mergeLogMarkers, SessionLogEvent } from './utils/sessionLogEvents';
import {
getSessionRowHttp,
SessionRow,
Expand All @@ -21,13 +22,15 @@ export default function Playbar({
playbackRange,
focus,
queriedConfig,
logEvents,
}: {
playerState: 'playing' | 'paused';
setPlayerState: (playerState: 'playing' | 'paused') => void;
focus: { ts: number; setBy: string } | undefined;
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();
Expand All @@ -49,7 +52,7 @@ export default function Playbar({
[data?.data],
);

const markers = useMemo<PlaybarMarker[]>(() => {
const spanMarkers = useMemo<PlaybarMarker[]>(() => {
return uniqBy(
events
.map(event => {
Expand Down Expand Up @@ -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 (
<PlaybarSlider
Expand Down
22 changes: 17 additions & 5 deletions packages/app/src/SessionEventList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import useRowWhere, { RowWhereResult } from '@/hooks/useRowWhere';

import { useQueriedChartConfig } from './hooks/useChartConfig';
import { useLogEventRows } from './hooks/useSessionLogEvents';
import type {
SessionEventOpen,
SessionLogEvents,
} from './utils/sessionLogEvents';
import {
getSessionRowHttp,
SessionRow,
Expand All @@ -28,7 +33,7 @@ import styles from '@styles/SessionSubpanelV2.module.scss';

type SessionEvent = {
id: string;
row: SessionRow; // original row object
open: SessionEventOpen;
isError: boolean;
isSuccess: boolean;
eventSource: 'navigation' | 'chat' | 'network' | 'custom' | 'log';
Expand Down Expand Up @@ -96,6 +101,7 @@ const EventRow = ({
export const SessionEventList = ({
queriedConfig,
aliasMap,
logEvents,
onClick,
onTimeClick,
focus,
Expand All @@ -105,6 +111,7 @@ export const SessionEventList = ({
}: {
queriedConfig: ChartConfigWithOptDateRange;
aliasMap: Record<string, string>;
logEvents: SessionLogEvents;
// highlightedResultId: string | undefined;
focus: { ts: number; setBy: string } | undefined;
minTs: number;
Expand All @@ -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;
Expand Down Expand Up @@ -167,7 +174,7 @@ export const SessionEventList = ({

return {
id: event.id,
row: event,
open: { kind: 'span', row: event },
isError,
isSuccess,
eventSource: isNavigation
Expand Down Expand Up @@ -216,6 +223,7 @@ export const SessionEventList = ({
}) ?? []
);
}, [events, showRelativeTime, minTs, formatTime]);
const rows = useLogEventRows(spanRows, logEvents, showRelativeTime, minTs);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Event list exceeds file limit

SessionEventList.tsx grows from 298 to 310 lines. The repository guide requires component files to stay under 300 lines, so this requirement must be met before merging. Extract EventRow into a sibling component to keep the file within that limit.

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex


const parentRef = React.useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -244,7 +252,7 @@ export const SessionEventList = ({
}
}, [currentEventIndex, eventsFollowPlayerPosition, rowVirtualizer]);

if (isLoading) {
if (isLoading || (logEvents.isLoading && spanRows.length === 0)) {
return (
<Stack p="sm" gap="xs">
<Skeleton height={36} />
Expand Down Expand Up @@ -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())}
/>
</div>
Expand Down
55 changes: 55 additions & 0 deletions packages/app/src/SessionEventSourcePanel.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof DBRowSidePanelInner>;

/**
* 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 ? (
<LogSourceEventPanel {...props} sourceId={sourceId} />
) : (
<DBRowSidePanelInner {...props} />
);
}

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 <Loader />;
}
return (
<RowSidePanelContext value={{ ...context, source }}>
<DBRowSidePanelInner {...props} source={source} />
</RowSidePanelContext>
);
}
20 changes: 15 additions & 5 deletions packages/app/src/SessionSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import { notifications } from '@mantine/notifications';
import { IconLink, IconX } from '@tabler/icons-react';

import {
DBRowSidePanelInner,
RowSidePanelContext,
SidePanelErrorFallback,
} from '@/components/DBRowSidePanel';
Expand All @@ -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';
Expand All @@ -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<SelectedEvent>(
Expand Down Expand Up @@ -137,11 +141,16 @@ export default function SessionSidePanel({
sidePanelStack.clearTrail();
}, [setSelectedEvent, sidePanelStack]);

const handleEventNavigate = useCallback(
(rowId: string, aliasWith: WithClause[]) => {
const handleEventNavigate = useCallback<SessionEventNavigate>(
(rowId, aliasWith, target) => {
clearSpanSelection();
sidePanelStack.clearTrail();
setSelectedEvent({ rowId, aliasWith, sessionId });
setSelectedEvent({
rowId,
aliasWith,
sessionId,
sourceId: target?.sourceId,
});
},
[clearSpanSelection, setSelectedEvent, sidePanelStack, sessionId],
);
Expand Down Expand Up @@ -229,7 +238,8 @@ export default function SessionSidePanel({
/>
)}
>
<DBRowSidePanelInner
<SessionEventSourcePanel
sourceId={selectedEvent.sourceId}
source={traceSource}
rowId={selectedEvent.rowId}
aliasWith={selectedEvent.aliasWith}
Expand Down
18 changes: 16 additions & 2 deletions packages/app/src/SessionSubpanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ import {
IconToggleRight,
} from '@tabler/icons-react';

import { RowWhereResult, WithClause } from '@/hooks/useRowWhere';
import { RowWhereResult } from '@/hooks/useRowWhere';
import { useSessionLogEvents } from '@/hooks/useSessionLogEvents';
import type { SessionIdKey } from '@/utils/sessionId';
import type { SessionEventNavigate } from '@/utils/sessionLogEvents';
import { buildSessionEventFilters } from '@/utils/sessions';

import SearchWhereInput from './components/SearchInput/SearchWhereInput';
Expand Down Expand Up @@ -234,7 +236,7 @@ export default function SessionSubpanel({
initialTs?: number;
whereLanguage?: SearchConditionLanguage;
onLanguageChange?: (lang: 'sql' | 'lucene') => void;
onEventNavigate?: (rowId: string, aliasWith: WithClause[]) => void;
onEventNavigate?: SessionEventNavigate;
}) {
const [tsQuery, setTsQuery] = useQueryState(
'ts',
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -485,6 +497,7 @@ export default function SessionSubpanel({
eventsFollowPlayerPosition={eventsFollowPlayerPosition}
aliasMap={aliasMap}
queriedConfig={eventsConfig}
logEvents={logEvents}
onClick={onSessionEventClick}
focus={focus}
onTimeClick={onSessionEventTimeClick}
Expand Down Expand Up @@ -528,6 +541,7 @@ export default function SessionSubpanel({
setFocus={setFocus}
playbackRange={playbackRange}
queriedConfig={eventsConfig}
logEvents={logEvents.events}
/>
)}
</div>
Expand Down
29 changes: 24 additions & 5 deletions packages/app/src/components/DBRowSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<SessionEventNavigate>(
(rowId, aliasWith, target) => {
const label = mainContent || 'Session Replay';
if (target?.sourceId === source.id) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major — Opening a same-source log event from the Session Replay tab leaves the panel on the Replay tab, so the clicked row is never shown

For a log row whose source is the trace's linked log source, handleNavigateToRow calls pushNav(..., preferredTab ?? defaultTab). In useSidePanelStack.ts, setTab remembers every tab outside NAVIGATIONAL_TABS ([Tab.Context, Tab.Logs]), so after the user opens Session Replay, preferredTab is Replay. The new log row belongs to the same session and also offers Replay, so the panel stays on the replay and only the breadcrumb changes. The cross-source branch avoids this because handleSourceStackPush always lands on Overview. Fix: for target?.sourceId === source.id, push the nav entry with Tab.Overview as the destination (call pushNav directly, or give handleNavigateToRow a destination-tab argument). Also extend the 'stays in the same source from a log on that source' test in DBRowSidePanel.traceLogsTab.test.tsx to assert that sidePanelTab is set to Tab.Overview; today it checks only sidePanelNavStack.

Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond

handleNavigateToRow(rowId, aliasWith, label, SourceKind.Log);
Comment on lines +625 to +626

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Log clicks reopen replay

When a user opens Session Replay from a log and selects a browser event on the same log source, handleNavigateToRow keeps the remembered Replay tab. If the selected event has a trace with a session, Replay remains available, so the panel shows the replay again instead of the selected log's details. Open these events on Overview, as the cross-source branch does.

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

} 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(
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/components/DBSessionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading