-
Notifications
You must be signed in to change notification settings - Fork 407
feat(traces): surface span links in the trace viewer #2463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,12 @@ | ||
| import { useCallback, useContext, useMemo } from 'react'; | ||
| import { useCallback, useContext, useMemo, useState } from 'react'; | ||
| import isString from 'lodash/isString'; | ||
| import pickBy from 'lodash/pickBy'; | ||
| import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; | ||
| import SqlString from 'sqlstring'; | ||
| import { | ||
| isTraceSource, | ||
| SourceKind, | ||
| TSource, | ||
| } from '@hyperdx/common-utils/dist/types'; | ||
| import { Accordion, Box, Flex, Text } from '@mantine/core'; | ||
|
|
||
| import { WithClause } from '@/hooks/useRowWhere'; | ||
|
|
@@ -14,25 +19,33 @@ import { | |
| useRowData, | ||
| } from './DBRowDataPanel'; | ||
| import { DBRowJsonViewer } from './DBRowJsonViewer'; | ||
| import { RowSidePanelContext } from './DBRowSidePanel'; | ||
| import DBRowSidePanelHeader from './DBRowSidePanelHeader'; | ||
| import DBRowSidePanel, { RowSidePanelContext } from './DBRowSidePanel'; | ||
| import DBRowSidePanelHeader, { | ||
| BreadcrumbNavigationCallback, | ||
| BreadcrumbPath, | ||
| } from './DBRowSidePanelHeader'; | ||
| import EventTag from './EventTag'; | ||
| import { ExceptionSubpanel } from './ExceptionSubpanel'; | ||
| import { NetworkPropertySubpanel } from './NetworkPropertyPanel'; | ||
| import { SpanEventsSubpanel } from './SpanEventsSubpanel'; | ||
| import { SpanLinkData, SpanLinksSubpanel } from './SpanLinksSubpanel'; | ||
|
|
||
| const EMPTY_OBJ = {}; | ||
| export function RowOverviewPanel({ | ||
| source, | ||
| rowId, | ||
| aliasWith, | ||
| hideHeader = false, | ||
| breadcrumbPath, | ||
| onBreadcrumbClick, | ||
| 'data-testid': dataTestId, | ||
| }: { | ||
| source: TSource; | ||
| rowId: string | undefined | null; | ||
| aliasWith?: WithClause[]; | ||
| hideHeader?: boolean; | ||
| breadcrumbPath?: BreadcrumbPath; | ||
| onBreadcrumbClick?: BreadcrumbNavigationCallback; | ||
| 'data-testid'?: string; | ||
| }) { | ||
| const { data } = useRowData({ source, rowId, aliasWith }); | ||
|
|
@@ -183,6 +196,31 @@ 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]); | ||
|
|
||
| // Open a linked span in a stacked side panel one level deeper, reusing the | ||
| // same nested-drawer flow the Surrounding Context tab uses, instead of | ||
| // navigating the page to the linked trace. The linked span is identified by | ||
| // the link's TraceId + SpanId against the trace source's id expressions. | ||
| const [openedLink, setOpenedLink] = useState<SpanLinkData | null>(null); | ||
|
|
||
| const openedLinkWhere = useMemo(() => { | ||
| if (!openedLink || !isTraceSource(source)) { | ||
| return null; | ||
| } | ||
| return SqlString.format('?=? AND ?=?', [ | ||
| SqlString.raw(source.spanIdExpression), | ||
| openedLink.SpanId, | ||
| SqlString.raw(source.traceIdExpression), | ||
| openedLink.TraceId, | ||
| ]); | ||
| }, [openedLink, source]); | ||
|
|
||
| const mainContentColumn = getEventBody(source); | ||
| const mainContent = isString(firstRow?.['__hdx_body']) | ||
| ? firstRow['__hdx_body'] | ||
|
|
@@ -214,6 +252,7 @@ export function RowOverviewPanel({ | |
| defaultValue={[ | ||
| 'exception', | ||
| 'spanEvents', | ||
| 'spanLinks', | ||
| 'network', | ||
| 'resourceAttributes', | ||
| 'eventAttributes', | ||
|
Comment on lines
252
to
258
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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! |
||
|
|
@@ -260,21 +299,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> | ||
|
|
@@ -313,6 +337,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={link => setOpenedLink(link)} | ||
| /> | ||
| </Box> | ||
| </Accordion.Panel> | ||
| </Accordion.Item> | ||
| )} | ||
|
|
||
| {Object.keys(resourceAttributes).length > 0 && ( | ||
| <Accordion.Item value="resourceAttributes"> | ||
| <Accordion.Control> | ||
|
|
@@ -357,6 +414,25 @@ export function RowOverviewPanel({ | |
| </Accordion.Item> | ||
| )} | ||
| </Accordion> | ||
| {openedLink && openedLinkWhere && ( | ||
| <DBRowSidePanel | ||
| source={source} | ||
| rowId={openedLinkWhere} | ||
| aliasWith={[]} | ||
| onClose={() => setOpenedLink(null)} | ||
| isNestedPanel | ||
| breadcrumbPath={[ | ||
| ...(breadcrumbPath ?? []), | ||
| { | ||
| label: | ||
| (typeof firstRow?.SpanName === 'string' && firstRow.SpanName) || | ||
| 'Span Link', | ||
| rowData: firstRow ?? {}, | ||
| }, | ||
| ]} | ||
| onBreadcrumbClick={onBreadcrumbClick} | ||
| /> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = () => ( | ||
| <SpanLinksSubpanel spanLinks={mockSpanLinks} onOpenTrace={() => {}} /> | ||
| ); | ||
|
|
||
| export const SingleLink = () => ( | ||
| <SpanLinksSubpanel spanLinks={[mockSpanLinks[0]]} onOpenTrace={() => {}} /> | ||
| ); | ||
|
|
||
| export const Empty = () => <SpanLinksSubpanel spanLinks={[]} />; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hasSpanLinksistruewhenever__hdx_span_linksis a non-empty array, butSpanLinksSubpanelapplies its own stricter type-filter insideuseMemo(requires stringTraceId, stringSpanId, and a definedAttributes). If every object in the array passes the array check but fails the type-filter, the accordion section renders with a "Span Links" header but shows "No span links available for this trace" inside it — a contradictory UX. In practice real OTel data won't hit this, but the defensive check would be cheap: mirror the sameSpanLinkDatatype guard inhasSpanLinks(or just reuse the filteredlinksarray length).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!