-
Notifications
You must be signed in to change notification settings - Fork 407
feat(traces): surface span links in the trace viewer #2440
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
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 listing each linked trace ID, span ID, trace state and attributes. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import React from 'react'; | ||
|
|
||
| import { SpanLinksSubpanel } from './SpanLinksSubpanel'; | ||
|
|
||
| export default { | ||
| title: 'Components/SpanLinksSubpanel', | ||
| component: SpanLinksSubpanel, | ||
| }; | ||
|
|
||
| const mockSpanLinks = [ | ||
| { | ||
| TraceId: '6d4a1f0d2c3b4a5e6f7081929394a5b6', | ||
| SpanId: '1a2b3c4d5e6f7081', | ||
| TraceState: '', | ||
| Attributes: { | ||
| 'link.kind': 'follows_from', | ||
| 'messaging.system': 'kafka', | ||
| }, | ||
| }, | ||
| { | ||
| TraceId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', | ||
| SpanId: '90a1b2c3d4e5f607', | ||
| TraceState: 'rojo=00f067aa0ba902b7', | ||
| Attributes: {}, | ||
| }, | ||
| ]; | ||
|
|
||
| export const Default = () => <SpanLinksSubpanel spanLinks={mockSpanLinks} />; | ||
|
|
||
| export const SingleLink = () => ( | ||
| <SpanLinksSubpanel spanLinks={[mockSpanLinks[0]]} /> | ||
| ); | ||
|
|
||
| export const Empty = () => <SpanLinksSubpanel spanLinks={[]} />; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,143 @@ | ||||||
| import React, { useMemo } from 'react'; | ||||||
| import { ErrorBoundary } from 'react-error-boundary'; | ||||||
| import { Box, Button } from '@mantine/core'; | ||||||
| import { IconChevronDown, IconChevronUp } from '@tabler/icons-react'; | ||||||
| import { ColumnDef } from '@tanstack/react-table'; | ||||||
|
|
||||||
| import { DBRowJsonViewer } from './DBRowJsonViewer'; | ||||||
| import { SectionWrapper, useShowMoreRows } from './ExceptionSubpanel'; | ||||||
| import { Table } from './Table'; | ||||||
|
|
||||||
| // Make sure SpanLinkData implements Record<string, unknown> | ||||||
| interface SpanLinkData extends Record<string, unknown> { | ||||||
| TraceId: string; | ||||||
| SpanId: string; | ||||||
| TraceState: string; | ||||||
| Attributes: Record<string, string>; | ||||||
| } | ||||||
|
|
||||||
| const spanLinkColumns: ColumnDef<SpanLinkData>[] = [ | ||||||
| { | ||||||
| accessorKey: 'TraceId', | ||||||
| header: 'Trace ID', | ||||||
| size: 280, | ||||||
| cell: ({ row }) => ( | ||||||
| <span className="font-monospace text-break">{row.original.TraceId}</span> | ||||||
| ), | ||||||
| }, | ||||||
| { | ||||||
| accessorKey: 'SpanId', | ||||||
| header: 'Span ID', | ||||||
| size: 160, | ||||||
| cell: ({ row }) => ( | ||||||
| <span className="font-monospace text-break">{row.original.SpanId}</span> | ||||||
| ), | ||||||
| }, | ||||||
| { | ||||||
| accessorKey: 'TraceState', | ||||||
| header: 'Trace State', | ||||||
| size: 120, | ||||||
| cell: ({ row }) => | ||||||
| row.original.TraceState ? ( | ||||||
| <span className="text-break">{row.original.TraceState}</span> | ||||||
| ) : ( | ||||||
| <span className="text-muted">Empty</span> | ||||||
| ), | ||||||
| }, | ||||||
| { | ||||||
| accessorKey: 'Attributes', | ||||||
| header: 'Attributes', | ||||||
| size: 400, | ||||||
| cell: ({ row }) => { | ||||||
| const attributes = row.original.Attributes; | ||||||
| if (attributes && Object.keys(attributes).length > 0) { | ||||||
| return ( | ||||||
| <Box> | ||||||
| <DBRowJsonViewer data={attributes} /> | ||||||
| </Box> | ||||||
| ); | ||||||
| } | ||||||
| return <span className="text-muted">Empty</span>; | ||||||
| }, | ||||||
| }, | ||||||
| ]; | ||||||
|
|
||||||
| export const SpanLinksSubpanel = ({ | ||||||
| spanLinks, | ||||||
| }: { | ||||||
| spanLinks?: Record<string, unknown>[] | null; | ||||||
| }) => { | ||||||
| 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) { | ||||||
|
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.
Suggested change
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! |
||||||
| return ( | ||||||
| <div className="p-3 text-muted fs-7"> | ||||||
| No span links available for this trace | ||||||
| </div> | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| return ( | ||||||
| <div> | ||||||
| <SectionWrapper> | ||||||
| <ErrorBoundary | ||||||
| onError={err => { | ||||||
| console.error(err); | ||||||
| }} | ||||||
| fallbackRender={() => ( | ||||||
| <div className="text-danger px-2 py-1 m-2 fs-7 font-monospace bg-danger-transparent p-4"> | ||||||
| An error occurred while rendering span links | ||||||
| </div> | ||||||
| )} | ||||||
| > | ||||||
| <Table | ||||||
| columns={spanLinkColumns} | ||||||
| data={visibleRows} | ||||||
| emptyMessage="No span links found" | ||||||
| /> | ||||||
| </ErrorBoundary> | ||||||
|
|
||||||
| {hiddenRowsCount ? ( | ||||||
| <Button | ||||||
| variant="secondary" | ||||||
| size="xs" | ||||||
| my="sm" | ||||||
| onClick={handleToggleMoreRows} | ||||||
| > | ||||||
| {isExpanded ? ( | ||||||
| <> | ||||||
| <IconChevronUp size={14} className="me-2" /> Hide links | ||||||
| </> | ||||||
| ) : ( | ||||||
| <> | ||||||
| <IconChevronDown size={14} className="me-2" /> | ||||||
| Show {hiddenRowsCount} more links | ||||||
| </> | ||||||
| )} | ||||||
| </Button> | ||||||
| ) : null} | ||||||
| </SectionWrapper> | ||||||
| </div> | ||||||
| ); | ||||||
| }; | ||||||
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.
TraceIdandSpanIdas strings but doesn't checkTraceState.SpanLinkDatadeclaresTraceState: string, so a row whereTraceStateis missing or non-string would pass the cast but violate the interface contract. The cell render handles it gracefully via a truthiness check, but the type safety is incomplete relative to the other two fields.