From b2487a7d280e08715414ad7fa5157e64e71fcf99 Mon Sep 17 00:00:00 2001 From: Karl Power Date: Thu, 6 Aug 2026 14:17:04 +0100 Subject: [PATCH] fix: SQL error when clicking search on a log while viewing trace --- .changeset/cross-source-search-from-trace.md | 5 + knip.json | 5 +- .../app/src/components/DBRowSidePanel.tsx | 32 +++++- packages/app/src/components/DBTracePanel.tsx | 27 ++++- .../__tests__/DBTracePanel.test.tsx | 108 ++++++++++++++++-- 5 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 .changeset/cross-source-search-from-trace.md diff --git a/.changeset/cross-source-search-from-trace.md b/.changeset/cross-source-search-from-trace.md new file mode 100644 index 0000000000..610c00f1fa --- /dev/null +++ b/.changeset/cross-source-search-from-trace.md @@ -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 diff --git a/knip.json b/knip.json index 8e9cf032c8..23d4e5774c 100644 --- a/knip.json +++ b/knip.json @@ -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" ], "include": ["nsExports"], "exclude": ["enumMembers", "duplicates"] diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 4d91dae2d5..d6c0849a6e 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -111,6 +111,29 @@ export type RowSidePanelContextProps = { export const RowSidePanelContext = createContext({}); +// 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, @@ -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({ diff --git a/packages/app/src/components/DBTracePanel.tsx b/packages/app/src/components/DBTracePanel.tsx index d833926816..35a7ea8774 100644 --- a/packages/app/src/components/DBTracePanel.tsx +++ b/packages/app/src/components/DBTracePanel.tsx @@ -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'; @@ -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'; @@ -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], @@ -123,7 +144,7 @@ function SpanDetailPanel({ : displayedTab; return ( - <> +
)} - + ); } diff --git a/packages/app/src/components/__tests__/DBTracePanel.test.tsx b/packages/app/src/components/__tests__/DBTracePanel.test.tsx index 27e19f8e1b..3aa7ca1c23 100644 --- a/packages/app/src/components/__tests__/DBTracePanel.test.tsx +++ b/packages/app/src/components/__tests__/DBTracePanel.test.tsx @@ -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 = {}; @@ -55,8 +56,33 @@ jest.mock('../DBRowDataPanel', () => ({ RowDataPanel: () =>
row data panel
, })); +// 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: () =>
overview panel
, + 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 ( +
+
overview panel
+ +
+ {ctx.onPropertyAddClick ? 'yes' : 'no'} +
+
+ ); + }, })); jest.mock('../DBInfraPanel', () => ({ @@ -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( + + + , + ); + 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'); + }); }); });