diff --git a/.changeset/trace-logs-side-panel-tab.md b/.changeset/trace-logs-side-panel-tab.md new file mode 100644 index 0000000000..d8846177d7 --- /dev/null +++ b/.changeset/trace-logs-side-panel-tab.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/app': minor +--- + +Add a "Trace logs" tab to the event side panel, listing the trace's logs as a flat chronological table over the same time window the waterfall uses. It appears on any row that carries a trace id and resolves a log source — a span (via the trace source's correlated log source) or a log (its own source). Previously the only route to a trace's logs was hunting for the green rows interleaved in the waterfall, which a log-heavy trace buries. + +The tab lists the trace unfiltered; "Open in search" hands the same query — same source, same trace, same window — to the search page for anything narrower. Picking a log opens it in the panel: a breadcrumb hop from a span, or a row change from another log. Sorting is ascending, which inside a trace is execution order. diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 36b7d4340a..17f3412748 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -80,6 +80,7 @@ import { import LogLevel from './LogLevel'; import SidePanelBreadcrumbs, { BreadcrumbItem } from './SidePanelBreadcrumbs'; import { SpanLinkData } from './SpanLinksSubpanel'; +import TraceLogsPanel from './TraceLogsPanel'; import { ViewTraceCalloutButton } from './ViewTraceCalloutButton'; import styles from '@/../styles/LogSidePanel.module.scss'; @@ -549,6 +550,14 @@ export const DBRowSidePanelInner = ({ ? source.traceSourceId : undefined; + const traceLogSourceId = isTraceSource(source) + ? childSourceId + : isLogSource(source) + ? source.id + : undefined; + const enableTraceLogs = !!traceId && !!traceLogSourceId; + const isOwnTraceLogSource = traceLogSourceId === source.id; + const enableServiceMap = traceId && traceSourceId; const { data: traceSourceData } = useSource({ id: traceSourceId }); @@ -611,6 +620,35 @@ export const DBRowSidePanelInner = ({ [traceSourceData, handleSourceStackPush, mainContent], ); + const handleTraceLogNavigate = useCallback( + (rowId: string, aliasWith: WithClause[], label: string) => { + if (traceLogSourceId == null) { + return; + } + // The row on screen is one of the logs listed, so this is a row change. + if (isOwnTraceLogSource) { + handleNavigateToRow(rowId, aliasWith, label, SourceKind.Log); + return; + } + handleSourceStackPush({ + sourceId: traceLogSourceId, + rowId, + aliasWith, + label, + sourceKind: SourceKind.Log, + // Every log in the trace is within seconds of the origin span. + focusTimestamp: rowFocusTimestamp, + }); + }, + [ + traceLogSourceId, + isOwnTraceLogSource, + handleNavigateToRow, + handleSourceStackPush, + rowFocusTimestamp, + ], + ); + // "Open trace" on a span link: the link carries only the linked span's // TraceId + SpanId, so resolve it against the current trace source. const handleOpenLinkedTrace = useCallback( @@ -786,6 +824,7 @@ export const DBRowSidePanelInner = ({ if (hasOverviewPanel && !sourceIsTrace) tabs.push(Tab.Overview); if (!sourceIsTrace) tabs.push(Tab.Parsed); if (sourceIsTrace) tabs.push(Tab.Trace); + if (enableTraceLogs) tabs.push(Tab.Logs); if (enableServiceMap) tabs.push(Tab.ServiceMap); tabs.push(Tab.Context); if (rumSessionId != null) tabs.push(Tab.Replay); @@ -794,6 +833,7 @@ export const DBRowSidePanelInner = ({ }, [ hasOverviewPanel, sourceIsTrace, + enableTraceLogs, enableServiceMap, rumSessionId, hasK8sContext, @@ -1020,6 +1060,14 @@ export const DBRowSidePanelInner = ({ }, ] : []), + ...(enableTraceLogs + ? [ + { + text: 'Trace Logs', + value: Tab.Logs, + }, + ] + : []), ...(enableServiceMap ? [ { @@ -1097,6 +1145,26 @@ export const DBRowSidePanelInner = ({ )} + {displayedTab === Tab.Logs && traceLogSourceId && traceId && ( + { + console.error(err); + }} + fallbackRender={() => ( +
+ An error occurred while rendering this event. +
+ )} + > + +
+ )} {displayedTab === Tab.ServiceMap && enableServiceMap && ( { diff --git a/packages/app/src/components/DBRowSidePanel.types.ts b/packages/app/src/components/DBRowSidePanel.types.ts index 78bdc2942a..1af6ad9154 100644 --- a/packages/app/src/components/DBRowSidePanel.types.ts +++ b/packages/app/src/components/DBRowSidePanel.types.ts @@ -6,6 +6,7 @@ export enum Tab { Parsed = 'parsed', Debug = 'debug', Trace = 'trace', + Logs = 'logs', ServiceMap = 'serviceMap', Context = 'context', Replay = 'replay', diff --git a/packages/app/src/components/TraceLogsPanel.tsx b/packages/app/src/components/TraceLogsPanel.tsx new file mode 100644 index 0000000000..fb1d813a2a --- /dev/null +++ b/packages/app/src/components/TraceLogsPanel.tsx @@ -0,0 +1,175 @@ +import { use, useCallback, useMemo } from 'react'; +import { useRouter } from 'next/router'; +import { buildSearchChartConfig } from '@hyperdx/common-utils/dist/core/searchChartConfig'; +import { + BuilderChartConfigWithDateRange, + SourceKind, +} from '@hyperdx/common-utils/dist/types'; +import { Button, Flex, Group } from '@mantine/core'; +import { IconExternalLink, IconLogs } from '@tabler/icons-react'; + +import EmptyState from '@/components/EmptyState'; +import { RowWhereResult, WithClause } from '@/hooks/useRowWhere'; +import { getEventBody, useSource } from '@/source'; +import { buildDirectTraceWhereClause } from '@/utils/directTrace'; +import { parseAsSortingStateString } from '@/utils/queryParsers'; + +import { RowSidePanelContext } from './DBRowSidePanel'; +import { DBSqlRowTable } from './DBRowTable'; + +export default function TraceLogsPanel({ + logSourceId, + traceId, + dateRange, + onNavigateToLog, + 'data-testid': dataTestId, +}: { + logSourceId: string; + traceId: string; + dateRange: [Date, Date]; + onNavigateToLog: ( + rowId: string, + aliasWith: WithClause[], + label: string, + ) => void; + 'data-testid'?: string; +}) { + const { data: logSource, isLoading } = useSource({ + id: logSourceId, + kinds: [SourceKind.Log], + }); + + // `min(1)` on the schema accepts a space, which would render as a bare `=`. + const traceIdExpression = logSource?.traceIdExpression?.trim(); + + const traceWhere = useMemo( + () => + traceIdExpression + ? buildDirectTraceWhereClause(traceIdExpression, traceId) + : undefined, + [traceIdExpression, traceId], + ); + + // Inside a trace, chronological order is execution order. + const orderBy = logSource + ? `${logSource.timestampValueExpression} ASC` + : undefined; + + const config = useMemo((): BuilderChartConfigWithDateRange | undefined => { + if (logSource == null || traceWhere == null) { + return undefined; + } + return { + // Carries the source's tableFilterExpression, sample weighting and id. + ...buildSearchChartConfig(logSource, { + where: traceWhere, + whereLanguage: 'sql', + orderBy, + }), + limit: { limit: 200 }, + dateRange, + }; + }, [logSource, traceWhere, orderBy, dateRange]); + + const handleRowDetailsClick = useCallback( + (rowWhere: RowWhereResult, row: Record) => { + // The table selects the source's expressions, not normalized aliases. + const body = logSource ? row[getEventBody(logSource) ?? ''] : undefined; + onNavigateToLog( + rowWhere.where, + rowWhere.aliasWith, + typeof body === 'string' && body.length > 0 ? body : 'Log', + ); + }, + [logSource, onNavigateToLog], + ); + + const parentContext = use(RowSidePanelContext); + const router = useRouter(); + + // These rows aren't the searched table's: its cell filter would write a log + // column into that source's filters, and its column removal maps by index. + const rowSidePanelContextValue = useMemo( + () => ({ + ...parentContext, + onPropertyAddClick: undefined, + displayedColumns: undefined, + toggleColumn: undefined, + }), + [parentContext], + ); + + if (isLoading) { + return null; + } + + if (config == null || logSource == null || traceWhere == null) { + return ( + } + title="Correlated logs unavailable" + description="This trace's log source is missing, or has no trace ID expression configured. Check the source's settings." + variant="card" + m="sm" + /> + ); + } + + const parsedSort = parseAsSortingStateString.parse(orderBy ?? ''); + const sortBy = parsedSort ? [parsedSort] : undefined; + + // Not `generateSearchUrl`: it stamps the search page's time range and filter + // pills, landing the reader on a different row set. `where` and `select` are + // read by `parseAsStringEncoded`, which decodes one level past the query + // string's own; `select` also seeds the page's SELECT input, which is blank + // without it. + // `router.basePath` rather than a root-relative path: a hard navigation isn't + // prefixed for the /clickstack build the way `next/link` would be. + const searchUrl = `${router.basePath}/search?${new URLSearchParams({ + source: logSource.id, + select: encodeURIComponent(logSource.defaultTableSelectExpression), + where: encodeURIComponent(traceWhere), + whereLanguage: 'sql', + from: dateRange[0].getTime().toString(), + to: dateRange[1].getTime().toString(), + isLive: 'false', + }).toString()}`; + + return ( + + + + + +
+ +
+
+
+ ); +} diff --git a/packages/app/src/components/__tests__/DBRowSidePanel.traceLogsTab.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanel.traceLogsTab.test.tsx new file mode 100644 index 0000000000..a6a4da0a6f --- /dev/null +++ b/packages/app/src/components/__tests__/DBRowSidePanel.traceLogsTab.test.tsx @@ -0,0 +1,328 @@ +import React from 'react'; +import { + SourceKind, + TLogSource, + TSource, + TTraceSource, +} from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { act, render } from '@testing-library/react'; + +// In-memory nuqs so the active tab can be seeded and the stack setters +// inspected. Prefixed with `mock` so jest.mock factories may reference them. +const mockQueryStore: Record = {}; +const mockSetters: Record = {}; + +function setterFor(key: string) { + if (!mockSetters[key]) mockSetters[key] = jest.fn(); + return mockSetters[key]; +} +function resetQueryState() { + Object.keys(mockQueryStore).forEach(k => delete mockQueryStore[k]); + Object.keys(mockSetters).forEach(k => delete mockSetters[k]); +} + +jest.mock('nuqs', () => { + const actual = jest.requireActual('nuqs'); + return { + ...actual, + useQueryState: (key: string, parser?: { defaultValue?: unknown }) => { + const hasValue = Object.prototype.hasOwnProperty.call( + mockQueryStore, + key, + ); + const fallback = + parser && 'defaultValue' in parser ? parser.defaultValue : null; + const value = hasValue ? mockQueryStore[key] : (fallback ?? null); + if (!mockSetters[key]) mockSetters[key] = jest.fn(); + return [value, mockSetters[key]]; + }, + }; +}); + +const mockUseRowData = jest.fn(); +jest.mock('../DBRowDataPanel', () => ({ + __esModule: true, + useRowData: (args: unknown) => mockUseRowData(args), + ROW_DATA_ALIASES: { + TIMESTAMP: '__hdx_timestamp', + DURATION_MS: '__hdx_duration', + SPAN_KIND: '__hdx_span_kind', + SERVICE_NAME: '__hdx_service_name', + SEVERITY_TEXT: '__hdx_severity_text', + }, + rowHasK8sContext: () => false, + RowDataPanel: () => null, + getJSONColumnNames: () => [], + getMapColumnNames: () => [], +})); + +const TRACE_SOURCE_WITH_LOGS: TTraceSource = { + id: 'trace-src', + kind: SourceKind.Trace, + name: 'Demo traces', + connection: 'conn', + from: { databaseName: 'default', tableName: 'otel_traces' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, ServiceName, SpanName', + durationExpression: 'Duration', + durationPrecision: 9, + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', + parentSpanIdExpression: 'ParentSpanId', + spanNameExpression: 'SpanName', + spanKindExpression: 'SpanKind', + resourceAttributesExpression: 'ResourceAttributes', + logSourceId: 'log-src', +}; + +const LOG_SOURCE: TLogSource = { + id: 'log-src', + kind: SourceKind.Log, + name: 'Demo logs', + connection: 'conn', + from: { databaseName: 'default', tableName: 'otel_logs' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, ServiceName, SeverityText, Body', + traceIdExpression: 'TraceId', + traceSourceId: 'trace-src', + resourceAttributesExpression: 'ResourceAttributes', +}; + +jest.mock('@/source', () => ({ + __esModule: true, + getEventBody: () => '__hdx_body', + useSource: ({ id }: { id: string | null }) => + id === 'trace-src' + ? { data: TRACE_SOURCE_WITH_LOGS } + : id === 'log-src' + ? { data: LOG_SOURCE } + : { data: undefined }, +})); + +type TabBarProps = { + items?: { text: string; value: string }[]; + activeItem?: unknown; + onClick?: (v: unknown) => void; +}; +const mockTabBarProps: { current: TabBarProps } = { current: {} }; +jest.mock('@/TabBar', () => ({ + __esModule: true, + default: (props: TabBarProps) => { + mockTabBarProps.current = props; + return null; + }, +})); + +type TraceLogsPanelProps = { + logSourceId?: string; + traceId?: string; + onNavigateToLog?: ( + rowId: string, + aliasWith: unknown[], + label: string, + ) => void; +}; +const mockTraceLogsProps: { current: TraceLogsPanelProps } = { current: {} }; +jest.mock('../TraceLogsPanel', () => ({ + __esModule: true, + default: (props: TraceLogsPanelProps) => { + mockTraceLogsProps.current = props; + return null; + }, +})); + +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('../DBRowSidePanelHeader', () => ({ + __esModule: true, + default: () => 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('@/useFormatTime', () => ({ + __esModule: true, + FormatTime: () => null, +})); + +// NOTE: imported after the mock factories above. +import { DBRowSidePanelInner } from '@/components/DBRowSidePanel'; +import { Tab } from '@/components/DBRowSidePanel.types'; +import useSidePanelStack from '@/hooks/useSidePanelStack'; + +const TRACE_ID = '7316d5a2ab0dc2efa72258f64a98a405'; +const SPAN_ID = 'e3748131832d6176'; +const TIMESTAMP_VALUE = '2024-05-01T10:00:00.123456789Z'; + +function rowResult(row: Record) { + return { + data: { + data: [row], + meta: [{ name: '__hdx_timestamp_value_0', type: 'DateTime64(9)' }], + }, + isLoading: false, + isSuccess: true, + isError: false, + error: null, + }; +} + +const ROW_WITH_TRACE = { + __hdx_trace_id: TRACE_ID, + __hdx_span_id: SPAN_ID, + __hdx_timestamp: TIMESTAMP_VALUE, + __hdx_timestamp_value_0: TIMESTAMP_VALUE, + __hdx_body: 'a log line', +}; + +function renderPanel(source: TSource, { rowId = 'row-1' } = {}) { + function Harness() { + const sidePanelStack = useSidePanelStack({ initialRowId: rowId }); + return ( + + ); + } + return render( + + + , + ); +} + +function tabValues() { + return (mockTabBarProps.current.items ?? []).map(item => item.value); +} + +function clickLogRow(rowId: string, label = 'another log line') { + act(() => mockTraceLogsProps.current.onNavigateToLog?.(rowId, [], label)); +} + +describe('DBRowSidePanelInner — trace logs tab', () => { + beforeEach(() => { + resetQueryState(); + localStorage.clear(); + mockTabBarProps.current = {}; + mockTraceLogsProps.current = {}; + mockUseRowData.mockReset(); + mockUseRowData.mockReturnValue(rowResult(ROW_WITH_TRACE)); + }); + + it('offers the tab on a span, pointed at the correlated log source', () => { + mockQueryStore.sidePanelTab = Tab.Logs; + renderPanel(TRACE_SOURCE_WITH_LOGS); + + expect(tabValues()).toContain(Tab.Logs); + expect(mockTraceLogsProps.current.logSourceId).toBe('log-src'); + expect(mockTraceLogsProps.current.traceId).toBe(TRACE_ID); + }); + + it('offers the tab on a log, pointed at its own source', () => { + mockQueryStore.sidePanelTab = Tab.Logs; + renderPanel(LOG_SOURCE); + + expect(tabValues()).toContain(Tab.Logs); + expect(mockTraceLogsProps.current.logSourceId).toBe('log-src'); + }); + + it('hides the tab when the trace source has no correlated log source', () => { + renderPanel({ ...TRACE_SOURCE_WITH_LOGS, logSourceId: undefined }); + + expect(tabValues()).not.toContain(Tab.Logs); + }); + + it('hides the tab when the row carries no trace id', () => { + mockUseRowData.mockReturnValue( + rowResult({ ...ROW_WITH_TRACE, __hdx_trace_id: '' }), + ); + renderPanel(LOG_SOURCE); + + expect(tabValues()).not.toContain(Tab.Logs); + }); + + it('pushes a cross-source frame when a log is picked from a span', () => { + mockQueryStore.sidePanelTab = Tab.Logs; + renderPanel(TRACE_SOURCE_WITH_LOGS); + + clickLogRow("TraceId='abc' AND SpanId='def'"); + + const frame = setterFor('sidePanelSourceStack').mock.calls[0][0][0]; + expect(frame).toMatchObject({ + sourceId: 'log-src', + rowId: "TraceId='abc' AND SpanId='def'", + label: 'another log line', + sourceKind: 'log', + focusTimestamp: new Date(TIMESTAMP_VALUE).toISOString(), + }); + expect(setterFor('sidePanelNavStack')).toHaveBeenCalledWith([]); + }); + + it('stays in the same source when a log is picked from another log', () => { + mockQueryStore.sidePanelTab = Tab.Logs; + renderPanel(LOG_SOURCE); + + clickLogRow('row-2'); + + expect(setterFor('sidePanelNavStack').mock.calls[0][0][0]).toMatchObject({ + rowId: 'row-2', + label: 'another log line', + sourceKind: 'log', + }); + expect(setterFor('sidePanelSourceStack')).toHaveBeenCalledWith([]); + }); + + it('lands the picked log on a reading view, not on another logs list', () => { + mockQueryStore.sidePanelTab = Tab.Logs; + renderPanel(LOG_SOURCE); + + clickLogRow('row-2'); + + expect(setterFor('sidePanelTab')).toHaveBeenCalledWith(Tab.Overview); + }); +}); diff --git a/packages/app/src/components/__tests__/TraceLogsPanel.test.tsx b/packages/app/src/components/__tests__/TraceLogsPanel.test.tsx new file mode 100644 index 0000000000..72f047a1b4 --- /dev/null +++ b/packages/app/src/components/__tests__/TraceLogsPanel.test.tsx @@ -0,0 +1,270 @@ +import React from 'react'; +import { + BuilderChartConfigWithDateRange, + SourceKind, + TLogSource, +} from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { act, render, screen } from '@testing-library/react'; + +type RowTableProps = { + config?: BuilderChartConfigWithDateRange; + sourceId?: string; + tableId?: string; + initialSortBy?: { id: string; desc: boolean }[]; + onRowDetailsClick?: ( + rowWhere: { where: string; aliasWith: unknown[] }, + row: Record, + ) => void; +}; +const mockRowTableProps: { current: RowTableProps } = { current: {} }; +const mockRowTableContext: { current: Record } = { + current: {}, +}; +jest.mock('../DBRowTable', () => { + const react = jest.requireActual('react'); + const panel = jest.requireMock<{ + RowSidePanelContext: React.Context>; + }>('../DBRowSidePanel'); + return { + __esModule: true, + DBSqlRowTable: (props: RowTableProps) => { + mockRowTableProps.current = props; + mockRowTableContext.current = react.use(panel.RowSidePanelContext); + return null; + }, + }; +}); + +jest.mock('../DBRowSidePanel', () => { + const react = jest.requireActual('react'); + return { + __esModule: true, + RowSidePanelContext: react.createContext({ + displayedColumns: ['Timestamp', 'Body'], + toggleColumn: jest.fn(), + onPropertyAddClick: jest.fn(), + generateSearchUrl: ({ + where, + whereLanguage, + source, + }: { + where: string; + whereLanguage: string; + source?: { id: string }; + }) => + `/search?where=${encodeURIComponent(where)}&whereLanguage=${whereLanguage}&source=${source?.id}`, + }), + deriveRowSidePanelContextForSource: (parent: unknown) => parent, + }; +}); + +let mockBasePath = ''; +jest.mock('next/router', () => ({ + __esModule: true, + useRouter: () => ({ basePath: mockBasePath }), +})); + +const mockUseSource = jest.fn(); +jest.mock('@/source', () => ({ + __esModule: true, + useSource: (args: unknown) => mockUseSource(args), + getEventBody: (source: { bodyExpression?: string }) => source.bodyExpression, +})); + +// NOTE: imported after the mock factories above. +import TraceLogsPanel from '@/components/TraceLogsPanel'; + +const TRACE_ID = '7316d5a2ab0dc2efa72258f64a98a405'; +const DATE_RANGE: [Date, Date] = [ + new Date('2024-05-01T09:00:00Z'), + new Date('2024-05-01T11:00:00Z'), +]; + +const LOG_SOURCE: TLogSource = { + id: 'log-src', + kind: SourceKind.Log, + name: 'Demo logs', + connection: 'conn', + from: { databaseName: 'default', tableName: 'otel_logs' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, ServiceName, SeverityText, Body', + bodyExpression: 'Body', + implicitColumnExpression: 'Body', + traceIdExpression: 'TraceId', +}; + +function renderPanel({ + source = LOG_SOURCE, + onNavigateToLog = jest.fn(), + traceId = TRACE_ID, +}: { + /** Null for a source that no longer resolves. */ + source?: TLogSource | null; + onNavigateToLog?: jest.Mock; + traceId?: string; +} = {}) { + mockUseSource.mockReturnValue({ + data: source ?? undefined, + isLoading: false, + }); + render( + + + , + ); + return { onNavigateToLog }; +} + +function searchParams() { + const href = screen + .getByTestId('trace-logs-open-in-search') + .getAttribute('href')!; + return new URLSearchParams(href.split('?')[1]); +} + +describe('TraceLogsPanel', () => { + beforeEach(() => { + mockRowTableProps.current = {}; + mockRowTableContext.current = {}; + mockUseSource.mockReset(); + mockBasePath = ''; + }); + + it('scopes the table to the trace', () => { + renderPanel(); + + const config = mockRowTableProps.current.config!; + expect(config.where).toBe(`TraceId = '${TRACE_ID}'`); + expect(config.whereLanguage).toBe('sql'); + expect(config.select).toBe(LOG_SOURCE.defaultTableSelectExpression); + expect(config.orderBy).toBe('Timestamp ASC'); + expect(config.dateRange).toBe(DATE_RANGE); + expect(config.limit).toEqual({ limit: 200 }); + }); + + it("keeps the source's mandatory table filter", () => { + renderPanel({ + source: { + ...LOG_SOURCE, + tableFilterExpression: "ServiceName != 'internal'", + }, + }); + + const config = mockRowTableProps.current.config!; + expect(config.filters).toEqual([ + { type: 'sql', condition: "ServiceName != 'internal'" }, + ]); + expect(config.source).toBe('log-src'); + }); + + it('escapes a trace id that carries SQL metacharacters', () => { + renderPanel({ traceId: "abc' OR 1=1--" }); + + expect(mockRowTableProps.current.config?.where).toBe( + "TraceId = 'abc\\' OR 1=1--'", + ); + }); + + it("drops the searched table's row actions", () => { + renderPanel(); + + expect(mockRowTableContext.current.toggleColumn).toBeUndefined(); + expect(mockRowTableContext.current.displayedColumns).toBeUndefined(); + expect(mockRowTableContext.current.onPropertyAddClick).toBeUndefined(); + }); + + it("labels a picked row with the log's body", () => { + const { onNavigateToLog } = renderPanel(); + + act(() => + mockRowTableProps.current.onRowDetailsClick?.( + { where: 'row-2', aliasWith: [] }, + { Body: 'upstream timeout' }, + ), + ); + + expect(onNavigateToLog).toHaveBeenCalledWith( + 'row-2', + [], + 'upstream timeout', + ); + }); + + it('falls back to a generic label when the body column is not selected', () => { + const { onNavigateToLog } = renderPanel(); + + act(() => + mockRowTableProps.current.onRowDetailsClick?.( + { where: 'row-2', aliasWith: [] }, + { ServiceName: 'cart' }, + ), + ); + + expect(onNavigateToLog).toHaveBeenCalledWith('row-2', [], 'Log'); + }); + + it('links to a search that reproduces the table', () => { + renderPanel(); + + const params = searchParams(); + expect(params.get('source')).toBe('log-src'); + expect(params.get('whereLanguage')).toBe('sql'); + expect(params.get('from')).toBe(DATE_RANGE[0].getTime().toString()); + expect(params.get('to')).toBe(DATE_RANGE[1].getTime().toString()); + // `parseAsStringEncoded` decodes one level past the query string's own. + expect(decodeURIComponent(params.get('where')!)).toBe( + `TraceId = '${TRACE_ID}'`, + ); + expect(decodeURIComponent(params.get('select')!)).toBe( + LOG_SOURCE.defaultTableSelectExpression, + ); + expect(params.get('orderBy')).toBeNull(); + }); + + it("prefixes the link with the deployment's base path", () => { + // A hard navigation isn't prefixed the way `next/link` would be, and the + // ClickHouse build mounts the app under /clickstack. + mockBasePath = '/clickstack'; + renderPanel(); + + expect( + screen + .getByTestId('trace-logs-open-in-search') + .getAttribute('href') + ?.startsWith('/clickstack/search?'), + ).toBe(true); + }); + + it('tells the table its own identity and sort state', () => { + renderPanel(); + + expect(mockRowTableProps.current.tableId).toBe('trace-logs'); + expect(mockRowTableProps.current.initialSortBy).toEqual([ + { id: 'Timestamp', desc: false }, + ]); + }); + + it.each([ + ['the source no longer resolves', { source: null }], + [ + 'the source has no trace id column', + { source: { ...LOG_SOURCE, traceIdExpression: undefined } }, + ], + // `min(1)` on the schema accepts a space. + [ + 'the trace id column is whitespace', + { source: { ...LOG_SOURCE, traceIdExpression: ' ' } }, + ], + ])('explains itself instead of querying when %s', (_label, props) => { + renderPanel(props); + + expect(screen.getByText('Correlated logs unavailable')).toBeVisible(); + expect(mockRowTableProps.current.config).toBeUndefined(); + }); +}); diff --git a/packages/app/src/hooks/__tests__/useSidePanelStack.test.tsx b/packages/app/src/hooks/__tests__/useSidePanelStack.test.tsx index 5f2580e4fa..f59d3d19b6 100644 --- a/packages/app/src/hooks/__tests__/useSidePanelStack.test.tsx +++ b/packages/app/src/hooks/__tests__/useSidePanelStack.test.tsx @@ -438,19 +438,24 @@ describe('useSidePanelStack', () => { expect(readLastTab()).toBe(Tab.Parsed); }); - it('does not remember a navigational tab as a reading view', () => { - const { result } = renderHook(() => - useSidePanelStack({ initialRowId: 'root-1' }), - ); - act(() => result.current.setTab(Tab.Parsed)); - // Opening Surrounding Context to hunt for a neighbouring row must not cost - // the reader the view they had chosen. - act(() => result.current.setTab(Tab.Context)); - - expect(readLastTab()).toBe(Tab.Parsed); - // It is still the active tab for the current row, just not remembered. - expect(setterFor('sidePanelTab')).toHaveBeenLastCalledWith(Tab.Context); - }); + it.each([Tab.Context, Tab.Logs])( + 'does not remember %s as a reading view', + navigationalTab => { + const { result } = renderHook(() => + useSidePanelStack({ initialRowId: 'root-1' }), + ); + act(() => result.current.setTab(Tab.Parsed)); + // Opening Surrounding Context or the trace's logs to hunt for a row + // must not cost the reader the view they had chosen. + act(() => result.current.setTab(navigationalTab)); + + expect(readLastTab()).toBe(Tab.Parsed); + // It is still the active tab for the current row, just not remembered. + expect(setterFor('sidePanelTab')).toHaveBeenLastCalledWith( + navigationalTab, + ); + }, + ); it('exposes the reading view separately from where the URL points', () => { seedLastTab(Tab.Parsed); diff --git a/packages/app/src/hooks/useSidePanelStack.ts b/packages/app/src/hooks/useSidePanelStack.ts index 2e1c3d95f5..8b6fe15577 100644 --- a/packages/app/src/hooks/useSidePanelStack.ts +++ b/packages/app/src/hooks/useSidePanelStack.ts @@ -15,11 +15,11 @@ export const LAST_TAB_STORAGE_KEY = 'hdx-side-panel-last-tab'; /** * Tabs that are a way to *find* another row rather than a way to read the - * current one, so they never become the remembered preference. Opening - * Surrounding Context to pick out a neighbour shouldn't cost you the reading - * view you had chosen, nor strand you on another context list once you land. + * current one, so they never become the remembered preference. Opening one to + * pick out a row shouldn't cost you the reading view you had chosen, nor + * strand you on another list once you land. */ -const NAVIGATIONAL_TABS: readonly Tab[] = [Tab.Context]; +const NAVIGATIONAL_TABS: readonly Tab[] = [Tab.Context, Tab.Logs]; const EMPTY_SOURCE_STACK: SourceFrame[] = []; const EMPTY_NAV_STACK: NavEntry[] = []; diff --git a/packages/app/src/utils/rowTimestamps.ts b/packages/app/src/utils/rowTimestamps.ts index 802051e1f3..e5e57c85e3 100644 --- a/packages/app/src/utils/rowTimestamps.ts +++ b/packages/app/src/utils/rowTimestamps.ts @@ -99,12 +99,13 @@ export function resolveRowTimestampAnchor({ * * Asymmetric because the window is derived from the *origin* row's instant but * filtered against the *destination* source's `timestampValueExpression`. The - * only push that carries an anchor today is "View Trace" (log → the span the log - * belongs to), and a span always starts at or before the logs that reference it - * while the traces schema's `Timestamp` is the span's *start* — so a symmetric - * window silently drops any span that ran longer than the window and logged late - * in its life. The lookback is therefore longer, and the lead only has to - * cover clock skew between the log and span emitters. + * lookback is sized for "View Trace" (log → the span the log belongs to): a span + * always starts at or before the logs that reference it, while the traces + * schema's `Timestamp` is the span's *start*, so a symmetric window silently + * drops any span that ran longer than the window and logged late in its life. + * The lead carries the opposite direction, the Trace Logs tab's span → log + * push, and is enough only because that tab lists logs within its own ±1h + * window. */ export const ROW_LOOKUP_WINDOW_LOOKBACK_HOURS = 4; export const ROW_LOOKUP_WINDOW_LEAD_HOURS = 1;