Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/cross-source-search-from-trace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': patch
---

fix: SQL error when clicking "Search" on a log attached to a trace while in the Traces view
5 changes: 3 additions & 2 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@
"docker/hyperdx/**",
".claude/**"
],
"ignoreBinaries": ["make", "migrate"],
"ignoreBinaries": ["make", "migrate", "stryker"],
"ignoreDependencies": [
"@dotenvx/dotenvx",
"concurrently",
"dotenv",
"babel-plugin-react-compiler"
"babel-plugin-react-compiler",
"@stryker-mutator/core"
],
Comment on lines 43 to 51

@karl-power karl-power Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

"include": ["nsExports"],
"exclude": ["enumMembers", "duplicates"]
Expand Down
32 changes: 30 additions & 2 deletions packages/app/src/components/DBRowSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,29 @@ export type RowSidePanelContextProps = {

export const RowSidePanelContext = createContext<RowSidePanelContextProps>({});

// Derives the context for rows rendered from `source`, which may differ from
// the source the surrounding search context was built for (e.g. a log event
// opened from a trace waterfall, or a span-link hop into another source's row).
export function deriveRowSidePanelContextForSource(
parentContext: RowSidePanelContextProps,
source: TSource,
): RowSidePanelContextProps {
const { generateSearchUrl } = parentContext;
const sameSource =
parentContext.source == null || parentContext.source.id === source.id;
return {
...parentContext,
generateSearchUrl: generateSearchUrl
? args => generateSearchUrl({ ...args, source: args.source ?? source })
: undefined,
onPropertyAddClick: sameSource
? parentContext.onPropertyAddClick
: undefined,
displayedColumns: sameSource ? parentContext.displayedColumns : undefined,
toggleColumn: sameSource ? parentContext.toggleColumn : undefined,
};
}

function SidePanelHeaderActions({
onClose,
isFullWidth,
Expand Down Expand Up @@ -572,8 +595,13 @@ export const DBRowSidePanelInner = ({
);

const rowSidePanelContextValue = useMemo(
() => ({ ...parentContext, onOpenLinkedTrace: handleOpenLinkedTrace }),
[parentContext, handleOpenLinkedTrace],
() => ({
// The displayed row belongs to the resolved leaf source, which can
// differ from the searched source after a cross-source hop.
...deriveRowSidePanelContextForSource(parentContext, source),
onOpenLinkedTrace: handleOpenLinkedTrace,
}),
[parentContext, handleOpenLinkedTrace, source],
);

const { rumSessionId, rumServiceName } = useSessionId({
Expand Down
27 changes: 24 additions & 3 deletions packages/app/src/components/DBTracePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import {
ReactNode,
use,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { useQueryState } from 'nuqs';
Expand Down Expand Up @@ -39,6 +46,10 @@ import { parseAsJsonEncoded } from '@/utils/queryParsers';
import DBInfraPanel from './DBInfraPanel';
import { RowDataPanel, rowHasK8sContext, useRowData } from './DBRowDataPanel';
import { RowOverviewPanel } from './DBRowOverviewPanel';
import {
deriveRowSidePanelContextForSource,
RowSidePanelContext,
} from './DBRowSidePanel';
import SourceSchemaPreview, {
isSourceSchemaPreviewEnabled,
} from './SourceSchemaPreview';
Expand Down Expand Up @@ -109,6 +120,16 @@ function SpanDetailPanel({
const { data: rowData } = useRowData({ source, rowId, aliasWith });
const normalizedRow = rowData?.data?.[0];

// The selected event may come from a different source than the search this
// panel was opened from (e.g. a log event on a trace opened in the Traces
// view). Rebind search-url generation to the event's own source and drop
// filter/column actions that only make sense against the searched source
const parentContext = use(RowSidePanelContext);
const rowSidePanelContextValue = useMemo(
() => deriveRowSidePanelContextForSource(parentContext, source),
[parentContext, source],
);

const hasK8sContext = useMemo(
() => rowHasK8sContext(source, normalizedRow),
[source, normalizedRow],
Expand All @@ -123,7 +144,7 @@ function SpanDetailPanel({
: displayedTab;

return (
<>
<RowSidePanelContext value={rowSidePanelContextValue}>
<div style={{ position: 'relative' }}>
<TabBar
className="fs-8"
Expand Down Expand Up @@ -212,7 +233,7 @@ function SpanDetailPanel({
<DBInfraPanel source={source} rowData={normalizedRow} />
</Box>
)}
</>
</RowSidePanelContext>
);
}

Expand Down
108 changes: 101 additions & 7 deletions packages/app/src/components/__tests__/DBTracePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import { fireEvent, screen } from '@testing-library/react';

import { RowSidePanelContext } from '@/components/DBRowSidePanel';
import DBTracePanel from '@/components/DBTracePanel';

let mockSources: Record<string, any> = {};
Expand Down Expand Up @@ -55,8 +56,33 @@ jest.mock('../DBRowDataPanel', () => ({
RowDataPanel: () => <div>row data panel</div>,
}));

// Stands in for the real overview panel but still consumes the (real)
// RowSidePanelContext, so the tests below can observe the source-aware
// context SpanDetailPanel derives for the selected event (HDX-5040).
jest.mock('../DBRowOverviewPanel', () => ({
RowOverviewPanel: () => <div>overview panel</div>,
RowOverviewPanel: () => {
const ReactActual = jest.requireActual('react');
// Required lazily so the circular DBTracePanel <-> DBRowSidePanel import
// is fully initialized by render time.
const { RowSidePanelContext: Ctx } =
jest.requireActual('../DBRowSidePanel');
const ctx = ReactActual.use(Ctx);
return (
<div>
<div>overview panel</div>
<button
onClick={() =>
ctx.generateSearchUrl?.({ where: 'x', whereLanguage: 'sql' })
}
>
generate search url
</button>
<div data-testid="can-add-to-filters">
{ctx.onPropertyAddClick ? 'yes' : 'no'}
</div>
</div>
);
},
}));

jest.mock('../DBInfraPanel', () => ({
Expand Down Expand Up @@ -165,17 +191,85 @@ describe('DBTracePanel', () => {
expect(
toggle.querySelector('.tabler-icon-layout-sidebar-right'),
).toBeInTheDocument();
expect(
JSON.parse(localStorage.getItem('hdx_trace_detail_layout') as string),
).toBe('bottom');
expect(JSON.parse(localStorage.getItem('hdx_trace_detail_layout')!)).toBe(
'bottom',
);

fireEvent.click(toggle);
// Back to 'side'; leave the shared atom at its default for other tests.
expect(
toggle.querySelector('.tabler-icon-layout-bottombar'),
).toBeInTheDocument();
expect(
JSON.parse(localStorage.getItem('hdx_trace_detail_layout') as string),
).toBe('side');
expect(JSON.parse(localStorage.getItem('hdx_trace_detail_layout')!)).toBe(
'side',
);
});

// The searched source is the trace source; the selected waterfall event may
// belong to the correlated log source. Search urls must target the event's
// own source, and filter actions (which mutate the searched source's query)
// must be gated off for cross-source events.
describe('span detail context for the selected event', () => {
const renderWithSearchContext = () => {
const generateSearchUrl = jest.fn(() => '/search?mock');
const onPropertyAddClick = jest.fn();
renderWithMantine(
<RowSidePanelContext
value={{
generateSearchUrl,
onPropertyAddClick,
source: mockSources['trace-source'],
}}
>
<DBTracePanel
traceId="trace-123"
parentSourceId="trace-source"
childSourceId="log-source"
dateRange={[new Date(0), new Date(1000)]}
focusDate={new Date(500)}
/>
</RowSidePanelContext>,
);
return { generateSearchUrl, onPropertyAddClick };
};

it('targets the log source for a selected log event and gates filter actions', () => {
mockEventRowWhere = {
id: 'log-1',
type: SourceKind.Log,
aliasWith: [],
traceId: 'trace-123',
};
const { generateSearchUrl } = renderWithSearchContext();

fireEvent.click(screen.getByText('generate search url'));
expect(generateSearchUrl).toHaveBeenCalledWith({
where: 'x',
whereLanguage: 'sql',
source: expect.objectContaining({ id: 'log-source' }),
});

// "Add to Filters" would inject log columns into the trace search.
expect(screen.getByTestId('can-add-to-filters')).toHaveTextContent('no');
});

it('keeps the searched source and filter actions for a selected span', () => {
mockEventRowWhere = {
id: 'span-1',
type: SourceKind.Trace,
aliasWith: [],
traceId: 'trace-123',
};
const { generateSearchUrl } = renderWithSearchContext();

fireEvent.click(screen.getByText('generate search url'));
expect(generateSearchUrl).toHaveBeenCalledWith({
where: 'x',
whereLanguage: 'sql',
source: expect.objectContaining({ id: 'trace-source' }),
});

expect(screen.getByTestId('can-add-to-filters')).toHaveTextContent('yes');
});
});
});
Loading