mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Workflow template cards, the template choosers, the auth wallpaper, insight previews, partner pages, SDK logos and every fixture stop pulling posthog's cloudinary; the CSP stops allowing it. The mascot easter egg leaves with its sprite package. insights-js resolves to @hanzo/insights for every consumer — the react wrapper's peer included, via a workspace package that carries the name — and its rrweb siblings map to our published forks. Icon names that outran our icon release alias to shipped glyphs.
1489 lines
64 KiB
TypeScript
1489 lines
64 KiB
TypeScript
import {
|
|
MakeLogicType,
|
|
actions,
|
|
afterMount,
|
|
connect,
|
|
events,
|
|
kea,
|
|
key,
|
|
listeners,
|
|
path,
|
|
props,
|
|
reducers,
|
|
selectors,
|
|
} from 'kea'
|
|
import { loaders } from 'kea-loaders'
|
|
import insights from 'insights-js'
|
|
|
|
import { toast } from '@hanzo/elements'
|
|
|
|
import api from 'lib/api'
|
|
import { dataColorVars } from 'lib/colors'
|
|
import { FEATURE_FLAGS } from 'lib/constants'
|
|
import { dayjs } from 'lib/dayjs'
|
|
import { featureFlagLogic, type FeatureFlagsSet } from 'lib/logic/featureFlagLogic'
|
|
import { humanFriendlyDetailedTime } from 'lib/utils/datetime'
|
|
|
|
import { AggregatedSpanRow, SpanTreeNode } from '~/queries/schema/schema-general'
|
|
import { PropertyGroupFilter } from '~/types'
|
|
|
|
import type { DateRange } from '../../../frontend/src/queries/schema/schema-general'
|
|
import type { UniversalFiltersGroup } from '../../../frontend/src/types'
|
|
import {
|
|
type DurationHistogramRow,
|
|
type LatencyHeatmapRow,
|
|
pivotDurationHistogram,
|
|
pivotLatencyHeatmap,
|
|
type TracingDurationHistogramData,
|
|
type TracingLatencyHeatmapData,
|
|
type VisibleDurationRange,
|
|
visibleDurationRange,
|
|
} from './durationBuckets'
|
|
import { type HeatmapBrushSelection, heatmapBrushToFilters } from './heatmapBrush'
|
|
import { traceLookupDateRange } from './traceLinks'
|
|
import {
|
|
type TracingFilters,
|
|
type TracingOrderBy,
|
|
TRACING_SCENE_VIEWER_ID,
|
|
tracingFiltersLogic,
|
|
} from './tracingFiltersLogic'
|
|
import type { OverlayWindow, TimeComparison, TracingOrderDirection, TracingViewMode } from './tracingFiltersLogic'
|
|
import type { Span } from './types'
|
|
|
|
export interface SparklineRow {
|
|
time: string
|
|
service: string
|
|
count: number
|
|
}
|
|
|
|
export interface TracingSparklineData {
|
|
data: { name: string; values: number[]; color: string }[]
|
|
dates: string[]
|
|
labels: string[]
|
|
}
|
|
|
|
export interface VisibleRowRange {
|
|
startIndex: number
|
|
stopIndex: number
|
|
}
|
|
|
|
export interface VisibleSpanTimeRange {
|
|
date_from: string
|
|
date_to: string
|
|
}
|
|
|
|
const DEFAULT_PAGE_SIZE = 100
|
|
// Matches the backend's hard cap (_ROW_LIMIT) for span aggregation. The Operations tab needs
|
|
// the full set for client-side sort/filter, unlike the smaller default the endpoint serves agents.
|
|
const OPERATIONS_AGGREGATION_LIMIT = 5000
|
|
export const PREFETCH_SPANS = 20
|
|
export const NEW_QUERY_STARTED_ERROR_MESSAGE = 'new query started' as const
|
|
|
|
export function isUserInitiatedError(error: unknown): boolean {
|
|
const errorStr = String(error).toLowerCase()
|
|
return error === NEW_QUERY_STARTED_ERROR_MESSAGE || errorStr.includes('abort')
|
|
}
|
|
|
|
// A ts hint (from a shared/cold link) bounds the lookup tightly around the trace instead of the
|
|
// scene's current date range — the table is time-keyed, so this is what keeps an id lookup from
|
|
// scanning the whole window. Guard validity: a hand-edited/corrupted ts would otherwise make dayjs
|
|
// throw on toISOString().
|
|
function resolveTraceLookupRange(
|
|
ts: string | null | undefined,
|
|
utcDateRange: { date_from?: string | null; date_to?: string | null }
|
|
): { date_from?: string | null; date_to?: string | null } {
|
|
if (ts && dayjs(ts).isValid()) {
|
|
return traceLookupDateRange(ts)
|
|
}
|
|
return {
|
|
date_from: utcDateRange.date_from ?? '-24h',
|
|
date_to: utcDateRange.date_to ?? undefined,
|
|
}
|
|
}
|
|
|
|
function captureTracingResults(count: number, queryType: 'spans' | 'aggregation'): void {
|
|
if (count === 0) {
|
|
insights.capture('tracing no results returned', { query_type: queryType })
|
|
} else {
|
|
insights.capture('tracing results returned', { count, query_type: queryType })
|
|
}
|
|
}
|
|
|
|
export interface TracingDataLogicProps {
|
|
id: string
|
|
// Run the first query on mount. Embedded viewers set this; the /tracing scene leaves it
|
|
// off because its first query is driven by urlToAction (which may first restore filters
|
|
// from the URL). Mirrors the LogsViewer `autoLoad` prop.
|
|
autoLoad?: boolean
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface tracingDataLogicValues {
|
|
featureFlags: FeatureFlagsSet; // featureFlagLogic
|
|
compareActive: boolean; // tracingFiltersLogic
|
|
currentWindowMs: OverlayWindow; // tracingFiltersLogic
|
|
filters: TracingFilters; // tracingFiltersLogic
|
|
orderBy: TracingOrderBy; // tracingFiltersLogic
|
|
previousWindowMs: OverlayWindow; // tracingFiltersLogic
|
|
queryFilterGroup: UniversalFiltersGroup; // tracingFiltersLogic
|
|
sparklineWindowMs: OverlayWindow; // tracingFiltersLogic
|
|
timeComparison: TimeComparison | null; // tracingFiltersLogic
|
|
utcDateRange: {
|
|
date_from: string | null | undefined;
|
|
date_to: string | null | undefined;
|
|
}; // tracingFiltersLogic
|
|
aggregation: {
|
|
current: AggregatedSpanRow[];
|
|
previous: AggregatedSpanRow[] | null;
|
|
};
|
|
aggregationAbortController: AbortController | null;
|
|
aggregationLoading: boolean;
|
|
durationHistogramAbortController: AbortController | null;
|
|
durationHistogramData: TracingDurationHistogramData;
|
|
durationHistogramLoading: boolean;
|
|
hasMoreToLoad: boolean;
|
|
hasRunQuery: boolean;
|
|
isDurationMode: boolean;
|
|
lastAggregationWindow: {
|
|
currentEndMs: number;
|
|
currentStartMs: number;
|
|
previousEndMs: number;
|
|
previousStartMs: number;
|
|
} | null;
|
|
latencyHeatmapAbortController: AbortController | null;
|
|
latencyHeatmapData: TracingLatencyHeatmapData;
|
|
latencyHeatmapLoading: boolean;
|
|
listRowDurations: number[];
|
|
listRows: Span[];
|
|
matchingCounts: {
|
|
count: number;
|
|
traceCount: number;
|
|
};
|
|
matchingCountsAbortController: AbortController | null;
|
|
matchingCountsLoading: boolean;
|
|
nextCursor: string | null;
|
|
rawDurationHistogram: DurationHistogramRow[];
|
|
rawDurationHistogramLoading: boolean;
|
|
rawLatencyHeatmap: LatencyHeatmapRow[];
|
|
rawLatencyHeatmapLoading: boolean;
|
|
rawSparklineData: SparklineRow[];
|
|
rawSparklineDataLoading: boolean;
|
|
showHeatmap: boolean;
|
|
spanTree: {
|
|
current: SpanTreeNode[];
|
|
previous: SpanTreeNode[] | null;
|
|
spanName: string | null;
|
|
};
|
|
spanTreeAbortController: AbortController | null;
|
|
spanTreeLoading: boolean;
|
|
spans: Span[];
|
|
spansAbortController: AbortController | null;
|
|
spansLoading: boolean;
|
|
sparklineAbortController: AbortController | null;
|
|
sparklineData: TracingSparklineData;
|
|
sparklineLoading: boolean;
|
|
totalMatchingFilters: number;
|
|
traceLoadContext: {
|
|
traceId: string;
|
|
ts?: string | null;
|
|
} | null;
|
|
traceSpans: Span[];
|
|
traceSpansHasMore: boolean;
|
|
traceSpansLoading: boolean;
|
|
traceSpansLoadingMore: boolean;
|
|
traceSpansNextOffset: number;
|
|
visibleRowDateRange: VisibleSpanTimeRange | null;
|
|
visibleRowDurationRange: VisibleDurationRange | null;
|
|
visibleRowRange: VisibleRowRange | null;
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface tracingDataLogicActions {
|
|
setFeatureFlags: (flags: string[], variants: Record<string, boolean | string>, payloads?: import("lib/logic/featureFlagLogic").FeatureFlagPayloads | undefined) => {
|
|
flags: string[];
|
|
payloads: import("lib/logic/featureFlagLogic").FeatureFlagPayloads;
|
|
variants: Record<string, boolean | string>;
|
|
}; // featureFlagLogic
|
|
setChartType: (chartType: import("./tracingFiltersLogic").TracingChartType) => {
|
|
chartType: import("./tracingFiltersLogic").TracingChartType;
|
|
}; // tracingFiltersLogic
|
|
setComparison: (comparison: TimeComparison | null) => {
|
|
comparison: TimeComparison | null;
|
|
}; // tracingFiltersLogic
|
|
setDateRange: (dateRange: DateRange) => {
|
|
dateRange: DateRange;
|
|
}; // tracingFiltersLogic
|
|
setFilterGroup: (filterGroup: UniversalFiltersGroup) => {
|
|
filterGroup: UniversalFiltersGroup;
|
|
}; // tracingFiltersLogic
|
|
setFilters: (filters: Partial<TracingFilters>) => {
|
|
filters: Partial<TracingFilters>;
|
|
}; // tracingFiltersLogic
|
|
setServiceNames: (serviceNames: string[]) => {
|
|
serviceNames: string[];
|
|
}; // tracingFiltersLogic
|
|
setSort: (orderBy: TracingOrderBy, orderDirection: TracingOrderDirection) => {
|
|
orderBy: TracingOrderBy;
|
|
orderDirection: TracingOrderDirection;
|
|
}; // tracingFiltersLogic
|
|
setViewMode: (viewMode: TracingViewMode) => {
|
|
viewMode: TracingViewMode;
|
|
}; // tracingFiltersLogic
|
|
updateComparisonWindows: (current: OverlayWindow, previous: OverlayWindow) => {
|
|
current: OverlayWindow;
|
|
previous: OverlayWindow;
|
|
}; // tracingFiltersLogic
|
|
applyHeatmapBrush: (selection: HeatmapBrushSelection) => {
|
|
selection: HeatmapBrushSelection;
|
|
};
|
|
cancelInProgressAggregation: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
cancelInProgressDurationHistogram: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
cancelInProgressLatencyHeatmap: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
cancelInProgressMatchingCounts: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
cancelInProgressSpanTree: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
cancelInProgressSpans: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
cancelInProgressSparkline: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
clearSpans: () => {
|
|
value: true;
|
|
};
|
|
clearSpansFailure: (error: string, errorObject?: any) => {
|
|
error: string;
|
|
errorObject?: any;
|
|
};
|
|
clearSpansSuccess: (spans: never[], payload?: {
|
|
value: true;
|
|
}) => {
|
|
spans: never[];
|
|
payload?: {
|
|
value: true;
|
|
};
|
|
};
|
|
fetchAggregation: (options?: {
|
|
fullRange?: boolean;
|
|
} | null) => {
|
|
fullRange?: boolean;
|
|
} | null;
|
|
fetchAggregationFailure: (error: string, errorObject?: any) => {
|
|
error: string;
|
|
errorObject?: any;
|
|
};
|
|
fetchAggregationSuccess: (aggregation: {
|
|
current: AggregatedSpanRow[];
|
|
previous: AggregatedSpanRow[] | null;
|
|
}, payload?: {
|
|
fullRange?: boolean;
|
|
} | null) => {
|
|
aggregation: {
|
|
current: AggregatedSpanRow[];
|
|
previous: AggregatedSpanRow[] | null;
|
|
};
|
|
payload?: {
|
|
fullRange?: boolean;
|
|
} | null;
|
|
};
|
|
fetchDurationHistogram: () => any;
|
|
fetchDurationHistogramFailure: (error: string, errorObject?: any) => {
|
|
error: string;
|
|
errorObject?: any;
|
|
};
|
|
fetchDurationHistogramSuccess: (rawDurationHistogram: {
|
|
bucket_ns: number;
|
|
count: number;
|
|
service: string;
|
|
}[], payload?: any) => {
|
|
rawDurationHistogram: {
|
|
bucket_ns: number;
|
|
count: number;
|
|
service: string;
|
|
}[];
|
|
payload?: any;
|
|
};
|
|
fetchLatencyHeatmap: () => any;
|
|
fetchLatencyHeatmapFailure: (error: string, errorObject?: any) => {
|
|
error: string;
|
|
errorObject?: any;
|
|
};
|
|
fetchLatencyHeatmapSuccess: (rawLatencyHeatmap: {
|
|
bucket_ns: number;
|
|
count: number;
|
|
time: string;
|
|
}[], payload?: any) => {
|
|
rawLatencyHeatmap: {
|
|
bucket_ns: number;
|
|
count: number;
|
|
time: string;
|
|
}[];
|
|
payload?: any;
|
|
};
|
|
fetchMatchingCounts: () => any;
|
|
fetchMatchingCountsFailure: (error: string, errorObject?: any) => {
|
|
error: string;
|
|
errorObject?: any;
|
|
};
|
|
fetchMatchingCountsSuccess: (matchingCounts: {
|
|
count: number;
|
|
traceCount: number;
|
|
}, payload?: any) => {
|
|
matchingCounts: {
|
|
count: number;
|
|
traceCount: number;
|
|
};
|
|
payload?: any;
|
|
};
|
|
fetchNextPage: () => {
|
|
value: true;
|
|
};
|
|
fetchNextPageFailure: (error: string, errorObject?: any) => {
|
|
error: string;
|
|
errorObject?: any;
|
|
};
|
|
fetchNextPageSuccess: (spans: Span[], payload?: {
|
|
value: true;
|
|
}) => {
|
|
spans: Span[];
|
|
payload?: {
|
|
value: true;
|
|
};
|
|
};
|
|
fetchSpanTree: (params: {
|
|
serviceName: string;
|
|
spanName: string;
|
|
}) => {
|
|
spanName: string;
|
|
serviceName: string;
|
|
};
|
|
fetchSpanTreeFailure: (error: string, errorObject?: any) => {
|
|
error: string;
|
|
errorObject?: any;
|
|
};
|
|
fetchSpanTreeSuccess: (spanTree: {
|
|
current: SpanTreeNode[];
|
|
previous: SpanTreeNode[] | null;
|
|
spanName: string;
|
|
}, payload?: {
|
|
spanName: string;
|
|
serviceName: string;
|
|
}) => {
|
|
spanTree: {
|
|
current: SpanTreeNode[];
|
|
previous: SpanTreeNode[] | null;
|
|
spanName: string;
|
|
};
|
|
payload?: {
|
|
spanName: string;
|
|
serviceName: string;
|
|
};
|
|
};
|
|
fetchSpans: () => any;
|
|
fetchSpansFailure: (error: string, errorObject?: any) => {
|
|
error: string;
|
|
errorObject?: any;
|
|
};
|
|
fetchSpansSuccess: (spans: Span[], payload?: any) => {
|
|
spans: Span[];
|
|
payload?: any;
|
|
};
|
|
fetchSparkline: () => any;
|
|
fetchSparklineFailure: (error: string, errorObject?: any) => {
|
|
error: string;
|
|
errorObject?: any;
|
|
};
|
|
fetchSparklineSuccess: (rawSparklineData: {
|
|
count: number;
|
|
service: string;
|
|
time: string;
|
|
}[], payload?: any) => {
|
|
rawSparklineData: {
|
|
count: number;
|
|
service: string;
|
|
time: string;
|
|
}[];
|
|
payload?: any;
|
|
};
|
|
handleFilterChange: (filterType: string, extraProps?: Record<string, unknown>) => {
|
|
extraProps: Record<string, unknown> | undefined;
|
|
filterType: string;
|
|
};
|
|
loadMoreTraceSpans: () => {
|
|
value: true;
|
|
};
|
|
loadMoreTraceSpansFailure: (error: string, errorObject?: any) => {
|
|
error: string;
|
|
errorObject?: any;
|
|
};
|
|
loadMoreTraceSpansSuccess: (traceSpans: Span[], payload?: {
|
|
value: true;
|
|
}) => {
|
|
traceSpans: Span[];
|
|
payload?: {
|
|
value: true;
|
|
};
|
|
};
|
|
loadTraceSpans: ({ traceId, ts }: {
|
|
traceId: string;
|
|
ts?: string | null;
|
|
}) => {
|
|
traceId: string;
|
|
ts?: string | null;
|
|
};
|
|
loadTraceSpansFailure: (error: string, errorObject?: any) => {
|
|
error: string;
|
|
errorObject?: any;
|
|
};
|
|
loadTraceSpansSuccess: (traceSpans: Span[], payload?: {
|
|
traceId: string;
|
|
ts?: string | null;
|
|
}) => {
|
|
traceSpans: Span[];
|
|
payload?: {
|
|
traceId: string;
|
|
ts?: string | null;
|
|
};
|
|
};
|
|
runQuery: () => {
|
|
value: true;
|
|
};
|
|
setAggregationAbortController: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
setDurationHistogramAbortController: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
setHasMoreToLoad: (hasMore: boolean) => {
|
|
hasMore: boolean;
|
|
};
|
|
setLastAggregationWindow: (window: {
|
|
currentEndMs: number;
|
|
currentStartMs: number;
|
|
previousEndMs: number;
|
|
previousStartMs: number;
|
|
}) => {
|
|
window: {
|
|
currentEndMs: number;
|
|
currentStartMs: number;
|
|
previousEndMs: number;
|
|
previousStartMs: number;
|
|
};
|
|
};
|
|
setLatencyHeatmapAbortController: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
setMatchingCountsAbortController: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
setNextCursor: (cursor: string | null) => {
|
|
cursor: string | null;
|
|
};
|
|
setSpanTreeAbortController: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
setSpansAbortController: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
setSparklineAbortController: (controller: AbortController | null) => {
|
|
controller: AbortController | null;
|
|
};
|
|
setTracePagination: (hasMore: boolean, nextOffset: number | null) => {
|
|
hasMore: boolean;
|
|
nextOffset: number | null;
|
|
};
|
|
setVisibleRowRange: (startIndex: number, stopIndex: number) => {
|
|
startIndex: number;
|
|
stopIndex: number;
|
|
};
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface tracingDataLogicMeta {
|
|
key: string;
|
|
__keaTypeGenInternalSelectorTypes: {
|
|
sparklineData: (rawSparklineData: SparklineRow[]) => TracingSparklineData;
|
|
totalMatchingFilters: (matchingCounts: {
|
|
count: number;
|
|
traceCount: number;
|
|
}, filters: TracingFilters) => number;
|
|
durationHistogramData: (rawDurationHistogram: DurationHistogramRow[]) => TracingDurationHistogramData;
|
|
latencyHeatmapData: (rawLatencyHeatmap: LatencyHeatmapRow[]) => TracingLatencyHeatmapData;
|
|
showHeatmap: (filters: TracingFilters, compareActive: boolean, featureFlags: FeatureFlagsSet) => boolean;
|
|
listRows: (spans: Span[], filters: TracingFilters) => Span[];
|
|
listRowDurations: (listRows: Span[]) => number[];
|
|
isDurationMode: (filters: TracingFilters, compareActive: boolean) => boolean;
|
|
visibleRowDurationRange: (visibleRowRange: VisibleRowRange | null, listRowDurations: number[], isDurationMode: boolean) => VisibleDurationRange | null;
|
|
visibleRowDateRange: (visibleRowRange: VisibleRowRange | null, listRows: Span[], orderBy: TracingOrderBy) => VisibleSpanTimeRange | null;
|
|
};
|
|
}
|
|
|
|
export type tracingDataLogicType = MakeLogicType<tracingDataLogicValues, tracingDataLogicActions, TracingDataLogicProps, tracingDataLogicMeta>;
|
|
|
|
export const tracingDataLogic = kea<tracingDataLogicType>([
|
|
props({ id: TRACING_SCENE_VIEWER_ID, autoLoad: false } as TracingDataLogicProps),
|
|
key((props) => props.id),
|
|
path((key) => ['products', 'tracing', 'frontend', 'tracingDataLogic', key]),
|
|
|
|
connect(({ id }: TracingDataLogicProps) => ({
|
|
values: [
|
|
tracingFiltersLogic({ id }),
|
|
[
|
|
'filters',
|
|
'orderBy',
|
|
'utcDateRange',
|
|
'queryFilterGroup',
|
|
'sparklineWindowMs',
|
|
'currentWindowMs',
|
|
'previousWindowMs',
|
|
'compareActive',
|
|
'timeComparison',
|
|
],
|
|
featureFlagLogic,
|
|
['featureFlags'],
|
|
],
|
|
actions: [
|
|
tracingFiltersLogic({ id }),
|
|
[
|
|
'setDateRange',
|
|
'setServiceNames',
|
|
'setFilterGroup',
|
|
'setSort',
|
|
'setViewMode',
|
|
'setChartType',
|
|
'setComparison',
|
|
'updateComparisonWindows',
|
|
'setFilters',
|
|
],
|
|
featureFlagLogic,
|
|
['setFeatureFlags'],
|
|
],
|
|
})),
|
|
|
|
actions({
|
|
// Analytics + re-query on a user filter interaction. Dispatched by the filter-action
|
|
// listeners below; the scene logic additionally listens to this to sync the URL.
|
|
// Mirrors the logs viewer's handleQueryChange.
|
|
handleFilterChange: (filterType: string, extraProps?: Record<string, unknown>) => ({ filterType, extraProps }),
|
|
// A completed 2D brush on the latency heatmap — maps to a date range + duration chips.
|
|
applyHeatmapBrush: (selection: HeatmapBrushSelection) => ({ selection }),
|
|
runQuery: true,
|
|
fetchNextPage: true,
|
|
loadMoreTraceSpans: true,
|
|
setTracePagination: (hasMore: boolean, nextOffset: number | null) => ({ hasMore, nextOffset }),
|
|
clearSpans: true,
|
|
cancelInProgressSpans: (controller: AbortController | null) => ({ controller }),
|
|
cancelInProgressSparkline: (controller: AbortController | null) => ({ controller }),
|
|
cancelInProgressMatchingCounts: (controller: AbortController | null) => ({ controller }),
|
|
cancelInProgressDurationHistogram: (controller: AbortController | null) => ({ controller }),
|
|
cancelInProgressLatencyHeatmap: (controller: AbortController | null) => ({ controller }),
|
|
cancelInProgressAggregation: (controller: AbortController | null) => ({ controller }),
|
|
cancelInProgressSpanTree: (controller: AbortController | null) => ({ controller }),
|
|
setSpansAbortController: (controller: AbortController | null) => ({ controller }),
|
|
setSparklineAbortController: (controller: AbortController | null) => ({ controller }),
|
|
setMatchingCountsAbortController: (controller: AbortController | null) => ({ controller }),
|
|
setDurationHistogramAbortController: (controller: AbortController | null) => ({ controller }),
|
|
setLatencyHeatmapAbortController: (controller: AbortController | null) => ({ controller }),
|
|
setAggregationAbortController: (controller: AbortController | null) => ({ controller }),
|
|
setSpanTreeAbortController: (controller: AbortController | null) => ({ controller }),
|
|
setHasMoreToLoad: (hasMore: boolean) => ({ hasMore }),
|
|
setNextCursor: (cursor: string | null) => ({ cursor }),
|
|
setVisibleRowRange: (startIndex: number, stopIndex: number) => ({ startIndex, stopIndex }),
|
|
/**
|
|
* Snapshot the resolved time windows used for the last aggregation fetch.
|
|
* The drill-down flame query reads this snapshot instead of recomputing from
|
|
* the (potentially shifted) `-1h`-style relative date range, so its numbers
|
|
* line up with the row the user clicked.
|
|
*/
|
|
setLastAggregationWindow: (window: {
|
|
currentStartMs: number
|
|
currentEndMs: number
|
|
previousStartMs: number
|
|
previousEndMs: number
|
|
}) => ({ window }),
|
|
}),
|
|
|
|
reducers({
|
|
spansAbortController: [
|
|
null as AbortController | null,
|
|
{ setSpansAbortController: (_, { controller }) => controller },
|
|
],
|
|
sparklineAbortController: [
|
|
null as AbortController | null,
|
|
{ setSparklineAbortController: (_, { controller }) => controller },
|
|
],
|
|
matchingCountsAbortController: [
|
|
null as AbortController | null,
|
|
{ setMatchingCountsAbortController: (_, { controller }) => controller },
|
|
],
|
|
durationHistogramAbortController: [
|
|
null as AbortController | null,
|
|
{ setDurationHistogramAbortController: (_, { controller }) => controller },
|
|
],
|
|
latencyHeatmapAbortController: [
|
|
null as AbortController | null,
|
|
{ setLatencyHeatmapAbortController: (_, { controller }) => controller },
|
|
],
|
|
aggregationAbortController: [
|
|
null as AbortController | null,
|
|
{ setAggregationAbortController: (_, { controller }) => controller },
|
|
],
|
|
spanTreeAbortController: [
|
|
null as AbortController | null,
|
|
{ setSpanTreeAbortController: (_, { controller }) => controller },
|
|
],
|
|
lastAggregationWindow: [
|
|
null as null | {
|
|
currentStartMs: number
|
|
currentEndMs: number
|
|
previousStartMs: number
|
|
previousEndMs: number
|
|
},
|
|
{ setLastAggregationWindow: (_, { window }) => window },
|
|
],
|
|
hasRunQuery: [
|
|
false as boolean,
|
|
{
|
|
fetchSpansSuccess: () => true,
|
|
fetchSpansFailure: () => true,
|
|
},
|
|
],
|
|
spansLoading: [
|
|
false as boolean,
|
|
{
|
|
fetchSpans: () => true,
|
|
fetchSpansSuccess: () => false,
|
|
// A superseded query is aborted by the newer one that already re-set loading true;
|
|
// keep loading so the list holds its spinner instead of flashing "No spans found".
|
|
fetchSpansFailure: (state, { error }) => (isUserInitiatedError(error) ? state : false),
|
|
fetchNextPage: () => true,
|
|
fetchNextPageSuccess: () => false,
|
|
fetchNextPageFailure: (state, { error }) => (isUserInitiatedError(error) ? state : false),
|
|
},
|
|
],
|
|
sparklineLoading: [
|
|
false as boolean,
|
|
{
|
|
fetchSparkline: () => true,
|
|
fetchSparklineSuccess: () => false,
|
|
fetchSparklineFailure: () => false,
|
|
},
|
|
],
|
|
durationHistogramLoading: [
|
|
false as boolean,
|
|
{
|
|
fetchDurationHistogram: () => true,
|
|
fetchDurationHistogramSuccess: () => false,
|
|
fetchDurationHistogramFailure: () => false,
|
|
},
|
|
],
|
|
latencyHeatmapLoading: [
|
|
false as boolean,
|
|
{
|
|
fetchLatencyHeatmap: () => true,
|
|
fetchLatencyHeatmapSuccess: () => false,
|
|
fetchLatencyHeatmapFailure: () => false,
|
|
},
|
|
],
|
|
aggregationLoading: [
|
|
false as boolean,
|
|
{
|
|
fetchAggregation: () => true,
|
|
fetchAggregationSuccess: () => false,
|
|
fetchAggregationFailure: (state, { error }) => (isUserInitiatedError(error) ? state : false),
|
|
},
|
|
],
|
|
spanTreeLoading: [
|
|
false as boolean,
|
|
{
|
|
fetchSpanTree: () => true,
|
|
fetchSpanTreeSuccess: () => false,
|
|
fetchSpanTreeFailure: () => false,
|
|
},
|
|
],
|
|
hasMoreToLoad: [
|
|
true as boolean,
|
|
{
|
|
setHasMoreToLoad: (_, { hasMore }) => hasMore,
|
|
clearSpans: () => true,
|
|
},
|
|
],
|
|
nextCursor: [
|
|
null as string | null,
|
|
{
|
|
setNextCursor: (_, { cursor }) => cursor,
|
|
clearSpans: () => null,
|
|
},
|
|
],
|
|
visibleRowRange: [
|
|
null as VisibleRowRange | null,
|
|
{
|
|
setVisibleRowRange: (_, { startIndex, stopIndex }) => ({ startIndex, stopIndex }),
|
|
clearSpans: () => null,
|
|
},
|
|
],
|
|
// Whether the open trace has more spans than the loaded pages (drives the waterfall's
|
|
// infinite scroll). Reset on every fresh trace open so a small trace can't inherit a
|
|
// previous trace's "load more" affordance.
|
|
traceSpansHasMore: [
|
|
false as boolean,
|
|
{
|
|
setTracePagination: (_, { hasMore }) => hasMore,
|
|
loadTraceSpans: () => false,
|
|
},
|
|
],
|
|
// Offset to request for the next waterfall page (count of spans already loaded for the trace).
|
|
traceSpansNextOffset: [
|
|
0 as number,
|
|
{
|
|
setTracePagination: (_, { nextOffset }) => nextOffset ?? 0,
|
|
loadTraceSpans: () => 0,
|
|
},
|
|
],
|
|
// The trace + ts the loaded spans belong to, so `loadMoreTraceSpans` can refetch the next
|
|
// page with the same lookup window without re-deriving it from a since-shifted date range.
|
|
traceLoadContext: [
|
|
null as { traceId: string; ts?: string | null } | null,
|
|
{
|
|
loadTraceSpans: (_, { traceId, ts }) => ({ traceId, ts }),
|
|
clearSpans: () => null,
|
|
},
|
|
],
|
|
// Separate from the loader's own `traceSpansLoading` so paging in more spans shows a small
|
|
// bottom spinner rather than the drawer's full-trace overlay (see `isLoadingFullTrace`).
|
|
traceSpansLoadingMore: [
|
|
false as boolean,
|
|
{
|
|
loadMoreTraceSpans: () => true,
|
|
loadMoreTraceSpansSuccess: () => false,
|
|
loadMoreTraceSpansFailure: () => false,
|
|
// A fresh trace fetch must show the full-trace overlay even if a stale load-more is
|
|
// still in flight.
|
|
loadTraceSpans: () => false,
|
|
},
|
|
],
|
|
}),
|
|
|
|
loaders(({ values, actions, cache }) => ({
|
|
spans: [
|
|
[] as Span[],
|
|
{
|
|
clearSpans: () => [],
|
|
fetchSpans: async () => {
|
|
const controller = new AbortController()
|
|
actions.cancelInProgressSpans(controller)
|
|
|
|
const response = await api.tracing.listSpans(
|
|
{
|
|
dateRange: values.utcDateRange,
|
|
orderBy: values.filters.orderBy,
|
|
orderDirection: values.filters.orderDirection,
|
|
serviceNames:
|
|
values.filters.serviceNames.length > 0 ? values.filters.serviceNames : undefined,
|
|
filterGroup: values.queryFilterGroup as PropertyGroupFilter,
|
|
prefetchSpans: PREFETCH_SPANS,
|
|
flatSpans: values.filters.viewMode === 'spans',
|
|
limit: DEFAULT_PAGE_SIZE,
|
|
},
|
|
controller.signal
|
|
)
|
|
|
|
actions.setSpansAbortController(null)
|
|
actions.setHasMoreToLoad(!!response.hasMore)
|
|
actions.setNextCursor(response.nextCursor ?? null)
|
|
return response.results as Span[]
|
|
},
|
|
fetchNextPage: async () => {
|
|
if (!values.hasMoreToLoad) {
|
|
return values.spans
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
actions.cancelInProgressSpans(controller)
|
|
|
|
// Duration ordering paginates by offset (it has no keyset cursor); timestamp
|
|
// ordering uses the `after` cursor. Offset is the count of rows already shown.
|
|
const pagination =
|
|
values.filters.orderBy === 'duration'
|
|
? { offset: values.listRows.length }
|
|
: { after: values.nextCursor ?? undefined }
|
|
|
|
const response = await api.tracing.listSpans(
|
|
{
|
|
dateRange: values.utcDateRange,
|
|
orderBy: values.filters.orderBy,
|
|
orderDirection: values.filters.orderDirection,
|
|
serviceNames:
|
|
values.filters.serviceNames.length > 0 ? values.filters.serviceNames : undefined,
|
|
filterGroup: values.queryFilterGroup as PropertyGroupFilter,
|
|
prefetchSpans: PREFETCH_SPANS,
|
|
flatSpans: values.filters.viewMode === 'spans',
|
|
limit: DEFAULT_PAGE_SIZE,
|
|
...pagination,
|
|
},
|
|
controller.signal
|
|
)
|
|
|
|
actions.setSpansAbortController(null)
|
|
actions.setHasMoreToLoad(!!response.hasMore)
|
|
actions.setNextCursor(response.nextCursor ?? null)
|
|
return [...values.spans, ...(response.results as Span[])]
|
|
},
|
|
},
|
|
],
|
|
traceSpans: [
|
|
[] as Span[],
|
|
{
|
|
loadTraceSpans: async ({ traceId, ts }: { traceId: string; ts?: string | null }): Promise<Span[]> => {
|
|
const response = await api.tracing.getTrace(traceId, {
|
|
dateRange: resolveTraceLookupRange(ts, values.utcDateRange),
|
|
serviceNames: values.filters.serviceNames.length > 0 ? values.filters.serviceNames : undefined,
|
|
filterGroup: values.queryFilterGroup as PropertyGroupFilter,
|
|
})
|
|
actions.setTracePagination(!!response.hasMore, response.nextOffset ?? null)
|
|
return response.results as Span[]
|
|
},
|
|
// Infinite scroll: page in the next batch of the open trace's spans (earliest first)
|
|
// and append. Guarded so a trailing scroll event after the last page is a no-op.
|
|
loadMoreTraceSpans: async (): Promise<Span[]> => {
|
|
if (!values.traceSpansHasMore || !values.traceLoadContext) {
|
|
return values.traceSpans
|
|
}
|
|
const { traceId, ts } = values.traceLoadContext
|
|
const response = await api.tracing.getTrace(traceId, {
|
|
dateRange: resolveTraceLookupRange(ts, values.utcDateRange),
|
|
serviceNames: values.filters.serviceNames.length > 0 ? values.filters.serviceNames : undefined,
|
|
filterGroup: values.queryFilterGroup as PropertyGroupFilter,
|
|
offset: values.traceSpansNextOffset,
|
|
})
|
|
// Bail if a new trace was opened mid-fetch — appending this stale page to the new
|
|
// trace's spans would corrupt the waterfall.
|
|
if (values.traceLoadContext?.traceId !== traceId) {
|
|
return values.traceSpans
|
|
}
|
|
// Dedupe against what's already loaded: the waterfall keys its tree by span_id,
|
|
// so a page that overlaps prior pages wouldn't grow the rendered rows but would
|
|
// still balloon this array (and pin the infinite-scroll trigger at the bottom,
|
|
// spinning the CPU). If a page adds nothing new, stop paging.
|
|
const seen = new Set(values.traceSpans.map((s) => s.span_id))
|
|
const newSpans = (response.results as Span[]).filter((s) => !seen.has(s.span_id))
|
|
if (newSpans.length === 0) {
|
|
actions.setTracePagination(false, null)
|
|
return values.traceSpans
|
|
}
|
|
actions.setTracePagination(!!response.hasMore, response.nextOffset ?? null)
|
|
return [...values.traceSpans, ...newSpans]
|
|
},
|
|
},
|
|
],
|
|
spanTree: [
|
|
{
|
|
spanName: null as string | null,
|
|
current: [] as SpanTreeNode[],
|
|
previous: null as SpanTreeNode[] | null,
|
|
},
|
|
{
|
|
fetchSpanTree: async (params: { spanName: string; serviceName: string }) => {
|
|
// Abort any in-flight tree fetch so rapid row-clicks can't deliver a stale
|
|
// response that overwrites the newer one — matches the pattern used by
|
|
// fetchSpans / fetchSparkline / fetchAggregation.
|
|
const controller = new AbortController()
|
|
actions.cancelInProgressSpanTree(controller)
|
|
|
|
// Use the snapshotted window from the last aggregation fetch when available
|
|
// so the drill-down numbers align with the row the user clicked. Fall back
|
|
// to the live computed window only on a cold start (e.g. deep link before
|
|
// any aggregation has run).
|
|
const snapshot = values.lastAggregationWindow
|
|
const currentStartMs = snapshot?.currentStartMs ?? values.currentWindowMs.startMs
|
|
const currentEndMs = snapshot?.currentEndMs ?? values.currentWindowMs.endMs
|
|
const previousStartMs = snapshot?.previousStartMs ?? values.previousWindowMs.startMs
|
|
|
|
const response = await api.tracing.tree(
|
|
{
|
|
spanName: params.spanName,
|
|
serviceName: params.serviceName,
|
|
dateRange: {
|
|
date_from: new Date(currentStartMs).toISOString(),
|
|
date_to: new Date(currentEndMs).toISOString(),
|
|
},
|
|
serviceNames:
|
|
values.filters.serviceNames.length > 0 ? values.filters.serviceNames : undefined,
|
|
filterGroup: values.queryFilterGroup as PropertyGroupFilter,
|
|
compareFilter: {
|
|
compare: values.timeComparison !== null,
|
|
compare_to: new Date(previousStartMs).toISOString(),
|
|
},
|
|
},
|
|
controller.signal
|
|
)
|
|
|
|
actions.setSpanTreeAbortController(null)
|
|
return {
|
|
spanName: params.spanName,
|
|
current: response.results ?? [],
|
|
previous: response.compare ?? null,
|
|
}
|
|
},
|
|
},
|
|
],
|
|
aggregation: [
|
|
{ current: [] as AggregatedSpanRow[], previous: null as AggregatedSpanRow[] | null },
|
|
{
|
|
// `fullRange` (the Operations tab) ignores any active comparison: the aggregate
|
|
// covers the whole selected range with no compare period, matching the rate
|
|
// denominator the OperationsTable divides by.
|
|
fetchAggregation: async (options?: { fullRange?: boolean } | null) => {
|
|
const controller = new AbortController()
|
|
actions.cancelInProgressAggregation(controller)
|
|
|
|
const fullRange = options?.fullRange ?? false
|
|
const currentWindow = fullRange ? values.sparklineWindowMs : values.currentWindowMs
|
|
const compare = !fullRange && values.timeComparison !== null
|
|
|
|
// Snapshot the resolved windows so the drill-down flame query uses the
|
|
// same absolute timestamps as this aggregation, even if the user's
|
|
// relative `-1h` range has since shifted forward in time. Dispatched
|
|
// BEFORE the await so a concurrent `fetchSpanTree` (e.g. fired from the
|
|
// same `updateComparisonWindows` listener) reads the new window, not the
|
|
// old. Full-range (Operations) fetches skip it — the flame drill-down
|
|
// only exists on the traces tab and must stay aligned with the compare
|
|
// aggregation, not the operations one.
|
|
const window = {
|
|
currentStartMs: currentWindow.startMs,
|
|
currentEndMs: currentWindow.endMs,
|
|
previousStartMs: values.previousWindowMs.startMs,
|
|
previousEndMs: values.previousWindowMs.endMs,
|
|
}
|
|
if (!fullRange) {
|
|
actions.setLastAggregationWindow(window)
|
|
}
|
|
|
|
const response = await api.tracing.aggregate(
|
|
{
|
|
dateRange: {
|
|
date_from: new Date(window.currentStartMs).toISOString(),
|
|
date_to: new Date(window.currentEndMs).toISOString(),
|
|
},
|
|
serviceNames:
|
|
values.filters.serviceNames.length > 0 ? values.filters.serviceNames : undefined,
|
|
filterGroup: values.queryFilterGroup as PropertyGroupFilter,
|
|
compareFilter: {
|
|
compare,
|
|
compare_to: new Date(window.previousStartMs).toISOString(),
|
|
},
|
|
// The Operations table sorts/filters the full result set client-side, so
|
|
// request the endpoint's hard cap rather than its small default page.
|
|
limit: OPERATIONS_AGGREGATION_LIMIT,
|
|
},
|
|
controller.signal
|
|
)
|
|
|
|
actions.setAggregationAbortController(null)
|
|
return {
|
|
current: response.results ?? [],
|
|
previous: response.compare ?? null,
|
|
}
|
|
},
|
|
},
|
|
],
|
|
rawSparklineData: [
|
|
[] as SparklineRow[],
|
|
{
|
|
fetchSparkline: async () => {
|
|
// The sparkline counts root spans in 'traces' mode and every span in 'spans' mode
|
|
// (via rootSpans below), so it depends on the data scope (date range, services,
|
|
// filters) AND the view mode — but not on sort or compare. Skip the re-fetch (and
|
|
// its spinner overlay) only when a sort/compare toggle re-runs the query without
|
|
// changing scope or view mode.
|
|
const scopeKey = JSON.stringify([
|
|
values.utcDateRange,
|
|
values.filters.serviceNames,
|
|
values.queryFilterGroup,
|
|
values.filters.viewMode,
|
|
])
|
|
if (scopeKey === cache.sparklineScope) {
|
|
return values.rawSparklineData
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
actions.cancelInProgressSparkline(controller)
|
|
|
|
const response = await api.tracing.sparkline(
|
|
{
|
|
dateRange: values.utcDateRange,
|
|
serviceNames:
|
|
values.filters.serviceNames.length > 0 ? values.filters.serviceNames : undefined,
|
|
filterGroup: values.queryFilterGroup as PropertyGroupFilter,
|
|
rootSpans: values.filters.viewMode === 'traces',
|
|
},
|
|
controller.signal
|
|
)
|
|
|
|
actions.setSparklineAbortController(null)
|
|
// Record the scope only after a successful fetch, so a failed/aborted request retries.
|
|
cache.sparklineScope = scopeKey
|
|
return response.results
|
|
},
|
|
},
|
|
],
|
|
matchingCounts: [
|
|
{ count: 0, traceCount: 0 } as { count: number; traceCount: number },
|
|
{
|
|
fetchMatchingCounts: async () => {
|
|
// The count is view-mode-independent — it returns both span and trace counts in one
|
|
// response, and the label selects which to show. So a Traces/Spans (or sort/compare)
|
|
// toggle that re-runs the query must not re-hit the endpoint; only the data scope
|
|
// (date range, services, filters) changes the result. Skip the fetch when unchanged.
|
|
const scopeKey = JSON.stringify([
|
|
values.utcDateRange,
|
|
values.filters.serviceNames,
|
|
values.queryFilterGroup,
|
|
])
|
|
if (scopeKey === cache.matchingCountsScope) {
|
|
return values.matchingCounts
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
actions.cancelInProgressMatchingCounts(controller)
|
|
|
|
const response = await api.tracing.count(
|
|
{
|
|
dateRange: values.utcDateRange,
|
|
serviceNames:
|
|
values.filters.serviceNames.length > 0 ? values.filters.serviceNames : undefined,
|
|
filterGroup: values.queryFilterGroup as PropertyGroupFilter,
|
|
},
|
|
controller.signal
|
|
)
|
|
|
|
actions.setMatchingCountsAbortController(null)
|
|
// Record the scope only after a successful fetch, so a failed/aborted request retries.
|
|
cache.matchingCountsScope = scopeKey
|
|
return response
|
|
},
|
|
},
|
|
],
|
|
rawLatencyHeatmap: [
|
|
[] as LatencyHeatmapRow[],
|
|
{
|
|
fetchLatencyHeatmap: async () => {
|
|
// Same scope semantics as the sparkline: the heatmap depends on the data scope
|
|
// and the view mode (root spans vs every span), but not on sort or compare.
|
|
// Skip the re-fetch when a sort toggle re-runs the query without changing scope.
|
|
const scopeKey = JSON.stringify([
|
|
values.utcDateRange,
|
|
values.filters.serviceNames,
|
|
values.queryFilterGroup,
|
|
values.filters.viewMode,
|
|
])
|
|
if (scopeKey === cache.latencyHeatmapScope) {
|
|
return values.rawLatencyHeatmap
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
actions.cancelInProgressLatencyHeatmap(controller)
|
|
|
|
const response = await api.tracing.latencyHeatmap(
|
|
{
|
|
dateRange: values.utcDateRange,
|
|
serviceNames:
|
|
values.filters.serviceNames.length > 0 ? values.filters.serviceNames : undefined,
|
|
filterGroup: values.queryFilterGroup as PropertyGroupFilter,
|
|
// Match the population the list shows: root spans in Traces view,
|
|
// every matching span in Spans view.
|
|
rootSpans: values.filters.viewMode === 'traces',
|
|
},
|
|
controller.signal
|
|
)
|
|
|
|
actions.setLatencyHeatmapAbortController(null)
|
|
// Record the scope only after a successful fetch, so a failed/aborted request retries.
|
|
cache.latencyHeatmapScope = scopeKey
|
|
return response.results
|
|
},
|
|
},
|
|
],
|
|
rawDurationHistogram: [
|
|
[] as DurationHistogramRow[],
|
|
{
|
|
fetchDurationHistogram: async () => {
|
|
const controller = new AbortController()
|
|
actions.cancelInProgressDurationHistogram(controller)
|
|
|
|
const response = await api.tracing.durationHistogram(
|
|
{
|
|
dateRange: values.utcDateRange,
|
|
serviceNames:
|
|
values.filters.serviceNames.length > 0 ? values.filters.serviceNames : undefined,
|
|
filterGroup: values.queryFilterGroup as PropertyGroupFilter,
|
|
// Match the population the list shows (and the sparkline counts):
|
|
// root spans in Traces view, every matching span in Spans view.
|
|
rootSpans: values.filters.viewMode === 'traces',
|
|
},
|
|
controller.signal
|
|
)
|
|
|
|
actions.setDurationHistogramAbortController(null)
|
|
return response.results
|
|
},
|
|
},
|
|
],
|
|
})),
|
|
|
|
selectors({
|
|
sparklineData: [
|
|
(s) => [s.rawSparklineData],
|
|
(rows: SparklineRow[]): TracingSparklineData => {
|
|
if (!rows.length) {
|
|
return { data: [], dates: [], labels: [] }
|
|
}
|
|
|
|
let lastTime = ''
|
|
let i = -1
|
|
const labels: string[] = []
|
|
const dates: string[] = []
|
|
const accumulated = rows.reduce(
|
|
(accumulator, currentItem) => {
|
|
if (currentItem.time !== lastTime) {
|
|
labels.push(
|
|
humanFriendlyDetailedTime(currentItem.time, 'YYYY-MM-DD', 'HH:mm:ss', {
|
|
timestampStyle: 'absolute',
|
|
})
|
|
)
|
|
dates.push(currentItem.time)
|
|
lastTime = currentItem.time
|
|
i++
|
|
}
|
|
const key = currentItem.service
|
|
if (!key) {
|
|
return accumulator
|
|
}
|
|
if (!accumulator[key]) {
|
|
accumulator[key] = []
|
|
}
|
|
while (accumulator[key].length <= i) {
|
|
accumulator[key].push(0)
|
|
}
|
|
accumulator[key][i] += currentItem.count
|
|
return accumulator
|
|
},
|
|
{} as Record<string, number[]>
|
|
)
|
|
|
|
const data = Object.entries(accumulated)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([name, values], index) => ({
|
|
name,
|
|
values: values as number[],
|
|
color: dataColorVars[index % dataColorVars.length],
|
|
}))
|
|
.filter((series) => series.values.reduce((a, b) => a + b) > 0)
|
|
|
|
return { data, labels, dates }
|
|
},
|
|
],
|
|
// Total matching the filters, in the unit the list is showing: distinct traces in 'traces'
|
|
// mode, individual spans in 'spans' mode. Fed by the count endpoint, which returns both.
|
|
totalMatchingFilters: [
|
|
(s) => [s.matchingCounts, s.filters],
|
|
(matchingCounts: { count: number; traceCount: number }, filters: TracingFilters): number =>
|
|
filters.viewMode === 'spans' ? matchingCounts.count : matchingCounts.traceCount,
|
|
],
|
|
durationHistogramData: [
|
|
(s) => [s.rawDurationHistogram],
|
|
(rows: DurationHistogramRow[]): TracingDurationHistogramData => pivotDurationHistogram(rows, dataColorVars),
|
|
],
|
|
latencyHeatmapData: [
|
|
(s) => [s.rawLatencyHeatmap],
|
|
(rows: LatencyHeatmapRow[]): TracingLatencyHeatmapData => pivotLatencyHeatmap(rows),
|
|
],
|
|
// Single owner of the "show the latency heatmap?" rule — gates the fetch, the rendering,
|
|
// and the duration-histogram skip together. The feature-flag check here means a
|
|
// ?chart=heatmap deep link with the flag off degrades to the activity chart without
|
|
// fetching. The comparison overlay is a time-window drag surface, so the heatmap also
|
|
// yields to the activity chart while comparing.
|
|
showHeatmap: [
|
|
(s) => [s.filters, s.compareActive, s.featureFlags],
|
|
(filters: TracingFilters, compareActive: boolean, featureFlags: FeatureFlagsSet): boolean =>
|
|
!!featureFlags[FEATURE_FLAGS.TRACING_LATENCY_HEATMAP] &&
|
|
filters.chartType === 'heatmap' &&
|
|
!compareActive,
|
|
],
|
|
// The rows the list renders. 'traces' mode shows root spans only (one row per trace);
|
|
// 'spans' mode shows every matching span (root and child) flat. The fetch passes flatSpans
|
|
// to match, so in 'spans' mode the loaded spans are already the flat set.
|
|
listRows: [
|
|
(s) => [s.spans, s.filters],
|
|
(spans: Span[], filters: TracingFilters): Span[] => {
|
|
return filters.viewMode === 'spans' ? spans : spans.filter((s) => s.is_root_span)
|
|
},
|
|
],
|
|
// Memoized separately so visibleRowDurationRange (recomputed on every scroll tick) doesn't
|
|
// re-allocate the durations array — this only changes when the loaded rows do.
|
|
listRowDurations: [
|
|
(s) => [s.listRows],
|
|
(listRows: Span[]): number[] => listRows.map((span) => span.duration_nano),
|
|
],
|
|
// Single owner of the "show the duration histogram?" rule — the fetch decision, the
|
|
// highlight selector, and the scene's rendering all derive from this one place.
|
|
// An active comparison replaces the span list with the aggregate table, so the histogram
|
|
// (which mirrors the duration-sorted list) only applies outside it.
|
|
isDurationMode: [
|
|
(s) => [s.filters, s.compareActive],
|
|
(filters: TracingFilters, compareActive: boolean): boolean =>
|
|
filters.orderBy === 'duration' && !compareActive,
|
|
],
|
|
|
|
// Duration range covered by the currently-visible rows — the duration-space sibling of
|
|
// visibleRowDateRange below. When sorted by duration the visible rows are contiguous in
|
|
// duration space, so the histogram can sweep a highlight across the distribution as the
|
|
// user scrolls (the same interaction the time sparkline has under timestamp sort).
|
|
visibleRowDurationRange: [
|
|
(s) => [s.visibleRowRange, s.listRowDurations, s.isDurationMode],
|
|
(
|
|
visibleRowRange: VisibleRowRange | null,
|
|
listRowDurations: number[],
|
|
isDurationMode: boolean
|
|
): VisibleDurationRange | null => {
|
|
if (!isDurationMode) {
|
|
return null
|
|
}
|
|
return visibleDurationRange(visibleRowRange, listRowDurations)
|
|
},
|
|
],
|
|
|
|
// Date range covered by the currently-visible (scrolled-into-view) rows, so the sparkline can
|
|
// highlight the window the list is showing (mirrors the logs viewer). Only meaningful when the
|
|
// list is time-ordered — under duration (or any non-timestamp) sort, consecutive rows aren't
|
|
// contiguous in time, so the highlight would be meaningless. Suppress it then; the duration
|
|
// histogram (visibleRowDurationRange above) covers the duration-sorted case.
|
|
visibleRowDateRange: [
|
|
(s) => [s.visibleRowRange, s.listRows, s.orderBy],
|
|
(
|
|
visibleRowRange: VisibleRowRange | null,
|
|
listRows: Span[],
|
|
orderBy: TracingOrderBy
|
|
): VisibleSpanTimeRange | null => {
|
|
if (orderBy !== 'timestamp' || !visibleRowRange || listRows.length === 0) {
|
|
return null
|
|
}
|
|
const startIndex = Math.max(0, Math.min(visibleRowRange.startIndex, listRows.length - 1))
|
|
const stopIndex = Math.max(0, Math.min(visibleRowRange.stopIndex, listRows.length - 1))
|
|
const a = listRows[startIndex]?.timestamp
|
|
const b = listRows[stopIndex]?.timestamp
|
|
if (!a || !b) {
|
|
return null
|
|
}
|
|
const ta = dayjs(a)
|
|
const tb = dayjs(b)
|
|
const [earlier, later] = ta.isBefore(tb) ? [ta, tb] : [tb, ta]
|
|
return {
|
|
date_from: earlier.toISOString(),
|
|
date_to: later.toISOString(),
|
|
}
|
|
},
|
|
],
|
|
}),
|
|
|
|
listeners(({ actions, values }) => ({
|
|
handleFilterChange: ({ filterType, extraProps }) => {
|
|
insights.capture('tracing filter changed', { filter_type: filterType, ...extraProps })
|
|
actions.runQuery()
|
|
},
|
|
setDateRange: () => actions.handleFilterChange('date_range'),
|
|
setServiceNames: () => actions.handleFilterChange('service_names'),
|
|
setFilterGroup: () => actions.handleFilterChange('filter_group'),
|
|
setSort: ({ orderBy, orderDirection }) =>
|
|
actions.handleFilterChange('sort', { column: orderBy, direction: orderDirection }),
|
|
setViewMode: ({ viewMode }) => actions.handleFilterChange('view_mode', { mode: viewMode }),
|
|
setChartType: ({ chartType }) => actions.handleFilterChange('chart_type', { chart_type: chartType }),
|
|
setComparison: ({ comparison }) =>
|
|
actions.handleFilterChange('comparison', {
|
|
enabled: comparison !== null,
|
|
mode: comparison?.mode ?? null,
|
|
preset: comparison?.mode === 'time' ? comparison.preset : null,
|
|
}),
|
|
// Bulk restores (URL params, saved views) re-query without an interaction capture.
|
|
setFilters: () => actions.runQuery(),
|
|
applyHeatmapBrush: ({ selection }) => {
|
|
// While a refetch is in flight the rendered heatmap still shows the previous query's
|
|
// buckets. Ignore brushes until it settles so the selection maps against the buckets
|
|
// actually on screen rather than a stale grid.
|
|
if (values.latencyHeatmapLoading) {
|
|
return
|
|
}
|
|
const updates = heatmapBrushToFilters(
|
|
values.latencyHeatmapData,
|
|
selection,
|
|
values.filters.filterGroup,
|
|
values.utcDateRange.date_to
|
|
)
|
|
if (!updates) {
|
|
return
|
|
}
|
|
insights.capture('tracing filter changed', {
|
|
filter_type: 'heatmap_brush',
|
|
with_duration: !!updates.filterGroup,
|
|
})
|
|
// One dispatch → one re-query via the setFilters listener (and one URL write in the
|
|
// scene logic) — separate setDateRange + setFilterGroup would each run the query.
|
|
actions.setFilters(updates)
|
|
},
|
|
// Feature flags land asynchronously (insights.js /flags), potentially after the first
|
|
// runQuery. If that flip just made the heatmap visible (?chart=heatmap deep link with
|
|
// the flag on), fetch the data the earlier runQuery skipped.
|
|
setFeatureFlags: () => {
|
|
if (values.showHeatmap && values.rawLatencyHeatmap.length === 0 && !values.latencyHeatmapLoading) {
|
|
actions.fetchLatencyHeatmap()
|
|
}
|
|
},
|
|
// Overlay drags only refetch the aggregation — the sparkline canvas range stays fixed
|
|
// while the user moves windows around within it. The compare-flame refetch (viewer UI
|
|
// state) lives in tracingViewerLogic.
|
|
updateComparisonWindows: () => actions.fetchAggregation(),
|
|
runQuery: () => {
|
|
actions.clearSpans()
|
|
// The time sparkline is always fetched — it keeps the chart warm when the user flips back
|
|
// to timestamp sort. Duration sort additionally fetches the histogram that replaces it
|
|
// visually. The count endpoint feeds the "N traces/spans matching filters" label.
|
|
actions.fetchSparkline()
|
|
actions.fetchMatchingCounts()
|
|
if (values.showHeatmap) {
|
|
actions.fetchLatencyHeatmap()
|
|
}
|
|
// The heatmap wins the chart slot, so skip the histogram fetch it would hide.
|
|
if (values.isDurationMode && !values.showHeatmap) {
|
|
actions.fetchDurationHistogram()
|
|
}
|
|
if (values.compareActive) {
|
|
actions.fetchAggregation()
|
|
} else {
|
|
actions.fetchSpans()
|
|
}
|
|
},
|
|
cancelInProgressSpans: ({ controller }) => {
|
|
if (values.spansAbortController !== null) {
|
|
values.spansAbortController.abort(NEW_QUERY_STARTED_ERROR_MESSAGE)
|
|
}
|
|
actions.setSpansAbortController(controller)
|
|
},
|
|
cancelInProgressSparkline: ({ controller }) => {
|
|
if (values.sparklineAbortController !== null) {
|
|
values.sparklineAbortController.abort(NEW_QUERY_STARTED_ERROR_MESSAGE)
|
|
}
|
|
actions.setSparklineAbortController(controller)
|
|
},
|
|
cancelInProgressMatchingCounts: ({ controller }) => {
|
|
if (values.matchingCountsAbortController !== null) {
|
|
values.matchingCountsAbortController.abort(NEW_QUERY_STARTED_ERROR_MESSAGE)
|
|
}
|
|
actions.setMatchingCountsAbortController(controller)
|
|
},
|
|
cancelInProgressDurationHistogram: ({ controller }) => {
|
|
if (values.durationHistogramAbortController !== null) {
|
|
values.durationHistogramAbortController.abort(NEW_QUERY_STARTED_ERROR_MESSAGE)
|
|
}
|
|
actions.setDurationHistogramAbortController(controller)
|
|
},
|
|
cancelInProgressLatencyHeatmap: ({ controller }) => {
|
|
if (values.latencyHeatmapAbortController !== null) {
|
|
values.latencyHeatmapAbortController.abort(NEW_QUERY_STARTED_ERROR_MESSAGE)
|
|
}
|
|
actions.setLatencyHeatmapAbortController(controller)
|
|
},
|
|
cancelInProgressAggregation: ({ controller }) => {
|
|
if (values.aggregationAbortController !== null) {
|
|
values.aggregationAbortController.abort(NEW_QUERY_STARTED_ERROR_MESSAGE)
|
|
}
|
|
actions.setAggregationAbortController(controller)
|
|
},
|
|
cancelInProgressSpanTree: ({ controller }) => {
|
|
if (values.spanTreeAbortController !== null) {
|
|
values.spanTreeAbortController.abort(NEW_QUERY_STARTED_ERROR_MESSAGE)
|
|
}
|
|
actions.setSpanTreeAbortController(controller)
|
|
},
|
|
fetchSpansSuccess: () => {
|
|
captureTracingResults(values.listRows.length, 'spans')
|
|
},
|
|
fetchAggregationSuccess: ({ aggregation }) => {
|
|
captureTracingResults(aggregation.current.length, 'aggregation')
|
|
},
|
|
fetchSpansFailure: ({ error }) => {
|
|
if (!isUserInitiatedError(error)) {
|
|
toast.error(`Failed to load traces: ${error}`)
|
|
insights.capture('tracing query failed', { query_type: 'spans', error_message: String(error) })
|
|
}
|
|
},
|
|
fetchMatchingCountsFailure: ({ error }) => {
|
|
if (!isUserInitiatedError(error)) {
|
|
toast.error(`Failed to load the matching count: ${error}`)
|
|
}
|
|
},
|
|
fetchAggregationFailure: ({ error }) => {
|
|
if (!isUserInitiatedError(error)) {
|
|
toast.error(`Failed to load span aggregation: ${error}`)
|
|
}
|
|
},
|
|
fetchSpanTreeFailure: ({ error }) => {
|
|
if (!isUserInitiatedError(error)) {
|
|
toast.error(`Failed to load call tree: ${error}`)
|
|
}
|
|
},
|
|
loadMoreTraceSpansFailure: ({ error }) => {
|
|
if (!isUserInitiatedError(error)) {
|
|
toast.error(`Failed to load more spans: ${error}`)
|
|
}
|
|
},
|
|
// Surface a real fetch failure instead of letting the chart render an empty grid that
|
|
// reads as "No results matching filters" — an unreachable endpoint is not an empty query.
|
|
fetchLatencyHeatmapFailure: ({ error }) => {
|
|
if (!isUserInitiatedError(error)) {
|
|
toast.error(`Failed to load the latency heatmap: ${error}`)
|
|
}
|
|
},
|
|
})),
|
|
|
|
events(({ values }) => ({
|
|
beforeUnmount: () => {
|
|
if (values.spansAbortController) {
|
|
values.spansAbortController.abort('unmounting component')
|
|
}
|
|
if (values.sparklineAbortController) {
|
|
values.sparklineAbortController.abort('unmounting component')
|
|
}
|
|
if (values.matchingCountsAbortController) {
|
|
values.matchingCountsAbortController.abort('unmounting component')
|
|
}
|
|
if (values.durationHistogramAbortController) {
|
|
values.durationHistogramAbortController.abort('unmounting component')
|
|
}
|
|
if (values.latencyHeatmapAbortController) {
|
|
values.latencyHeatmapAbortController.abort('unmounting component')
|
|
}
|
|
if (values.spanTreeAbortController) {
|
|
values.spanTreeAbortController.abort('unmounting component')
|
|
}
|
|
if (values.aggregationAbortController) {
|
|
values.aggregationAbortController.abort('unmounting component')
|
|
}
|
|
},
|
|
})),
|
|
|
|
afterMount(({ actions, props: logicProps, values }) => {
|
|
if (logicProps.autoLoad && !values.hasRunQuery) {
|
|
actions.runQuery()
|
|
}
|
|
}),
|
|
])
|