Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a036a05
feat: Add a trace logs tab to the event side panel
MikeShi42 Sep 14, 2026
0c59d60
fix: Address review on the trace logs tab
MikeShi42 Sep 14, 2026
f7740e8
fix: Type the trace logs row callback and its test mock
MikeShi42 Sep 14, 2026
c5dfd79
fix: Build the trace logs query through buildSearchChartConfig
MikeShi42 Sep 14, 2026
101d633
fix: Keep the trace logs tab's columns stable, and share the row label
MikeShi42 Sep 14, 2026
f934f6e
fix: Treat a blank trace id expression as unconfigured
MikeShi42 Sep 14, 2026
690f14e
test: Cover the trace logs tab's unresolved source and id escaping
MikeShi42 Sep 14, 2026
edb4569
fix: Highlight a trace log only where its row id is comparable
MikeShi42 Sep 14, 2026
d98d95e
refactor: Cut the trace logs tab back to its core
MikeShi42 Sep 15, 2026
4974611
test: Type the source fixtures instead of casting them
MikeShi42 Sep 15, 2026
c33c378
Merge remote-tracking branch 'origin/main' into mikeshi/trace-logs-tab
MikeShi42 Sep 15, 2026
e23298c
fix: Stop the trace logs tab reading the waterfall's filter
MikeShi42 Sep 15, 2026
a954f6c
fix: Drop the searched table's cell filter action in the trace logs tab
MikeShi42 Sep 16, 2026
56d5c19
fix: Carry select and pre-encode the trace logs search link
MikeShi42 Sep 16, 2026
3d07907
fix: Give the trace logs table its order, identity and sort state
MikeShi42 Sep 16, 2026
9eefc58
fix: Let the source decide the ordering of the linked search
MikeShi42 Sep 17, 2026
748fd88
refactor: Cut the trace logs tab's comments back to what isn't obvious
MikeShi42 Sep 21, 2026
d27e06a
Merge remote-tracking branch 'origin/main' into mikeshi/trace-logs-tab
MikeShi42 Sep 21, 2026
2e4d987
fix: Open the trace logs search as a fresh navigation
MikeShi42 Sep 21, 2026
e6cc62b
fix: Prefix the trace logs search link with the deployment base path
MikeShi42 Sep 22, 2026
aafd57d
Merge branch 'main' into mikeshi/trace-logs-tab
kodiakhq[bot] Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/trace-logs-side-panel-tab.md
Original file line number Diff line number Diff line change
@@ -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.
68 changes: 68 additions & 0 deletions packages/app/src/components/DBRowSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -549,6 +550,14 @@ export const DBRowSidePanelInner = ({
? source.traceSourceId
: undefined;

const traceLogSourceId = isTraceSource(source)
Comment thread
MikeShi42 marked this conversation as resolved.
Resolved
? 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 });
Expand Down Expand Up @@ -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,
Comment thread
MikeShi42 marked this conversation as resolved.
Resolved
});
},
[
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(
Expand Down Expand Up @@ -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);
Expand All @@ -794,6 +833,7 @@ export const DBRowSidePanelInner = ({
}, [
hasOverviewPanel,
sourceIsTrace,
enableTraceLogs,
enableServiceMap,
rumSessionId,
hasK8sContext,
Expand Down Expand Up @@ -1020,6 +1060,14 @@ export const DBRowSidePanelInner = ({
},
]
: []),
...(enableTraceLogs
? [
{
text: 'Trace Logs',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tab label uses title case

The new user-facing label Trace Logs uses title case, but the repository requires UI text to use sentence case. Change it to Trace logs; this repository requirement must be satisfied before merging.

Suggested change
text: 'Trace Logs',
text: 'Trace logs',

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Tab label "Trace Logs" is Title Case, against the sentence-case convention

The repository conventions require sentence case for every user-facing string, explicitly including tab items. Use text: 'Trace logs' — which is also the spelling the changeset and PR description use.

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

value: Tab.Logs,
},
]
: []),
...(enableServiceMap
? [
{
Expand Down Expand Up @@ -1097,6 +1145,26 @@ export const DBRowSidePanelInner = ({
</Box>
</ErrorBoundary>
)}
{displayedTab === Tab.Logs && traceLogSourceId && traceId && (
<ErrorBoundary
onError={err => {
console.error(err);
}}
fallbackRender={() => (
<div className="text-danger px-2 py-1 m-2 fs-7 font-monospace bg-danger-transparent p-4">
An error occurred while rendering this event.
</div>
)}
>
<TraceLogsPanel
data-testid="side-panel-tab-logs"
logSourceId={traceLogSourceId}
traceId={traceId}
dateRange={oneHourRange}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
onNavigateToLog={handleTraceLogNavigate}
/>
</ErrorBoundary>
)}
{displayedTab === Tab.ServiceMap && enableServiceMap && (
<ErrorBoundary
onError={err => {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/DBRowSidePanel.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export enum Tab {
Parsed = 'parsed',
Debug = 'debug',
Trace = 'trace',
Logs = 'logs',
ServiceMap = 'serviceMap',
Context = 'context',
Replay = 'replay',
Expand Down
175 changes: 175 additions & 0 deletions packages/app/src/components/TraceLogsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — orderBy is built as a string, parsed back into sort state, and the string is then dead

DBSqlRowTable seeds its own orderBy state from initialSortBy?.[0] and, whenever that state is non-null, overwrites config.orderBy with [{valueExpression: id, ordering}] (packages/app/src/components/DBRowTable.tsx:1611 and :1647-1661). Since initialSortBy is always populated here, the orderBy string handed to buildSearchChartConfig can never reach the query. Build the sort state once — const sortBy = [{ id: logSource.timestampValueExpression, desc: false }] — drop the parseAsSortingStateString round-trip and the redundant orderBy input, so there is one representation of "ascending by timestamp" instead of two that must agree.

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

? `${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 },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Hardcoded limit: { limit: 200 } shadows the team's configured Search Row Limit

DBSqlRowTableComponent already builds its query config as { ...searchChartConfigDefaults(me?.team), ...config } (packages/app/src/components/DBRowTable.tsx:1595-1599), and searchChartConfigDefaults resolves limit to team?.searchRowLimit ?? DEFAULT_SEARCH_ROW_LIMIT (packages/app/src/defaults.ts:60-68, where DEFAULT_SEARCH_ROW_LIMIT === 200). Because config spreads last, the explicit limit here wins: a team that raises Search Row Limit to 1000 in Team settings still gets 200-row pages in this tab, and the literal 200 is a second copy of the default constant. Drop the limit key from the config and let the table apply the team default.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Hardcoded limit: 200 overrides the team's configured search row limit

DBSqlRowTable already merges searchChartConfigDefaults(me?.team) under the passed config (DBRowTable.tsx:1647), which resolves to team.searchRowLimit ?? DEFAULT_SEARCH_ROW_LIMIT (200, @/defaults). Setting limit here shadows that: a team that raises "Search Row Limit" to 1000 in Team Settings gets 1000-row pages everywhere except this tab, which silently stays at 200 — and the literal duplicates the exported DEFAULT_SEARCH_ROW_LIMIT constant. Drop the limit key from the config object and let the table apply the team default.

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

dateRange,
Comment thread
MikeShi42 marked this conversation as resolved.
Resolved
};
}, [logSource, traceWhere, orderBy, dateRange]);

const handleRowDetailsClick = useCallback(
Comment thread
MikeShi42 marked this conversation as resolved.
Resolved
(rowWhere: RowWhereResult, row: Record<string, unknown>) => {
// The table selects the source's expressions, not normalized aliases.
const body = logSource ? row[getEventBody(logSource) ?? ''] : undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Breadcrumb label looks the row up by SELECT expression, not by the returned column name

row comes from DBSqlRowTable's result set, keyed by ClickHouse's derived column names — the same file warns "CH may rewrite column names in the result set (e.g. expression formatting), so we cannot rely on string matching between CH column names and SELECT expressions" (DBRowTable.tsx:1689) and uses position-based mapping instead. getEventBody(logSource) returns the raw SELECT expression, so for a source whose bodyExpression is not a bare identifier (e.g. LogAttributes['message'], which ClickHouse returns as arrayElement(LogAttributes, 'message')) the lookup always misses and every log picked from this tab gets the generic Log breadcrumb instead of its message. Resolve the column via the table's column meta/aliasMap (or by index, as onRemoveColumnFromTable does) rather than by expression string. Note the unit test at __tests__/TraceLogsPanel.test.tsx hand-builds the row as { Body: 'upstream timeout' }, i.e. keyed by the SELECT expression, so it cannot catch this.

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

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(
Comment thread
MikeShi42 marked this conversation as resolved.
Resolved
Comment thread
MikeShi42 marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Hand-rolls deriveRowSidePanelContextForSource and leaves generateSearchUrl bound to the span's source

Build the value as { ...deriveRowSidePanelContextForSource(parentContext, logSource), onPropertyAddClick: undefined, displayedColumns: undefined, toggleColumn: undefined } — reusing the existing helper (DBRowSidePanel.tsx:123) for the cross-source drops and, importantly, its generateSearchUrl rebinding, with the explicit overrides kept for the same-source case the helper would let through.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Row-context narrowing is hand-rolled next to the existing deriveRowSidePanelContextForSource

deriveRowSidePanelContextForSource (packages/app/src/components/DBRowSidePanel.tsx:123, used by DBTracePanel.tsx:129) already exists to strip onPropertyAddClick/displayedColumns/toggleColumn for rows that aren't the searched table's — and additionally rebinds generateSearchUrl to the rendered rows' source, which this copy does not (harmless only because showExpandButton={false} keeps DBRowJsonViewer unreachable here). The narrowing needed here is stricter than the helper's sameSource test (this table always selects the source default, even when the searched source is this log source), so extend the helper with a "always drop" option and call it, rather than keeping a second, silently divergent narrowing.

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

() => ({
...parentContext,
onPropertyAddClick: undefined,
displayedColumns: undefined,
toggleColumn: undefined,
}),
[parentContext],
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

if (isLoading) {
return null;
}

if (config == null || logSource == null || traceWhere == null) {
return (
<EmptyState
icon={<IconLogs size={24} />}
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 ?? '');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — initialSortBy id is timestampValueExpression, which is not one of the table's columns on the default log source

Derive the sort id from the column the table actually renders — getDisplayedTimestampValueExpression(logSource) (packages/app/src/source.ts:73) — rather than timestampValueExpression; note that DBSqlRowTable feeds initialSortBy back into mergedConfigObj.orderBy (DBRowTable.tsx:1600), so this also moves the query's ORDER BY onto the displayed column. If keeping the ORDER BY on timestampValueExpression matters more, drop initialSortBy and the comment claiming the header reflects it. Either way change the test fixture's timestampValueExpression to 'TimestampTime' (as OnboardingModal.tsx:93 creates) so the id/column mismatch is exercised.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — initialSortBy is keyed on timestampValueExpression, which is usually not a displayed column

orderBy (line 55) is built from logSource.timestampValueExpression, and parseAsSortingStateString.parse turns it into initialSortBy's id. DBSqlRowTable passes that straight into TanStack's state.sorting (DBRowTable.tsx:1922 → :754), where it only matches a column whose id is that exact string. For the canonical OTel log source in this repo — timestampValueExpression: 'TimestampTime', defaultTableSelectExpression: 'Timestamp, ServiceName, SeverityText, Body' (see packages/common-utils/src/core/__tests__/searchChartConfig.test.ts:17-18, packages/app/src/hooks/__tests__/useReleaseAnnotations.test.tsx:108-109) — no header ever renders as sorted, so the tab's "ascending = execution order" state is invisible; a composite "EventDate, EventTime" expression (called out as conventional in packages/app/src/utils/rowTimestamps.ts:22) produces the same result via a comma-containing id. The SQL is unaffected. Derive the initialSortBy id from the timestamp column the table actually shows (e.g. getDisplayedTimestampValueExpression / getFirstTimestampValueExpression in packages/app/src/source.ts:65,73) when it appears in the select, and otherwise drop initialSortBy and let config.orderBy carry the ordering alone, as ContextSubpanel does.

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

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 (
<RowSidePanelContext value={rowSidePanelContextValue}>
<Flex
direction="column"
mih={0}
px="md"
style={{ flexGrow: 1 }}
data-testid={dataTestId}
>
<Group justify="flex-end" py="xs">
<Button
variant="link"
size="xs"
component="a"
href={searchUrl}
Comment thread
MikeShi42 marked this conversation as resolved.
Resolved
rightSection={<IconExternalLink size={14} />}
data-testid="trace-logs-open-in-search"
>
Open in search
</Button>
</Group>
<div style={{ height: '100%', overflow: 'auto' }}>
<DBSqlRowTable
Comment thread
MikeShi42 marked this conversation as resolved.
Resolved
Comment thread
MikeShi42 marked this conversation as resolved.
Resolved
sourceId={logSource.id}
config={config}
queryKeyPrefix="trace-logs"
Comment thread
MikeShi42 marked this conversation as resolved.
Resolved
tableId="trace-logs"
initialSortBy={sortBy}
isLive={false}
showExpandButton={false}
onRowDetailsClick={handleRowDetailsClick}
onChildModalOpen={parentContext.setChildModalOpen}
/>
</div>
</Flex>
</RowSidePanelContext>
);
}
Loading
Loading