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
12 changes: 12 additions & 0 deletions .changeset/span-links-trace-viewer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@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 place in the same
panel, with a breadcrumb trail you can step back through, and shows the link's
trace state and attributes as chips.
1 change: 1 addition & 0 deletions packages/api/src/models/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export const TraceSource = Source.discriminator<ITraceSource>(
resourceAttributesExpression: String,
eventAttributesExpression: String,
spanEventsValueExpression: String,
spanLinksValueExpression: String,
implicitColumnExpression: String,
knownColumnsListExpression: String,
useTextIndexForImplicitColumn: {
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/components/DBRowDataPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand Down
56 changes: 40 additions & 16 deletions packages/app/src/components/DBRowOverviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import EventTag from './EventTag';
import { ExceptionSubpanel } from './ExceptionSubpanel';
import { NetworkPropertySubpanel } from './NetworkPropertyPanel';
import { SpanEventsSubpanel } from './SpanEventsSubpanel';
import { getValidSpanLinks, SpanLinksSubpanel } from './SpanLinksSubpanel';

const EMPTY_OBJ = {};
export function RowOverviewPanel({
Expand All @@ -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(() => {
Expand Down Expand Up @@ -183,6 +184,10 @@ export function RowOverviewPanel({
);
}, [firstRow?.__hdx_span_events]);

const hasSpanLinks = useMemo(() => {
return getValidSpanLinks(firstRow?.__hdx_span_links).length > 0;
}, [firstRow?.__hdx_span_links]);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

const mainContentColumn = getEventBody(source);
const mainContent = isString(firstRow?.['__hdx_body'])
? firstRow['__hdx_body']
Expand Down Expand Up @@ -213,6 +218,7 @@ export function RowOverviewPanel({
defaultValue={[
'exception',
'spanEvents',
'spanLinks',
'network',
'resourceAttributes',
'eventAttributes',
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -259,21 +265,6 @@ export function RowOverviewPanel({
</Accordion.Item>
)}

{hasSpanEvents && (
<Accordion.Item value="spanEvents">
<Accordion.Control>
<Text size="sm" ps="md">
Span Events
</Text>
</Accordion.Control>
<Accordion.Panel>
<Box px="md">
<SpanEventsSubpanel spanEvents={firstRow?.__hdx_span_events} />
</Box>
</Accordion.Panel>
</Accordion.Item>
)}

{Object.keys(topLevelAttributes).length > 0 && (
<Accordion.Item value="topLevelAttributes">
<Accordion.Control>
Expand Down Expand Up @@ -312,6 +303,39 @@ export function RowOverviewPanel({
</Accordion.Item>
)}

{hasSpanEvents && (
<Accordion.Item value="spanEvents">
<Accordion.Control>
<Text size="sm" ps="md">
Span Events
</Text>
</Accordion.Control>
<Accordion.Panel>
<Box px="md">
<SpanEventsSubpanel spanEvents={firstRow?.__hdx_span_events} />
</Box>
</Accordion.Panel>
</Accordion.Item>
)}

{hasSpanLinks && (
<Accordion.Item value="spanLinks">
<Accordion.Control>
<Text size="sm" ps="md">
Span Links
</Text>
</Accordion.Control>
<Accordion.Panel>
<Box px="md">
<SpanLinksSubpanel
spanLinks={firstRow?.__hdx_span_links}
onOpenTrace={onOpenLinkedTrace}
/>
</Box>
</Accordion.Panel>
</Accordion.Item>
)}

{Object.keys(resourceAttributes).length > 0 && (
<Accordion.Item value="resourceAttributes">
<Accordion.Control>
Expand Down
68 changes: 65 additions & 3 deletions packages/app/src/components/DBRowSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { add } from 'date-fns';
Expand Down Expand Up @@ -74,6 +75,7 @@ import {
} from './DrawerUtils';
import LogLevel from './LogLevel';
import SidePanelBreadcrumbs, { BreadcrumbItem } from './SidePanelBreadcrumbs';
import { SpanLinkData } from './SpanLinksSubpanel';

import styles from '@/../styles/LogSidePanel.module.scss';

Expand Down Expand Up @@ -104,6 +106,7 @@ export type RowSidePanelContextProps = {
isChildModalOpen?: boolean;
setChildModalOpen?: (open: boolean) => void;
source?: TLogSource | TTraceSource;
onOpenLinkedTrace?: (link: SpanLinkData) => void;
};

export const RowSidePanelContext = createContext<RowSidePanelContextProps>({});
Expand Down Expand Up @@ -398,6 +401,24 @@ export const DBRowSidePanelInner = ({
}
}, [mainContent, initialMainContent, hasActiveStacks]);

// Once the hop's row loads we learn the landed span name and use it as the crumb label.
const spanLinkFrameRowIdsRef = useRef<Set<string>>(new Set());
const [resolvedFrameLabels, setResolvedFrameLabels] = useState<
Record<string, string>
>({});

useEffect(() => {
if (
activeRowId != null &&
mainContent != null &&
spanLinkFrameRowIdsRef.current.has(activeRowId) &&
resolvedFrameLabels[activeRowId] !== mainContent
) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setResolvedFrameLabels(prev => ({ ...prev, [activeRowId]: mainContent }));
}
}, [activeRowId, mainContent, resolvedFrameLabels]);

const highlightedAttributeValues = useMemo(() => {
const attributeExpressions: NonNullable<
(TLogSource | TTraceSource)['highlightedRowAttributeExpressions']
Expand Down Expand Up @@ -511,6 +532,44 @@ 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.
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 ');
// Mark this frame so its breadcrumb switches from the `Trace <id>`
// fallback below to the landed span name once the row loads.
spanLinkFrameRowIdsRef.current.add(rowId);
handleSourceStackPush({
sourceId: traceSourceData.id,
rowId,
label: `Trace ${link.TraceId.slice(0, 8)}`,
sourceKind: traceSourceData.kind as SourceKind,
aliasWith: [],
});
},
[
traceSourceData,
traceIdExpression,
spanIdExpression,
handleSourceStackPush,
],
);

const rowSidePanelContextValue = useMemo(
() => ({ ...parentContext, onOpenLinkedTrace: handleOpenLinkedTrace }),
[parentContext, handleOpenLinkedTrace],
);

const { rumSessionId, rumServiceName } = useSessionId({
sourceId: traceSourceId,
traceId,
Expand Down Expand Up @@ -574,7 +633,9 @@ export const DBRowSidePanelInner = ({
const isLeafSource = i === crumbSourceStack.length - 1;
const isCurrent = isLeafSource && crumbNavStack.length === 0;
items.push({
label: entry.label,
// Span-link hops resolve to the landed span name once loaded; every
// other frame keeps the label it was pushed with.
label: resolvedFrameLabels[entry.rowId] ?? entry.label,
sourceKind: entry.sourceKind,
onClick: isCurrent
? undefined
Expand Down Expand Up @@ -608,6 +669,7 @@ export const DBRowSidePanelInner = ({
sourceIsTrace,
mainContent,
initialMainContent,
resolvedFrameLabels,
source.kind,
handleBreadcrumbNavigation,
parentBreadcrumbs,
Expand Down Expand Up @@ -698,7 +760,7 @@ export const DBRowSidePanelInner = ({
const showLogTraceActions = !sourceIsTrace && traceId && traceSourceId;

return (
<>
<RowSidePanelContext value={rowSidePanelContextValue}>
<Box px="sm" pt="sm" pb="xs">
{controls}
<Group gap="xs" wrap="wrap">
Expand Down Expand Up @@ -1036,7 +1098,7 @@ export const DBRowSidePanelInner = ({
</Box>
</ErrorBoundary>
)}
</>
</RowSidePanelContext>
);
};

Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/components/DBTracePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,9 @@ export default function DBTracePanel({
// otherwise we'll show stale span details
useEffect(() => {
if (eventRowWhere != null && eventRowWhere.traceId !== traceId) {
setEventRowWhere(null);
setEventRowWhere(prev =>
prev != null && prev.traceId !== traceId ? null : prev,
);
}
}, [eventRowWhere, traceId, setEventRowWhere]);

Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/components/DBTraceWaterfallChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,11 @@ export function DBTraceWaterfallChartContainer({
// ourselves, so it never fires twice for the same hint; it re-fires only when
// the hint genuinely changes (different originating span / trace).
const appliedHighlightHintRef = useRef<string | null>(null);

useEffect(() => {
appliedHighlightHintRef.current = null;
}, [traceId]);

useEffect(() => {
if (!initialRowHighlightHint || !onClick) {
return;
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/OnboardingModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ async function addOtelDemoSources({
statusCodeExpression: 'StatusCode',
statusMessageExpression: 'StatusMessage',
spanEventsValueExpression: 'Events',
spanLinksValueExpression: 'Links',
highlightedTraceAttributeExpressions:
traceSourceHighlightedTraceAttributes,
materializedViews: traceSourceMaterializedViews,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,21 @@ export function TraceTableModelForm(props: TableModelProps) {
placeholder="Events"
/>
</FormRow>
<FormRow
label={'Span Links Expression'}
helpText="Expression to extract span links. Used to capture links from a span to spans in other traces. Expected to be Nested ( TraceId String, SpanId String, TraceState String, Attributes Map(LowCardinality(String), String) )"
>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name="spanLinksValueExpression"
placeholder="Links"
/>
</FormRow>
<ExpressionFormRow
control={control}
setValue={setValue}
Expand Down
35 changes: 35 additions & 0 deletions packages/app/src/components/SpanLinksSubpanel.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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 = () => <SpanLinksSubpanel spanLinks={mockSpanLinks} />;

export const SingleLink = () => (
<SpanLinksSubpanel spanLinks={[mockSpanLinks[0]]} />
);

export const Empty = () => <SpanLinksSubpanel spanLinks={[]} />;
Loading
Loading