diff --git a/.changeset/span-links-trace-viewer.md b/.changeset/span-links-trace-viewer.md new file mode 100644 index 0000000000..77f3d8011d --- /dev/null +++ b/.changeset/span-links-trace-viewer.md @@ -0,0 +1,7 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/app": patch +"@hyperdx/api": patch +--- + +feat: surface OpenTelemetry span links in the trace view. Trace sources gain an optional `spanLinksValueExpression` field (auto-detected from the OTel `Links` column), and the span detail panel shows a new "Span Links" section. Each link has an "Open trace" action that opens the linked trace in a stacked panel, with its trace state and attributes shown as chips. diff --git a/packages/api/src/models/source.ts b/packages/api/src/models/source.ts index bb0ce45758..221bb66138 100644 --- a/packages/api/src/models/source.ts +++ b/packages/api/src/models/source.ts @@ -197,6 +197,7 @@ export const TraceSource = Source.discriminator( resourceAttributesExpression: String, eventAttributesExpression: String, spanEventsValueExpression: String, + spanLinksValueExpression: String, implicitColumnExpression: String, knownColumnsListExpression: String, useTextIndexForImplicitColumn: { diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx index 1f631148ae..e0cea35350 100644 --- a/packages/app/src/components/DBRowDataPanel.tsx +++ b/packages/app/src/components/DBRowDataPanel.tsx @@ -34,6 +34,7 @@ export enum ROW_DATA_ALIASES { SPAN_EVENTS = '__hdx_span_events', DURATION_MS = '__hdx_duration_ms', SPAN_KIND = '__hdx_span_kind', + SPAN_LINKS = '__hdx_span_links', } export function useRowData({ @@ -176,6 +177,14 @@ export function useRowData({ }, ] : []), + ...(source.kind === SourceKind.Trace && source.spanLinksValueExpression + ? [ + { + valueExpression: source.spanLinksValueExpression, + alias: ROW_DATA_ALIASES.SPAN_LINKS, + }, + ] + : []), ...selectHighlightedRowAttributes, ], where: rowId ?? '0=1', diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx index 1ea1e945dd..efcfe17962 100644 --- a/packages/app/src/components/DBRowOverviewPanel.tsx +++ b/packages/app/src/components/DBRowOverviewPanel.tsx @@ -20,6 +20,7 @@ import EventTag from './EventTag'; import { ExceptionSubpanel } from './ExceptionSubpanel'; import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; import { SpanEventsSubpanel } from './SpanEventsSubpanel'; +import { SpanLinksSubpanel } from './SpanLinksSubpanel'; const EMPTY_OBJ = {}; export function RowOverviewPanel({ @@ -36,7 +37,7 @@ export function RowOverviewPanel({ 'data-testid'?: string; }) { const { data } = useRowData({ source, rowId, aliasWith }); - const { onPropertyAddClick, generateSearchUrl } = + const { onPropertyAddClick, generateSearchUrl, onOpenLinkedTrace } = useContext(RowSidePanelContext); const highlightedAttributeValues = useMemo(() => { @@ -183,6 +184,13 @@ export function RowOverviewPanel({ ); }, [firstRow?.__hdx_span_events]); + const hasSpanLinks = useMemo(() => { + return ( + Array.isArray(firstRow?.__hdx_span_links) && + firstRow?.__hdx_span_links.length > 0 + ); + }, [firstRow?.__hdx_span_links]); + const mainContentColumn = getEventBody(source); const mainContent = isString(firstRow?.['__hdx_body']) ? firstRow['__hdx_body'] @@ -213,6 +221,7 @@ export function RowOverviewPanel({ defaultValue={[ 'exception', 'spanEvents', + 'spanLinks', 'network', 'resourceAttributes', 'eventAttributes', @@ -259,21 +268,6 @@ export function RowOverviewPanel({ )} - {hasSpanEvents && ( - - - - Span Events - - - - - - - - - )} - {Object.keys(topLevelAttributes).length > 0 && ( @@ -312,6 +306,39 @@ export function RowOverviewPanel({ )} + {hasSpanEvents && ( + + + + Span Events + + + + + + + + + )} + + {hasSpanLinks && ( + + + + Span Links + + + + + + + + + )} + {Object.keys(resourceAttributes).length > 0 && ( diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 456d58469e..88772bfcad 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -73,6 +73,7 @@ import { } from './DrawerUtils'; import LogLevel from './LogLevel'; import SidePanelBreadcrumbs, { BreadcrumbItem } from './SidePanelBreadcrumbs'; +import { SpanLinkData } from './SpanLinksSubpanel'; import styles from '@/../styles/LogSidePanel.module.scss'; @@ -103,6 +104,12 @@ export type RowSidePanelContextProps = { isChildModalOpen?: boolean; setChildModalOpen?: (open: boolean) => void; source?: TLogSource | TTraceSource; + // Jump to the trace a span link points at, in place, via the same + // cross-source stack "View Trace" uses. Provided by DBRowSidePanelInner + // for its own subtree; absent for callers outside a trace-aware side + // panel (for example the standalone direct-trace drawer), where "Open + // trace" on a span link has no in-place destination to push to. + onOpenLinkedTrace?: (link: SpanLinkData) => void; }; export const RowSidePanelContext = createContext({}); @@ -505,6 +512,48 @@ export const DBRowSidePanelInner = ({ [traceSourceData, handleSourceStackPush, mainContent], ); + // "Open trace" on a span link: the link carries only the linked span's + // TraceId + SpanId, so resolve it against the current trace source, the + // same assumption the Span Links display already makes. This is the same + // cross-source push "View Trace" uses above, just keyed off the link's + // ids instead of the current row's. + const handleOpenLinkedTrace = useCallback( + (link: SpanLinkData) => { + if (!traceSourceData || !traceIdExpression || !spanIdExpression) { + return; + } + const rowId = [ + SqlString.format('?=?', [ + SqlString.raw(traceIdExpression), + link.TraceId, + ]), + SqlString.format('?=?', [SqlString.raw(spanIdExpression), link.SpanId]), + ].join(' AND '); + handleSourceStackPush({ + sourceId: traceSourceData.id, + rowId, + label: `Trace ${link.TraceId.slice(0, 8)}`, + sourceKind: traceSourceData.kind as SourceKind, + aliasWith: [], + }); + }, + [ + traceSourceData, + traceIdExpression, + spanIdExpression, + handleSourceStackPush, + ], + ); + + // Re-provide RowSidePanelContext for this subtree so RowOverviewPanel (in + // both the Overview tab here and the trace tab's span detail inside + // DBTracePanel) can reach onOpenLinkedTrace, while still inheriting + // whatever the enclosing table/session view already put on the context. + const rowSidePanelContextValue = useMemo( + () => ({ ...parentContext, onOpenLinkedTrace: handleOpenLinkedTrace }), + [parentContext, handleOpenLinkedTrace], + ); + const { rumSessionId, rumServiceName } = useSessionId({ sourceId: traceSourceId, traceId, @@ -676,7 +725,7 @@ export const DBRowSidePanelInner = ({ const showLogTraceActions = !sourceIsTrace && traceId && traceSourceId; return ( - <> + )} - + ); }; diff --git a/packages/app/src/components/SpanLinksSubpanel.stories.tsx b/packages/app/src/components/SpanLinksSubpanel.stories.tsx new file mode 100644 index 0000000000..c0dbdebcbd --- /dev/null +++ b/packages/app/src/components/SpanLinksSubpanel.stories.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { SpanLinksSubpanel } from './SpanLinksSubpanel'; + +export default { + title: 'Components/SpanLinksSubpanel', + component: SpanLinksSubpanel, +}; + +const mockSpanLinks = [ + { + TraceId: '6d4a1f0d2c3b4a5e6f7081929394a5b6', + SpanId: '1a2b3c4d5e6f7081', + TraceState: 'rojo=00f067aa0ba902b7', + Attributes: { + 'messaging.system': 'kafka', + 'messaging.kafka.partition': '3', + 'order.id': 'A-7741', + }, + }, + { + TraceId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + SpanId: '90a1b2c3d4e5f607', + TraceState: '', + Attributes: {}, + }, +]; + +export const Default = () => ( + {}} /> +); + +export const SingleLink = () => ( + {}} /> +); + +export const Empty = () => ; diff --git a/packages/app/src/components/SpanLinksSubpanel.tsx b/packages/app/src/components/SpanLinksSubpanel.tsx new file mode 100644 index 0000000000..b5e164b53f --- /dev/null +++ b/packages/app/src/components/SpanLinksSubpanel.tsx @@ -0,0 +1,182 @@ +import React, { useMemo } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { Anchor, Button, Flex, Stack, Tooltip } from '@mantine/core'; +import { + IconArrowUpRight, + IconChevronDown, + IconChevronUp, +} from '@tabler/icons-react'; + +import EventTag from './EventTag'; +import { SectionWrapper, useShowMoreRows } from './ExceptionSubpanel'; + +// Make sure SpanLinkData implements Record +export interface SpanLinkData extends Record { + TraceId: string; + SpanId: string; + TraceState: string; + Attributes: Record; +} + +// A single span link rendered as a compact row. The linked trace id is the +// widest, least-scannable part of a link, so it is not printed inline: the +// row leads with a labelled "Open trace" action (themed link color, visible +// at rest) and the full Trace ID / Span ID live in the hover tooltip. Trace +// state and attributes render below the action as uniform chips, so a link +// with no trace state and no attributes collapses to a single short line. +function SpanLinkRow({ + link, + onOpenTrace, +}: { + link: SpanLinkData; + onOpenTrace?: (link: SpanLinkData) => void; +}) { + const attributeEntries = Object.entries(link.Attributes ?? {}); + const hasTraceState = + typeof link.TraceState === 'string' && link.TraceState.length > 0; + const hasChips = hasTraceState || attributeEntries.length > 0; + + return ( + + +
Trace: {link.TraceId}
+
Span: {link.SpanId}
+ + } + > + onOpenTrace?.(link)} + size="sm" + fw={500} + className="d-inline-flex align-items-center" + > + + Open trace + +
+ + {hasChips ? ( + + {hasTraceState ? ( + + ) : null} + {attributeEntries.map(([key, value]) => ( + + ))} + + ) : null} +
+ ); +} + +export const SpanLinksSubpanel = ({ + spanLinks, + onOpenTrace, +}: { + spanLinks?: Record[] | null; + onOpenTrace?: (link: SpanLinkData) => void; +}) => { + const links = useMemo(() => { + if (!spanLinks || spanLinks.length === 0) { + return []; + } + + // Ensure links have the right shape with type checking. Span links carry + // no timestamp, so they keep the order ClickHouse returns them in (the + // order they appear in the span's Links column). + return spanLinks.filter((link): link is SpanLinkData => { + return ( + typeof link.TraceId === 'string' && + typeof link.SpanId === 'string' && + link.Attributes !== undefined + ); + }); + }, [spanLinks]); + + const { handleToggleMoreRows, hiddenRowsCount, visibleRows, isExpanded } = + useShowMoreRows({ + rows: links, + maxRows: 5, + }); + + if (!links || links.length === 0) { + return ( +
+ No span links available for this trace +
+ ); + } + + return ( +
+ + { + console.error(err); + }} + fallbackRender={() => ( +
+ An error occurred while rendering span links +
+ )} + > + + {visibleRows.map((link, index) => ( +
0 ? 'pt-2 border-top border-dark' : undefined + } + > + +
+ ))} +
+
+ + {hiddenRowsCount ? ( + + ) : null} +
+
+ ); +}; diff --git a/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx new file mode 100644 index 0000000000..aa96ffd8da --- /dev/null +++ b/packages/app/src/components/__tests__/DBRowSidePanel.spanLinks.test.tsx @@ -0,0 +1,242 @@ +import React from 'react'; +import SqlString from 'sqlstring'; +import { TSource } from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { fireEvent, render, screen } from '@testing-library/react'; + +// Controlled, in-memory replacement for nuqs' useQueryState so each side-panel +// URL param can be seeded and its setter inspected independently. Values are +// the already-parsed shapes the component consumes (arrays / strings), not URL +// strings. Prefixed with `mock` so jest.mock's factory 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, + // eslint-disable-next-line @eslint-react/no-unnecessary-use-prefix + 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); + const setters = mockSetters; + if (!setters[key]) setters[key] = jest.fn(); + return [value, setters[key]]; + }, + }; +}); + +// A single successful row carrying one span link and nothing else, so only +// the Span Links accordion section renders (Exception / Span Events / +// Resource / Event Attributes all stay gated off). RowOverviewPanel is left +// unmocked in this file so the real context wiring (DBRowSidePanelInner -> +// RowSidePanelContext.onOpenLinkedTrace -> RowOverviewPanel -> +// SpanLinksSubpanel.onOpenTrace) is exercised end to end. +const LINK = { + TraceId: 'aaaa1111bbbb2222cccc3333dddd4444', + SpanId: '1111222233334444', + TraceState: '', + Attributes: {}, +}; + +const mockUseRowData = jest.fn(); +jest.mock('../DBRowDataPanel', () => ({ + __esModule: true, + useRowData: (args: unknown) => mockUseRowData(args), + ROW_DATA_ALIASES: { + 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: () => [], +})); + +// The current row lives on a log source; the linked trace resolves against a +// separate trace source, the same split "View Trace" already relies on. +const TRACE_SOURCE = { + id: 'trace-src', + kind: 'trace', + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', +}; + +jest.mock('@/source', () => ({ + __esModule: true, + getEventBody: () => undefined, + useSource: ({ id }: { id: string | null }) => + id === 'trace-src' ? { data: TRACE_SOURCE } : { data: undefined }, +})); + +jest.mock('../DBSessionPanel', () => ({ + __esModule: true, + useSessionId: () => ({ rumSessionId: undefined, rumServiceName: undefined }), + DBSessionPanel: () => null, +})); + +jest.mock('@/utils/highlightedAttributes', () => ({ + __esModule: true, + getHighlightedAttributesFromData: () => [], +})); + +// Heavy leaf components / chart deps the panel imports but never renders for +// this row shape (no trace tab, no exception/resource/event attributes). +jest.mock('../DBTracePanel', () => ({ __esModule: true, default: () => null })); +jest.mock('../ContextSidePanel', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../DBInfraPanel', () => ({ __esModule: true, default: () => 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('@/TabBar', () => ({ __esModule: true, default: () => null })); +jest.mock('@/useFormatTime', () => ({ + __esModule: true, + FormatTime: () => null, +})); + +// NOTE: this import is intentionally placed after the mock factories above, +// which close over the `mock*` helpers declared at the top of this file. +import { DBRowSidePanelInner } from '@/components/DBRowSidePanel'; +import useSidePanelStack from '@/hooks/useSidePanelStack'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const ROOT_SOURCE = { + id: 'log-src', + kind: 'log', + traceSourceId: 'trace-src', + // Makes hasOverviewPanel true so a non-trace source still gets an Overview + // tab (and it is the default tab, since sourceIsTrace is false here). + resourceAttributesExpression: 'ResourceAttributes', +} as TSource; + +function InnerHarness({ + rowId, + source = ROOT_SOURCE, +}: { + rowId: string; + source?: TSource; +}) { + const sidePanelStack = useSidePanelStack({ initialRowId: rowId }); + return ( + + ); +} + +function renderInner(rowId: string) { + return render( + + + , + ); +} + +describe('DBRowSidePanelInner, span link "Open trace" push wiring (HDX-3191)', () => { + beforeEach(() => { + resetQueryState(); + mockUseRowData.mockReset(); + mockUseRowData.mockReturnValue({ + data: { data: [{ __hdx_span_links: [LINK] }], meta: [] }, + isLoading: false, + isSuccess: true, + isError: false, + error: null, + }); + }); + + it("pushes a trace-source frame keyed on the link's ids onto the shared source stack", () => { + renderInner('row-1'); + + fireEvent.click(screen.getByTestId('span-link-open-trace')); + + const expectedRowId = [ + SqlString.format('?=?', [SqlString.raw('TraceId'), LINK.TraceId]), + SqlString.format('?=?', [SqlString.raw('SpanId'), LINK.SpanId]), + ].join(' AND '); + + expect(setterFor('sidePanelSourceStack')).toHaveBeenCalledTimes(1); + const pushedStack = setterFor('sidePanelSourceStack').mock.calls[0][0]; + expect(pushedStack).toHaveLength(1); + expect(pushedStack[0]).toMatchObject({ + sourceId: 'trace-src', + rowId: expectedRowId, + sourceKind: 'trace', + }); + expect(pushedStack[0].label).toContain(LINK.TraceId.slice(0, 8)); + + // Cross-source push: nav stack clears and the destination tab is Trace, + // matching handleSourceStackPush's Trace-vs-Overview routing. + expect(setterFor('sidePanelNavStack')).toHaveBeenCalledWith([]); + expect(setterFor('sidePanelTab')).toHaveBeenCalledWith('trace'); + }); + + it('does not push when the current row has no resolvable trace source', () => { + // Root source with no traceSourceId: traceSourceData never resolves, so + // the guard in handleOpenLinkedTrace should no-op instead of throwing. + const sourceWithoutTrace = { + ...ROOT_SOURCE, + traceSourceId: undefined, + } as TSource; + + render( + + + , + ); + + fireEvent.click(screen.getByTestId('span-link-open-trace')); + + expect(setterFor('sidePanelSourceStack')).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx new file mode 100644 index 0000000000..4d753429b9 --- /dev/null +++ b/packages/app/src/components/__tests__/SpanLinksSubpanel.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import { SpanLinksSubpanel } from '@/components/SpanLinksSubpanel'; + +const LINK_A = { + TraceId: 'aaaa1111bbbb2222cccc3333dddd4444', + SpanId: '1111222233334444', + TraceState: '', + Attributes: { 'link.kind': 'child_of' }, +}; + +const LINK_B = { + TraceId: 'eeee5555ffff6666aaaa7777bbbb8888', + SpanId: '5555666677778888', + TraceState: 'congo=t61rcWkgMzE', + Attributes: {}, +}; + +const LINK_C = { + TraceId: 'cccc9999dddd0000eeee1111ffff2222', + SpanId: '9999000011112222', + TraceState: '', + Attributes: {}, +}; + +describe('SpanLinksSubpanel', () => { + it('renders the empty state when spanLinks is undefined', () => { + renderWithMantine(); + expect( + screen.getByText('No span links available for this trace'), + ).toBeInTheDocument(); + }); + + it('renders the empty state when spanLinks is an empty array', () => { + renderWithMantine(); + expect( + screen.getByText('No span links available for this trace'), + ).toBeInTheDocument(); + }); + + it('renders an Open trace action and the link attributes as chips', () => { + renderWithMantine(); + + expect(screen.getByText('Open trace')).toBeInTheDocument(); + // Attributes render as compact EventTag pills (key: value). + expect(screen.getByText('link.kind: child_of')).toBeInTheDocument(); + }); + + it('calls onOpenTrace with the link when Open trace is clicked', () => { + const onOpenTrace = jest.fn(); + renderWithMantine( + , + ); + + fireEvent.click(screen.getByText('Open trace')); + + expect(onOpenTrace).toHaveBeenCalledTimes(1); + expect(onOpenTrace).toHaveBeenCalledWith( + expect.objectContaining({ + TraceId: LINK_A.TraceId, + SpanId: LINK_A.SpanId, + }), + ); + }); + + it('renders one Open trace action per link', () => { + renderWithMantine(); + + expect(screen.getAllByText('Open trace')).toHaveLength(2); + expect(screen.getByText('link.kind: child_of')).toBeInTheDocument(); + }); + + it('renders trace state as a labeled chip', () => { + renderWithMantine(); + + expect( + screen.getByText('trace state: congo=t61rcWkgMzE'), + ).toBeInTheDocument(); + }); + + it('collapses to just the action when a link has no trace state or attributes', () => { + renderWithMantine(); + + expect(screen.getByText('Open trace')).toBeInTheDocument(); + expect(screen.queryByText(/trace state:/)).not.toBeInTheDocument(); + }); + + it('filters out malformed links missing a string TraceId or SpanId', () => { + const malformed = { + TraceId: 12345, + SpanId: 'deadbeefdeadbeef', + TraceState: '', + Attributes: {}, + } as unknown as Record; + + renderWithMantine(); + + expect(screen.getAllByText('Open trace')).toHaveLength(1); + expect(screen.getByText('link.kind: child_of')).toBeInTheDocument(); + }); + + it('renders the empty state when every link is malformed', () => { + const malformed = { + SpanId: 'deadbeefdeadbeef', + Attributes: {}, + } as unknown as Record; + + renderWithMantine(); + + expect( + screen.getByText('No span links available for this trace'), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/source.ts b/packages/app/src/source.ts index 5bf68b6947..acb0825775 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -358,6 +358,9 @@ export async function inferTableSourceConfig({ // Check if SpanEvents column is available const hasSpanEvents = columns.some(col => col.name === 'Events.Timestamp'); + // Check if span Links column is available + const hasSpanLinks = columns.some(col => col.name === 'Links.TraceId'); + // Check if metadata rollup tables exist and, if so, infer the bucketing // granularity from the key-rollup view's `as_select` const rollupMeta = @@ -435,6 +438,7 @@ export async function inferTableSourceConfig({ statusCodeExpression: 'StatusCode', statusMessageExpression: 'StatusMessage', ...(hasSpanEvents ? { spanEventsValueExpression: 'Events' } : {}), + ...(hasSpanLinks ? { spanLinksValueExpression: 'Links' } : {}), ...metadataMVsConfig, } : {}), diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 067ab8f267..326754a9c0 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1793,6 +1793,7 @@ export const TraceSourceSchema = BaseSourceSchema.extend({ resourceAttributesExpression: z.string().optional(), eventAttributesExpression: z.string().optional(), spanEventsValueExpression: z.string().optional(), + spanLinksValueExpression: z.string().optional(), implicitColumnExpression: z.string().optional(), knownColumnsListExpression: z.string().optional(), displayedTimestampValueExpression: z.string().optional(),