diff --git a/.changeset/browser-event-row-summary.md b/.changeset/browser-event-row-summary.md
new file mode 100644
index 0000000000..0b4e69838f
--- /dev/null
+++ b/.changeset/browser-event-row-summary.md
@@ -0,0 +1,5 @@
+---
+'@hyperdx/app': patch
+---
+
+feat: Row side panel summarizes clicks, navigations and console messages from OpenTelemetry browser SDKs, and navigation rows no longer show an HTTP Request section
diff --git a/packages/app/src/components/BrowserEventSubpanel.tsx b/packages/app/src/components/BrowserEventSubpanel.tsx
new file mode 100644
index 0000000000..2c7bcbb118
--- /dev/null
+++ b/packages/app/src/components/BrowserEventSubpanel.tsx
@@ -0,0 +1,78 @@
+import {
+ BrowserEventKind,
+ CONSOLE_METHOD_ATTRIBUTE,
+ firstLine,
+ getElementAttributes,
+} from '@/utils/browserEvents';
+import {
+ Attributes,
+ firstSetString,
+ isSet,
+} from '@/utils/spanAttributeReaders';
+
+import { RequestDetailRow, RequestDetailsTable } from './RequestDetailsTable';
+
+const PAGE_URL_KEY = 'browser.document.url.full';
+
+function read(attributes: Attributes, key: string): string | undefined {
+ return firstSetString(attributes, [key]);
+}
+
+// Map columns hold the flag as 'true'/'false', JSON columns as a Bool.
+function yesNo(value: unknown): string | undefined {
+ if (!isSet(value)) {
+ return undefined;
+ }
+ return String(value) === 'true' ? 'Yes' : 'No';
+}
+
+function getClickRows(attributes: Attributes): RequestDetailRow[] {
+ return [
+ ['Element', read(attributes, 'browser.tag_name')?.toLowerCase()],
+ ['CSS selector', read(attributes, 'browser.css_selector')],
+ ...getElementAttributes(attributes).map(
+ ([name, value]): RequestDetailRow => [`data-otel-${name}`, value],
+ ),
+ ['Page URL', read(attributes, PAGE_URL_KEY)],
+ ];
+}
+
+function getNavigationRows(attributes: Attributes): RequestDetailRow[] {
+ return [
+ ['URL', read(attributes, 'url.full')],
+ ['Navigation type', read(attributes, 'browser.navigation.type')],
+ ['Same document', yesNo(attributes['browser.navigation.same_document'])],
+ ['Hash change', yesNo(attributes['browser.navigation.hash_change'])],
+ ];
+}
+
+function getConsoleRows(
+ attributes: Attributes,
+ body: string | undefined,
+): RequestDetailRow[] {
+ return [
+ ['Method', read(attributes, CONSOLE_METHOD_ATTRIBUTE)],
+ // The SDK sends the console message as the body, never as an attribute.
+ ['Message', body ? firstLine(body) : undefined],
+ ['Page URL', read(attributes, PAGE_URL_KEY)],
+ ];
+}
+
+export function BrowserEventSubpanel({
+ kind,
+ attributes,
+ body,
+}: {
+ kind: BrowserEventKind;
+ /** The row's flattened event attributes. */
+ attributes: Attributes;
+ body: string | undefined;
+}) {
+ const rows =
+ kind === 'click'
+ ? getClickRows(attributes)
+ : kind === 'navigation'
+ ? getNavigationRows(attributes)
+ : getConsoleRows(attributes, body);
+ return ;
+}
diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx
index ddf77f48f5..35053b2b2f 100644
--- a/packages/app/src/components/DBRowDataPanel.tsx
+++ b/packages/app/src/components/DBRowDataPanel.tsx
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { flatten } from 'flat';
import type { ResponseJSON } from '@hyperdx/common-utils/dist/clickhouse';
+import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import {
isLogSource,
isTraceSource,
@@ -15,6 +16,7 @@ import {
getSelectAllColumnsQuerySettings,
useSelectAllColumnsQuerySettings,
} from '@/hooks/useMaterializedAliasColumnsOption';
+import { useColumns } from '@/hooks/useMetadata';
import { WithClause } from '@/hooks/useRowWhere';
import {
getDisplayedTimestampValueExpression,
@@ -43,6 +45,7 @@ export enum ROW_DATA_ALIASES {
DURATION_MS = '__hdx_duration_ms',
SPAN_KIND = '__hdx_span_kind',
SPAN_LINKS = '__hdx_span_links',
+ EVENT_NAME = '__hdx_event_name',
}
export function useRowData({
@@ -97,6 +100,46 @@ export function useRowData({
// select that instead of `*` when fetching full row data.
const knownColumns = getKnownColumnsList(source);
+ // A bare column comes back from `*` or the Known Columns List whenever the
+ // table has it, so it is read from the row instead of selected: older OTel
+ // log tables have no EventName column, and selecting it would fail the whole
+ // lookup.
+ const eventNameExpr = isLogSource(source)
+ ? source.eventNameExpression?.trim()
+ : undefined;
+ const eventNameColumn =
+ eventNameExpr && /^[A-Za-z_][A-Za-z0-9_]*$/.test(eventNameExpr)
+ ? eventNameExpr
+ : undefined;
+
+ // `*` leaves out ALIAS and MATERIALIZED columns, so the table's columns
+ // decide whether a bare column is selected after all. renderChartConfig
+ // already reads these cached columns before the lookup runs, so waiting on
+ // them adds no query. A Known Columns List names its columns, so it returns
+ // such a column whenever it lists it.
+ const checksEventNameColumn =
+ rowId != null && eventNameColumn != null && knownColumns == null;
+ const { data: columns, isLoading: isColumnsLoading } = useColumns(
+ tcFromSource(source),
+ {
+ enabled: checksEventNameColumn,
+ // A failed DESCRIBE only costs the event name, so the lookup does not
+ // wait out retries.
+ retry: false,
+ },
+ );
+ const isEventNameColumnPending = checksEventNameColumn && isColumnsLoading;
+ // A disabled query still returns columns another page has cached.
+ const eventNameColumnKind = checksEventNameColumn
+ ? columns?.find(column => column.name === eventNameColumn)?.default_type
+ : undefined;
+ const selectsEventNameColumn =
+ eventNameColumnKind === 'ALIAS' || eventNameColumnKind === 'MATERIALIZED';
+ const selectedEventNameExpr =
+ eventNameColumn == null || selectsEventNameColumn
+ ? eventNameExpr
+ : undefined;
+
const baseConfig = {
source: source.id,
connection: source.connection,
@@ -168,6 +211,14 @@ export function useRowData({
},
]
: []),
+ ...(selectedEventNameExpr
+ ? [
+ {
+ valueExpression: selectedEventNameExpr,
+ alias: ROW_DATA_ALIASES.EVENT_NAME,
+ },
+ ]
+ : []),
...(source.kind === SourceKind.Trace && source.spanEventsValueExpression
? [
{
@@ -214,7 +265,15 @@ export function useRowData({
const additionalQuerySettings = useSelectAllColumnsQuerySettings(source);
- const baseQueryKey = ['row_side_panel', rowId, aliasWith, source];
+ // This key replaces the config-derived one, so it has to carry the column
+ // check's effect on the select.
+ const baseQueryKey = [
+ 'row_side_panel',
+ rowId,
+ aliasWith,
+ source,
+ selectsEventNameColumn,
+ ];
// Both halves of the filter are needed for `renderChartConfig` to emit one, so
// a source with no usable timestamp expression can't be bounded at all.
const hasWindow = dateRange != null && timestampValueExpr != null;
@@ -228,7 +287,7 @@ export function useRowData({
},
{
queryKey: [...baseQueryKey, dateRange],
- enabled: rowId != null && hasWindow,
+ enabled: rowId != null && hasWindow && !isEventNameColumnPending,
additionalQuerySettings,
},
);
@@ -249,7 +308,7 @@ export function useRowData({
// often resolve from cache instead of scanning.
const fallbackResult = useQueriedChartConfig(baseConfig, {
queryKey: [...baseQueryKey, undefined],
- enabled: rowId != null && isFallbackActive,
+ enabled: rowId != null && isFallbackActive && !isEventNameColumnPending,
additionalQuerySettings,
});
@@ -259,7 +318,9 @@ export function useRowData({
// report loading until it settles rather than briefly claiming the row is
// absent.
const isLoading =
- queryResult.isLoading || (isBoundedEmpty && queryResult.isPending);
+ isEventNameColumnPending ||
+ queryResult.isLoading ||
+ (isBoundedEmpty && queryResult.isPending);
// Normalize resource, event and span-event exception attributes to always use flat keys for both JSON and Map columns
const normalizedData = useMemo(() => {
@@ -288,11 +349,15 @@ export function useRowData({
);
}
+ if (eventNameColumn != null && !selectsEventNameColumn) {
+ normalizedRow[ROW_DATA_ALIASES.EVENT_NAME] = row[eventNameColumn];
+ }
+
return {
...queryResult.data,
data: [normalizedRow],
};
- }, [queryResult.data]);
+ }, [queryResult.data, eventNameColumn, selectsEventNameColumn]);
return {
...queryResult,
diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx
index ebad17c2e9..49d6509654 100644
--- a/packages/app/src/components/DBRowOverviewPanel.tsx
+++ b/packages/app/src/components/DBRowOverviewPanel.tsx
@@ -1,11 +1,16 @@
import { use, useCallback, useMemo } from 'react';
import isString from 'lodash/isString';
import pickBy from 'lodash/pickBy';
-import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
+import {
+ isLogSource,
+ SourceKind,
+ TSource,
+} from '@hyperdx/common-utils/dist/types';
import { Accordion, Box, Flex, Text } from '@mantine/core';
import { WithClause } from '@/hooks/useRowWhere';
import { getEventBody } from '@/source';
+import { getBrowserEventKind, isBrowserEventRow } from '@/utils/browserEvents';
import { getHighlightedAttributesFromData } from '@/utils/highlightedAttributes';
import {
isJavaScriptRuntime,
@@ -15,6 +20,7 @@ import { resolveRowTimestampAnchor } from '@/utils/rowTimestamps';
import { getRequestPanelTitle } from '@/utils/rpcSpanAttributes';
import { isSet } from '@/utils/spanAttributeReaders';
+import { BrowserEventSubpanel } from './BrowserEventSubpanel';
import {
getJSONColumnNames,
getMapColumnNames,
@@ -98,6 +104,7 @@ export function RowOverviewPanel({
// assume OTel column names for now
const topLevelAttributeKeys = [
'ServiceName',
+ 'EventName',
'SpanName',
'Duration',
'SeverityText',
@@ -110,7 +117,16 @@ export function RowOverviewPanel({
'ScopeName',
'ScopeVersion',
];
- const topLevelAttributes = pickBy(firstRow, (value, key) => {
+ // `*` leaves out an ALIAS or MATERIALIZED EventName, so `useRowData` only
+ // returns it under its alias.
+ const rowWithEventName =
+ isLogSource(source) &&
+ source.eventNameExpression?.trim() === 'EventName' &&
+ firstRow?.EventName == null &&
+ firstRow?.__hdx_event_name != null
+ ? { ...firstRow, EventName: firstRow.__hdx_event_name }
+ : firstRow;
+ const topLevelAttributes = pickBy(rowWithEventName, (value, key) => {
if (value === '') {
return false;
}
@@ -151,6 +167,24 @@ export function RowOverviewPanel({
[flattenedEventAttributes],
);
+ // Only log sources select the event name, and gating here keeps every trace
+ // row out whatever attributes it carries.
+ const { browserEventKind, isBrowserRow } = useMemo(() => {
+ if (!isLogSource(source)) {
+ return { browserEventKind: undefined, isBrowserRow: false };
+ }
+
+ const row = {
+ eventName: firstRow?.__hdx_event_name,
+ attributes: flattenedEventAttributes,
+ };
+
+ return {
+ browserEventKind: getBrowserEventKind(row),
+ isBrowserRow: isBrowserEventRow(row),
+ };
+ }, [source, firstRow?.__hdx_event_name, flattenedEventAttributes]);
+
const exceptionAttributes = useMemo(() => {
const spanEventAttributes = firstRow?.__hdx_events_exception_attributes;
if (
@@ -271,6 +305,7 @@ export function RowOverviewPanel({
// In that case suppress the body paper entirely instead of
// rendering an "[Empty]" placeholder.
bodyConfigured={mainContentColumn !== undefined}
+ hideEmptyBody={isBrowserRow}
severityText={firstRow?.__hdx_severity_text}
rowData={firstRow}
/>
@@ -280,6 +315,7 @@ export function RowOverviewPanel({
mt="sm"
defaultValue={[
'exception',
+ 'browserEvent',
'spanEvents',
'spanLinks',
'linkedFrom',
@@ -311,7 +347,29 @@ export function RowOverviewPanel({
)}
- {requestPanelTitle != null && (
+ {browserEventKind != null && (
+
+
+
+ Browser event
+
+
+
+
+
+
+
+
+ )}
+
+ {requestPanelTitle != null && browserEventKind == null && (
diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx
index 161224a67e..b7fccb9640 100644
--- a/packages/app/src/components/DBRowSidePanel.tsx
+++ b/packages/app/src/components/DBRowSidePanel.tsx
@@ -52,6 +52,7 @@ import TabBar from '@/TabBar';
import { SearchConfig } from '@/types';
import { FormatTime } from '@/useFormatTime';
import { formatDistanceToNowStrictShort } from '@/utils';
+import { isBrowserEventRow } from '@/utils/browserEvents';
import { getHighlightedAttributesFromData } from '@/utils/highlightedAttributes';
import {
getRowLookupWindow,
@@ -1109,6 +1110,13 @@ export const DBRowSidePanelInner = ({
mainContent={mainContent}
mainContentHeader={mainContentColumn}
bodyShownInTitle={isBodyShownInTitle}
+ hideEmptyBody={
+ isLogSource(source) &&
+ isBrowserEventRow({
+ eventName: normalizedRow?.[ROW_DATA_ALIASES.EVENT_NAME],
+ attributes: normalizedRow?.[ROW_DATA_ALIASES.EVENT_ATTRIBUTES],
+ })
+ }
severityText={severityText}
rowData={normalizedRow}
/>
diff --git a/packages/app/src/components/DBRowSidePanelHeader.tsx b/packages/app/src/components/DBRowSidePanelHeader.tsx
index 8995a9ab7c..684dc9d092 100644
--- a/packages/app/src/components/DBRowSidePanelHeader.tsx
+++ b/packages/app/src/components/DBRowSidePanelHeader.tsx
@@ -28,6 +28,8 @@ export default function DBRowSidePanelHeader({
// trace span's name, for example), so the body paper is omitted rather
// than repeating the title directly below it.
bodyShownInTitle = false,
+ // Rows that never carry a body, such as browser events, skip the empty state.
+ hideEmptyBody = false,
severityText,
rowData,
}: {
@@ -35,6 +37,7 @@ export default function DBRowSidePanelHeader({
mainContentHeader?: string;
bodyConfigured?: boolean;
bodyShownInTitle?: boolean;
+ hideEmptyBody?: boolean;
attributes?: HighlightedAttribute[];
severityText?: string;
rowData?: Record;
@@ -90,7 +93,9 @@ export default function DBRowSidePanelHeader({
return (
<>
- {!bodyConfigured || bodyShownInTitle ? null : mainContent ? (
+ {!bodyConfigured ||
+ bodyShownInTitle ||
+ (hideEmptyBody && !mainContent) ? null : mainContent ? (
({
useQueriedChartConfig: jest.fn(),
}));
+type ColumnsResult = {
+ data?: { name: string; default_type: string }[];
+ isLoading: boolean;
+};
+let mockColumns: ColumnsResult;
+const mockUseColumns = jest.fn(
+ (_table: unknown, _options: { enabled?: boolean }) => mockColumns,
+);
+jest.mock('@/hooks/useMetadata', () => ({
+ useColumns: (table: unknown, options: { enabled?: boolean }) =>
+ mockUseColumns(table, options),
+}));
+
const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock;
const showMaterializedAliasColumns = () =>
localStorage.setItem('hdx-row-show-materialized-alias-columns', 'true');
@@ -32,6 +45,7 @@ describe('DBRowDataPanel', () => {
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
+ mockColumns = { data: undefined, isLoading: false };
mockUseQueriedChartConfig.mockReturnValue({
data: {
data: [],
@@ -126,6 +140,154 @@ describe('DBRowDataPanel', () => {
expect(config.select).not.toContainEqual({ valueExpression: '*' });
});
+ describe('event name', () => {
+ const eventNameAlias = expect.objectContaining({
+ alias: '__hdx_event_name',
+ });
+ const eventNameColumn = (default_type: string) => [
+ { name: 'Body', default_type: '' },
+ { name: 'EventName', default_type },
+ ];
+ const lookUp = (
+ overrides: Partial,
+ row: Record = {},
+ ) => {
+ mockUseQueriedChartConfig.mockReturnValue({
+ data: { data: [row], meta: [], rows: 1, isComplete: true },
+ isLoading: false,
+ isError: false,
+ isSuccess: true,
+ });
+ const { result } = renderHook(() =>
+ useRowData({ source: { ...source, ...overrides }, rowId: "id='a'" }),
+ );
+ const [[{ select }, { queryKey }]] = mockUseQueriedChartConfig.mock.calls;
+ return {
+ select,
+ queryKey,
+ row: result.current.data?.data[0],
+ checksColumns: mockUseColumns.mock.calls[0][1].enabled,
+ };
+ };
+
+ it('reads a plain column from the row instead of selecting it', () => {
+ mockColumns = { data: eventNameColumn(''), isLoading: false };
+ const { select, row, checksColumns } = lookUp(
+ { eventNameExpression: 'EventName' },
+ { EventName: 'browser.navigation' },
+ );
+ expect(checksColumns).toBe(true);
+ expect(select).not.toContainEqual(eventNameAlias);
+ expect(row?.__hdx_event_name).toBe('browser.navigation');
+ });
+
+ it.each(['ALIAS', 'MATERIALIZED'])(
+ 'selects a %s column, which `*` leaves out',
+ kind => {
+ mockColumns = { data: eventNameColumn(kind), isLoading: false };
+ const { select, row } = lookUp(
+ { eventNameExpression: 'EventName' },
+ { __hdx_event_name: 'browser.resource_timing' },
+ );
+ expect(select).toContainEqual({
+ valueExpression: 'EventName',
+ alias: '__hdx_event_name',
+ });
+ expect(row?.__hdx_event_name).toBe('browser.resource_timing');
+ },
+ );
+
+ it('keys the lookup by whether it selects the column', () => {
+ mockColumns = { data: eventNameColumn(''), isLoading: false };
+ const plain = lookUp({ eventNameExpression: 'EventName' }).queryKey;
+ mockUseQueriedChartConfig.mockClear();
+ mockColumns = { data: eventNameColumn('ALIAS'), isLoading: false };
+ const alias = lookUp({ eventNameExpression: 'EventName' }).queryKey;
+ expect(alias).not.toEqual(plain);
+ });
+
+ it('leaves the event name unset when the table has no such column', () => {
+ mockColumns = {
+ data: [{ name: 'Body', default_type: '' }],
+ isLoading: false,
+ };
+ const { select, row } = lookUp({ eventNameExpression: 'EventName' });
+ expect(select).not.toContainEqual(eventNameAlias);
+ expect(row?.__hdx_event_name).toBeUndefined();
+ });
+
+ it('reads the row when the table columns fail to load', () => {
+ const { select, row } = lookUp(
+ { eventNameExpression: 'EventName' },
+ { EventName: 'browser.navigation' },
+ );
+ expect(select).not.toContainEqual(eventNameAlias);
+ expect(row?.__hdx_event_name).toBe('browser.navigation');
+ });
+
+ it.each<{ lookup: string; dateRange?: [Date, Date] }>([
+ { lookup: 'unbounded' },
+ { lookup: 'bounded', dateRange: [new Date(0), new Date(1000)] },
+ ])(
+ 'waits for the table columns before the $lookup lookup',
+ ({ dateRange }) => {
+ mockColumns = { data: undefined, isLoading: true };
+ const { result } = renderHook(() =>
+ useRowData({
+ source: { ...source, eventNameExpression: 'EventName' },
+ rowId: "id='a'",
+ dateRange,
+ }),
+ );
+ for (const [, options] of mockUseQueriedChartConfig.mock.calls) {
+ expect(options.enabled).toBe(false);
+ }
+ expect(result.current.isLoading).toBe(true);
+ },
+ );
+
+ // Columns another page cached must not add a select that the Known
+ // Columns List leaves out.
+ it('selects nothing that a Known Columns List leaves out', () => {
+ mockColumns = { data: eventNameColumn('ALIAS'), isLoading: false };
+ const { select, row, checksColumns } = lookUp({
+ eventNameExpression: 'EventName',
+ knownColumnsListExpression: 'Timestamp, Body',
+ });
+ expect(checksColumns).toBe(false);
+ expect(select).not.toContainEqual(eventNameAlias);
+ expect(row?.__hdx_event_name).toBeUndefined();
+ });
+
+ it('reads a column that a Known Columns List names from the row', () => {
+ const { row } = lookUp(
+ {
+ eventNameExpression: 'EventName',
+ knownColumnsListExpression: 'Timestamp, Body, EventName',
+ },
+ { EventName: 'browser.navigation' },
+ );
+ expect(row?.__hdx_event_name).toBe('browser.navigation');
+ });
+
+ it('selects any other expression under the alias', () => {
+ const { select, checksColumns } = lookUp({
+ eventNameExpression: "LogAttributes['event.name']",
+ });
+ expect(checksColumns).toBe(false);
+ expect(select).toContainEqual({
+ valueExpression: "LogAttributes['event.name']",
+ alias: '__hdx_event_name',
+ });
+ });
+
+ it('reads no event name when the expression is unset', () => {
+ const { select, row } = lookUp({}, { EventName: 'browser.navigation' });
+ expect(select).not.toContainEqual(eventNameAlias);
+ expect(row?.__hdx_event_name).toBeUndefined();
+ });
+ });
+
it('adds no query settings while the viewer option is off', () => {
renderHook(() => useRowData({ source, rowId: "id='abc123'" }));
diff --git a/packages/app/src/components/__tests__/DBRowOverviewPanel.browserEvent.test.tsx b/packages/app/src/components/__tests__/DBRowOverviewPanel.browserEvent.test.tsx
new file mode 100644
index 0000000000..7e96d162b4
--- /dev/null
+++ b/packages/app/src/components/__tests__/DBRowOverviewPanel.browserEvent.test.tsx
@@ -0,0 +1,218 @@
+import React from 'react';
+import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
+import { screen, within } from '@testing-library/react';
+
+import { RowOverviewPanel } from '@/components/DBRowOverviewPanel';
+
+let mockRow: Record;
+jest.mock('../DBRowDataPanel', () => ({
+ useRowData: () => ({ data: { data: [mockRow] } }),
+ getJSONColumnNames: () => [],
+ getMapColumnNames: () => [],
+}));
+
+jest.mock('../NetworkPropertyPanel', () => ({
+ NetworkPropertySubpanel: () => null,
+}));
+
+jest.mock('../ExceptionSubpanel', () => ({ ExceptionSubpanel: () => null }));
+
+const mockJsonViewer = jest.fn((_props: { data: unknown }) => null);
+jest.mock('../DBRowJsonViewer', () => ({
+ DBRowJsonViewer: (props: { data: unknown }) => mockJsonViewer(props),
+}));
+
+jest.mock('../linkedSpans', () => ({
+ useReverseSpanLinks: () => ({ links: [] }),
+ useLinkedSpanDetails: () => ({ details: new Map() }),
+}));
+
+// `any`, not `as`, keeps this partial fixture off the lint warning budget.
+const asSource = (value: any): TSource => value;
+const LOG_SOURCE = asSource({
+ kind: SourceKind.Log,
+ eventAttributesExpression: 'LogAttributes',
+ bodyExpression: 'Body',
+});
+const TRACE_SOURCE = asSource({
+ kind: SourceKind.Trace,
+ eventAttributesExpression: 'SpanAttributes',
+});
+
+const PAGE_URL = 'http://localhost:5173/';
+const SELECTOR =
+ '#root > main > section.card.p-4:nth-child(2) > button.btn.btn-primary';
+const CLICK_ATTRIBUTES = {
+ 'browser.tag_name': 'BUTTON',
+ 'browser.css_selector': SELECTOR,
+ 'browser.document.url.full': PAGE_URL,
+ 'browser.element.attributes.testid': 'checkout',
+};
+
+function renderRow(
+ attributes: Record,
+ row: Record,
+ source = LOG_SOURCE,
+) {
+ mockRow = { __hdx_body: '', __hdx_event_attributes: attributes, ...row };
+ renderWithMantine();
+}
+
+const section = () => screen.getByTestId('browser-event-section');
+const expectDetail = (label: string, value: string) =>
+ expect(
+ within(section()).getByRole('row', { name: `${label} ${value}` }),
+ ).toBeInTheDocument();
+const noBodyPaper = () => screen.queryByText('No body for this event.');
+
+describe('RowOverviewPanel browser event section', () => {
+ it('summarizes a click and hides the empty body', () => {
+ renderRow(CLICK_ATTRIBUTES, {
+ __hdx_event_name: 'browser.user_action.click',
+ });
+
+ expect(within(section()).getByText('Browser event')).toBeInTheDocument();
+ expectDetail('Element', 'button');
+ expectDetail('CSS selector', SELECTOR);
+ expectDetail('data-otel-testid', 'checkout');
+ expectDetail('Page URL', PAGE_URL);
+ expect(noBodyPaper()).not.toBeInTheDocument();
+ });
+
+ it('shows a navigation instead of an HTTP request', () => {
+ renderRow(
+ {
+ 'url.full': `${PAGE_URL}checkout`,
+ 'browser.navigation.same_document': 'false',
+ 'browser.navigation.hash_change': 'false',
+ },
+ { __hdx_event_name: 'browser.navigation' },
+ );
+
+ expectDetail('URL', `${PAGE_URL}checkout`);
+ expectDetail('Same document', 'No');
+ expectDetail('Hash change', 'No');
+ expect(screen.queryByText('HTTP Request')).not.toBeInTheDocument();
+ });
+
+ it('reads navigation flags from a JSON column', () => {
+ renderRow(
+ {
+ 'browser.navigation.same_document': true,
+ 'browser.navigation.hash_change': false,
+ 'browser.navigation.type': 'push',
+ },
+ { __hdx_event_name: 'browser.navigation' },
+ );
+
+ expectDetail('Navigation type', 'push');
+ expectDetail('Same document', 'Yes');
+ expectDetail('Hash change', 'No');
+ });
+
+ it('shows the console method and the first line of the message', () => {
+ renderRow(
+ { 'browser.console.method': 'log' },
+ {
+ __hdx_event_name: 'browser.console',
+ __hdx_body: 'Checkout started\nstep 2',
+ },
+ );
+
+ expectDetail('Method', 'log');
+ expectDetail('Message', 'Checkout started');
+ expect(screen.getByText('Checkout started step 2')).toBeInTheDocument();
+ });
+
+ it('finds a click by its attributes when the row has no event name', () => {
+ renderRow(CLICK_ATTRIBUTES, {});
+
+ expectDetail('Element', 'button');
+ expect(noBodyPaper()).not.toBeInTheDocument();
+ });
+
+ it('keeps the HTTP request on a resource timing row', () => {
+ renderRow(
+ { 'url.full': `${PAGE_URL}assets/app.js` },
+ { __hdx_event_name: 'browser.resource_timing' },
+ );
+
+ expect(screen.getByText('HTTP Request')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('browser-event-section'),
+ ).not.toBeInTheDocument();
+ expect(noBodyPaper()).not.toBeInTheDocument();
+ });
+
+ it('keeps the exception section and the empty body on an exception', () => {
+ renderRow(
+ { 'exception.type': 'TypeError', 'exception.message': 'x is undefined' },
+ { __hdx_event_name: 'exception' },
+ );
+
+ expect(screen.getByText('Exception')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('browser-event-section'),
+ ).not.toBeInTheDocument();
+ expect(noBodyPaper()).toBeInTheDocument();
+ });
+
+ it('shows no section on a trace row', () => {
+ renderRow(CLICK_ATTRIBUTES, {}, TRACE_SOURCE);
+
+ expect(
+ screen.queryByTestId('browser-event-section'),
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe('RowOverviewPanel top level attributes', () => {
+ const topLevelData = () =>
+ mockJsonViewer.mock.calls.map(([props]) => props.data);
+
+ beforeEach(() => mockJsonViewer.mockClear());
+
+ it('lists the EventName column', () => {
+ renderRow(CLICK_ATTRIBUTES, {
+ ServiceName: 'web',
+ EventName: 'browser.user_action.click',
+ });
+
+ expect(topLevelData()).toContainEqual({
+ ServiceName: 'web',
+ EventName: 'browser.user_action.click',
+ });
+ });
+
+ it('leaves out an empty EventName', () => {
+ renderRow({}, { ServiceName: 'api', EventName: '' });
+
+ expect(topLevelData()).toContainEqual({ ServiceName: 'api' });
+ });
+
+ it('lists an ALIAS EventName from the row alias', () => {
+ renderRow(
+ CLICK_ATTRIBUTES,
+ { ServiceName: 'web', __hdx_event_name: 'browser.user_action.click' },
+ asSource({ ...LOG_SOURCE, eventNameExpression: 'EventName' }),
+ );
+
+ expect(topLevelData()).toContainEqual({
+ ServiceName: 'web',
+ EventName: 'browser.user_action.click',
+ });
+ });
+
+ it('does not list another event name expression as EventName', () => {
+ renderRow(
+ CLICK_ATTRIBUTES,
+ { ServiceName: 'web', __hdx_event_name: 'browser.user_action.click' },
+ asSource({
+ ...LOG_SOURCE,
+ eventNameExpression: "LogAttributes['event.name']",
+ }),
+ );
+
+ expect(topLevelData()).toContainEqual({ ServiceName: 'web' });
+ });
+});
diff --git a/packages/app/src/components/__tests__/DBRowSidePanel.browserEvent.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanel.browserEvent.test.tsx
new file mode 100644
index 0000000000..22aca215fa
--- /dev/null
+++ b/packages/app/src/components/__tests__/DBRowSidePanel.browserEvent.test.tsx
@@ -0,0 +1,169 @@
+import React from 'react';
+import { TSource } from '@hyperdx/common-utils/dist/types';
+import { MantineProvider } from '@mantine/core';
+import { render } from '@testing-library/react';
+
+jest.mock('nuqs', () => {
+ const actual = jest.requireActual('nuqs');
+ return {
+ ...actual,
+ useQueryState: (_key: string, parser?: { defaultValue?: unknown }) => [
+ parser && 'defaultValue' in parser ? parser.defaultValue : null,
+ jest.fn(),
+ ],
+ };
+});
+
+const mockRow: { current: Record } = { current: {} };
+jest.mock('../DBRowDataPanel', () => ({
+ __esModule: true,
+ useRowData: () => ({
+ data: { data: [mockRow.current] },
+ isLoading: false,
+ isSuccess: true,
+ isError: false,
+ error: null,
+ }),
+ ROW_DATA_ALIASES: {
+ DURATION_MS: '__hdx_duration',
+ SPAN_KIND: '__hdx_span_kind',
+ SERVICE_NAME: '__hdx_service_name',
+ SEVERITY_TEXT: '__hdx_severity_text',
+ EVENT_NAME: '__hdx_event_name',
+ EVENT_ATTRIBUTES: '__hdx_event_attributes',
+ },
+ rowHasK8sContext: () => false,
+ RowDataPanel: () => null,
+}));
+
+const mockHeaderProps: { current: { hideEmptyBody?: boolean } } = {
+ current: {},
+};
+jest.mock('../DBRowSidePanelHeader', () => ({
+ __esModule: true,
+ default: (props: { hideEmptyBody?: boolean }) => {
+ mockHeaderProps.current = props;
+ return null;
+ },
+}));
+
+jest.mock('@/source', () => ({
+ __esModule: true,
+ getEventBody: () => '__hdx_body',
+ useSource: () => ({ data: undefined }),
+}));
+jest.mock('../DBSessionPanel', () => ({
+ __esModule: true,
+ useSessionId: () => ({ rumSessionId: undefined, rumServiceName: undefined }),
+ DBSessionPanel: () => null,
+}));
+jest.mock('@/utils/highlightedAttributes', () => ({
+ __esModule: true,
+ getHighlightedAttributesFromData: () => [],
+}));
+jest.mock('../DBTracePanel', () => ({ __esModule: true, default: () => null }));
+jest.mock('../ContextSidePanel', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('../DBInfraPanel', () => ({ __esModule: true, default: () => null }));
+jest.mock('../DBRowOverviewPanel', () => ({
+ __esModule: true,
+ RowOverviewPanel: () => null,
+}));
+jest.mock('../DBRowSidePanelErrorState', () => ({
+ __esModule: true,
+ DBRowSidePanelErrorState: () => null,
+}));
+jest.mock('../SidePanelBreadcrumbs', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('../LogLevel', () => ({ __esModule: true, default: () => null }));
+jest.mock('../ServiceMap/ServiceMapSidePanel', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('../TimelineChart/utils', () => ({
+ __esModule: true,
+ renderMs: () => '',
+}));
+jest.mock('../DrawerUtils', () => ({
+ __esModule: true,
+ DrawerFullWidthToggle: () => null,
+ INITIAL_DRAWER_WIDTH_PERCENT: 50,
+}));
+jest.mock('@/LogSidePanelElements', () => ({
+ __esModule: true,
+ KeyboardShortcutsModal: () => null,
+}));
+jest.mock('@/TabBar', () => ({ __esModule: true, default: () => null }));
+jest.mock('@/useFormatTime', () => ({
+ __esModule: true,
+ FormatTime: () => null,
+}));
+
+// Imported after the mock factories above.
+import { DBRowSidePanelInner } from '@/components/DBRowSidePanel';
+import useSidePanelStack from '@/hooks/useSidePanelStack';
+import { makeLogSource, makeTraceSource } from '@/llm/__fixtures__/sources';
+
+const LOG_SOURCE = makeLogSource();
+const TRACE_SOURCE = makeTraceSource();
+
+function Harness({ source }: { source: TSource }) {
+ const sidePanelStack = useSidePanelStack({ initialRowId: 'row-1' });
+ return (
+
+ );
+}
+
+function hideEmptyBodyFor(source: TSource, row: Record) {
+ mockRow.current = { __hdx_body: '', ...row };
+ render(
+
+
+ ,
+ );
+ return mockHeaderProps.current.hideEmptyBody;
+}
+
+describe('DBRowSidePanelInner, empty body on browser event rows', () => {
+ beforeEach(() => {
+ mockHeaderProps.current = {};
+ });
+
+ // Only the event name identifies a resource timing row; it has no detection
+ // keys for the attribute fallback to find.
+ it('hides it on a log row the event name marks as a browser event', () => {
+ expect(
+ hideEmptyBodyFor(LOG_SOURCE, {
+ __hdx_event_name: 'browser.resource_timing',
+ __hdx_event_attributes: { 'url.full': 'https://example.com/app.js' },
+ }),
+ ).toBe(true);
+ });
+
+ it('keeps it on a log row with no browser event name or keys', () => {
+ expect(
+ hideEmptyBodyFor(LOG_SOURCE, {
+ __hdx_event_name: '',
+ __hdx_event_attributes: { 'http.route': '/api' },
+ }),
+ ).toBe(false);
+ });
+
+ it('keeps it on a trace row that carries click keys', () => {
+ expect(
+ hideEmptyBodyFor(TRACE_SOURCE, {
+ __hdx_event_attributes: { 'browser.tag_name': 'BUTTON' },
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/app/src/components/__tests__/DBRowSidePanelHeader.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanelHeader.test.tsx
index 9b49e56c3c..1a12539290 100644
--- a/packages/app/src/components/__tests__/DBRowSidePanelHeader.test.tsx
+++ b/packages/app/src/components/__tests__/DBRowSidePanelHeader.test.tsx
@@ -89,6 +89,20 @@ describe('DBRowSidePanelHeader: body section (HDX-4373)', () => {
expect(screen.queryByText('No body for this event.')).toBeInTheDocument();
});
+ it('drops the empty-state paper when hideEmptyBody is set', () => {
+ renderWithMantine();
+ expect(
+ screen.queryByText('No body for this event.'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('still renders a non-empty body when hideEmptyBody is set', () => {
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText('checkout failed')).toBeInTheDocument();
+ });
+
it('omits the body paper when the panel title already shows the body', () => {
renderWithMantine(
{
},
);
});
+
+describe('getBrowserEventKind', () => {
+ it.each([
+ [CLICK, {}, 'click'],
+ [NAVIGATION, {}, 'navigation'],
+ [CONSOLE, {}, 'console'],
+ [EXCEPTION, { 'exception.type': 'TypeError' }, undefined],
+ [undefined, { 'browser.tag_name': 'BUTTON' }, 'click'],
+ ['', { 'browser.console.method': 'log' }, 'console'],
+ // JSON columns keep the Bool, Map columns hold the string.
+ [undefined, { 'browser.navigation.same_document': false }, 'navigation'],
+ [undefined, { 'browser.navigation.same_document': 'false' }, 'navigation'],
+ [undefined, { 'url.full': 'http://localhost:5173/' }, undefined],
+ [undefined, undefined, undefined],
+ ])('reads event %p with %p as %p', (eventName, attributes, kind) => {
+ expect(getBrowserEventKind({ eventName, attributes })).toBe(kind);
+ });
+});
+
+describe('isBrowserEventRow', () => {
+ it.each([
+ ['browser.web_vital', {}, true],
+ [EXCEPTION, {}, false],
+ [undefined, { 'browser.tag_name': 'BUTTON' }, true],
+ [undefined, { 'service.name': 'api' }, false],
+ ])('reads event %p with %p as %p', (eventName, attributes, expected) => {
+ expect(isBrowserEventRow({ eventName, attributes })).toBe(expected);
+ });
+});
diff --git a/packages/app/src/utils/browserEvents.ts b/packages/app/src/utils/browserEvents.ts
index 6591d35d13..f023d5804a 100644
--- a/packages/app/src/utils/browserEvents.ts
+++ b/packages/app/src/utils/browserEvents.ts
@@ -38,7 +38,7 @@ function joinWords(...words: Array): string {
return words.filter(Boolean).join(' ');
}
-function firstLine(text: string): string {
+export function firstLine(text: string): string {
return text.split('\n', 1)[0].trim();
}
@@ -51,7 +51,9 @@ function setPairs(entries: [string, unknown][]): [string, string][] {
// 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][] {
+export function getElementAttributes(
+ attributes: Attributes,
+): [string, string][] {
const prefix = `${ELEMENT_ATTRIBUTES_KEY}.`;
const flatPairs = setPairs(
Object.entries(attributes)
@@ -153,6 +155,12 @@ function getExceptionLabel(
};
}
+function toAttributes(attributes: unknown): Attributes {
+ return isRecord(attributes)
+ ? flatten(attributes)
+ : {};
+}
+
export function getBrowserEventLabel({
eventName,
body,
@@ -163,9 +171,7 @@ export function getBrowserEventLabel({
/** Flat keys from a Map column or nested objects from a JSON column. */
attributes: unknown;
}): BrowserEventLabel {
- const flat: Attributes = isRecord(attributes)
- ? flatten(attributes)
- : {};
+ const flat = toAttributes(attributes);
const text = typeof body === 'string' ? body : '';
switch (eventName) {
case BROWSER_EVENT_NAMES.CLICK:
@@ -186,3 +192,52 @@ export function getBrowserEventLabel({
};
}
}
+
+export type BrowserEventKind = 'click' | 'navigation' | 'console';
+
+type BrowserEventRow = {
+ eventName: unknown;
+ /** Flat keys from a Map column or nested objects from a JSON column. */
+ attributes: unknown;
+};
+
+const KIND_BY_EVENT_NAME = new Map([
+ [BROWSER_EVENT_NAMES.CLICK, 'click'],
+ [BROWSER_EVENT_NAMES.NAVIGATION, 'navigation'],
+ [BROWSER_EVENT_NAMES.CONSOLE, 'console'],
+]);
+
+// A row has no event name when its source predates the Event Name Expression
+// field, or when its table (or Known Columns List) has no such column. The SDK
+// always sends these keys with their kind, and only with it.
+const KIND_ATTRIBUTE_KEYS: [BrowserEventKind, string[]][] = [
+ ['click', ['browser.tag_name', 'browser.css_selector']],
+ [
+ 'navigation',
+ [
+ 'browser.navigation.same_document',
+ 'browser.navigation.hash_change',
+ 'browser.navigation.type',
+ ],
+ ],
+ ['console', [CONSOLE_METHOD_ATTRIBUTE]],
+];
+
+export function getBrowserEventKind({
+ eventName,
+ attributes,
+}: BrowserEventRow): BrowserEventKind | undefined {
+ if (isSet(eventName)) {
+ return KIND_BY_EVENT_NAME.get(String(eventName));
+ }
+ const flat = toAttributes(attributes);
+ return KIND_ATTRIBUTE_KEYS.find(([, keys]) =>
+ keys.some(key => isSet(flat[key])),
+ )?.[0];
+}
+
+export function isBrowserEventRow(row: BrowserEventRow): boolean {
+ return isSet(row.eventName)
+ ? String(row.eventName).startsWith('browser.')
+ : getBrowserEventKind(row) != null;
+}
diff --git a/packages/app/tests/e2e/features/sessions.spec.ts b/packages/app/tests/e2e/features/sessions.spec.ts
index 6e84754f17..806af2c167 100644
--- a/packages/app/tests/e2e/features/sessions.spec.ts
+++ b/packages/app/tests/e2e/features/sessions.spec.ts
@@ -93,6 +93,7 @@ test.describe('Client Sessions Functionality', { tag: ['@sessions'] }, () => {
}) => {
const panel = sessionsPage.sessionSidePanel;
const rows = sessionsPage.getSessionEventRows();
+ const section = panel.getByTestId('browser-event-section');
await test.step('Highlighted shows the console error but not the click', async () => {
await page.goto('/search');
@@ -116,6 +117,14 @@ test.describe('Client Sessions Functionality', { tag: ['@sessions'] }, () => {
await click.getByText('Clicked button').click();
await expect(panel.getByTestId('side-panel-tab-overview')).toBeVisible();
await expect(page.getByTestId('row-side-panel')).toHaveCount(0);
+ // The Log Attributes viewer below repeats these values verbatim.
+ await expect(
+ section.getByText('data-otel-testid', { exact: true }),
+ ).toBeVisible();
+ await expect(
+ section.getByText('#root > main > button.checkout', { exact: true }),
+ ).toBeVisible();
+ await expect(panel.getByText('No body for this event.')).toHaveCount(0);
});
await test.step('Escape returns to the event list', async () => {
@@ -123,6 +132,15 @@ test.describe('Client Sessions Functionality', { tag: ['@sessions'] }, () => {
await expect(rows.first()).toBeVisible();
});
+ await test.step('A navigation opens as a browser event, not an HTTP request', async () => {
+ const navigation = rows.filter({ hasText: 'Navigated' });
+ await navigation.getByText('Navigated').click();
+ await expect(section.getByText('push', { exact: true })).toBeVisible();
+ await expect(panel.getByText('HTTP Request')).toHaveCount(0);
+ 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
diff --git a/packages/app/tests/e2e/seed-otel-sessions.ts b/packages/app/tests/e2e/seed-otel-sessions.ts
index 74ede603f4..ffd57684cb 100644
--- a/packages/app/tests/e2e/seed-otel-sessions.ts
+++ b/packages/app/tests/e2e/seed-otel-sessions.ts
@@ -23,7 +23,35 @@ function replayRows(
{
type: 2,
data: {
- node: { type: 0, childNodes: [], id: 1 },
+ // rrweb inserts its style rules into , so replay throws without one.
+ node: {
+ type: 0,
+ id: 1,
+ childNodes: [
+ {
+ type: 2,
+ tagName: 'html',
+ attributes: {},
+ id: 2,
+ childNodes: [
+ {
+ type: 2,
+ tagName: 'head',
+ attributes: {},
+ childNodes: [],
+ id: 3,
+ },
+ {
+ type: 2,
+ tagName: 'body',
+ attributes: {},
+ childNodes: [],
+ id: 4,
+ },
+ ],
+ },
+ ],
+ },
initialOffset: { left: 0, top: 0 },
},
},
@@ -74,7 +102,8 @@ const RESOURCE_TIMING = {
/**
* OTel SDK log events around the +500 ms span: the click 3 ms before it that
- * triggers its trace, its request timing, and a console error.
+ * triggers its trace, its request timing, a same-document navigation, and a
+ * console error.
*/
export function generateOtelSessionLogEvents(seedRef: number): string {
const ns = (offsetMs: number) =>
@@ -89,6 +118,7 @@ export function generateOtelSessionLogEvents(seedRef: number): string {
return [
click(newSdk),
`('${ns(520)}', '${newSdk}-trace', '${newSdk}-span', 0, '', 9, '${SERVICE}', '', '', ${resource}, '', '@opentelemetry/browser-instrumentation/resource-timing', '', {}, {'session.id':'${newSdk}','browser.document.url.full':'${PAGE_URL}','url.full':'${PAGE_URL}api/error',${timing}}, 'browser.resource_timing')`,
+ `('${ns(700)}', '', '', 0, '', 9, '${SERVICE}', '', '', ${resource}, '', '', '', {}, {'session.id':'${newSdk}','url.full':'${PAGE_URL}checkout','browser.document.url.full':'${PAGE_URL}checkout','browser.navigation.same_document':'true','browser.navigation.hash_change':'false','browser.navigation.type':'push'}, 'browser.navigation')`,
`('${ns(800)}', '', '', 0, 'error', 17, '${SERVICE}', 'Error: checkout failed', '', ${resource}, '', '', '', {}, {'session.id':'${newSdk}','browser.console.method':'error','browser.document.url.full':'${PAGE_URL}'}, 'browser.console')`,
click(bothKeys),
].join(',\n');