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 (
+
+
+
+ }
+ data-testid="trace-logs-open-in-search"
+ >
+ Open in search
+
+
+
+
+
+
+
+ );
+}
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;