Repository navigation
feat: browser event summary in row side panel #3442
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9eec6d9
da6339d
9cf0608
4805a0c
f68b6ec
6b74ad6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)], | ||
| ]; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Navigation URL row ignores the
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Navigation URL row reads only Read the same keys the label reads: Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
|
|
||
| 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 <RequestDetailsTable rows={rows} />; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 major — A bare event-name column that is ALIAS or MATERIALIZED never reaches the row, so detection quietly falls back to attribute keys ClickHouse leaves ALIAS and MATERIALIZED columns out of Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @SpencerTorres I think we should fix this
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Bare event name column is lost when
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — A bare event-name column that is ALIAS or MATERIALIZED never reaches the row
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — A bare EventName that is an ALIAS or MATERIALIZED column is never read A bare identifier is only read from the Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — A bare event-name column that is MATERIALIZED or ALIAS is never read
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| 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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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({ | |
| </Accordion.Item> | ||
| )} | ||
|
|
||
| {requestPanelTitle != null && ( | ||
| {browserEventKind != null && ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The 'Browser event' section can open with nothing in it The section shows whenever Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| <Accordion.Item | ||
| value="browserEvent" | ||
| data-testid="browser-event-section" | ||
| > | ||
| <Accordion.Control> | ||
| <Text size="sm" ps={contentPx}> | ||
| Browser event | ||
| </Text> | ||
| </Accordion.Control> | ||
| <Accordion.Panel> | ||
| <Box px={contentPx}> | ||
| <BrowserEventSubpanel | ||
| kind={browserEventKind} | ||
| attributes={flattenedEventAttributes} | ||
| body={mainContent} | ||
| /> | ||
| </Box> | ||
| </Accordion.Panel> | ||
| </Accordion.Item> | ||
| )} | ||
|
|
||
| {requestPanelTitle != null && browserEventKind == null && ( | ||
| <Accordion.Item value="network"> | ||
| <Accordion.Control> | ||
| <Text size="sm" ps={contentPx}> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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={ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The browser-row check for
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Two copies of the empty-body decision, one per header caller
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The browser-row predicate is built in two places that must stay in sync
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The 'is this a browser log row' check is written out in two places
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Browser-row detection with the log-source gate is duplicated in two components
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The rule for hiding the empty body is written out twice and must be kept in sync by hand Both Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Browser-row detection is duplicated in DBRowSidePanel and RowOverviewPanel and has to be kept in sync by hand
Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| isLogSource(source) && | ||
| isBrowserEventRow({ | ||
| eventName: normalizedRow?.[ROW_DATA_ALIASES.EVENT_NAME], | ||
| attributes: normalizedRow?.[ROW_DATA_ALIASES.EVENT_ATTRIBUTES], | ||
| }) | ||
| } | ||
| severityText={severityText} | ||
| rowData={normalizedRow} | ||
| /> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 minor — Navigation URL row skips the
browser.document.url.fullfallback the event label usesgetNavigationLabelinutils/browserEvents.tsreads['url.full', 'browser.document.url.full'], but this row only readsurl.full. On a navigation row that carries only the document URL, the list shows "Navigated /checkout" while the panel shows no URL. If the flags are also missing,RequestDetailsTablereturns null and the section is an empty "Browser event" accordion, and HTTP Request is hidden too. Use the samefirstSetString(attributes, ['url.full', PAGE_URL_KEY])lookup, and don't render the accordion item when it has no rows.Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond