hyperdx/packages/app/src/components/DBTraceWaterfallChart.tsx
Aaron Knudtson ce8506478d
fix: better source validation and refine required source fields (#1895)
## Summary

Large refactor changing the TSource type to a true discriminated union. This means that the expected fields for `kind: 'log'` will differ from those for `'trace', 'session', 'metrics'`.  This avoids the current laissez faire source type that currently exists, and required extensive changes across the api and app packages. Also includes a nice addition to `useSource` - you can now specify a `kind` field, which will properly infer the type of the returned source. 

This also makes use of discriminators in mongoose. This does change a bit of the way that we create and update sources. Obvious changes to sources have also been made, namely making `timeValueExpression` required on sources. Care has been taken to avoid requiring a migration.

### How to test locally or on Vercel

1. `yarn dev`
2. Play around with the app, especially around source creation, source edits, and loading existing sources from a previous version

### References

- Linear Issue: References HDX-3352
- Related PRs:

Ref: HDX-3352
2026-03-19 12:56:08 +00:00

999 lines
29 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import _, { omit } from 'lodash';
import { useForm } from 'react-hook-form';
import TimestampNano from 'timestamp-nano';
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import {
ChartConfig,
ChartConfigWithDateRange,
SelectList,
SourceKind,
TLogSource,
TSource,
TTraceSource,
} from '@hyperdx/common-utils/dist/types';
import {
Anchor,
Box,
Center,
Checkbox,
Code,
Divider,
Group,
Text,
} from '@mantine/core';
import {
IconChevronDown,
IconChevronRight,
IconLogs,
} from '@tabler/icons-react';
import { ContactSupportText } from '@/components/ContactSupportText';
import SearchInputV2 from '@/components/SearchInput/SearchInputV2';
import { TimelineChart } from '@/components/TimelineChart';
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
import useResizable from '@/hooks/useResizable';
import useRowWhere, { WithClause } from '@/hooks/useRowWhere';
import useWaterfallSearchState from '@/hooks/useWaterfallSearchState';
import {
getDisplayedTimestampValueExpression,
getDurationSecondsExpression,
getEventBody,
getSpanEventBody,
} from '@/source';
import { useFormatTime } from '@/useFormatTime';
import {
getChartColorError,
getChartColorErrorHighlight,
getChartColorSuccess,
getChartColorSuccessHighlight,
getChartColorWarning,
getChartColorWarningHighlight,
} from '@/utils';
import {
getHighlightedAttributesFromData,
getSelectExpressionsForHighlightedAttributes,
} from '@/utils/highlightedAttributes';
import { DBHighlightedAttributesList } from './DBHighlightedAttributesList';
import styles from '@/../styles/LogSidePanel.module.scss';
import resizeStyles from '@/../styles/ResizablePanel.module.scss';
export type SpanRow = {
Body: string;
Timestamp: string;
Duration: number; // seconds
SpanId: string;
ParentSpanId: string;
StatusCode?: string;
ServiceName?: string;
SeverityText?: string;
HyperDXEventType: 'span';
type?: string;
SpanAttributes?: Record<string, any>;
SpanEvents?: Array<{
Timestamp: string;
Name: string;
Attributes: Record<string, any>;
}>;
__hdx_hidden?: boolean | 1 | 0;
};
function textColor(condition: { isError: boolean; isWarn: boolean }): string {
const { isError, isWarn } = condition;
if (isError) return 'text-danger';
if (isWarn) return 'text-warning';
return '';
}
function barColor(condition: {
isError: boolean;
isWarn: boolean;
isHighlighted: boolean;
type: string | undefined;
}) {
const { isError, isWarn, isHighlighted, type } = condition;
if (isError)
return isHighlighted ? getChartColorErrorHighlight() : getChartColorError();
if (isWarn)
return isHighlighted
? getChartColorWarningHighlight()
: getChartColorWarning();
if (type === SourceKind.Log) {
return isHighlighted
? getChartColorSuccessHighlight()
: getChartColorSuccess();
}
return isHighlighted ? '#A9AFB7' : '#6A7077';
}
function getTableBody(tableModel: TSource) {
if (tableModel?.kind === SourceKind.Trace) {
return getSpanEventBody(tableModel) ?? '';
} else if (tableModel?.kind === SourceKind.Log) {
return getEventBody(tableModel) ?? '';
} else {
return '';
}
}
function getConfig(
source: TTraceSource | TLogSource,
traceId: string,
hiddenRowExpression?: string,
) {
const alias: Record<string, string> = {
Body: getTableBody(source),
Timestamp: getDisplayedTimestampValueExpression(source),
Duration:
source.kind === SourceKind.Trace && source.durationExpression
? getDurationSecondsExpression(source)
: '',
TraceId: source.traceIdExpression ?? '',
SpanId: source.spanIdExpression ?? '',
ParentSpanId:
source.kind === SourceKind.Trace
? (source.parentSpanIdExpression ?? '')
: '',
StatusCode:
source.kind === SourceKind.Trace
? (source.statusCodeExpression ?? '')
: '',
ServiceName: source.serviceNameExpression ?? '',
SeverityText:
source.kind === SourceKind.Log
? (source.severityTextExpression ?? '')
: '',
SpanAttributes: source.eventAttributesExpression ?? '',
SpanEvents:
source.kind === SourceKind.Trace
? (source.spanEventsValueExpression ?? '')
: '',
};
// Aliases for trace attributes must be added here to ensure
// the returned `alias` object includes them and useRowWhere works.
if (source.highlightedTraceAttributeExpressions) {
for (const expr of source.highlightedTraceAttributeExpressions) {
if (expr.alias) {
alias[expr.alias] = expr.sqlExpression;
}
}
}
const select: SelectList = [
{
valueExpression: alias.Body,
alias: 'Body',
},
{
valueExpression: alias.Timestamp,
alias: 'Timestamp',
},
{
valueExpression: alias.SpanId,
alias: 'SpanId',
},
...(alias.ServiceName
? [
{
valueExpression: alias.ServiceName,
alias: 'ServiceName',
},
]
: []),
...(hiddenRowExpression
? [
{
valueExpression: hiddenRowExpression,
valueExpressionLanguage: 'lucene' as const,
alias: '__hdx_hidden',
},
]
: []),
];
if (source.kind === SourceKind.Trace || source.kind === SourceKind.Log) {
select.push(
...getSelectExpressionsForHighlightedAttributes(
source.highlightedTraceAttributeExpressions,
),
);
}
if (hiddenRowExpression) {
alias['__hdx_hidden'] = hiddenRowExpression;
}
if (source.kind === SourceKind.Trace) {
select.push(
...[
{
// in Seconds, f64 holds ns precision for durations up to ~3 months
valueExpression: alias.Duration,
alias: 'Duration',
},
{
valueExpression: alias.ParentSpanId,
alias: 'ParentSpanId',
},
...(alias.StatusCode
? [
{
valueExpression: alias.StatusCode,
alias: 'StatusCode',
},
]
: []),
...(alias.SpanAttributes
? [
{
valueExpression: alias.SpanAttributes,
alias: 'SpanAttributes',
},
]
: []),
...(alias.SpanEvents
? [
{
valueExpression: alias.SpanEvents,
alias: 'SpanEvents',
},
]
: []),
],
);
} else if (source.kind === SourceKind.Log) {
select.push(
...[
...(alias.SeverityText
? [
{
valueExpression: alias.SeverityText,
alias: 'SeverityText',
},
]
: []),
],
);
}
const config = {
select,
from: source.from,
timestampValueExpression: source.timestampValueExpression,
where: `${alias.TraceId} = '${traceId}'`,
limit: { limit: 50000 },
connection: source.connection,
};
return { config, alias, type: source.kind };
}
export function useEventsData({
config,
dateRangeStartInclusive,
dateRange,
enabled,
}: {
config: ChartConfig;
dateRangeStartInclusive: boolean;
dateRange: [Date, Date];
enabled: boolean;
}) {
const query: ChartConfigWithDateRange = useMemo(() => {
return {
...config,
dateRange,
dateRangeStartInclusive,
};
}, [config, dateRange, dateRangeStartInclusive]);
return useOffsetPaginatedQuery(query, { enabled });
}
export function useEventsAroundFocus({
tableSource,
focusDate,
dateRange,
traceId,
enabled,
hiddenRowExpression,
}: {
tableSource: TTraceSource | TLogSource;
focusDate: Date;
dateRange: [Date, Date];
traceId: string;
enabled: boolean;
/** A lucene expression that identifies rows to be hidden. Hidden rows will be returned with a `__hdx_hidden: true` column. */
hiddenRowExpression?: string;
}) {
const { config, alias, type } = useMemo(
() => getConfig(tableSource, traceId, hiddenRowExpression),
[tableSource, traceId, hiddenRowExpression],
);
const {
data: beforeSpanData,
isFetching: isBeforeSpanFetching,
error: beforeSpanError,
} = useEventsData({
config,
dateRangeStartInclusive: true,
dateRange: [dateRange[0], focusDate],
enabled,
});
const {
data: afterSpanData,
isFetching: isAfterSpanFetching,
error: afterSpanError,
} = useEventsData({
config,
dateRangeStartInclusive: false,
dateRange: [focusDate, dateRange[1]],
enabled,
});
const isFetching = isBeforeSpanFetching || isAfterSpanFetching;
const meta = beforeSpanData?.meta ?? afterSpanData?.meta;
const error = beforeSpanError || afterSpanError;
const getRowWhere = useRowWhere({ meta, aliasMap: alias });
const rows = useMemo(() => {
// Sometimes meta has not loaded yet
// DO NOT REMOVE, useRowWhere will error if no meta
if (!meta || meta.length === 0) return [];
return [
...(beforeSpanData?.data ?? []),
...(afterSpanData?.data ?? []),
].map(cd => {
// Omit SpanAttributes, SpanEvents and __hdx_hidden from rowWhere id generation.
// SpanAttributes and SpanEvents can be large objects, and __hdx_hidden may be a lucene expression.
const rowWhereResult = getRowWhere(
omit(cd, ['SpanAttributes', 'SpanEvents', '__hdx_hidden']),
);
return {
// Keep all fields available for display
...cd,
// Added for typing
SpanId: cd?.SpanId,
__hdx_hidden: cd?.__hdx_hidden,
type,
id: rowWhereResult.where,
// Don't pass aliasWith for trace waterfall chart - the WHERE clause already uses
// raw column expressions (e.g., SpanName='value'), and the aliasMap creates
// redundant WITH clauses like (Timestamp) AS Timestamp that interfere with queries.
aliasWith: [],
};
});
}, [afterSpanData, beforeSpanData, meta, getRowWhere, type]);
return {
rows,
meta,
isFetching,
error,
};
}
// TODO: Optimize with ts lookup tables
export function DBTraceWaterfallChartContainer({
traceTableSource,
logTableSource,
traceId,
dateRange,
focusDate,
onClick,
highlightedRowWhere,
initialRowHighlightHint,
}: {
traceTableSource: TTraceSource;
logTableSource: TLogSource | null;
traceId: string;
dateRange: [Date, Date];
focusDate: Date;
onClick?: (rowWhere: {
id: string;
type: string;
aliasWith: WithClause[];
}) => void;
highlightedRowWhere?: string | null;
initialRowHighlightHint?: {
timestamp: string;
spanId: string;
body: string;
};
}) {
const { size, startResize } = useResizable(30, 'bottom');
const formatTime = useFormatTime();
const {
traceWhere,
logWhere,
clear: clearFilters,
isFilterActive,
isFilterExpanded,
setIsFilterExpanded,
onSubmit: onSubmitFilters,
} = useWaterfallSearchState({
hasLogSource: !!logTableSource,
});
const { control, handleSubmit, setValue } = useForm({
defaultValues: {
traceWhere: traceWhere ?? '',
logWhere: logWhere ?? '',
},
});
const onClearFilters = useCallback(() => {
setValue('traceWhere', '');
setValue('logWhere', '');
clearFilters();
}, [clearFilters, setValue]);
const {
rows: traceRowsData,
isFetching: traceIsFetching,
meta: traceRowsMeta,
error: traceError,
} = useEventsAroundFocus({
tableSource: traceTableSource,
focusDate,
dateRange,
traceId,
hiddenRowExpression: traceWhere ? `NOT (${traceWhere})` : undefined,
enabled: true,
});
const {
rows: logRowsData,
isFetching: logIsFetching,
meta: logRowsMeta,
error: logError,
} = useEventsAroundFocus({
// search data if logTableModel exist
// search invalid date range if no logTableModel(react hook need execute no matter what)
tableSource: logTableSource ? logTableSource : traceTableSource,
focusDate,
dateRange: logTableSource ? dateRange : [dateRange[1], dateRange[0]], // different query to prevent cache
traceId,
hiddenRowExpression: logWhere ? `NOT (${logWhere})` : undefined,
enabled: logTableSource ? true : false, // disable fire query if logSource is not exist
});
const isFetching = traceIsFetching || logIsFetching;
const error = traceError || logError;
const rows: any[] = useMemo(
() => [...traceRowsData, ...logRowsData],
[traceRowsData, logRowsData],
);
rows.sort((a, b) => {
const aDate = TimestampNano.fromString(a.Timestamp);
const bDate = TimestampNano.fromString(b.Timestamp);
const secDiff = aDate.getTimeT() - bDate.getTimeT();
if (secDiff === 0) {
return aDate.getNano() - bDate.getNano();
} else {
return secDiff;
}
});
const highlightedAttributeValues = useMemo(() => {
const visibleTraceRowsData = traceRowsData?.filter(
row => !row.__hdx_hidden,
);
const attributes = getHighlightedAttributesFromData(
traceTableSource,
traceTableSource.highlightedTraceAttributeExpressions,
visibleTraceRowsData,
traceRowsMeta,
);
if (logTableSource && logRowsData && logRowsMeta) {
const visibleLogRowsData = logRowsData?.filter(row => !row.__hdx_hidden);
attributes.push(
...getHighlightedAttributesFromData(
logTableSource,
logTableSource.highlightedTraceAttributeExpressions,
visibleLogRowsData,
logRowsMeta,
),
);
}
return attributes.sort(
(a, b) =>
a.displayedKey.localeCompare(b.displayedKey) ||
a.value.localeCompare(b.value),
);
}, [
traceTableSource,
traceRowsData,
traceRowsMeta,
logTableSource,
logRowsData,
logRowsMeta,
]);
useEffect(() => {
if (initialRowHighlightHint && onClick && highlightedRowWhere == null) {
const initialRowHighlightIndex = rows.findIndex(row => {
return (
row.Timestamp === initialRowHighlightHint.timestamp &&
row.SpanId === initialRowHighlightHint.spanId &&
row.Body === initialRowHighlightHint.body
);
});
if (initialRowHighlightIndex !== -1) {
onClick?.({
id: rows[initialRowHighlightIndex].id,
type: rows[initialRowHighlightIndex].type ?? '',
aliasWith: rows[initialRowHighlightIndex].aliasWith,
});
}
}
}, [initialRowHighlightHint, rows, onClick, highlightedRowWhere]);
// 3 Edge-cases
// 1. No spans, just logs (ex. sampling)
// 2. Spans, but with missing spans inbetween (ex. missing intermediary spans)
// 3. Spans, with multiple root nodes (ex. somehow disjoint traces fe/be)
// Parse out a DAG of spans
type Node = SpanRow & {
id: string;
parentId: string;
children: SpanRow[];
aliasWith: WithClause[];
};
const validSpanIDs = useMemo(() => {
return new Set(
traceRowsData // only spans in traces can define valid span ids
?.filter(row => _.isString(row.SpanId) && row.SpanId.length > 0)
.map(row => row.SpanId) ?? [],
);
}, [traceRowsData]);
const rootNodes: Node[] = [];
const nodesMap = new Map(); // Maps result.id (or placeholder id) -> Node
const spanIdMap = new Map(); // Maps SpanId -> result.id of FIRST node with that SpanId
for (const result of rows ?? []) {
const { type, SpanId, ParentSpanId } = result;
// ignore everything without spanId
if (!SpanId) continue;
// log have duplicate span id, tag it with -log
const nodeSpanId = type === SourceKind.Log ? `${SpanId}-log` : SpanId; // prevent log spanId overwrite trace spanId
const nodeParentSpanId =
type === SourceKind.Log ? SpanId : ParentSpanId || '';
const curNode = {
...result,
children: [],
};
if (type === SourceKind.Trace) {
// Check if this is the first node with this SpanId
if (!spanIdMap.has(nodeSpanId)) {
// First occurrence - this becomes the canonical node for this SpanId
spanIdMap.set(nodeSpanId, result.id);
// Check if there's a placeholder parent waiting for this SpanId
const placeholderId = `placeholder-${nodeSpanId}`;
const placeholder = nodesMap.get(placeholderId);
if (placeholder) {
// Inherit children from placeholder
curNode.children = placeholder.children || [];
// Remove placeholder
nodesMap.delete(placeholderId);
}
}
// Always add to nodesMap with unique result.id
nodesMap.set(result.id, curNode);
}
// root if: is trace event, and (has no parent or parent id is not valid)
const isRootNode =
type === SourceKind.Trace &&
(!nodeParentSpanId || !validSpanIDs.has(nodeParentSpanId));
if (isRootNode) {
rootNodes.push(curNode);
} else {
// Look up parent by SpanId
const parentResultId = spanIdMap.get(nodeParentSpanId);
let parentNode = parentResultId
? nodesMap.get(parentResultId)
: undefined;
if (!parentNode) {
// Parent doesn't exist yet, create placeholder
const placeholderId = `placeholder-${nodeParentSpanId}`;
parentNode = nodesMap.get(placeholderId);
if (!parentNode) {
parentNode = { children: [] } as any;
nodesMap.set(placeholderId, parentNode);
}
}
parentNode.children.push(curNode);
}
}
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
const [showSpanEvents, setShowSpanEvents] = useState(true);
const toggleCollapse = useCallback(
(id: string) => {
setCollapsedIds(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
},
[setCollapsedIds],
);
type NodeWithLevel = Node & { level: number };
// flatten the rootnode dag into an array via in-order traversal
const traverse = (node: Node, arr: NodeWithLevel[], level = 0) => {
// Filter out hidden nodes, but still traverse their (non-hidden) descendants
if (!node.__hdx_hidden) {
arr.push({
level,
...node,
});
}
// Filter out collapsed nodes
if (collapsedIds.has(node.id)) {
return;
}
node?.children?.forEach((child: any) => traverse(child, arr, level + 1));
};
const flattenedNodes: NodeWithLevel[] = [];
if (rootNodes.length > 0) {
rootNodes.forEach(rootNode => traverse(rootNode, flattenedNodes));
}
const spanCount = flattenedNodes.length;
const errorCount = flattenedNodes.filter(
node =>
node.StatusCode === 'Error' ||
node.SeverityText?.toLowerCase() === 'error',
).length;
const spanCountString = `${spanCount} span${spanCount !== 1 ? 's' : ''}`;
const errorCountString = `${errorCount} error${errorCount !== 1 ? 's' : ''}`;
// TODO: Add duration filter?
// TODO: Add backend filters for duration and collapsing?
// All units in ms!
const foundMinOffset =
rows?.reduce((acc, result) => {
return Math.min(acc, new Date(result.Timestamp).getTime());
}, Number.MAX_SAFE_INTEGER) ?? 0;
const minOffset =
foundMinOffset === Number.MAX_SAFE_INTEGER ? 0 : foundMinOffset;
const timelineRows = flattenedNodes.map((result, i) => {
const tookMs = (result.Duration || 0) * 1000;
const startOffset = new Date(result.Timestamp).getTime();
const start = startOffset - minOffset;
const end = start + tookMs;
const {
Body: _body,
ServiceName: serviceName,
id,
type,
aliasWith,
} = result;
let body = `${_body}`;
try {
body = typeof _body === 'string' ? _body : JSON.stringify(_body);
} catch (e) {
console.warn("DBTraceWaterfallChart: Couldn't JSON stringify Body", e);
}
// Extract HTTP-related logic
const eventAttributes = result.SpanAttributes || {};
const hasHttpAttributes =
eventAttributes['http.url'] || eventAttributes['http.method'];
const httpUrl = eventAttributes['http.url'];
const displayText =
hasHttpAttributes && httpUrl ? `${body} ${httpUrl}` : body;
// Process span events into markers (only if showSpanEvents is enabled)
const markers =
showSpanEvents && result.SpanEvents
? result.SpanEvents.map(spanEvent => ({
timestamp: new Date(spanEvent.Timestamp).getTime() - minOffset,
name: spanEvent.Name,
attributes: spanEvent.Attributes || {},
}))
: [];
// Extract status logic
// TODO: Legacy schemas will have STATUS_CODE_ERROR
// See: https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/34799/files#diff-1ec84547ed93f2c8bfb21c371ca0b5304f01371e748d4b02bf397313a4b1dfa4L197
const isError =
result.StatusCode == 'Error' || result.SeverityText === 'error';
const status = result.StatusCode || result.SeverityText;
const isWarn = result.SeverityText === 'warn';
const isHighlighted = highlightedRowWhere === id;
return {
id,
type,
aliasWith,
label: (
<div
className={`${textColor({ isError, isWarn })} ${
isHighlighted && styles.traceTimelineLabelHighlighted
} text-truncate cursor-pointer ps-2 ${styles.traceTimelineLabel}`}
role="button"
onClick={() => {
onClick?.({ id, type: type ?? '', aliasWith });
}}
>
<div className="d-flex align-items-center" style={{ height: 24 }}>
{Array.from({ length: result.level }).map((_, index) => (
<div
key={index}
style={{
borderLeft: '1px solid var(--color-border)',
marginLeft: 7,
width: 8,
minWidth: 8,
maxWidth: 8,
flexGrow: 1,
flexShrink: 0,
height: '100%',
}}
></div>
))}
<Center
style={{
opacity: result.children.length > 0 ? 1 : 0,
}}
onClick={() => {
toggleCollapse(id);
}}
>
{collapsedIds.has(id) ? (
<IconChevronRight size={16} className="me-1 text-muted-hover" />
) : (
<IconChevronDown size={16} className="me-1 text-muted-hover" />
)}{' '}
</Center>
{!isFilterActive && (
<Text span size="xxs" me="xs" pt="2px">
{result.children.length > 0
? `(${result.children.length})`
: ''}
</Text>
)}
<Group gap={0} wrap="nowrap">
{type === SourceKind.Log ? (
<IconLogs
size={14}
className="align-middle me-2"
aria-label="Correlated Log Line"
/>
) : null}
<Text
size="xxs"
truncate="end"
// style={{ width: 200 }}
span
// onClick={() => {
// toggleCollapse(id);
// }}
title={`${serviceName}${hasHttpAttributes && httpUrl ? ` | ${displayText}` : ''}`}
role="button"
>
{serviceName ? `${serviceName} | ` : ''}
{displayText}
</Text>
</Group>
</div>
</div>
),
style: {
// paddingTop: 1,
marginTop: i === 0 ? 32 : 0,
},
isActive: isHighlighted,
events: [
{
id,
type,
aliasWith,
start,
end,
tooltip: `${displayText} ${tookMs >= 0 ? `took ${tookMs.toFixed(4)}ms` : ''} ${status ? `| Status: ${status}` : ''}${!isNaN(startOffset) ? ` | Started at ${formatTime(new Date(startOffset), { format: 'withMs' })}` : ''}`,
color: 'var(--color-text-inverted)',
backgroundColor: barColor({ isError, isWarn, isHighlighted, type }),
body: <span>{displayText}</span>,
minWidthPerc: 1,
isError,
markers,
showDuration: type !== SourceKind.Log,
},
],
};
});
// TODO: Highlighting support
const initialScrollRowIndex = flattenedNodes.findIndex(v => {
return v.id === highlightedRowWhere;
});
const heightPx = (size / 100) * window.innerHeight;
return (
<>
{isFilterExpanded && (
<form onSubmit={handleSubmit(onSubmitFilters)}>
<Box
style={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
alignItems: 'center',
gap: '12px',
}}
>
<Text size="xs">Spans filter</Text>
<SearchInputV2
tableConnection={tcFromSource(traceTableSource)}
placeholder={
'Search trace spans w/ Lucene ex. StatusCode:"Error"'
}
language="lucene"
name="traceWhere"
control={control}
size="xs"
onSubmit={handleSubmit(onSubmitFilters)}
data-testid="trace-search-input"
/>
{logTableSource && (
<>
<Text size="xs">Logs filter</Text>
<SearchInputV2
tableConnection={tcFromSource(logTableSource)}
placeholder={
'Search trace logs w/ Lucene ex. SeverityText:"error"'
}
language="lucene"
name="logWhere"
control={control}
size="xs"
onSubmit={handleSubmit(onSubmitFilters)}
data-testid="log-search-input"
/>
</>
)}
</Box>
</form>
)}
<Group my="xs" justify="space-between">
<Group gap="md">
<Text size="xs">
{spanCountString},{' '}
<span className={errorCount ? 'text-danger' : ''}>
{errorCountString}
</span>
</Text>
<Checkbox
size="xs"
label="Show span events"
checked={showSpanEvents}
onChange={() => setShowSpanEvents(!showSpanEvents)}
/>
</Group>
<span>
<Anchor
underline="always"
onClick={() => setIsFilterExpanded(prev => !prev)}
size="xs"
>
{isFilterExpanded ? 'Hide Filters' : 'Show Filters'}{' '}
{isFilterActive && '(active)'}
</Anchor>
{isFilterActive && (
<Anchor
underline="always"
onClick={onClearFilters}
size="xs"
ms="xs"
>
Clear Filters
</Anchor>
)}
</span>
</Group>
{!isFetching && !error && highlightedAttributeValues?.length > 0 && (
<DBHighlightedAttributesList attributes={highlightedAttributeValues} />
)}
<div
style={{
position: 'relative',
overflow: 'hidden',
maxHeight: `${heightPx}px`,
}}
>
{isFetching ? (
<div className="my-3">Loading Traces...</div>
) : error ? (
<Box mt="lg">
<Text my="sm" size="sm">
An error occurred while fetching trace data:
</Text>
<Code
block
style={{
whiteSpace: 'pre-wrap',
}}
>
{error.message}
</Code>
</Box>
) : rows == null ? (
<div>
An unknown error occurred. <ContactSupportText />
</div>
) : flattenedNodes.length === 0 ? (
<div className="my-3">No matching spans or logs found</div>
) : (
<>
<TimelineChart
style={{
overflowY: 'auto',
maxHeight: `${heightPx}px`,
}}
rowHeight={22}
labelWidth={300}
onEventClick={(event: {
id: string;
type?: string;
aliasWith?: WithClause[];
}) => {
onClick?.({
id: event.id,
type: event.type ?? '',
aliasWith: event.aliasWith ?? [],
});
}}
cursors={[]}
rows={timelineRows}
initialScrollRowIndex={initialScrollRowIndex}
/>
</>
)}
</div>
<Divider
mt="md"
className={resizeStyles.resizeYHandle}
onMouseDown={startResize}
style={{ position: 'relative', bottom: 0 }}
/>
</>
);
}