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');