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/tender-monkeys-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

Fix bug when accessing session replay panel from search page
1 change: 1 addition & 0 deletions packages/app/src/SessionEventList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const EventRow = React.forwardRef(
return (
<div
data-index={dataIndex}
data-testid={`session-event-row-${dataIndex}`}
ref={ref}
className={cx(styles.eventRow, {
[styles.eventRowError]: event.isError,
Expand Down
5 changes: 4 additions & 1 deletion packages/app/src/SessionSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@ export default function SessionSidePanel({
className="border-start"
>
<ZIndexContext.Provider value={zIndex}>
<div className="d-flex flex-column h-100">
<div
className="d-flex flex-column h-100"
data-testid="session-side-panel"
>
<div>
<div className="p-3 d-flex align-items-center justify-content-between border-bottom border-dark">
<div style={{ width: '50%', maxWidth: 500 }}>
Expand Down
24 changes: 15 additions & 9 deletions packages/app/src/SessionSubpanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {

import DBRowSidePanel from '@/components/DBRowSidePanel';
import { RowWhereResult, WithClause } from '@/hooks/useRowWhere';
import { useZIndex, ZIndexContext } from '@/zIndex';

import SearchWhereInput from './components/SearchInput/SearchWhereInput';
import useFieldExpressionGenerator from './hooks/useFieldExpressionGenerator';
Expand Down Expand Up @@ -267,6 +268,8 @@ export default function SessionSubpanel({
whereLanguage?: SearchConditionLanguage;
onLanguageChange?: (lang: 'sql' | 'lucene') => void;
}) {
const contextZIndex = useZIndex();

const [rowId, setRowId] = useState<string | undefined>(undefined);
const [aliasWith, setAliasWith] = useState<WithClause[]>([]);

Expand Down Expand Up @@ -466,15 +469,18 @@ export default function SessionSubpanel({
<div className={styles.wrapper}>
{rowId != null && traceSource && (
<Portal>
<DBRowSidePanel
source={traceSource}
rowId={rowId}
aliasWith={aliasWith}
onClose={() => {
setDrawerOpen(false);
setRowId(undefined);
}}
/>
<ZIndexContext.Provider value={contextZIndex}>
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This was the actual file to fix the issue - the rest is adding/improving e2e tests

<DBRowSidePanel
source={traceSource}
rowId={rowId}
aliasWith={aliasWith}
isNestedPanel
onClose={() => {
setDrawerOpen(false);
setRowId(undefined);
}}
/>
</ZIndexContext.Provider>
</Portal>
)}
<div className={cx(styles.eventList, { 'd-none': playerFullWidth })}>
Expand Down
7 changes: 4 additions & 3 deletions packages/app/src/components/DBRowSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -506,9 +506,11 @@ const DBRowSidePanel = ({
</div>
)}
>
<div className="overflow-hidden flex-grow-1">
<div
className="overflow-hidden flex-grow-1"
data-testid="side-panel-tab-replay"
>
<DBSessionPanel
data-testid="side-panel-tab-replay"
dateRange={fourHourRange}
focusDate={focusDate}
setSubDrawerOpen={setSubDrawerOpen}
Expand Down Expand Up @@ -590,7 +592,6 @@ export default function DBRowSidePanelErrorBoundary({
<Drawer
opened={rowId != null}
withCloseButton={false}
withOverlay={!isNestedPanel}
onClose={() => {
if (!subDrawerOpen) {
_onClose();
Expand Down
77 changes: 77 additions & 0 deletions packages/app/tests/e2e/features/sessions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,81 @@ test.describe('Client Sessions Functionality', { tag: ['@sessions'] }, () => {
await sessionsPage.openFirstSession();
});
});

test(
'clicking a session event opens the event detail panel with tabs, not another session replay',
{ tag: ['@full-stack'] },
async ({ page }) => {
await test.step('Navigate and open a session (with sidePanelTab=replay pre-set in URL to simulate search-page flow)', async () => {
// Pre-set sidePanelTab=replay in the URL to simulate navigating from a search page
// row detail panel that had the Session Replay tab open. Without isNestedPanel=true,
// the inner DBRowSidePanel would inherit this URL param and open to the Replay tab again.
await page.goto('/search');
await sessionsPage.goto();
await sessionsPage.selectDataSource();
await expect(sessionsPage.getFirstSessionCard()).toBeVisible();
// Inject sidePanelTab=replay into the URL before opening the session
const currentUrl = page.url();
await page.goto(
currentUrl.includes('?')
? `${currentUrl}&sidePanelTab=replay`
: `${currentUrl}?sidePanelTab=replay`,
);
await expect(sessionsPage.getFirstSessionCard()).toBeVisible();
await sessionsPage.openFirstSession();
});

await test.step('Wait for session replay drawer and event rows to load', async () => {
await expect(sessionsPage.sessionSidePanel).toBeVisible();
// Wait for the session event list to populate (routeChange/console.error events are seeded)
await expect(sessionsPage.getSessionEventRows().first()).toBeVisible({
timeout: 15000,
});
});

await test.step('Click a session event row', async () => {
await sessionsPage.clickFirstSessionEvent();
});

await test.step('Event detail panel opens alongside the session replay — not replacing it', async () => {
// The row-side-panel must be visible (event detail drawer opened on top of session replay)
await expect(sessionsPage.rowSidePanel).toBeVisible();

// The original session replay panel must still be open (not replaced/closed)
await expect(sessionsPage.sessionSidePanel).toBeVisible();

// Only one session-side-panel must exist (not a second replay opened inside the detail panel)
await expect(page.getByTestId('session-side-panel')).toHaveCount(1);

// The row-side-panel must show the event detail TabBar (Overview, Trace, etc.)
// This guards against the regression where the inner panel re-opened session replay
// instead of showing event details (which has no TabBar, just the replay player)
await expect(
sessionsPage.rowSidePanel.getByTestId('side-panel-tabs'),
).toBeVisible();

// The inner panel must NOT be showing the Session Replay tab content.
// Without isNestedPanel=true (broken), the inner DBRowSidePanel reads sidePanelTab=replay
// from the URL (injected above) and renders the Session Replay tab content (side-panel-tab-replay).
// With isNestedPanel=true (fixed), the inner panel uses local state and ignores the URL,
// opening to its default tab (Trace/Overview) instead.
await expect(
sessionsPage.rowSidePanel.getByTestId('side-panel-tab-replay'),
).toHaveCount(0);
});

await test.step('Clicking the overlay closes the event detail panel but keeps the session replay open', async () => {
// Without the fix, withOverlay={!isNestedPanel} removed the overlay on nested panels,
// so there was nothing to click to close the panel (it had to be ESC only).
// With the fix (withOverlay always true), clicking the Mantine overlay dismisses the inner panel.
await sessionsPage.clickTopmostDrawerOverlay();

// The event detail panel must close
await expect(sessionsPage.rowSidePanel).toBeHidden();

// The session replay drawer must still be open
await expect(sessionsPage.sessionSidePanel).toBeVisible();
});
},
);
});
40 changes: 40 additions & 0 deletions packages/app/tests/e2e/page-objects/SessionsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,46 @@ export class SessionsPage {
await this.getFirstSessionCard().click();
}

/**
* Get the session side panel (the replay drawer)
*/
get sessionSidePanel() {
return this.page.getByTestId('session-side-panel');
}

/**
* Get all session event rows inside the replay drawer
*/
getSessionEventRows() {
return this.page.locator('[data-testid^="session-event-row-"]');
}

/**
* Click the first session event row to open its detail panel
*/
async clickFirstSessionEvent() {
await this.getSessionEventRows().first().click();
}

/**
* Get the row side panel (event detail drawer opened from within session replay)
*/
get rowSidePanel() {
return this.page.getByTestId('row-side-panel');
}

/**
* Click the Mantine overlay of the topmost open drawer to close it.
* Mantine renders one overlay per open Drawer. The last one belongs to
* the innermost (topmost) drawer.
*/
async clickTopmostDrawerOverlay() {
// Mantine overlays are siblings of the drawer content inside the portal root.
// Use the last one since the inner panel's overlay is rendered on top.
const overlay = this.page.locator('.mantine-Drawer-overlay').last();
await overlay.click({ position: { x: 10, y: 10 } });
}

// Getters for assertions

get form() {
Expand Down
23 changes: 23 additions & 0 deletions packages/app/tests/e2e/seed-clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,29 @@ function generateSessionTraces(
`('${timestampNs}', '${traceId}', '${spanId}', '', '', '${spanName}', 'SPAN_KIND_INTERNAL', 'browser', {'rum.sessionId':'${sessionId}','service.name':'browser'}, '', '', {'component':'${component}','page.url':'https://example.com/dashboard','teamId':'${teamId}','teamName':'${teamName}','userEmail':'${userEmail}','userName':'${userName}'}, 0, '${statusCode}', '', [], [], [], [], [], [], [])`,
);
}

// Add visible events for the session event list:
// - A routeChange (navigation) event — shown in both Highlighted and All Events tabs
// - A console.error event — shown in both tabs
const sessionUserIndex = i % 5;
const sessionUserEmail = `test${sessionUserIndex}@example.com`;
const sessionUserName = `Test User ${sessionUserIndex}`;
const sessionTeamId = 'test-team-id';
const sessionTeamName = 'Test Team';

const navTimestampNs = (baseTime - 1000) * 1000000;
const navTraceId = `session-nav-${i}`;
const navSpanId = `session-nav-span-${i}`;
rows.push(
`('${navTimestampNs}', '${navTraceId}', '${navSpanId}', '', '', 'routeChange', 'SPAN_KIND_INTERNAL', 'browser', {'rum.sessionId':'${sessionId}','service.name':'browser'}, '', '', {'component':'navigation','location.href':'https://example.com/dashboard','teamId':'${sessionTeamId}','teamName':'${sessionTeamName}','userEmail':'${sessionUserEmail}','userName':'${sessionUserName}'}, 0, 'STATUS_CODE_OK', '', [], [], [], [], [], [], [])`,
);

const errTimestampNs = (baseTime - 2000) * 1000000;
const errTraceId = `session-err-${i}`;
const errSpanId = `session-err-span-${i}`;
rows.push(
`('${errTimestampNs}', '${errTraceId}', '${errSpanId}', '', '', 'console.error', 'SPAN_KIND_INTERNAL', 'browser', {'rum.sessionId':'${sessionId}','service.name':'browser'}, '', '', {'component':'error','message':'E2E test error ${i}','teamId':'${sessionTeamId}','teamName':'${sessionTeamName}','userEmail':'${sessionUserEmail}','userName':'${sessionUserName}'}, 0, 'STATUS_CODE_ERROR', '', [], [], [], [], [], [], [])`,
);
}

return rows.join(',\n');
Expand Down
Loading