From 80a18e776163db80170faaf8a2ef529a4009685d Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Thu, 6 Aug 2026 11:40:21 -0400 Subject: [PATCH 1/7] feat(dashboards): deployment markers on tile charts Correlating a latency or error spike with a release meant leaving the product. Dashboard tiles can now overlay the moment each release went out, derived from changes in the OpenTelemetry `service.version` resource attribute, so no instrumentation change is needed. Markers reuse the annotation overlay built for alert firing/recovery lines, which was designed source-agnostic for exactly this. Three properties make the markers trustworthy rather than noisy: - Scoped to the tile. The query runs against the tile's own source with the tile's own filters, so a chart filtered to one service is not annotated with another service's releases. - Tinted to the series. On a chart grouped by service, each marker takes its service's line color, so a release of one service cannot be read as another's. - Suppressed when unattributable. An aggregate line spanning several services gets no markers at all, since a marker naming a service with no visible line invites false attribution. The shared annotation renderer also gains label collapsing, so dense clusters read as "N deploys" instead of overlapping text, sized from the estimated label width rather than a fixed gap. --- .../deployment-markers-on-dashboard-charts.md | 10 + packages/app/src/ChartUtils.tsx | 27 + packages/app/src/DBDashboardPage.tsx | 70 ++- packages/app/src/HDXMultiSeriesTimeChart.tsx | 23 +- packages/app/src/__tests__/ChartUtils.test.ts | 55 ++ .../__tests__/chartAnnotations.test.tsx | 302 ++++++++++- .../components/charts/chartAnnotations.tsx | 227 ++++++++- .../useDeploymentAnnotations.test.tsx | 474 ++++++++++++++++++ .../src/hooks/useDeploymentAnnotations.tsx | 319 ++++++++++++ .../e2e/features/deployment-markers.spec.ts | 173 +++++++ .../tests/e2e/page-objects/DashboardPage.ts | 38 ++ 11 files changed, 1695 insertions(+), 23 deletions(-) create mode 100644 .changeset/deployment-markers-on-dashboard-charts.md create mode 100644 packages/app/src/hooks/__tests__/useDeploymentAnnotations.test.tsx create mode 100644 packages/app/src/hooks/useDeploymentAnnotations.tsx create mode 100644 packages/app/tests/e2e/features/deployment-markers.spec.ts diff --git a/.changeset/deployment-markers-on-dashboard-charts.md b/.changeset/deployment-markers-on-dashboard-charts.md new file mode 100644 index 0000000000..bdf9b7cab9 --- /dev/null +++ b/.changeset/deployment-markers-on-dashboard-charts.md @@ -0,0 +1,10 @@ +--- +'@hyperdx/app': minor +--- + +Overlay deployment markers on dashboard tile charts, derived from changes in the +OpenTelemetry `service.version` resource attribute. Markers are scoped to the +data each tile is charting and tinted to match their service's series color, and +are suppressed on charts where they can't be tied to a visible line — so an +aggregate line spanning many services isn't annotated with releases you can't +attribute to it. diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index d9e2ad8a70..b77bb78ff3 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -191,6 +191,33 @@ export function useTimeChartSettings( export const ChartKeyJoiner = ' · '; const PreviousPeriodSuffix = ' (previous)'; +/** + * Finds the color of the series a group value belongs to — e.g. the color of + * the `checkout-service` line on a chart grouped by service name. + * + * Series keys are the group values joined by `ChartKeyJoiner`, prefixed by the + * value column name when the chart has more than one, so the group value is + * matched against the key's components rather than the whole key. Previous + * period series are skipped; they mirror a current period series' color. + * + * Used to tint chart annotations (deployment markers) to match the series they + * describe, so a marker for one service can't be read as another's. + */ +export function getSeriesColorForGroup( + lineData: LineData[], + group: string, +): string | undefined { + for (const line of lineData) { + if (line.isDashed) { + continue; + } + if (line.currentPeriodKey.split(ChartKeyJoiner).includes(group)) { + return line.color; + } + } + return undefined; +} + // Note: roundToNearestMinutes is broken in date-fns currently // additionally it doesn't support seconds or > 30min // so we need to write our own :( diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 1db1a63c25..605d400a2b 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -102,6 +102,7 @@ import { IconPlus, IconPresentation, IconRefresh, + IconRocket, IconSearch, IconSquaresDiagonal, IconTags, @@ -113,6 +114,7 @@ import { } from '@tabler/icons-react'; import { IsolatedChartSyncProvider } from '@/chartSync'; +import { mergeAnnotations } from '@/components/charts/chartAnnotations'; import { ContactSupportText } from '@/components/ContactSupportText'; import SnapGridLayout from '@/components/dashboard/SnapGridLayout'; import DashboardContainer from '@/components/DashboardContainer'; @@ -149,6 +151,7 @@ import useDashboardContainers, { TabDeleteAction, } from '@/hooks/useDashboardContainers'; import { useDashboardKioskMode } from '@/hooks/useDashboardKioskMode'; +import { useDeploymentAnnotations } from '@/hooks/useDeploymentAnnotations'; import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; import ChartContainer, { @@ -385,6 +388,7 @@ const Tile = forwardRef( onTimeRangeSelect, filters, showAlertAnnotations, + showDeployAnnotations, isLive, readOnly, @@ -414,6 +418,8 @@ const Tile = forwardRef( filters?: Filter[]; // When true, draw alert firing/recovery annotations on this tile's chart. showAlertAnnotations?: boolean; + // When true, draw release markers on this tile's chart. + showDeployAnnotations?: boolean; isLive?: boolean; readOnly?: boolean; @@ -655,6 +661,30 @@ const Tile = forwardRef( showAlertAnnotations, ); + // Release markers, over the same visible window. Scoped to this tile: the + // query runs against the tile's own source with the tile's own predicates, + // so a chart filtered to one service isn't annotated with another's + // releases. Tiles sharing a source and filters share one query. + const deployAnnotations = useDeploymentAnnotations( + isFullscreen ? fullscreenDateRange : dateRange, + showDeployAnnotations, + { + source, + where: isBuilderSavedChartConfig(chart.config) + ? chart.config.where + : undefined, + whereLanguage: isBuilderSavedChartConfig(chart.config) + ? chart.config.whereLanguage + : undefined, + filters, + }, + ); + + const annotations = useMemo( + () => mergeAnnotations(alertAnnotations, deployAnnotations), + [alertAnnotations, deployAnnotations], + ); + const filterWarning = useMemo(() => { const doFiltersExist = !!filters?.filter( f => (f.type === 'lucene' || f.type === 'sql') && f.condition.trim(), @@ -1103,7 +1133,7 @@ const Tile = forwardRef( showDisplaySwitcher={!readOnly} enabled={chartEnabled} config={effectiveQueriedConfig} - annotations={alertAnnotations} + annotations={annotations} onTimeRangeSelect={ readOnly ? undefined @@ -1315,7 +1345,7 @@ const Tile = forwardRef( isSourceMissing, isSourceUnset, hasBeenVisible, - alertAnnotations, + annotations, isLive, readOnly, ], @@ -1728,6 +1758,11 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { 'alertAnnotations', parseAsBoolean.withDefault(false), ); + // Same for release markers, derived from `service.version` changes. + const [showDeployAnnotations, setShowDeployAnnotations] = useQueryState( + 'deployMarkers', + parseAsBoolean.withDefault(false), + ); // Track if we've initialized query for this dashboard const initializedDashboard = useRef(undefined); @@ -2189,6 +2224,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ]} onTimeRangeSelect={onTimeRangeSelect} showAlertAnnotations={showAlertAnnotations} + showDeployAnnotations={showDeployAnnotations} isHighlighted={highlightedTileId === chart.id} onUpdateChart={ isKioskMode @@ -2286,6 +2322,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { whereLanguage, onTimeRangeSelect, showAlertAnnotations, + showDeployAnnotations, getFilterQueriesForSource, moveTargetContainers, handleMoveTileToGroup, @@ -2712,15 +2749,26 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { {(hasTiles || containers.length > 0) && ( <> {hasTiles && ( - } - onClick={() => setShowAlertAnnotations(v => !v)} - data-testid="toggle-alert-annotations-menu-item" - > - {showAlertAnnotations - ? 'Hide alert annotations' - : 'Show alert annotations'} - + <> + } + onClick={() => setShowAlertAnnotations(v => !v)} + data-testid="toggle-alert-annotations-menu-item" + > + {showAlertAnnotations + ? 'Hide alert annotations' + : 'Show alert annotations'} + + } + onClick={() => setShowDeployAnnotations(v => !v)} + data-testid="toggle-deploy-annotations-menu-item" + > + {showDeployAnnotations + ? 'Hide deployment markers' + : 'Show deployment markers'} + + )} {containers.length > 0 && ( <> diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index b334956ecb..f395faee71 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -37,6 +37,7 @@ import { COLORS, formatNumber, truncateMiddle } from '@/utils'; import { ChartAnnotation, getAnnotationElements, + resolveAnnotationSeries, } from './components/charts/chartAnnotations'; import { ChartTooltipContainer, @@ -48,6 +49,7 @@ import { import { useChartSyncId } from './chartSync'; import { findNearestSeriesKey, + getSeriesColorForGroup, LineData, MAX_TIME_CHART_SERIES, toStartOfInterval, @@ -1024,15 +1026,30 @@ export const MemoChart = memo(function MemoChart({ // Alert/event markers as dashed lines, clamped to the chart's x-axis domain so // an edge marker (e.g. an alert already firing at window open) stays visible // instead of being dropped. Labels float in the reserved top headroom. - const annotationElements = useMemo(() => { + // Tint each marker to match the series it describes and drop the ones that + // can't be tied to anything on this chart — see `resolveAnnotationSeries`. + const coloredAnnotations = useMemo(() => { if (!annotations?.length) { + return annotations; + } + return resolveAnnotationSeries(annotations, group => + getSeriesColorForGroup(lineData, group), + ); + }, [annotations, lineData]); + + const annotationElements = useMemo(() => { + if (!coloredAnnotations?.length) { return null; } // xAxisDomain is a [min, max] tuple at runtime (declared as AxisDomain). - return getAnnotationElements(annotations, { + return getAnnotationElements(coloredAnnotations, { domain: xAxisDomain as [number, number], + // Drawable width, so markers too close together share one label. Zero on + // the first paint (before ResponsiveContainer measures), which the + // renderer treats as "label everything". + plotWidth: Math.max(0, containerWidth - Y_AXIS_WIDTH), }); - }, [annotations, xAxisDomain]); + }, [coloredAnnotations, xAxisDomain, containerWidth]); return (
{ }); }); }); + +describe('getSeriesColorForGroup', () => { + const line = ( + currentPeriodKey: string, + color: string, + isDashed = false, + ): LineData => ({ + dataKey: currentPeriodKey, + currentPeriodKey, + previousPeriodKey: `${currentPeriodKey} (previous)`, + displayName: currentPeriodKey, + valueColumnName: 'count()', + color, + isDashed, + }); + + // Single value column + group by: the series key is just the group value. + it('matches a group value that is the whole series key', () => { + const lineData = [line('checkout-service', '#blue'), line('api', '#gold')]; + + expect(getSeriesColorForGroup(lineData, 'api')).toBe('#gold'); + }); + + // Multiple value columns: the key is prefixed with the column name, joined + // by ChartKeyJoiner, so the group value is only one component of it. + it('matches a group value that is one component of a composite key', () => { + const lineData = [ + line(`count()${ChartKeyJoiner}checkout-service`, '#blue'), + line(`count()${ChartKeyJoiner}api`, '#gold'), + ]; + + expect(getSeriesColorForGroup(lineData, 'api')).toBe('#gold'); + }); + + it('returns undefined when no series covers the group', () => { + expect( + getSeriesColorForGroup([line('checkout-service', '#blue')], 'api'), + ).toBeUndefined(); + }); + + it('returns undefined for an empty series list', () => { + expect(getSeriesColorForGroup([], 'api')).toBeUndefined(); + }); + + // Previous-period series mirror a current-period series' color, so skipping + // them keeps the marker tied to the solid line the user sees. + it('skips previous-period series', () => { + const lineData = [line('api', '#dashed', true), line('api', '#solid')]; + + expect(getSeriesColorForGroup(lineData, 'api')).toBe('#solid'); + }); +}); diff --git a/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx b/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx index 8591bf3e8a..c024220edb 100644 --- a/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx +++ b/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx @@ -2,8 +2,12 @@ import { ReactElement } from 'react'; import { ReferenceLine } from 'recharts'; import { + type ChartAnnotation, getAnnotationElements, + labelSeparationPx, MAX_ANNOTATION_MARKERS, + mergeAnnotations, + resolveAnnotationSeries, } from '@/components/charts/chartAnnotations'; // ReferenceLine element props are typed loosely; narrow for assertions. @@ -11,10 +15,13 @@ const lineProps = (el: ReactElement) => el.props as { stroke: string; strokeDasharray?: string; + strokeOpacity?: number; x: number; - label?: unknown; + label?: { props: { value: string } }; }; +const labelOf = (el: ReactElement) => lineProps(el).label?.props.value; + // A domain wide enough that no marker clamps, and one bounded window for the // clamp cases. const wide = { domain: [0, 2_000_000_000] as [number, number] }; @@ -97,4 +104,297 @@ describe('getAnnotationElements', () => { expect(lines[1].key).toEqual(expect.any(String)); expect(lines[0].key).not.toEqual(lines[1].key); }); + + it('drops an unparseable time instead of rendering a broken line', () => { + const lines = getAnnotationElements( + [{ time: 'not a date' }, { time: 1_000_300_000 }], + bounded, + ); + + expect(lines).toHaveLength(1); + expect(lineProps(lines[0]).x).toBe(1_000_300); + }); +}); + +describe('mergeAnnotations', () => { + const at = (ms: number, label: string): ChartAnnotation => ({ + time: ms, + label, + }); + + it('returns undefined when every list is empty or absent', () => { + expect(mergeAnnotations(undefined, [], undefined)).toBeUndefined(); + }); + + it('concatenates lists in ascending time order, ignoring absent ones', () => { + const merged = mergeAnnotations( + [at(3_000, 'c'), at(1_000, 'a')], + undefined, + [at(2_000, 'b')], + ); + + expect(merged?.map(a => a.label)).toEqual(['a', 'b', 'c']); + }); + + it('sorts mixed Date / ISO / epoch-ms times together', () => { + const merged = mergeAnnotations( + [{ time: new Date(3_000), label: 'c' }], + [{ time: '1970-01-01T00:00:01.000Z', label: 'a' }], + [{ time: 2_000, label: 'b' }], + ); + + expect(merged?.map(a => a.label)).toEqual(['a', 'b', 'c']); + }); + + // Recharts 3 keeps props in an Immer store and hands back frozen arrays, so + // merging must never sort a caller's list in place. + it('does not mutate or reorder the input lists', () => { + const input = Object.freeze([at(3_000, 'c'), at(1_000, 'a')]); + + expect(() => mergeAnnotations(input)).not.toThrow(); + expect(input.map(a => a.label)).toEqual(['c', 'a']); + }); +}); + +describe('labelSeparationPx', () => { + // Labels are centered on their marker, so the room two neighbours need is + // half of each width, plus a gap. + it('scales with the combined width of both labels', () => { + expect(labelSeparationPx('1.0.0', '2.0.0')).toBeLessThan( + labelSeparationPx('2026.08.04-abcdef12', '2026.08.04-abcdef34'), + ); + }); + + it('still separates unlabelled markers by a gap', () => { + expect(labelSeparationPx(undefined, undefined)).toBeGreaterThan(0); + }); +}); + +describe('getAnnotationElements label collapsing', () => { + // 1000px over a 1000s domain => 1px per second, so a marker's `time` in + // seconds is also its pixel offset. + const collapsing = { + domain: [0, 1000] as [number, number], + plotWidth: 1000, + }; + const deploy = (seconds: number, label: string): ChartAnnotation => ({ + time: seconds * 1000, + label, + kind: 'deployment', + groupNoun: 'deploys', + }); + + it('leaves markers with room for both labels individually labelled', () => { + const lines = getAnnotationElements( + [deploy(0, '1.0.0'), deploy(60, '2.0.0')], + collapsing, + ); + + expect(lines.map(labelOf)).toEqual(['1.0.0', '2.0.0']); + }); + + it('collapses markers whose labels would overlap', () => { + const lines = getAnnotationElements( + [deploy(0, '1.0.0'), deploy(20, '2.0.0')], + collapsing, + ); + + expect(lines.map(labelOf)).toEqual(['2 deploys', undefined]); + }); + + // Regression: a fixed separation let long version strings render on top of + // each other ("2.0.0" and "2 deploys" colliding into "2.0.02 depl…"). + it('reserves more room for longer labels at the same spacing', () => { + const lines = getAnnotationElements( + [deploy(0, '2026.08.04-abcdef12'), deploy(60, '2026.08.04-abcdef34')], + collapsing, + ); + + // Same 60px gap that leaves short labels alone is not enough for these. + expect(lines.map(labelOf)).toEqual(['2 deploys', undefined]); + }); + + // Anchoring on the group's first member (not the running last one) is what + // stops a chain of near-threshold gaps collapsing into one giant group. + it('groups against the first member, not the running last one', () => { + const lines = getAnnotationElements( + [ + deploy(0, '1.0.0-beta'), + deploy(40, '1.1.0-beta'), + deploy(100, '1.2.0-beta'), + ], + collapsing, + ); + + // 40 is inside the anchor's label footprint; 100 is clear of it and starts + // its own group. Anchoring on the last member would have swallowed it. + expect(lines).toHaveLength(3); + expect(labelOf(lines[0])).toBe('2 deploys'); + expect(labelOf(lines[1])).toBeUndefined(); + expect(labelOf(lines[2])).toBe('1.2.0-beta'); + }); + + it('mutes the line of a marker whose label was collapsed away', () => { + const [anchor, absorbed] = getAnnotationElements( + [deploy(0, '1.0.0'), deploy(10, '2.0.0')], + collapsing, + ); + + expect(lineProps(anchor).strokeOpacity).toBe(0.9); + expect(lineProps(absorbed).strokeOpacity).toBeLessThan(0.9); + // The line is still drawn, so a dense cluster stays visible. + expect(lineProps(absorbed).x).toBe(10); + }); + + it('falls back to "events" when no group noun is supplied', () => { + const lines = getAnnotationElements( + [ + { time: 0, label: 'a', kind: 'alert' }, + { time: 10_000, label: 'b', kind: 'alert' }, + ], + collapsing, + ); + + expect(labelOf(lines[0])).toBe('2 events'); + }); + + it('never collapses markers of different kinds together', () => { + const lines = getAnnotationElements( + [deploy(0, '1.0.0'), { time: 5_000, label: 'Alert', kind: 'alert' }], + collapsing, + ); + + // Both are within each other's label footprint, but they are different + // kinds, so each keeps its own label rather than becoming "2 deploys". + expect(lines.map(labelOf).sort()).toEqual(['1.0.0', 'Alert']); + }); + + it('labels everything when the plot width is not yet measured', () => { + const annotations = [deploy(0, '1.0.0'), deploy(10, '2.0.0')]; + + // plotWidth 0 is the pre-measure state; omitted is the legacy caller. + expect( + getAnnotationElements(annotations, { ...collapsing, plotWidth: 0 }).map( + labelOf, + ), + ).toEqual(['1.0.0', '2.0.0']); + expect( + getAnnotationElements(annotations, { + domain: collapsing.domain, + }).map(labelOf), + ).toEqual(['1.0.0', '2.0.0']); + }); + + it('labels everything when the domain collapses to a single point', () => { + const lines = getAnnotationElements([deploy(5, 'v1'), deploy(5, 'v2')], { + domain: [5, 5], + plotWidth: 1000, + }); + + expect(lines.map(labelOf)).toEqual(['v1', 'v2']); + }); + + it('still caps the rendered markers after collapsing', () => { + const many = Array.from({ length: MAX_ANNOTATION_MARKERS + 50 }, (_, i) => + deploy(i / 10, `v${i}`), + ); + + expect(getAnnotationElements(many, collapsing)).toHaveLength( + MAX_ANNOTATION_MARKERS, + ); + }); +}); + +describe('resolveAnnotationSeries', () => { + const deploy = (group: string, label: string): ChartAnnotation => ({ + time: 1_000, + label, + group, + color: '#fallback', + }); + // A chart grouped by service: each of these has its own line. + const charted = (group: string) => + ({ checkout: '#blue', payments: '#teal' })[group]; + const nothingCharted = () => undefined; + + it('tints a marker to match its series', () => { + const [resolved] = resolveAnnotationSeries( + [deploy('checkout', '1.0.0')], + charted, + ); + + expect(resolved.color).toBe('#blue'); + }); + + it('keeps markers for every service the chart breaks out', () => { + const resolved = resolveAnnotationSeries( + [deploy('checkout', '1.0.0'), deploy('payments', '2.0.0')], + charted, + ); + + expect(resolved.map(a => a.color)).toEqual(['#blue', '#teal']); + }); + + // A tile filtered to one service: there is no per-service line to match, but + // the whole chart is about that service, so the markers are unambiguous. + it('keeps markers when every one belongs to the same group', () => { + const resolved = resolveAnnotationSeries( + [deploy('checkout', '1.0.0'), deploy('checkout', '2.0.0')], + nothingCharted, + ); + + expect(resolved.map(a => a.label)).toEqual(['1.0.0', '2.0.0']); + // No series to borrow from, so the marker keeps its own color. + expect(resolved[0].color).toBe('#fallback'); + }); + + // The case this rule exists for: an aggregate line over several services. + // A marker naming a service the reader cannot locate invites false + // attribution, so it is dropped rather than drawn. + it('drops markers that span several groups none of which are charted', () => { + const resolved = resolveAnnotationSeries( + [deploy('checkout', '1.0.0'), deploy('payments', '2.0.0')], + nothingCharted, + ); + + expect(resolved).toEqual([]); + }); + + it('keeps only the charted services when a chart shows a subset', () => { + const resolved = resolveAnnotationSeries( + [ + deploy('checkout', '1.0.0'), + deploy('payments', '2.0.0'), + deploy('billing', '3.0.0'), + ], + charted, + ); + + expect(resolved.map(a => a.label)).toEqual(['1.0.0', '2.0.0']); + }); + + // Alert markers describe the whole chart, not one series. + it('always keeps markers that carry no group', () => { + const resolved = resolveAnnotationSeries( + [ + { time: 1_000, label: 'Alert', color: '#red' }, + deploy('checkout', '1.0.0'), + deploy('payments', '2.0.0'), + ], + nothingCharted, + ); + + expect(resolved.map(a => a.label)).toEqual(['Alert']); + }); + + it('returns an empty list for no annotations', () => { + expect(resolveAnnotationSeries([], charted)).toEqual([]); + }); + + it('does not mutate the input annotations', () => { + const input = deploy('checkout', '1.0.0'); + resolveAnnotationSeries([input], charted); + + expect(input.color).toBe('#fallback'); + }); }); diff --git a/packages/app/src/components/charts/chartAnnotations.tsx b/packages/app/src/components/charts/chartAnnotations.tsx index d2430c05b1..4459afd46f 100644 --- a/packages/app/src/components/charts/chartAnnotations.tsx +++ b/packages/app/src/components/charts/chartAnnotations.tsx @@ -15,6 +15,24 @@ export type ChartAnnotation = { color?: string; /** Stable React key; defaults to the resolved timestamp + index. */ key?: string; + /** + * Feature that produced the marker ('alert', 'deployment', …). Markers only + * ever share a collapsed label with others of the same kind, so a deploy is + * never counted as an alert. + */ + kind?: string; + /** + * Plural noun for the collapsed label when several markers of this kind sit + * too close to label individually ("3 deploys"). Defaults to 'events'. + */ + groupNoun?: string; + /** + * Series this marker belongs to (e.g. a service name on a chart grouped by + * service). The chart tints the marker to match that series' color, so a + * marker for one service can't be read as another's. Ignored when the chart + * has no matching series — `color` is then used as the fallback. + */ + group?: string; }; // Safety valve: past this many markers the chart is unreadable anyway, and @@ -22,6 +40,186 @@ export type ChartAnnotation = { // flapping alert over a wide window). export const MAX_ANNOTATION_MARKERS = 1000; +// Labels are centered on their marker, so two neighbours collide once they are +// closer than half of each label's width plus a gap. Widths are estimated from +// the character count — a fixed separation would either let long version +// strings overlap or collapse short ones that had room to spare. +const LABEL_CHAR_WIDTH_PX = 6; // approximate advance at fontSize 10 +const LABEL_GAP_PX = 8; + +function estimateLabelWidthPx(label: string | undefined): number { + return (label?.length ?? 0) * LABEL_CHAR_WIDTH_PX; +} + +/** Minimum distance between two labelled markers before they overlap. */ +export function labelSeparationPx( + left: string | undefined, + right: string | undefined, +): number { + return ( + (estimateLabelWidthPx(left) + estimateLabelWidthPx(right)) / 2 + + LABEL_GAP_PX + ); +} + +const STROKE_OPACITY = 0.9; +// Members of a collapsed group still get a line (so the density is visible), +// but a faint one, so the labelled anchor stays legible. +const MUTED_STROKE_OPACITY = 0.35; + +/** A marker resolved to its clamped x position, in unix seconds. */ +type PositionedAnnotation = ChartAnnotation & { + x: number; + /** Label suppressed — a neighbour carries the group label for this cluster. */ + muted?: boolean; +}; + +/** + * Merges annotation lists from independent features (alerts, deployments, …) + * into the single array the chart takes. Returns `undefined` when nothing is + * left, matching the chart's "no annotations" prop state. + */ +export function mergeAnnotations( + ...lists: (readonly ChartAnnotation[] | undefined)[] +): ChartAnnotation[] | undefined { + // `flat` already produces a fresh array, so the sort below never touches the + // caller's lists — which matters because Recharts 3 keeps props in an Immer + // store and hands back frozen arrays. + const merged = lists.filter((list): list is readonly ChartAnnotation[] => + Boolean(list?.length), + ); + if (merged.length === 0) { + return undefined; + } + return merged + .flat() + .sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()); +} + +/** + * Ties markers to the chart's series, dropping the ones that can't be tied to + * anything visible. + * + * A marker only aids correlation if the reader can attribute it. Three cases: + * + * - The marker's group has its own series (a chart grouped by service): tint it + * to match that line, so a release of one service can't be read as another's. + * - Every grouped marker shares one group (a tile filtered to one service): the + * chart is entirely about that thing, so the markers are unambiguous even + * with no series to match. Kept with their own color. + * - Otherwise — several services' releases over a chart that doesn't break them + * out — the marker names something the reader cannot locate on the chart. A + * wall of those reads as noise and invites false attribution, so they are + * dropped. + * + * Markers with no group at all (alerts) are always kept; they describe the + * whole chart rather than one series. + */ +export function resolveAnnotationSeries( + annotations: ChartAnnotation[], + seriesColorFor: (group: string) => string | undefined, +): ChartAnnotation[] { + const groups = new Set(); + for (const annotation of annotations) { + if (annotation.group != null) { + groups.add(annotation.group); + } + } + const isSingleGroup = groups.size <= 1; + + const resolved: ChartAnnotation[] = []; + for (const annotation of annotations) { + if (annotation.group == null) { + resolved.push(annotation); + continue; + } + const seriesColor = seriesColorFor(annotation.group); + if (seriesColor != null) { + resolved.push({ ...annotation, color: seriesColor }); + } else if (isSingleGroup) { + resolved.push(annotation); + } + } + return resolved; +} + +function positionAnnotations( + annotations: ChartAnnotation[], + [minX, maxX]: [number, number], +): PositionedAnnotation[] { + const positioned: PositionedAnnotation[] = []; + for (const annotation of annotations) { + const seconds = new Date(annotation.time).getTime() / 1000; + if (!Number.isFinite(seconds)) { + // An unparseable time would render as a broken line and poison the + // collapse sort. Drop it rather than draw it. + continue; + } + // Clamp into the visible domain so edge markers snap to the edge. + positioned.push({ + ...annotation, + x: Math.min(Math.max(seconds, minX), maxX), + }); + } + return positioned; +} + +/** + * Groups markers that are too close together to label individually, keeping one + * labelled anchor per cluster and muting the rest. Grouping is per `kind`, so an + * alert marker is never folded into a deploy's count. + */ +function collapseLabels( + positioned: PositionedAnnotation[], + pxPerSecond: number, +): PositionedAnnotation[] { + const byKind = new Map(); + for (const annotation of positioned) { + const kind = annotation.kind ?? ''; + const bucket = byKind.get(kind); + if (bucket) { + bucket.push(annotation); + } else { + byKind.set(kind, [annotation]); + } + } + + const collapsed: PositionedAnnotation[] = []; + for (const bucket of byKind.values()) { + const sorted = [...bucket].sort((a, b) => a.x - b.x); + const noun = sorted[0].groupNoun ?? 'events'; + let start = 0; + while (start < sorted.length) { + const anchor = sorted[start]; + // Measure every candidate against the group's *first* member, not the + // running last one — otherwise a long run of markers each just under the + // threshold apart would chain into one arbitrarily wide group. The + // anchor's label widens to "N events" as it absorbs, so re-measure it. + let end = start + 1; + while (end < sorted.length) { + const groupSize = end - start + 1; + const anchorLabel = + groupSize > 1 ? `${groupSize} ${noun}` : anchor.label; + const required = labelSeparationPx(anchorLabel, sorted[end].label); + if ((sorted[end].x - anchor.x) * pxPerSecond >= required) { + break; + } + end++; + } + + const size = end - start; + collapsed.push( + size === 1 ? anchor : { ...anchor, label: `${size} ${noun}` }, + ); + for (let i = start + 1; i < end; i++) { + collapsed.push({ ...sorted[i], label: undefined, muted: true }); + } + start = end; + } + } + return collapsed; +} + /** * Renders annotation markers as dashed vertical reference lines, with the label * floated in the chart's top headroom (above the line) so it stays legible and @@ -33,27 +231,40 @@ export const MAX_ANNOTATION_MARKERS = 1000; * already firing when the window opens, pinned to a coarser-quantized start * time — snaps to the visible edge instead of being dropped by Recharts. * + * `plotWidth` (the drawable width in pixels) enables label collapsing for dense + * clusters. It is optional because the chart cannot measure itself on the first + * paint; without it every marker is labelled, as before. + * * Generic over source — feature hooks map their events to `ChartAnnotation[]`. * Capped at `MAX_ANNOTATION_MARKERS` to protect against pathological inputs. */ export function getAnnotationElements( annotations: ChartAnnotation[], - opts: { domain: [number, number] }, + opts: { domain: [number, number]; plotWidth?: number }, ): ReactElement[] { const [minX, maxX] = opts.domain; + const positioned = positionAnnotations(annotations, [minX, maxX]); - return annotations.slice(0, MAX_ANNOTATION_MARKERS).map((annotation, i) => { - const rawSeconds = new Date(annotation.time).getTime() / 1000; - // Clamp into the visible domain so edge markers snap to the edge. - const x = Math.min(Math.max(rawSeconds, minX), maxX); + // Collapse only when the pixel geometry is known. `plotWidth` is 0 before + // ResponsiveContainer measures, and the domain collapses to a point on a + // single-bucket chart — both degrade to "label everything", never to + // "drop markers". + const spanSeconds = maxX - minX; + const plotWidth = opts.plotWidth ?? 0; + const laidOut = + plotWidth > 0 && spanSeconds > 0 + ? collapseLabels(positioned, plotWidth / spanSeconds) + : positioned; + + return laidOut.slice(0, MAX_ANNOTATION_MARKERS).map((annotation, i) => { const color = annotation.color ?? 'var(--color-border)'; return ( ({ + useQueriedChartConfig: jest.fn(), +})); +jest.mock('@mantine/notifications', () => ({ + notifications: { show: jest.fn() }, +})); + +// Untyped handle on the mocked hook. Going through the module object keeps the +// mock's argument and return types loose, so the fixtures below don't need +// `as any` casts to stand in for a full react-query result. +const chartConfigModule: { useQueriedChartConfig: jest.Mock } = + jest.requireMock('@/hooks/useChartConfig'); +const mockedUseQueriedChartConfig = chartConfigModule.useQueriedChartConfig; + +// Fully-formed sources, typed as their concrete kind rather than asserted, so +// a schema change surfaces here instead of being silently cast away. +const logSource: TLogSource = { + id: 'log-1', + name: 'Logs', + kind: SourceKind.Log, + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'otel_logs' }, + timestampValueExpression: 'TimestampTime', + defaultTableSelectExpression: 'Timestamp, Body', + serviceNameExpression: 'ServiceName', + implicitColumnExpression: 'Body', +}; + +const traceSource: TTraceSource = { + id: 'trace-1', + name: 'Traces', + kind: SourceKind.Trace, + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'otel_traces' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, SpanName', + durationExpression: 'Duration', + durationPrecision: 9, + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', + parentSpanIdExpression: 'ParentSpanId', + spanNameExpression: 'SpanName', + spanKindExpression: 'SpanKind', + serviceNameExpression: 'ServiceName', +}; + +const metricSource: TMetricSource = { + id: 'metric-1', + name: 'Metrics', + kind: SourceKind.Metric, + connection: 'conn-1', + from: { databaseName: 'default', tableName: '' }, + timestampValueExpression: 'TimeUnix', + resourceAttributesExpression: 'ResourceAttributes', + metricTables: { + gauge: 'otel_metrics_gauge', + histogram: 'otel_metrics_histogram', + sum: 'otel_metrics_sum', + summary: 'otel_metrics_summary', + 'exponential histogram': 'otel_metrics_exponential_histogram', + }, +}; + +const sessionSource: TSessionSource = { + id: 'session-1', + name: 'Sessions', + kind: SourceKind.Session, + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'hyperdx_sessions' }, + timestampValueExpression: 'TimestampTime', + traceSourceId: 'trace-1', +}; + +describe('canDeriveDeployments', () => { + it('accepts log and trace sources', () => { + expect(canDeriveDeployments(logSource)).toBe(true); + expect(canDeriveDeployments(traceSource)).toBe(true); + }); + + // The deployments query re-aggregates the tile's own table, which is what + // makes the tile's filters meaningful against it. Metric sources resolve + // their table per metric type, so there is no single table to re-aggregate. + it('rejects metric, session and disabled sources', () => { + expect(canDeriveDeployments(metricSource)).toBe(false); + expect(canDeriveDeployments(sessionSource)).toBe(false); + expect(canDeriveDeployments({ ...logSource, disabled: true })).toBe(false); + }); + + it('rejects a missing source', () => { + expect(canDeriveDeployments(undefined)).toBe(false); + }); +}); + +describe('buildDeploymentChartConfig', () => { + const range: [Date, Date] = [new Date(1_000), new Date(2_000)]; + + it('selects the first timestamp each version was seen at, grouped by version and service', () => { + const config = buildDeploymentChartConfig( + logSource, + DEFAULT_VERSION_EXPRESSION, + range, + ); + + expect(config.select).toBe( + "min(TimestampTime) AS firstSeen, ResourceAttributes['service.version'] AS version, ServiceName AS service", + ); + expect(config.groupBy).toBe( + "ResourceAttributes['service.version'], ServiceName", + ); + expect(config.where).toBe("ResourceAttributes['service.version'] != ''"); + expect(config.whereLanguage).toBe('sql'); + expect(config.orderBy).toBe('firstSeen ASC'); + expect(config.limit).toEqual({ limit: 500 }); + expect(config.dateRange).toBe(range); + expect(config.source).toBe('log-1'); + expect(config.from).toBe(logSource.from); + }); + + // The group-by columns are already named in `select`; leaving this unset + // makes renderChartConfig append them a second time. + it('disables automatic group-by projection', () => { + expect( + buildDeploymentChartConfig(logSource, DEFAULT_VERSION_EXPRESSION, range) + .selectGroupBy, + ).toBe(false); + }); + + it('omits the service column when the source has no service expression', () => { + const noService = { ...logSource, serviceNameExpression: undefined }; + const config = buildDeploymentChartConfig( + noService, + DEFAULT_VERSION_EXPRESSION, + range, + ); + + expect(config.select).toBe( + "min(TimestampTime) AS firstSeen, ResourceAttributes['service.version'] AS version", + ); + expect(config.groupBy).toBe("ResourceAttributes['service.version']"); + }); + + it('uses only the first expression of a multi-column timestamp', () => { + const multi = { + ...logSource, + timestampValueExpression: 'TimestampTime, Timestamp', + }; + + expect( + buildDeploymentChartConfig(multi, DEFAULT_VERSION_EXPRESSION, range) + .select, + ).toContain('min(TimestampTime) AS firstSeen'); + }); + + it('honors a custom version expression', () => { + const config = buildDeploymentChartConfig( + logSource, + "LogAttributes['release']", + range, + ); + + expect(config.select).toContain("LogAttributes['release'] AS version"); + expect(config.groupBy).toContain("LogAttributes['release']"); + expect(config.where).toBe("LogAttributes['release'] != ''"); + }); + + describe('tile scoping', () => { + it('sends no filters when the tile is unfiltered', () => { + const config = buildDeploymentChartConfig( + logSource, + DEFAULT_VERSION_EXPRESSION, + range, + { where: ' ', filters: [] }, + ); + + expect(config.filters).toBeUndefined(); + }); + + // The whole point: a tile filtered to one service must not be annotated + // with another service's releases. + it("carries the tile's own where clause as a filter", () => { + const config = buildDeploymentChartConfig( + logSource, + DEFAULT_VERSION_EXPRESSION, + range, + { where: 'ServiceName:"checkout"', whereLanguage: 'lucene' }, + ); + + expect(config.filters).toEqual([ + { type: 'lucene', condition: 'ServiceName:"checkout"' }, + ]); + // The config's own `where` stays reserved for the SQL version predicate. + expect(config.where).toBe("ResourceAttributes['service.version'] != ''"); + }); + + it('preserves a SQL tile where clause as a SQL filter', () => { + const config = buildDeploymentChartConfig( + logSource, + DEFAULT_VERSION_EXPRESSION, + range, + { where: "ServiceName = 'checkout'", whereLanguage: 'sql' }, + ); + + expect(config.filters).toEqual([ + { type: 'sql', condition: "ServiceName = 'checkout'" }, + ]); + }); + + it('appends dashboard filters and drops empty ones', () => { + const config = buildDeploymentChartConfig( + logSource, + DEFAULT_VERSION_EXPRESSION, + range, + { + where: 'ServiceName:"checkout"', + whereLanguage: 'lucene', + filters: [ + { type: 'lucene', condition: '' }, + { type: 'sql', condition: "Env = 'prod'" }, + ], + }, + ); + + expect(config.filters).toEqual([ + { type: 'lucene', condition: 'ServiceName:"checkout"' }, + { type: 'sql', condition: "Env = 'prod'" }, + ]); + }); + + // Lucene bare terms need the source's implicit column to render. + it('carries the implicit column expression for Lucene filters', () => { + const config = buildDeploymentChartConfig( + logSource, + DEFAULT_VERSION_EXPRESSION, + range, + ); + + expect(config.implicitColumnExpression).toBe('Body'); + }); + }); +}); + +describe('deploymentRowsToAnnotations', () => { + const windowStart = new Date('2026-07-01T00:00:00.000Z'); + const inside = '2026-07-01T00:30:00.000Z'; + + it('maps a version first seen inside the window to a marker', () => { + const [annotation] = deploymentRowsToAnnotations( + [{ firstSeen: inside, version: '1.43.1', service: 'checkout' }], + { windowStart }, + ); + + expect(annotation).toMatchObject({ + time: new Date(inside).getTime(), + label: '1.43.1', + color: getChartColorInfo(), + kind: 'deployment', + groupNoun: 'deploys', + }); + }); + + // The query range is widened backwards so the version that was already + // running shows up; it must not be drawn as a deploy at the left edge. + it('drops the version that was already running when the window opened', () => { + const annotations = deploymentRowsToAnnotations( + [ + { firstSeen: '2026-06-30T23:00:00.000Z', version: '1.43.0' }, + { firstSeen: inside, version: '1.43.1' }, + ], + { windowStart }, + ); + + expect(annotations.map(a => a.label)).toEqual(['1.43.1']); + }); + + it('keeps a version first seen exactly at the window start', () => { + const annotations = deploymentRowsToAnnotations( + [{ firstSeen: windowStart.toISOString(), version: '1.43.1' }], + { windowStart }, + ); + + expect(annotations).toHaveLength(1); + }); + + it('drops rows with a missing or unparseable timestamp or version', () => { + const annotations = deploymentRowsToAnnotations( + [ + { firstSeen: null, version: '1.0.0' }, + { firstSeen: 'not a date', version: '1.0.0' }, + { firstSeen: inside, version: '' }, + { firstSeen: inside, version: null }, + { firstSeen: inside, version: '1.43.1' }, + ], + { windowStart }, + ); + + expect(annotations.map(a => a.label)).toEqual(['1.43.1']); + }); + + it('gives each marker a distinct key so React can reconcile them', () => { + const annotations = deploymentRowsToAnnotations( + [ + { firstSeen: inside, version: '1.43.1', service: 'checkout' }, + { firstSeen: inside, version: '1.43.1', service: 'payments' }, + ], + { windowStart }, + ); + + expect(annotations[0].key).not.toEqual(annotations[1].key); + }); +}); + +describe('useDeploymentAnnotations', () => { + const range: [Date, Date] = [ + new Date('2026-07-01T00:00:00.000Z'), + new Date('2026-07-01T01:00:00.000Z'), + ]; + + const mockQuery = (rows: unknown[] | undefined, isFetching = false) => + mockedUseQueriedChartConfig.mockReturnValue({ + data: rows ? { data: rows } : undefined, + isFetching, + }); + + /** The config and options the hook last handed to `useQueriedChartConfig`. */ + const lastCall = (): [ + BuilderChartConfigWithDateRange, + { enabled: boolean }, + ] => + mockedUseQueriedChartConfig.mock.calls[ + mockedUseQueriedChartConfig.mock.calls.length - 1 + ]; + + beforeEach(() => mockQuery([])); + afterEach(() => jest.clearAllMocks()); + + it('returns undefined and keeps the query idle when disabled', () => { + mockQuery([{ firstSeen: '2026-07-01T00:30:00.000Z', version: '1.0.0' }]); + + const { result } = renderHook(() => + useDeploymentAnnotations(range, false, { source: logSource }), + ); + + expect(result.current).toBeUndefined(); + expect(lastCall()[1]).toMatchObject({ enabled: false }); + }); + + it('keeps the query idle when the tile has no source', () => { + renderHook(() => useDeploymentAnnotations(range, true)); + + expect(lastCall()[1]).toMatchObject({ enabled: false }); + }); + + it('keeps the query idle for a source that cannot provide releases', () => { + renderHook(() => + useDeploymentAnnotations(range, true, { source: metricSource }), + ); + + expect(lastCall()[1]).toMatchObject({ enabled: false }); + }); + + it('returns markers for versions first seen inside the window', () => { + mockQuery([ + { firstSeen: '2026-07-01T00:30:00.000Z', version: '1.43.1' }, + { firstSeen: '2026-06-30T20:00:00.000Z', version: '1.43.0' }, + ]); + + const { result } = renderHook(() => + useDeploymentAnnotations(range, true, { source: logSource }), + ); + + expect(result.current).toHaveLength(1); + expect(result.current?.[0]).toMatchObject({ + label: '1.43.1', + kind: 'deployment', + }); + }); + + it('returns undefined rather than an empty array when nothing is found', () => { + mockQuery([{ firstSeen: '2026-06-30T20:00:00.000Z', version: '1.43.0' }]); + + const { result } = renderHook(() => + useDeploymentAnnotations(range, true, { source: logSource }), + ); + + expect(result.current).toBeUndefined(); + }); + + it("scopes the query with the tile's filters", () => { + renderHook(() => + useDeploymentAnnotations(range, true, { + source: logSource, + where: 'ServiceName:"checkout"', + whereLanguage: 'lucene', + }), + ); + + expect(lastCall()[0].filters).toEqual([ + { type: 'lucene', condition: 'ServiceName:"checkout"' }, + ]); + }); + + it('widens the query range backwards to spot the already-running version', () => { + renderHook(() => + useDeploymentAnnotations(range, true, { source: logSource }), + ); + + const [queryStart, queryEnd] = lastCall()[0].dateRange; + + // 10% of a one-hour window is under the 30 minute floor, so the floor wins. + expect(range[0].getTime() - queryStart.getTime()).toBe(30 * 60_000); + expect(queryEnd.getTime()).toBe(range[1].getTime()); + }); + + // A live window ending mid-minute; both bounds quantize to the same bucket + // as it slides forward a few seconds. + const liveRange: [Date, Date] = [ + new Date('2026-07-01T00:00:00.000Z'), + new Date('2026-07-01T01:00:30.000Z'), + ]; + const slid = (ms: number): [Date, Date] => [ + liveRange[0], + new Date(liveRange[1].getTime() + ms), + ]; + + // Callers rebuild the filters array every render. If that churned the config, + // react-query would issue one request per tile instead of sharing one. + it('reuses one config object as a live window slides within the minute', () => { + const { rerender } = renderHook( + ({ dateRange }) => + useDeploymentAnnotations(dateRange, true, { + source: logSource, + filters: [{ type: 'sql', condition: "Env = 'prod'" }], + }), + { initialProps: { dateRange: liveRange } }, + ); + const first = mockedUseQueriedChartConfig.mock.calls[0][0]; + + rerender({ dateRange: slid(5_000) }); + + expect(lastCall()[0]).toBe(first); + }); + + it('rebuilds the config once the window crosses a minute boundary', () => { + const { rerender } = renderHook( + ({ dateRange }) => + useDeploymentAnnotations(dateRange, true, { source: logSource }), + { initialProps: { dateRange: liveRange } }, + ); + const first = mockedUseQueriedChartConfig.mock.calls[0][0]; + + rerender({ dateRange: slid(120_000) }); + + expect(lastCall()[0]).not.toBe(first); + }); +}); diff --git a/packages/app/src/hooks/useDeploymentAnnotations.tsx b/packages/app/src/hooks/useDeploymentAnnotations.tsx new file mode 100644 index 0000000000..ceb75928a7 --- /dev/null +++ b/packages/app/src/hooks/useDeploymentAnnotations.tsx @@ -0,0 +1,319 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { + BuilderChartConfigWithDateRange, + Filter, + SearchConditionLanguage, + SourceKind, + TSource, +} from '@hyperdx/common-utils/dist/types'; +import { notifications } from '@mantine/notifications'; + +import { ChartAnnotation } from '@/components/charts/chartAnnotations'; +import { useQueriedChartConfig } from '@/hooks/useChartConfig'; +import { getFirstTimestampValueExpression } from '@/source'; +import { getChartColorInfo } from '@/utils'; + +/** + * Resource attribute carrying the running release. `service.version` is OTel + * resource semconv, so this works out of the box for instrumented services. + */ +export const DEFAULT_VERSION_EXPRESSION = + "ResourceAttributes['service.version']"; + +const DEPLOY_EMPTY_NOTIFICATION_ID = 'deployment-markers-empty'; + +/** Distinct versions fetched per window. Far above any real release cadence. */ +const MAX_DEPLOY_ROWS = 500; + +/** + * Query bounds are floored/ceiled to this, so a live-tailing dashboard reuses + * one cached result instead of issuing a fresh query every tick. Matches the + * bucketing `api.useAlertHistory` applies for the same reason. + */ +const BUCKET_MS = 60_000; + +// How far back before the visible window we look for the already-running +// version. See `deploymentRowsToAnnotations` for why. +const MIN_LOOKBACK_MS = 30 * 60_000; +const LOOKBACK_RATIO = 0.1; + +/** A row of the deployments query. Values arrive as strings from ClickHouse. */ +export type DeploymentRow = { + firstSeen?: string | number | null; + version?: string | null; + service?: string | null; +}; + +/** Narrows the tile's filters to the ones worth sending. */ +type DeploymentScope = { + where?: string; + whereLanguage?: SearchConditionLanguage; + filters?: Filter[]; +}; + +/** + * Whether releases can be derived from this source. + * + * Only log and trace sources qualify: the deployments query runs against the + * *same table* the tile charts, which is what makes the tile's own filters + * meaningful against it. Metric sources resolve their table from `metricTables` + * per metric type, so there is no single table to re-aggregate, and a tile + * filter written against metric columns would not apply to a log table. + */ +export function canDeriveDeployments( + source: TSource | undefined, +): source is TSource { + return ( + source != null && + !source.disabled && + (source.kind === SourceKind.Log || source.kind === SourceKind.Trace) + ); +} + +/** + * Builds the "when did each release first appear" query: one row per version + * (per service), carrying the earliest timestamp it was seen at. + * + * `scope` carries the tile's own predicates so the markers describe the slice + * the chart is actually showing — a tile filtered to one service must not be + * annotated with another service's releases. + * + * Uses string `select`/`groupBy` rather than the structured builder form. This + * is a fixed one-off aggregate rather than a user-editable series, and + * `SelectListSchema` accepts a raw string for exactly that case — which also + * keeps `min()` over a `DateTime64` out of the aggregate-function machinery. + */ +export function buildDeploymentChartConfig( + source: TSource, + versionExpression: string, + dateRange: [Date, Date], + scope: DeploymentScope = {}, +): BuilderChartConfigWithDateRange { + const timestampExpression = getFirstTimestampValueExpression( + source.timestampValueExpression, + ); + const serviceExpression = + 'serviceNameExpression' in source + ? source.serviceNameExpression + : undefined; + + // The tile's own `where` and the dashboard filters both go through `filters`, + // which carries a language per entry — the config's own `where` is reserved + // for the SQL version predicate below. + const scopeFilters: Filter[] = [ + ...(scope.where?.trim() + ? [ + { + type: scope.whereLanguage === 'sql' ? 'sql' : 'lucene', + condition: scope.where, + } as Filter, + ] + : []), + ...(scope.filters ?? []).filter( + filter => !('condition' in filter) || filter.condition?.trim(), + ), + ]; + + return { + connection: source.connection, + source: source.id, + from: source.from, + timestampValueExpression: source.timestampValueExpression, + // Needed for Lucene scope filters to resolve bare terms. + implicitColumnExpression: + 'implicitColumnExpression' in source + ? source.implicitColumnExpression + : undefined, + useTextIndexForImplicitColumn: + 'useTextIndexForImplicitColumn' in source + ? source.useTextIndexForImplicitColumn + : undefined, + bodyExpression: + 'bodyExpression' in source ? source.bodyExpression : undefined, + select: [ + `min(${timestampExpression}) AS firstSeen`, + `${versionExpression} AS version`, + ...(serviceExpression ? [`${serviceExpression} AS service`] : []), + ].join(', '), + where: `${versionExpression} != ''`, + whereLanguage: 'sql', + ...(scopeFilters.length ? { filters: scopeFilters } : {}), + groupBy: [ + versionExpression, + ...(serviceExpression ? [serviceExpression] : []), + ].join(', '), + // The group-by columns are already spelled out in `select`; without this + // the renderer appends them a second time. + selectGroupBy: false, + orderBy: 'firstSeen ASC', + limit: { limit: MAX_DEPLOY_ROWS }, + dateRange, + }; +} + +/** + * Placeholder so the hook can call `useQueriedChartConfig` unconditionally when + * the tile's source can't provide releases. The query is disabled in that case + * and never runs; the module-level identity keeps the query key stable. + */ +const NO_SOURCE_CONFIG: BuilderChartConfigWithDateRange = { + connection: '', + from: { databaseName: '', tableName: '' }, + select: '', + where: '', + whereLanguage: 'sql', + timestampValueExpression: '', + dateRange: [new Date(0), new Date(0)], +}; + +/** + * Maps query rows to markers, keeping only versions whose first appearance + * lands inside the visible window. + * + * The query range is widened backwards (see `useDeploymentAnnotations`) so the + * version that was *already running* when the window opened also comes back — + * its `min(timestamp)` would otherwise sit at the left edge and read as a + * deploy that never happened. Anything first seen before `windowStart` is that + * incumbent, so it is dropped. + * + * Residual artifact: a service idle for longer than the lookback has no rows in + * the widened prefix, so its first post-idle row still reads as a deploy at the + * left edge. + */ +export function deploymentRowsToAnnotations( + rows: DeploymentRow[], + { windowStart }: { windowStart: Date }, +): ChartAnnotation[] { + // Resolve the theme color once (it reads computed styles). + const color = getChartColorInfo(); + const windowStartMs = windowStart.getTime(); + const annotations: ChartAnnotation[] = []; + + for (const row of rows) { + if (row.firstSeen == null || !row.version) { + continue; + } + const firstSeenMs = new Date(row.firstSeen).getTime(); + if (!Number.isFinite(firstSeenMs) || firstSeenMs < windowStartMs) { + continue; + } + annotations.push({ + time: firstSeenMs, + label: row.version, + // Fallback only: the chart re-tints the marker to its service's series + // color when that service is charted (see `getSeriesColorForGroup`). + color, + kind: 'deployment', + groupNoun: 'deploys', + group: row.service ?? undefined, + key: `deploy-annotation-${firstSeenMs}-${row.version}-${row.service ?? ''}`, + }); + } + + return annotations; +} + +/** + * Returns deployment markers for a tile, derived from changes in the + * `service.version` resource attribute. + * + * Scoped to the tile: the query runs against the tile's own source with the + * tile's own filters, so the markers describe the data the chart is showing. An + * unfiltered tile spanning every service therefore does show every service's + * releases — that is consistent, not noise. + * + * Returns annotation *data*; the chart renders it (clamping and label + * collapsing need the chart's x-axis domain). The query stays idle unless + * `enabled` is true and the source can provide releases. + */ +export function useDeploymentAnnotations( + dateRange: [Date, Date], + enabled: boolean = false, + options?: DeploymentScope & { + source?: TSource; + versionExpression?: string; + }, +): ChartAnnotation[] | undefined { + const source = options?.source; + const versionExpression = + options?.versionExpression || DEFAULT_VERSION_EXPRESSION; + const isSupported = canDeriveDeployments(source); + + // Quantize before memoizing so a sliding "last 15 minutes" window produces a + // stable config for a whole minute. + const windowStartMs = + Math.floor(dateRange[0].getTime() / BUCKET_MS) * BUCKET_MS; + const windowEndMs = Math.ceil(dateRange[1].getTime() / BUCKET_MS) * BUCKET_MS; + + // Callers rebuild the filter array every render, so key the memo on its + // content. Tiles sharing a source and filters then share one query. + const scopeKey = JSON.stringify({ + where: options?.where, + whereLanguage: options?.whereLanguage, + filters: options?.filters, + }); + + const config = useMemo(() => { + if (!isSupported || !source) { + return NO_SOURCE_CONFIG; + } + const lookbackMs = Math.max( + MIN_LOOKBACK_MS, + (windowEndMs - windowStartMs) * LOOKBACK_RATIO, + ); + return buildDeploymentChartConfig( + source, + versionExpression, + [new Date(windowStartMs - lookbackMs), new Date(windowEndMs)], + JSON.parse(scopeKey), + ); + }, [ + isSupported, + source, + versionExpression, + windowStartMs, + windowEndMs, + scopeKey, + ]); + + const { data, isFetching } = useQueriedChartConfig(config, { + enabled: enabled && isSupported, + }); + + const annotations = useMemo(() => { + if (!enabled || !data?.data?.length) { + return undefined; + } + const mapped = deploymentRowsToAnnotations(data.data, { + windowStart: new Date(windowStartMs), + }); + return mapped.length ? mapped : undefined; + }, [enabled, data, windowStartMs]); + + // Toggling markers on and seeing nothing reads as broken, and the common + // cause is simply that the service does not emit `service.version`. Say so + // once (Mantine dedupes concurrent tiles by notification id). + const hasWarnedRef = useRef(false); + useEffect(() => { + if (!enabled) { + hasWarnedRef.current = false; + return; + } + if (isFetching || data == null || annotations != null) { + return; + } + if (hasWarnedRef.current) { + return; + } + hasWarnedRef.current = true; + notifications.show({ + id: DEPLOY_EMPTY_NOTIFICATION_ID, + color: 'yellow', + title: 'No deployments found', + message: + 'Deployment markers are derived from the OpenTelemetry `service.version` resource attribute. No version changes were found in this time range.', + }); + }, [enabled, isFetching, data, annotations]); + + return annotations; +} diff --git a/packages/app/tests/e2e/features/deployment-markers.spec.ts b/packages/app/tests/e2e/features/deployment-markers.spec.ts new file mode 100644 index 0000000000..66f3d38eb1 --- /dev/null +++ b/packages/app/tests/e2e/features/deployment-markers.spec.ts @@ -0,0 +1,173 @@ +/** + * Deployment markers: releases show up as dashed vertical lines on dashboard + * tile charts, labelled with the version. + * + * Markers are derived from `ResourceAttributes['service.version']` — one marker + * per version whose first appearance falls inside the visible window. Nothing + * in the global seed sets that attribute, so this spec seeds its own rows: two + * versions, each under its own unique service name so parallel specs and the + * global seed can't contribute markers of their own. + */ +import { DashboardPage } from '../page-objects/DashboardPage'; +import { expect, test } from '../utils/base-test'; +import { + DEFAULT_LOGS_SOURCE_NAME, + E2E_CLICKHOUSE_DATABASE, + E2E_LOGS_TABLE, +} from '../utils/constants'; + +const CLICKHOUSE_HOST = + process.env.CLICKHOUSE_HOST || + `http://localhost:${process.env.HDX_E2E_CH_PORT || '20500'}`; +const CLICKHOUSE_USER = process.env.CLICKHOUSE_USER || 'default'; +const CLICKHOUSE_PASSWORD = process.env.CLICKHOUSE_PASSWORD || ''; + +async function clickhouseQuery(sql: string): Promise { + const url = new URL(CLICKHOUSE_HOST); + url.searchParams.set('user', CLICKHOUSE_USER); + if (CLICKHOUSE_PASSWORD) { + url.searchParams.set('password', CLICKHOUSE_PASSWORD); + } + + const response = await fetch(url.toString(), { + method: 'POST', + body: sql, + headers: { 'Content-Type': 'text/plain' }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `ClickHouse query failed (${response.status}): ${errorText}`, + ); + } +} + +// Spread across the dashboard's default "Past 1h" window with ~20 minutes +// between each. Labels collapse into "N deploys" once neighbours are closer +// than roughly half their combined width, and a dashboard tile's plot is only +// a few hundred pixels wide — keep these generously spaced (and the version +// strings short) so the assertions below aren't sitting on that threshold. +const OLD_VERSION_AGO_MS = 50 * 60 * 1000; +const NEW_VERSION_AGO_MS = 8 * 60 * 1000; + +// Versions are fixed rather than per-run unique, which makes seeding +// idempotent: a Playwright retry would otherwise add more versions a few +// seconds from the first attempt's — close enough to collapse into a +// "2 deploys" label and break the assertions. Re-inserting the same versions +// still groups to the same markers, since the query is +// `min(Timestamp) GROUP BY version, service`. The global seed truncates the +// table between suite runs, so nothing accumulates. +const OLD_VERSION = 'e2e-1.0.0'; +const NEW_VERSION = 'e2e-2.0.0'; +const DEPLOY_SERVICE = 'deploy_markers_e2e'; + +// A second service releasing in the same window. Markers are scoped to the data +// a tile is charting, so filtering to DEPLOY_SERVICE must hide this one. +const OTHER_VERSION = 'oth-9.9.9'; +const OTHER_SERVICE = 'deploy_markers_other_e2e'; +const OTHER_VERSION_AGO_MS = 29 * 60 * 1000; + +/** Seed releases for two services into the shared logs table. */ +async function seedReleases(): Promise { + const row = (agoMs: number, version: string, service: string) => { + // Timestamp is written in nanoseconds; TimestampTime is a DEFAULT column. + const timestampNs = (Date.now() - agoMs) * 1_000_000; + return ( + `('${timestampNs}', '', '', 0, 'info', 0, ` + + `'${service}', 'release ${version}', '', ` + + `{'service.name':'${service}','service.version':'${version}'}, ` + + `'', '', '', {}, {})` + ); + }; + + const values = [ + row(OLD_VERSION_AGO_MS, OLD_VERSION, DEPLOY_SERVICE), + row(NEW_VERSION_AGO_MS, NEW_VERSION, DEPLOY_SERVICE), + row(OTHER_VERSION_AGO_MS, OTHER_VERSION, OTHER_SERVICE), + ].join(', '); + + await clickhouseQuery(` + INSERT INTO ${E2E_CLICKHOUSE_DATABASE}.${E2E_LOGS_TABLE} ( + Timestamp, TraceId, SpanId, TraceFlags, SeverityText, SeverityNumber, + ServiceName, Body, ResourceSchemaUrl, ResourceAttributes, ScopeSchemaUrl, + ScopeName, ScopeVersion, ScopeAttributes, LogAttributes + ) VALUES ${values} + `); +} + +test.describe( + 'Deployment markers', + { tag: ['@full-stack', '@dashboard'] }, + () => { + let dashboardPage: DashboardPage; + + test.beforeEach(async ({ page }) => { + dashboardPage = new DashboardPage(page); + await dashboardPage.goto(); + }); + + test('overlays a labelled marker per release and clears them when toggled off', async ({ + page, + }) => { + await seedReleases(); + + await dashboardPage.createNewDashboard(); + await dashboardPage.addTileWithSource( + 'Deployment markers chart', + DEFAULT_LOGS_SOURCE_NAME, + ); + // Scope the chart to one service so its releases are attributable — see + // the suppression test below for why that matters. + await dashboardPage.setGlobalFilter(`ServiceName:"${DEPLOY_SERVICE}"`); + + const markers = dashboardPage.getAnnotationMarkers(); + const labels = dashboardPage.getAnnotationLabels(); + await expect(markers).toHaveCount(0); + + await dashboardPage.toggleDeployAnnotations(); + + // Ephemeral view state, carried in the URL so a shared link keeps it. + await expect(page).toHaveURL(/deployMarkers=true/); + + // Both releases first appear inside the window and are far enough apart + // to stay individually labelled rather than collapsing. + await expect(markers).toHaveCount(2); + await expect(labels.filter({ hasText: OLD_VERSION })).toBeVisible(); + await expect(labels.filter({ hasText: NEW_VERSION })).toBeVisible(); + + // Regression: markers used to be read from the source globally, so a + // chart filtered to one service was still annotated with every other + // service's releases. + await expect(labels.filter({ hasText: OTHER_VERSION })).toHaveCount(0); + + await dashboardPage.toggleDeployAnnotations(); + + await expect(markers).toHaveCount(0); + await expect(page).not.toHaveURL(/deployMarkers=true/); + }); + + // A marker only aids correlation if the reader can tie it to something on + // the chart. An aggregate line over several services can't do that, so + // rather than draw a wall of markers naming services with no visible line, + // none are drawn at all. + test('suppresses markers it cannot attribute to the chart', async ({ + page, + }) => { + await seedReleases(); + + await dashboardPage.createNewDashboard(); + // No filter and no group by: one line covering both seeded services. + await dashboardPage.addTileWithSource( + 'Unattributable markers', + DEFAULT_LOGS_SOURCE_NAME, + ); + await dashboardPage.toggleDeployAnnotations(); + + await expect(page).toHaveURL(/deployMarkers=true/); + // The releases are found by the query — they just aren't drawn, because + // neither service has its own line here. + await expect(dashboardPage.getAnnotationMarkers()).toHaveCount(0); + }); + }, +); diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index 2301c9e927..a7ba8cdc58 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -98,6 +98,7 @@ export class DashboardPage { private readonly removeDefaultQueryAndFiltersMenuItem: Locator; private readonly exportDashboardMenuItem: Locator; private readonly enterKioskModeMenuItem: Locator; + private readonly toggleDeployAnnotationsMenuItem: Locator; private readonly exitKioskModeBtn: Locator; private readonly kioskHeaderContainer: Locator; private readonly kioskLiveStatusBadge: Locator; @@ -162,6 +163,9 @@ export class DashboardPage { this.enterKioskModeMenuItem = page.getByTestId( 'enter-kiosk-mode-menu-item', ); + this.toggleDeployAnnotationsMenuItem = page.getByTestId( + 'toggle-deploy-annotations-menu-item', + ); this.exitKioskModeBtn = page.getByTestId('exit-kiosk-mode-button'); this.kioskHeaderContainer = page.getByTestId('kiosk-header'); this.kioskLiveStatusBadge = page.getByTestId('kiosk-live-status'); @@ -659,6 +663,10 @@ export class DashboardPage { */ async setGlobalFilter(filter: string) { await this.searchInput.fill(filter); + // Dismiss the suggestion popover, which otherwise overlays the submit + // button and fails its actionability check. Blur (not Escape) so it can't + // close a surrounding modal — same reasoning as `dismissSqlAutocomplete`. + await this.searchInput.blur(); await this.searchSubmitButton.click(); } @@ -1293,6 +1301,36 @@ export class DashboardPage { .click(); } + // ---- Chart annotation helpers ---- + + /** + * Open the dashboard overflow menu and toggle deployment markers on tile + * charts. Expects the menu item with + * data-testid="toggle-deploy-annotations-menu-item", which only renders once + * the dashboard has at least one tile. + */ + async toggleDeployAnnotations() { + await this.dashboardMenuButton.click(); + await this.toggleDeployAnnotationsMenuItem.click(); + } + + /** + * Annotation markers (deployments, alerts) drawn on a tile's time chart as + * dashed vertical Recharts reference lines. + */ + getAnnotationMarkers(tileIndex = 0): Locator { + return this.getTile(tileIndex).locator('.recharts-reference-line'); + } + + /** + * Text labels for the annotation markers. Recharts hoists reference-line + * labels into a separate z-index layer, so they are NOT descendants of the + * `.recharts-reference-line` group and cannot be matched by filtering it. + */ + getAnnotationLabels(tileIndex = 0): Locator { + return this.getTile(tileIndex).locator('text.recharts-label'); + } + // ---- Kiosk mode helpers ---- /** From 3a6b99f0816bf8090232078f3c157beb5fe2538a Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Fri, 7 Aug 2026 10:58:24 -0400 Subject: [PATCH 2/7] feat(sources): configurable service version expression Deployment markers assumed every team emits the OpenTelemetry `service.version` resource attribute. Plenty don't: under GitOps the release identifier is the container image tag, reaching telemetry as `container.image.tag` rather than a version attribute. Those teams saw no markers at all and had no way to say where their version lives short of changing instrumentation. Log and trace sources now carry an optional `serviceVersionExpression`. It defaults to `service.version`, so nothing changes for teams already following resource semconv, and points anywhere else for teams that aren't. This mirrors how ServiceNow Cloud Observability lets you register the attribute that identifies a release. The integration tests round-trip the field through the database rather than only through the Zod schema, because a field missing from the Mongoose discriminator is dropped silently on write. --- .../deployment-markers-on-dashboard-charts.md | 15 ++- packages/api/openapi.json | 12 ++ packages/api/src/models/source.ts | 2 + .../routers/api/__tests__/sources.int.test.ts | 104 ++++++++++++++++++ .../src/routers/external-api/v2/sources.ts | 10 ++ .../Sources/SourceForm/LogTableModelForm.tsx | 11 ++ .../SourceForm/TraceTableModelForm.tsx | 11 ++ .../useDeploymentAnnotations.test.tsx | 66 +++++++++++ .../src/hooks/useDeploymentAnnotations.tsx | 22 +++- .../app/src/utils/sourceFieldSuggestions.tsx | 13 +++ .../app/tests/e2e/features/sources.spec.ts | 2 + packages/common-utils/src/types.ts | 9 ++ 12 files changed, 269 insertions(+), 8 deletions(-) diff --git a/.changeset/deployment-markers-on-dashboard-charts.md b/.changeset/deployment-markers-on-dashboard-charts.md index bdf9b7cab9..8f7dfbe122 100644 --- a/.changeset/deployment-markers-on-dashboard-charts.md +++ b/.changeset/deployment-markers-on-dashboard-charts.md @@ -1,10 +1,13 @@ --- '@hyperdx/app': minor +'@hyperdx/api': minor --- -Overlay deployment markers on dashboard tile charts, derived from changes in the -OpenTelemetry `service.version` resource attribute. Markers are scoped to the -data each tile is charting and tinted to match their service's series color, and -are suppressed on charts where they can't be tied to a visible line — so an -aggregate line spanning many services isn't annotated with releases you can't -attribute to it. +Overlay deployment markers on dashboard tile charts, derived from changes in a +service's version. Defaults to the OpenTelemetry `service.version` resource +attribute, with a new optional `serviceVersionExpression` on log and trace +sources for teams whose release identifier lives elsewhere, such as a container +image tag under GitOps. Markers are scoped to the data each tile is charting and +tinted to match their service's series color, and are suppressed on charts where +they can't be tied to a visible line — so an aggregate line spanning many +services isn't annotated with releases you can't attribute to it. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index c2fbbb9aee..d3d9e93047 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -3820,6 +3820,12 @@ "nullable": true, "example": "ServiceName" }, + "serviceVersionExpression": { + "type": "string", + "description": "Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset.", + "nullable": true, + "example": "ResourceAttributes['service.version']" + }, "severityTextExpression": { "type": "string", "description": "Expression to extract the severity/log level text.", @@ -4094,6 +4100,12 @@ "nullable": true, "example": "ServiceName" }, + "serviceVersionExpression": { + "type": "string", + "description": "Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset.", + "nullable": true, + "example": "ResourceAttributes['service.version']" + }, "resourceAttributesExpression": { "type": "string", "description": "Expression to extract resource-level attributes.", diff --git a/packages/api/src/models/source.ts b/packages/api/src/models/source.ts index 0de9634a48..45d80cb072 100644 --- a/packages/api/src/models/source.ts +++ b/packages/api/src/models/source.ts @@ -139,6 +139,7 @@ export const LogSource = Source.discriminator( new Schema({ defaultTableSelectExpression: String, serviceNameExpression: String, + serviceVersionExpression: String, severityTextExpression: String, bodyExpression: String, eventAttributesExpression: String, @@ -199,6 +200,7 @@ export const TraceSource = Source.discriminator( statusCodeExpression: String, statusMessageExpression: String, serviceNameExpression: String, + serviceVersionExpression: String, resourceAttributesExpression: String, eventAttributesExpression: String, spanEventsValueExpression: String, diff --git a/packages/api/src/routers/api/__tests__/sources.int.test.ts b/packages/api/src/routers/api/__tests__/sources.int.test.ts index 59f619bd9d..56d19680c2 100644 --- a/packages/api/src/routers/api/__tests__/sources.int.test.ts +++ b/packages/api/src/routers/api/__tests__/sources.int.test.ts @@ -420,6 +420,110 @@ describe('sources router', () => { expect(updatedSource.severityTextExpression).toBe('SeverityText'); }); + // Regression guard: a field present in the Zod schema but missing from the + // Mongoose discriminator is silently dropped on write, with no error. Only a + // round-trip through the database catches that. + describe('serviceVersionExpression', () => { + const VERSION_EXPR = "ResourceAttributes['container.image.tag']"; + + /** The single persisted source, narrowed to a kind that carries the field. */ + const persisted = async (kind: SourceKind.Log | SourceKind.Trace) => { + const [source] = await Source.find({}).lean(); + if (source?.kind !== kind) { + throw new Error(`Expected a ${kind} source, got ${source?.kind}`); + } + return source; + }; + + it('POST / - persists the expression on a log source', async () => { + const { agent } = await getLoggedInAgent(server); + + await agent + .post('/sources') + .send({ ...MOCK_SOURCE, serviceVersionExpression: VERSION_EXPR }) + .expect(200); + + expect((await persisted(SourceKind.Log)).serviceVersionExpression).toBe( + VERSION_EXPR, + ); + }); + + it('POST / - persists the expression on a trace source', async () => { + const { agent } = await getLoggedInAgent(server); + + const traceSource: Omit, 'id'> = { + kind: SourceKind.Trace, + name: 'Test Trace Source', + connection: new Types.ObjectId().toString(), + from: { databaseName: 'test_db', tableName: 'otel_traces' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: 'Timestamp, SpanName', + durationExpression: 'Duration', + durationPrecision: 9, + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', + parentSpanIdExpression: 'ParentSpanId', + spanNameExpression: 'SpanName', + spanKindExpression: 'SpanKind', + serviceVersionExpression: VERSION_EXPR, + }; + + await agent.post('/sources').send(traceSource).expect(200); + + expect((await persisted(SourceKind.Trace)).serviceVersionExpression).toBe( + VERSION_EXPR, + ); + }); + + it('GET / - returns the expression', async () => { + const { agent } = await getLoggedInAgent(server); + await agent + .post('/sources') + .send({ ...MOCK_SOURCE, serviceVersionExpression: VERSION_EXPR }) + .expect(200); + + const response = await agent.get('/sources').expect(200); + + expect(response.body[0]).toMatchObject({ + serviceVersionExpression: VERSION_EXPR, + }); + }); + + it('PUT /:id - updates the expression, and clears it when omitted', async () => { + const { agent } = await getLoggedInAgent(server); + const created = await agent + .post('/sources') + .send({ ...MOCK_SOURCE, serviceVersionExpression: VERSION_EXPR }) + .expect(200); + const id = created.body.id; + + await agent + .put(`/sources/${id}`) + .send({ ...MOCK_SOURCE, id, serviceVersionExpression: 'Version' }) + .expect(200); + expect((await persisted(SourceKind.Log)).serviceVersionExpression).toBe( + 'Version', + ); + + await agent + .put(`/sources/${id}`) + .send({ ...MOCK_SOURCE, id }) + .expect(200); + expect( + (await persisted(SourceKind.Log)).serviceVersionExpression, + ).toBeUndefined(); + }); + + it('POST / - is optional', async () => { + const { agent } = await getLoggedInAgent(server); + await agent.post('/sources').send(MOCK_SOURCE).expect(200); + + expect( + (await persisted(SourceKind.Log)).serviceVersionExpression, + ).toBeUndefined(); + }); + }); + describe('seriesTable', () => { it('POST / - creates a metric source with seriesTable and it round-trips', async () => { const { agent } = await getLoggedInAgent(server); diff --git a/packages/api/src/routers/external-api/v2/sources.ts b/packages/api/src/routers/external-api/v2/sources.ts index 5f540b5dad..0a209b60c2 100644 --- a/packages/api/src/routers/external-api/v2/sources.ts +++ b/packages/api/src/routers/external-api/v2/sources.ts @@ -438,6 +438,11 @@ function formatExternalSource(source: SourceDocument) { * description: Expression to extract the service name from log rows. * nullable: true * example: ServiceName + * serviceVersionExpression: + * type: string + * description: Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset. + * nullable: true + * example: ResourceAttributes['service.version'] * severityTextExpression: * type: string * description: Expression to extract the severity/log level text. @@ -656,6 +661,11 @@ function formatExternalSource(source: SourceDocument) { * description: Expression to extract the service name from trace rows. * nullable: true * example: ServiceName + * serviceVersionExpression: + * type: string + * description: Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset. + * nullable: true + * example: ResourceAttributes['service.version'] * resourceAttributesExpression: * type: string * description: Expression to extract resource-level attributes. diff --git a/packages/app/src/components/Sources/SourceForm/LogTableModelForm.tsx b/packages/app/src/components/Sources/SourceForm/LogTableModelForm.tsx index 0ebb3c96a9..e50eaad9e4 100644 --- a/packages/app/src/components/Sources/SourceForm/LogTableModelForm.tsx +++ b/packages/app/src/components/Sources/SourceForm/LogTableModelForm.tsx @@ -118,6 +118,17 @@ export function LogTableModelForm(props: TableModelProps) { sourceKind={SourceKind.Log} tableConnection={tableConnection} /> + + { }); }); +describe('resolveVersionExpression', () => { + it('falls back to the OTel service.version resource attribute', () => { + expect(resolveVersionExpression(logSource)).toBe( + DEFAULT_VERSION_EXPRESSION, + ); + expect(resolveVersionExpression(undefined)).toBe( + DEFAULT_VERSION_EXPRESSION, + ); + }); + + // The point of the setting: GitOps teams whose release identifier is a + // container image tag shouldn't have to change instrumentation. + it("uses the source's own expression when configured", () => { + expect( + resolveVersionExpression({ + ...logSource, + serviceVersionExpression: "ResourceAttributes['container.image.tag']", + }), + ).toBe("ResourceAttributes['container.image.tag']"); + }); + + it('works on trace sources too', () => { + expect( + resolveVersionExpression({ + ...traceSource, + serviceVersionExpression: "SpanAttributes['release']", + }), + ).toBe("SpanAttributes['release']"); + }); + + it('treats a blank expression as unset', () => { + expect( + resolveVersionExpression({ + ...logSource, + serviceVersionExpression: ' ', + }), + ).toBe(DEFAULT_VERSION_EXPRESSION); + }); + + // Metric and session sources have no such field; it must not throw. + it('falls back for sources that cannot carry the field', () => { + expect(resolveVersionExpression(metricSource)).toBe( + DEFAULT_VERSION_EXPRESSION, + ); + expect(resolveVersionExpression(sessionSource)).toBe( + DEFAULT_VERSION_EXPRESSION, + ); + }); +}); + describe('buildDeploymentChartConfig', () => { const range: [Date, Date] = [new Date(1_000), new Date(2_000)]; @@ -404,6 +455,21 @@ describe('useDeploymentAnnotations', () => { expect(result.current).toBeUndefined(); }); + it("uses the source's configured version expression", () => { + renderHook(() => + useDeploymentAnnotations(range, true, { + source: { + ...logSource, + serviceVersionExpression: "ResourceAttributes['container.image.tag']", + }, + }), + ); + + expect(lastCall()[0].select).toContain( + "ResourceAttributes['container.image.tag'] AS version", + ); + }); + it("scopes the query with the tile's filters", () => { renderHook(() => useDeploymentAnnotations(range, true, { diff --git a/packages/app/src/hooks/useDeploymentAnnotations.tsx b/packages/app/src/hooks/useDeploymentAnnotations.tsx index ceb75928a7..76405376d2 100644 --- a/packages/app/src/hooks/useDeploymentAnnotations.tsx +++ b/packages/app/src/hooks/useDeploymentAnnotations.tsx @@ -70,6 +70,22 @@ export function canDeriveDeployments( ); } +/** + * The expression that yields a service's running release for this source. + * + * Falls back to the OpenTelemetry `service.version` resource attribute, which + * covers teams following resource semconv. Teams whose version lives elsewhere + * — a container image tag under GitOps, a custom attribute — set + * `serviceVersionExpression` on the source instead of changing instrumentation. + */ +export function resolveVersionExpression(source: TSource | undefined): string { + const configured = + source != null && 'serviceVersionExpression' in source + ? source.serviceVersionExpression + : undefined; + return configured?.trim() || DEFAULT_VERSION_EXPRESSION; +} + /** * Builds the "when did each release first appear" query: one row per version * (per service), carrying the earliest timestamp it was seen at. @@ -231,13 +247,15 @@ export function useDeploymentAnnotations( enabled: boolean = false, options?: DeploymentScope & { source?: TSource; + /** Overrides the source's own expression. Mainly a testing seam. */ versionExpression?: string; }, ): ChartAnnotation[] | undefined { const source = options?.source; - const versionExpression = - options?.versionExpression || DEFAULT_VERSION_EXPRESSION; const isSupported = canDeriveDeployments(source); + const versionExpression = + options?.versionExpression || + resolveVersionExpression(isSupported ? source : undefined); // Quantize before memoizing so a sliding "last 15 minutes" window produces a // stable config for a whole minute. diff --git a/packages/app/src/utils/sourceFieldSuggestions.tsx b/packages/app/src/utils/sourceFieldSuggestions.tsx index 3e36cee647..b0f9fab58a 100644 --- a/packages/app/src/utils/sourceFieldSuggestions.tsx +++ b/packages/app/src/utils/sourceFieldSuggestions.tsx @@ -10,6 +10,7 @@ export type SourceFieldKind = | 'bodyExpression' | 'implicitColumnExpression' | 'serviceNameExpression' + | 'serviceVersionExpression' | 'severityTextExpression' | 'eventAttributesExpression' | 'resourceAttributesExpression' @@ -98,6 +99,18 @@ const FIELD_RULES: Record = { typeMatches: isStringy, recommendStrategy: 'name-only', }, + // Usually a map lookup rather than a column, so these only hit on schemas + // that materialize the version out. No match just leaves the placeholder. + serviceVersionExpression: { + canonicalNames: [ + 'ServiceVersion', + 'service.version', + 'service_version', + 'version', + ], + typeMatches: isStringy, + recommendStrategy: 'name-only', + }, severityTextExpression: { canonicalNames: [ 'SeverityText', diff --git a/packages/app/tests/e2e/features/sources.spec.ts b/packages/app/tests/e2e/features/sources.spec.ts index 9029916365..b8b6d045a0 100644 --- a/packages/app/tests/e2e/features/sources.spec.ts +++ b/packages/app/tests/e2e/features/sources.spec.ts @@ -22,6 +22,7 @@ const COMMON_FIELDS = [ const LOG_FIELDS = [ ...COMMON_FIELDS, 'Service Name Expression', + 'Service Version Expression', 'Log Level Expression', 'Body Expression', 'Log Attributes Expression', @@ -50,6 +51,7 @@ const TRACE_FIELDS = [ 'Status Code Expression', 'Status Message Expression', 'Service Name Expression', + 'Service Version Expression', 'Resource Attributes Expression', 'Event Attributes Expression', 'Span Events Expression', diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 154da9d24a..02655a2a6a 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1801,6 +1801,13 @@ export const LogSourceSchema = BaseSourceSchema.extend({ .min(1, 'Default Select Expression is required'), // Optional fields for logs serviceNameExpression: z.string().optional(), + /** + * Expression yielding the running release of a service, used to derive + * deployment markers on charts. Defaults to the OpenTelemetry + * `service.version` resource attribute when unset; teams whose version lives + * elsewhere (a container image tag, a custom attribute) can point it there. + */ + serviceVersionExpression: z.string().optional(), severityTextExpression: z.string().optional(), bodyExpression: z.string().optional(), eventAttributesExpression: z.string().optional(), @@ -1858,6 +1865,8 @@ export const TraceSourceSchema = BaseSourceSchema.extend({ statusCodeExpression: z.string().optional(), statusMessageExpression: z.string().optional(), serviceNameExpression: z.string().optional(), + /** See `serviceVersionExpression` on `LogSourceSchema`. */ + serviceVersionExpression: z.string().optional(), resourceAttributesExpression: z.string().optional(), eventAttributesExpression: z.string().optional(), spanEventsValueExpression: z.string().optional(), From 7ec5a42f8c0b1818d0490201281918ac8f60294d Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Fri, 7 Aug 2026 14:27:33 -0400 Subject: [PATCH 3/7] fix(charts): don't let a mixed marker cluster wear one series' color When two services deployed within a label's width of each other, the cluster collapsed to "N deploys" in the anchor's series color. On a chart grouped by service that reads as "this service shipped twice" when in fact two different services shipped once each, which is exactly the misattribution the per-series tinting exists to prevent. A cluster spanning several series now uses a neutral color. The count stays true, and the absorbed markers keep their own colors, so which services took part is still readable from the lines. Surfaced by pointing one source's version expression at a coalesce over two attributes, which is how a heterogeneous fleet gets markers for services that carry `service.version` and services that only carry an image tag. --- .../__tests__/chartAnnotations.test.tsx | 30 +++++++++++++++++++ .../components/charts/chartAnnotations.tsx | 24 +++++++++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx b/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx index c024220edb..10887f5088 100644 --- a/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx +++ b/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx @@ -234,6 +234,36 @@ describe('getAnnotationElements label collapsing', () => { expect(labelOf(lines[2])).toBe('1.2.0-beta'); }); + // Regression: a cluster spanning two services took the anchor's series color, + // so "2 deploys" rendered in one service's color while counting the other's + // release as that service's. + it('drops the series color when a cluster spans several series', () => { + const [anchor] = getAnnotationElements( + [ + { ...deploy(0, '9.0.0'), group: 'semconv-api', color: '#blue' }, + { ...deploy(10, 'v8.0.0'), group: 'gitops-api', color: '#gold' }, + ], + collapsing, + ); + + expect(labelOf(anchor)).toBe('2 deploys'); + // Neutral, and not either series' colour. + expect(lineProps(anchor).stroke).toBe('var(--color-text-muted)'); + }); + + it('keeps the series color when a cluster is all one series', () => { + const [anchor] = getAnnotationElements( + [ + { ...deploy(0, '9.0.0'), group: 'semconv-api', color: '#blue' }, + { ...deploy(10, '9.1.0'), group: 'semconv-api', color: '#blue' }, + ], + collapsing, + ); + + expect(labelOf(anchor)).toBe('2 deploys'); + expect(lineProps(anchor).stroke).toBe('#blue'); + }); + it('mutes the line of a marker whose label was collapsed away', () => { const [anchor, absorbed] = getAnnotationElements( [deploy(0, '1.0.0'), deploy(10, '2.0.0')], diff --git a/packages/app/src/components/charts/chartAnnotations.tsx b/packages/app/src/components/charts/chartAnnotations.tsx index 4459afd46f..79b1382807 100644 --- a/packages/app/src/components/charts/chartAnnotations.tsx +++ b/packages/app/src/components/charts/chartAnnotations.tsx @@ -62,6 +62,11 @@ export function labelSeparationPx( ); } +// Colour for a cluster that covers several series. Legible, unlike the border +// default, and deliberately not a palette colour so it can't be mistaken for +// one of the series it summarises. +const MIXED_SERIES_COLOR = 'var(--color-text-muted)'; + const STROKE_OPACITY = 0.9; // Members of a collapsed group still get a line (so the density is visible), // but a faint one, so the labelled anchor stays legible. @@ -208,9 +213,22 @@ function collapseLabels( } const size = end - start; - collapsed.push( - size === 1 ? anchor : { ...anchor, label: `${size} ${noun}` }, - ); + if (size === 1) { + collapsed.push(anchor); + } else { + // A cluster covering several series can't wear one of their colors + // without claiming the others' events as that series'. Go neutral so + // the count stays true and the color stops implying an owner; the + // absorbed markers keep their own colors, so which series took part is + // still visible in the lines. + const spansSeries = + new Set(sorted.slice(start, end).map(a => a.group)).size > 1; + collapsed.push({ + ...anchor, + label: `${size} ${noun}`, + color: spansSeries ? MIXED_SERIES_COLOR : anchor.color, + }); + } for (let i = start + 1; i < end; i++) { collapsed.push({ ...sorted[i], label: undefined, muted: true }); } From fb9052b8ac18642a80a817c6e3cf8567892024cd Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Fri, 7 Aug 2026 14:39:13 -0400 Subject: [PATCH 4/7] docs(sources): document the multi-attribute version fallback A fleet where some services carry the release on `service.version` and others only on a container image tag needs one expression covering both, which a coalesce already does. Nothing about that is guessable from an empty field, so say it in the help text and the API docs. Shared as a constant rather than duplicated across the log and trace forms, matching KNOWN_COLUMNS_EXPRESSION_HELP_TEXT. --- packages/api/openapi.json | 4 ++-- packages/api/src/routers/external-api/v2/sources.ts | 4 ++-- .../src/components/Sources/SourceForm/LogTableModelForm.tsx | 3 ++- .../src/components/Sources/SourceForm/TraceTableModelForm.tsx | 3 ++- packages/app/src/components/Sources/SourceForm/constants.ts | 3 +++ 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/api/openapi.json b/packages/api/openapi.json index d3d9e93047..4a21075d50 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -3822,7 +3822,7 @@ }, "serviceVersionExpression": { "type": "string", - "description": "Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset.", + "description": "Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset. Where services carry the release on different attributes, fall back across them with coalesce(nullIf(a, ''), nullIf(b, '')).", "nullable": true, "example": "ResourceAttributes['service.version']" }, @@ -4102,7 +4102,7 @@ }, "serviceVersionExpression": { "type": "string", - "description": "Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset.", + "description": "Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset. Where services carry the release on different attributes, fall back across them with coalesce(nullIf(a, ''), nullIf(b, '')).", "nullable": true, "example": "ResourceAttributes['service.version']" }, diff --git a/packages/api/src/routers/external-api/v2/sources.ts b/packages/api/src/routers/external-api/v2/sources.ts index 0a209b60c2..44c4262461 100644 --- a/packages/api/src/routers/external-api/v2/sources.ts +++ b/packages/api/src/routers/external-api/v2/sources.ts @@ -440,7 +440,7 @@ function formatExternalSource(source: SourceDocument) { * example: ServiceName * serviceVersionExpression: * type: string - * description: Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset. + * description: Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset. Where services carry the release on different attributes, fall back across them with coalesce(nullIf(a, ''), nullIf(b, '')). * nullable: true * example: ResourceAttributes['service.version'] * severityTextExpression: @@ -663,7 +663,7 @@ function formatExternalSource(source: SourceDocument) { * example: ServiceName * serviceVersionExpression: * type: string - * description: Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset. + * description: Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset. Where services carry the release on different attributes, fall back across them with coalesce(nullIf(a, ''), nullIf(b, '')). * nullable: true * example: ResourceAttributes['service.version'] * resourceAttributesExpression: diff --git a/packages/app/src/components/Sources/SourceForm/LogTableModelForm.tsx b/packages/app/src/components/Sources/SourceForm/LogTableModelForm.tsx index e50eaad9e4..fb2cbac79c 100644 --- a/packages/app/src/components/Sources/SourceForm/LogTableModelForm.tsx +++ b/packages/app/src/components/Sources/SourceForm/LogTableModelForm.tsx @@ -12,6 +12,7 @@ import { useBrandDisplayName } from '@/theme/ThemeProvider'; import { DEFAULT_DATABASE, KNOWN_COLUMNS_EXPRESSION_HELP_TEXT, + SERVICE_VERSION_EXPRESSION_HELP_TEXT, } from './constants'; import { ExpressionFormRow } from './ExpressionFormRow'; import { FormRow } from './FormRow'; @@ -124,7 +125,7 @@ export function LogTableModelForm(props: TableModelProps) { name="serviceVersionExpression" label="Service Version Expression" placeholder="ResourceAttributes['service.version']" - helpText="Used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute; point it elsewhere if your release identifier lives in another attribute, such as a container image tag." + helpText={SERVICE_VERSION_EXPRESSION_HELP_TEXT} columns={columns} sourceKind={SourceKind.Log} tableConnection={tableConnection} diff --git a/packages/app/src/components/Sources/SourceForm/TraceTableModelForm.tsx b/packages/app/src/components/Sources/SourceForm/TraceTableModelForm.tsx index f3c244f37d..38c2fcaedb 100644 --- a/packages/app/src/components/Sources/SourceForm/TraceTableModelForm.tsx +++ b/packages/app/src/components/Sources/SourceForm/TraceTableModelForm.tsx @@ -10,6 +10,7 @@ import { useBrandDisplayName } from '@/theme/ThemeProvider'; import { DEFAULT_DATABASE, KNOWN_COLUMNS_EXPRESSION_HELP_TEXT, + SERVICE_VERSION_EXPRESSION_HELP_TEXT, } from './constants'; import { ExpressionFormRow } from './ExpressionFormRow'; import { FormRow } from './FormRow'; @@ -280,7 +281,7 @@ export function TraceTableModelForm(props: TableModelProps) { name="serviceVersionExpression" label="Service Version Expression" placeholder="ResourceAttributes['service.version']" - helpText="Used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute; point it elsewhere if your release identifier lives in another attribute, such as a container image tag." + helpText={SERVICE_VERSION_EXPRESSION_HELP_TEXT} columns={columns} sourceKind={SourceKind.Trace} tableConnection={tableConnection} diff --git a/packages/app/src/components/Sources/SourceForm/constants.ts b/packages/app/src/components/Sources/SourceForm/constants.ts index 4c3b689c05..83a01481bf 100644 --- a/packages/app/src/components/Sources/SourceForm/constants.ts +++ b/packages/app/src/components/Sources/SourceForm/constants.ts @@ -6,6 +6,9 @@ export const DEFAULT_DATABASE = 'default'; export const KNOWN_COLUMNS_EXPRESSION_HELP_TEXT = 'For Distributed table sources whose target tables have non-matching column sets. Provide a list of columns supported across all target tables; it is used instead of SELECT * when fetching full row data (e.g. the row side panel). Leave blank to select all columns. This should be a comma-separated list of column names - do not include non-column expressions or aliases.'; +export const SERVICE_VERSION_EXPRESSION_HELP_TEXT = + "Identifies the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute. Point it elsewhere if your release identifier lives in another attribute - under GitOps it is often the container image tag. If services in this table use different attributes, fall back across them: coalesce(nullIf(ResourceAttributes['service.version'], ''), nullIf(ResourceAttributes['container.image.tag'], ''))."; + // Placeholder written into from.databaseName / from.tableName when the // selected connection is Prometheus-only. export const PROMETHEUS_PLACEHOLDER = 'prometheus'; From 0e8981a1a7cd2a80f437eef9310dda2cccdf027e Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Fri, 7 Aug 2026 15:00:11 -0400 Subject: [PATCH 5/7] refactor: rename deployment markers to release markers The feature marks when a new service version is first observed in telemetry. That is not the same as a deployment, and the old name over-claimed in both directions. It marked non-deployments: a service idle past the lookback draws a marker when it scales back up, and a canary at 5% of pods looks like a full rollout. It also missed real deployments, which is the damaging half - config changes, secret rotations, and teams shipping from main with a static version all deploy without changing the version string, so a user could deploy twenty times and reasonably conclude the feature was broken. "Release markers" is true to the mechanism, stays true if we later accept pushed deploy events from CI, and pairs with the serviceVersionExpression source field. Its failure mode also reads correctly: no version change, no release to mark. "Deployment" stays in the help text and empty state, both for discoverability and because the empty state is now the right place to explain that deploying without changing the version marks nothing. --- .../deployment-markers-on-dashboard-charts.md | 13 -- .../release-markers-on-dashboard-charts.md | 14 ++ packages/api/openapi.json | 4 +- .../src/routers/external-api/v2/sources.ts | 4 +- packages/app/src/ChartUtils.tsx | 2 +- packages/app/src/DBDashboardPage.tsx | 32 ++-- packages/app/src/HDXMultiSeriesTimeChart.tsx | 2 +- .../Sources/SourceForm/constants.ts | 2 +- .../__tests__/chartAnnotations.test.tsx | 20 +-- .../components/charts/chartAnnotations.tsx | 12 +- ...est.tsx => useReleaseAnnotations.test.tsx} | 89 +++++----- ...otations.tsx => useReleaseAnnotations.tsx} | 56 +++--- ...arkers.spec.ts => release-markers.spec.ts} | 162 +++++++++--------- .../tests/e2e/page-objects/DashboardPage.ts | 12 +- 14 files changed, 209 insertions(+), 215 deletions(-) delete mode 100644 .changeset/deployment-markers-on-dashboard-charts.md create mode 100644 .changeset/release-markers-on-dashboard-charts.md rename packages/app/src/hooks/__tests__/{useDeploymentAnnotations.test.tsx => useReleaseAnnotations.test.tsx} (87%) rename packages/app/src/hooks/{useDeploymentAnnotations.tsx => useReleaseAnnotations.tsx} (87%) rename packages/app/tests/e2e/features/{deployment-markers.spec.ts => release-markers.spec.ts} (50%) diff --git a/.changeset/deployment-markers-on-dashboard-charts.md b/.changeset/deployment-markers-on-dashboard-charts.md deleted file mode 100644 index 8f7dfbe122..0000000000 --- a/.changeset/deployment-markers-on-dashboard-charts.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@hyperdx/app': minor -'@hyperdx/api': minor ---- - -Overlay deployment markers on dashboard tile charts, derived from changes in a -service's version. Defaults to the OpenTelemetry `service.version` resource -attribute, with a new optional `serviceVersionExpression` on log and trace -sources for teams whose release identifier lives elsewhere, such as a container -image tag under GitOps. Markers are scoped to the data each tile is charting and -tinted to match their service's series color, and are suppressed on charts where -they can't be tied to a visible line — so an aggregate line spanning many -services isn't annotated with releases you can't attribute to it. diff --git a/.changeset/release-markers-on-dashboard-charts.md b/.changeset/release-markers-on-dashboard-charts.md new file mode 100644 index 0000000000..eaca770881 --- /dev/null +++ b/.changeset/release-markers-on-dashboard-charts.md @@ -0,0 +1,14 @@ +--- +'@hyperdx/app': minor +'@hyperdx/api': minor +--- + +Overlay release markers on dashboard tile charts, showing when each version of a +service first appeared so a deployment can be lined up against a change in the +data. Defaults to the OpenTelemetry `service.version` resource attribute, with a +new optional `serviceVersionExpression` on log and trace sources for teams whose +release identifier lives elsewhere, such as a container image tag under GitOps. +Markers are scoped to the data each tile is charting and tinted to match their +service's series color, and are suppressed on charts where they can't be tied to +a visible line, so an aggregate line spanning many services isn't annotated with +releases you can't attribute to it. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 4a21075d50..8b96a335ef 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -3822,7 +3822,7 @@ }, "serviceVersionExpression": { "type": "string", - "description": "Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset. Where services carry the release on different attributes, fall back across them with coalesce(nullIf(a, ''), nullIf(b, '')).", + "description": "Expression to extract the running release of a service, used to place release markers on charts, so a deployment can be lined up against a change in the data. Defaults to the OpenTelemetry service.version resource attribute when unset. Where services carry the release on different attributes, fall back across them with coalesce(nullIf(a, ''), nullIf(b, '')).", "nullable": true, "example": "ResourceAttributes['service.version']" }, @@ -4102,7 +4102,7 @@ }, "serviceVersionExpression": { "type": "string", - "description": "Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset. Where services carry the release on different attributes, fall back across them with coalesce(nullIf(a, ''), nullIf(b, '')).", + "description": "Expression to extract the running release of a service, used to place release markers on charts, so a deployment can be lined up against a change in the data. Defaults to the OpenTelemetry service.version resource attribute when unset. Where services carry the release on different attributes, fall back across them with coalesce(nullIf(a, ''), nullIf(b, '')).", "nullable": true, "example": "ResourceAttributes['service.version']" }, diff --git a/packages/api/src/routers/external-api/v2/sources.ts b/packages/api/src/routers/external-api/v2/sources.ts index 44c4262461..e1d0cd505e 100644 --- a/packages/api/src/routers/external-api/v2/sources.ts +++ b/packages/api/src/routers/external-api/v2/sources.ts @@ -440,7 +440,7 @@ function formatExternalSource(source: SourceDocument) { * example: ServiceName * serviceVersionExpression: * type: string - * description: Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset. Where services carry the release on different attributes, fall back across them with coalesce(nullIf(a, ''), nullIf(b, '')). + * description: Expression to extract the running release of a service, used to place release markers on charts, so a deployment can be lined up against a change in the data. Defaults to the OpenTelemetry service.version resource attribute when unset. Where services carry the release on different attributes, fall back across them with coalesce(nullIf(a, ''), nullIf(b, '')). * nullable: true * example: ResourceAttributes['service.version'] * severityTextExpression: @@ -663,7 +663,7 @@ function formatExternalSource(source: SourceDocument) { * example: ServiceName * serviceVersionExpression: * type: string - * description: Expression to extract the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute when unset. Where services carry the release on different attributes, fall back across them with coalesce(nullIf(a, ''), nullIf(b, '')). + * description: Expression to extract the running release of a service, used to place release markers on charts, so a deployment can be lined up against a change in the data. Defaults to the OpenTelemetry service.version resource attribute when unset. Where services carry the release on different attributes, fall back across them with coalesce(nullIf(a, ''), nullIf(b, '')). * nullable: true * example: ResourceAttributes['service.version'] * resourceAttributesExpression: diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index b77bb78ff3..a926650106 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -200,7 +200,7 @@ const PreviousPeriodSuffix = ' (previous)'; * matched against the key's components rather than the whole key. Previous * period series are skipped; they mirror a current period series' color. * - * Used to tint chart annotations (deployment markers) to match the series they + * Used to tint chart annotations (release markers) to match the series they * describe, so a marker for one service can't be read as another's. */ export function getSeriesColorForGroup( diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 605d400a2b..8e290a29ca 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -151,7 +151,7 @@ import useDashboardContainers, { TabDeleteAction, } from '@/hooks/useDashboardContainers'; import { useDashboardKioskMode } from '@/hooks/useDashboardKioskMode'; -import { useDeploymentAnnotations } from '@/hooks/useDeploymentAnnotations'; +import { useReleaseAnnotations } from '@/hooks/useReleaseAnnotations'; import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; import ChartContainer, { @@ -388,7 +388,7 @@ const Tile = forwardRef( onTimeRangeSelect, filters, showAlertAnnotations, - showDeployAnnotations, + showReleaseAnnotations, isLive, readOnly, @@ -419,7 +419,7 @@ const Tile = forwardRef( // When true, draw alert firing/recovery annotations on this tile's chart. showAlertAnnotations?: boolean; // When true, draw release markers on this tile's chart. - showDeployAnnotations?: boolean; + showReleaseAnnotations?: boolean; isLive?: boolean; readOnly?: boolean; @@ -665,9 +665,9 @@ const Tile = forwardRef( // query runs against the tile's own source with the tile's own predicates, // so a chart filtered to one service isn't annotated with another's // releases. Tiles sharing a source and filters share one query. - const deployAnnotations = useDeploymentAnnotations( + const releaseAnnotations = useReleaseAnnotations( isFullscreen ? fullscreenDateRange : dateRange, - showDeployAnnotations, + showReleaseAnnotations, { source, where: isBuilderSavedChartConfig(chart.config) @@ -681,8 +681,8 @@ const Tile = forwardRef( ); const annotations = useMemo( - () => mergeAnnotations(alertAnnotations, deployAnnotations), - [alertAnnotations, deployAnnotations], + () => mergeAnnotations(alertAnnotations, releaseAnnotations), + [alertAnnotations, releaseAnnotations], ); const filterWarning = useMemo(() => { @@ -1759,8 +1759,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { parseAsBoolean.withDefault(false), ); // Same for release markers, derived from `service.version` changes. - const [showDeployAnnotations, setShowDeployAnnotations] = useQueryState( - 'deployMarkers', + const [showReleaseAnnotations, setShowReleaseAnnotations] = useQueryState( + 'releaseMarkers', parseAsBoolean.withDefault(false), ); @@ -2224,7 +2224,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ]} onTimeRangeSelect={onTimeRangeSelect} showAlertAnnotations={showAlertAnnotations} - showDeployAnnotations={showDeployAnnotations} + showReleaseAnnotations={showReleaseAnnotations} isHighlighted={highlightedTileId === chart.id} onUpdateChart={ isKioskMode @@ -2322,7 +2322,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { whereLanguage, onTimeRangeSelect, showAlertAnnotations, - showDeployAnnotations, + showReleaseAnnotations, getFilterQueriesForSource, moveTargetContainers, handleMoveTileToGroup, @@ -2761,12 +2761,12 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { } - onClick={() => setShowDeployAnnotations(v => !v)} - data-testid="toggle-deploy-annotations-menu-item" + onClick={() => setShowReleaseAnnotations(v => !v)} + data-testid="toggle-release-annotations-menu-item" > - {showDeployAnnotations - ? 'Hide deployment markers' - : 'Show deployment markers'} + {showReleaseAnnotations + ? 'Hide release markers' + : 'Show release markers'} )} diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 162758a41e..f5584f72ef 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -673,7 +673,7 @@ export const MemoChart = memo(function MemoChart({ lineData: LineData[]; referenceLines?: React.ReactNode; /** - * Event markers (alerts, deploys, …) drawn as dashed vertical lines with a + * Event markers (alerts, releases, …) drawn as dashed vertical lines with a * label above. Passed as data rather than pre-rendered elements so the chart * can clamp them to its own x-axis domain. Distinct from `referenceLines` * (threshold lines). diff --git a/packages/app/src/components/Sources/SourceForm/constants.ts b/packages/app/src/components/Sources/SourceForm/constants.ts index 83a01481bf..614e1fe442 100644 --- a/packages/app/src/components/Sources/SourceForm/constants.ts +++ b/packages/app/src/components/Sources/SourceForm/constants.ts @@ -7,7 +7,7 @@ export const KNOWN_COLUMNS_EXPRESSION_HELP_TEXT = 'For Distributed table sources whose target tables have non-matching column sets. Provide a list of columns supported across all target tables; it is used instead of SELECT * when fetching full row data (e.g. the row side panel). Leave blank to select all columns. This should be a comma-separated list of column names - do not include non-column expressions or aliases.'; export const SERVICE_VERSION_EXPRESSION_HELP_TEXT = - "Identifies the running release of a service, used to place deployment markers on charts. Defaults to the OpenTelemetry service.version resource attribute. Point it elsewhere if your release identifier lives in another attribute - under GitOps it is often the container image tag. If services in this table use different attributes, fall back across them: coalesce(nullIf(ResourceAttributes['service.version'], ''), nullIf(ResourceAttributes['container.image.tag'], ''))."; + "Identifies the running release of a service, used to place release markers on charts, so a deployment can be lined up against a change in the data. Defaults to the OpenTelemetry service.version resource attribute. Point it elsewhere if your release identifier lives in another attribute - under GitOps it is often the container image tag. If services in this table use different attributes, fall back across them: coalesce(nullIf(ResourceAttributes['service.version'], ''), nullIf(ResourceAttributes['container.image.tag'], ''))."; // Placeholder written into from.databaseName / from.tableName when the // selected connection is Prometheus-only. diff --git a/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx b/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx index 10887f5088..af5ff334d2 100644 --- a/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx +++ b/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx @@ -180,8 +180,8 @@ describe('getAnnotationElements label collapsing', () => { const deploy = (seconds: number, label: string): ChartAnnotation => ({ time: seconds * 1000, label, - kind: 'deployment', - groupNoun: 'deploys', + kind: 'release', + groupNoun: 'releases', }); it('leaves markers with room for both labels individually labelled', () => { @@ -199,11 +199,11 @@ describe('getAnnotationElements label collapsing', () => { collapsing, ); - expect(lines.map(labelOf)).toEqual(['2 deploys', undefined]); + expect(lines.map(labelOf)).toEqual(['2 releases', undefined]); }); // Regression: a fixed separation let long version strings render on top of - // each other ("2.0.0" and "2 deploys" colliding into "2.0.02 depl…"). + // each other ("2.0.0" and "2 releases" colliding into "2.0.02 depl…"). it('reserves more room for longer labels at the same spacing', () => { const lines = getAnnotationElements( [deploy(0, '2026.08.04-abcdef12'), deploy(60, '2026.08.04-abcdef34')], @@ -211,7 +211,7 @@ describe('getAnnotationElements label collapsing', () => { ); // Same 60px gap that leaves short labels alone is not enough for these. - expect(lines.map(labelOf)).toEqual(['2 deploys', undefined]); + expect(lines.map(labelOf)).toEqual(['2 releases', undefined]); }); // Anchoring on the group's first member (not the running last one) is what @@ -229,13 +229,13 @@ describe('getAnnotationElements label collapsing', () => { // 40 is inside the anchor's label footprint; 100 is clear of it and starts // its own group. Anchoring on the last member would have swallowed it. expect(lines).toHaveLength(3); - expect(labelOf(lines[0])).toBe('2 deploys'); + expect(labelOf(lines[0])).toBe('2 releases'); expect(labelOf(lines[1])).toBeUndefined(); expect(labelOf(lines[2])).toBe('1.2.0-beta'); }); // Regression: a cluster spanning two services took the anchor's series color, - // so "2 deploys" rendered in one service's color while counting the other's + // so "2 releases" rendered in one service's color while counting the other's // release as that service's. it('drops the series color when a cluster spans several series', () => { const [anchor] = getAnnotationElements( @@ -246,7 +246,7 @@ describe('getAnnotationElements label collapsing', () => { collapsing, ); - expect(labelOf(anchor)).toBe('2 deploys'); + expect(labelOf(anchor)).toBe('2 releases'); // Neutral, and not either series' colour. expect(lineProps(anchor).stroke).toBe('var(--color-text-muted)'); }); @@ -260,7 +260,7 @@ describe('getAnnotationElements label collapsing', () => { collapsing, ); - expect(labelOf(anchor)).toBe('2 deploys'); + expect(labelOf(anchor)).toBe('2 releases'); expect(lineProps(anchor).stroke).toBe('#blue'); }); @@ -295,7 +295,7 @@ describe('getAnnotationElements label collapsing', () => { ); // Both are within each other's label footprint, but they are different - // kinds, so each keeps its own label rather than becoming "2 deploys". + // kinds, so each keeps its own label rather than becoming "2 releases". expect(lines.map(labelOf).sort()).toEqual(['1.0.0', 'Alert']); }); diff --git a/packages/app/src/components/charts/chartAnnotations.tsx b/packages/app/src/components/charts/chartAnnotations.tsx index 79b1382807..934de4c8e8 100644 --- a/packages/app/src/components/charts/chartAnnotations.tsx +++ b/packages/app/src/components/charts/chartAnnotations.tsx @@ -3,7 +3,7 @@ import { Label, ReferenceLine } from 'recharts'; /** * A single marker to overlay on a timeseries chart at a point in time. - * Source-agnostic: alerts, deployments, incidents, config changes, etc. all map + * Source-agnostic: alerts, releases, incidents, config changes, etc. all map * to this shape. */ export type ChartAnnotation = { @@ -16,14 +16,14 @@ export type ChartAnnotation = { /** Stable React key; defaults to the resolved timestamp + index. */ key?: string; /** - * Feature that produced the marker ('alert', 'deployment', …). Markers only - * ever share a collapsed label with others of the same kind, so a deploy is + * Feature that produced the marker ('alert', 'release', …). Markers only + * ever share a collapsed label with others of the same kind, so a release is * never counted as an alert. */ kind?: string; /** * Plural noun for the collapsed label when several markers of this kind sit - * too close to label individually ("3 deploys"). Defaults to 'events'. + * too close to label individually ("3 releases"). Defaults to 'events'. */ groupNoun?: string; /** @@ -80,7 +80,7 @@ type PositionedAnnotation = ChartAnnotation & { }; /** - * Merges annotation lists from independent features (alerts, deployments, …) + * Merges annotation lists from independent features (alerts, releases, …) * into the single array the chart takes. Returns `undefined` when nothing is * left, matching the chart's "no annotations" prop state. */ @@ -172,7 +172,7 @@ function positionAnnotations( /** * Groups markers that are too close together to label individually, keeping one * labelled anchor per cluster and muting the rest. Grouping is per `kind`, so an - * alert marker is never folded into a deploy's count. + * alert marker is never folded into a release's count. */ function collapseLabels( positioned: PositionedAnnotation[], diff --git a/packages/app/src/hooks/__tests__/useDeploymentAnnotations.test.tsx b/packages/app/src/hooks/__tests__/useReleaseAnnotations.test.tsx similarity index 87% rename from packages/app/src/hooks/__tests__/useDeploymentAnnotations.test.tsx rename to packages/app/src/hooks/__tests__/useReleaseAnnotations.test.tsx index 50d667abdc..bcfd17df70 100644 --- a/packages/app/src/hooks/__tests__/useDeploymentAnnotations.test.tsx +++ b/packages/app/src/hooks/__tests__/useReleaseAnnotations.test.tsx @@ -9,13 +9,13 @@ import { import { renderHook } from '@testing-library/react'; import { - buildDeploymentChartConfig, - canDeriveDeployments, + buildReleaseChartConfig, + canDeriveReleases, DEFAULT_VERSION_EXPRESSION, - deploymentRowsToAnnotations, + releaseRowsToAnnotations, resolveVersionExpression, - useDeploymentAnnotations, -} from '@/hooks/useDeploymentAnnotations'; + useReleaseAnnotations, +} from '@/hooks/useReleaseAnnotations'; import { getChartColorInfo } from '@/utils'; jest.mock('@/hooks/useChartConfig', () => ({ @@ -91,23 +91,23 @@ const sessionSource: TSessionSource = { traceSourceId: 'trace-1', }; -describe('canDeriveDeployments', () => { +describe('canDeriveReleases', () => { it('accepts log and trace sources', () => { - expect(canDeriveDeployments(logSource)).toBe(true); - expect(canDeriveDeployments(traceSource)).toBe(true); + expect(canDeriveReleases(logSource)).toBe(true); + expect(canDeriveReleases(traceSource)).toBe(true); }); // The deployments query re-aggregates the tile's own table, which is what // makes the tile's filters meaningful against it. Metric sources resolve // their table per metric type, so there is no single table to re-aggregate. it('rejects metric, session and disabled sources', () => { - expect(canDeriveDeployments(metricSource)).toBe(false); - expect(canDeriveDeployments(sessionSource)).toBe(false); - expect(canDeriveDeployments({ ...logSource, disabled: true })).toBe(false); + expect(canDeriveReleases(metricSource)).toBe(false); + expect(canDeriveReleases(sessionSource)).toBe(false); + expect(canDeriveReleases({ ...logSource, disabled: true })).toBe(false); }); it('rejects a missing source', () => { - expect(canDeriveDeployments(undefined)).toBe(false); + expect(canDeriveReleases(undefined)).toBe(false); }); }); @@ -161,11 +161,11 @@ describe('resolveVersionExpression', () => { }); }); -describe('buildDeploymentChartConfig', () => { +describe('buildReleaseChartConfig', () => { const range: [Date, Date] = [new Date(1_000), new Date(2_000)]; it('selects the first timestamp each version was seen at, grouped by version and service', () => { - const config = buildDeploymentChartConfig( + const config = buildReleaseChartConfig( logSource, DEFAULT_VERSION_EXPRESSION, range, @@ -190,14 +190,14 @@ describe('buildDeploymentChartConfig', () => { // makes renderChartConfig append them a second time. it('disables automatic group-by projection', () => { expect( - buildDeploymentChartConfig(logSource, DEFAULT_VERSION_EXPRESSION, range) + buildReleaseChartConfig(logSource, DEFAULT_VERSION_EXPRESSION, range) .selectGroupBy, ).toBe(false); }); it('omits the service column when the source has no service expression', () => { const noService = { ...logSource, serviceNameExpression: undefined }; - const config = buildDeploymentChartConfig( + const config = buildReleaseChartConfig( noService, DEFAULT_VERSION_EXPRESSION, range, @@ -216,13 +216,12 @@ describe('buildDeploymentChartConfig', () => { }; expect( - buildDeploymentChartConfig(multi, DEFAULT_VERSION_EXPRESSION, range) - .select, + buildReleaseChartConfig(multi, DEFAULT_VERSION_EXPRESSION, range).select, ).toContain('min(TimestampTime) AS firstSeen'); }); it('honors a custom version expression', () => { - const config = buildDeploymentChartConfig( + const config = buildReleaseChartConfig( logSource, "LogAttributes['release']", range, @@ -235,7 +234,7 @@ describe('buildDeploymentChartConfig', () => { describe('tile scoping', () => { it('sends no filters when the tile is unfiltered', () => { - const config = buildDeploymentChartConfig( + const config = buildReleaseChartConfig( logSource, DEFAULT_VERSION_EXPRESSION, range, @@ -248,7 +247,7 @@ describe('buildDeploymentChartConfig', () => { // The whole point: a tile filtered to one service must not be annotated // with another service's releases. it("carries the tile's own where clause as a filter", () => { - const config = buildDeploymentChartConfig( + const config = buildReleaseChartConfig( logSource, DEFAULT_VERSION_EXPRESSION, range, @@ -263,7 +262,7 @@ describe('buildDeploymentChartConfig', () => { }); it('preserves a SQL tile where clause as a SQL filter', () => { - const config = buildDeploymentChartConfig( + const config = buildReleaseChartConfig( logSource, DEFAULT_VERSION_EXPRESSION, range, @@ -276,7 +275,7 @@ describe('buildDeploymentChartConfig', () => { }); it('appends dashboard filters and drops empty ones', () => { - const config = buildDeploymentChartConfig( + const config = buildReleaseChartConfig( logSource, DEFAULT_VERSION_EXPRESSION, range, @@ -298,7 +297,7 @@ describe('buildDeploymentChartConfig', () => { // Lucene bare terms need the source's implicit column to render. it('carries the implicit column expression for Lucene filters', () => { - const config = buildDeploymentChartConfig( + const config = buildReleaseChartConfig( logSource, DEFAULT_VERSION_EXPRESSION, range, @@ -309,12 +308,12 @@ describe('buildDeploymentChartConfig', () => { }); }); -describe('deploymentRowsToAnnotations', () => { +describe('releaseRowsToAnnotations', () => { const windowStart = new Date('2026-07-01T00:00:00.000Z'); const inside = '2026-07-01T00:30:00.000Z'; it('maps a version first seen inside the window to a marker', () => { - const [annotation] = deploymentRowsToAnnotations( + const [annotation] = releaseRowsToAnnotations( [{ firstSeen: inside, version: '1.43.1', service: 'checkout' }], { windowStart }, ); @@ -323,15 +322,15 @@ describe('deploymentRowsToAnnotations', () => { time: new Date(inside).getTime(), label: '1.43.1', color: getChartColorInfo(), - kind: 'deployment', - groupNoun: 'deploys', + kind: 'release', + groupNoun: 'releases', }); }); // The query range is widened backwards so the version that was already // running shows up; it must not be drawn as a deploy at the left edge. it('drops the version that was already running when the window opened', () => { - const annotations = deploymentRowsToAnnotations( + const annotations = releaseRowsToAnnotations( [ { firstSeen: '2026-06-30T23:00:00.000Z', version: '1.43.0' }, { firstSeen: inside, version: '1.43.1' }, @@ -343,7 +342,7 @@ describe('deploymentRowsToAnnotations', () => { }); it('keeps a version first seen exactly at the window start', () => { - const annotations = deploymentRowsToAnnotations( + const annotations = releaseRowsToAnnotations( [{ firstSeen: windowStart.toISOString(), version: '1.43.1' }], { windowStart }, ); @@ -352,7 +351,7 @@ describe('deploymentRowsToAnnotations', () => { }); it('drops rows with a missing or unparseable timestamp or version', () => { - const annotations = deploymentRowsToAnnotations( + const annotations = releaseRowsToAnnotations( [ { firstSeen: null, version: '1.0.0' }, { firstSeen: 'not a date', version: '1.0.0' }, @@ -367,7 +366,7 @@ describe('deploymentRowsToAnnotations', () => { }); it('gives each marker a distinct key so React can reconcile them', () => { - const annotations = deploymentRowsToAnnotations( + const annotations = releaseRowsToAnnotations( [ { firstSeen: inside, version: '1.43.1', service: 'checkout' }, { firstSeen: inside, version: '1.43.1', service: 'payments' }, @@ -379,7 +378,7 @@ describe('deploymentRowsToAnnotations', () => { }); }); -describe('useDeploymentAnnotations', () => { +describe('useReleaseAnnotations', () => { const range: [Date, Date] = [ new Date('2026-07-01T00:00:00.000Z'), new Date('2026-07-01T01:00:00.000Z'), @@ -407,7 +406,7 @@ describe('useDeploymentAnnotations', () => { mockQuery([{ firstSeen: '2026-07-01T00:30:00.000Z', version: '1.0.0' }]); const { result } = renderHook(() => - useDeploymentAnnotations(range, false, { source: logSource }), + useReleaseAnnotations(range, false, { source: logSource }), ); expect(result.current).toBeUndefined(); @@ -415,14 +414,14 @@ describe('useDeploymentAnnotations', () => { }); it('keeps the query idle when the tile has no source', () => { - renderHook(() => useDeploymentAnnotations(range, true)); + renderHook(() => useReleaseAnnotations(range, true)); expect(lastCall()[1]).toMatchObject({ enabled: false }); }); it('keeps the query idle for a source that cannot provide releases', () => { renderHook(() => - useDeploymentAnnotations(range, true, { source: metricSource }), + useReleaseAnnotations(range, true, { source: metricSource }), ); expect(lastCall()[1]).toMatchObject({ enabled: false }); @@ -435,13 +434,13 @@ describe('useDeploymentAnnotations', () => { ]); const { result } = renderHook(() => - useDeploymentAnnotations(range, true, { source: logSource }), + useReleaseAnnotations(range, true, { source: logSource }), ); expect(result.current).toHaveLength(1); expect(result.current?.[0]).toMatchObject({ label: '1.43.1', - kind: 'deployment', + kind: 'release', }); }); @@ -449,7 +448,7 @@ describe('useDeploymentAnnotations', () => { mockQuery([{ firstSeen: '2026-06-30T20:00:00.000Z', version: '1.43.0' }]); const { result } = renderHook(() => - useDeploymentAnnotations(range, true, { source: logSource }), + useReleaseAnnotations(range, true, { source: logSource }), ); expect(result.current).toBeUndefined(); @@ -457,7 +456,7 @@ describe('useDeploymentAnnotations', () => { it("uses the source's configured version expression", () => { renderHook(() => - useDeploymentAnnotations(range, true, { + useReleaseAnnotations(range, true, { source: { ...logSource, serviceVersionExpression: "ResourceAttributes['container.image.tag']", @@ -472,7 +471,7 @@ describe('useDeploymentAnnotations', () => { it("scopes the query with the tile's filters", () => { renderHook(() => - useDeploymentAnnotations(range, true, { + useReleaseAnnotations(range, true, { source: logSource, where: 'ServiceName:"checkout"', whereLanguage: 'lucene', @@ -485,9 +484,7 @@ describe('useDeploymentAnnotations', () => { }); it('widens the query range backwards to spot the already-running version', () => { - renderHook(() => - useDeploymentAnnotations(range, true, { source: logSource }), - ); + renderHook(() => useReleaseAnnotations(range, true, { source: logSource })); const [queryStart, queryEnd] = lastCall()[0].dateRange; @@ -512,7 +509,7 @@ describe('useDeploymentAnnotations', () => { it('reuses one config object as a live window slides within the minute', () => { const { rerender } = renderHook( ({ dateRange }) => - useDeploymentAnnotations(dateRange, true, { + useReleaseAnnotations(dateRange, true, { source: logSource, filters: [{ type: 'sql', condition: "Env = 'prod'" }], }), @@ -528,7 +525,7 @@ describe('useDeploymentAnnotations', () => { it('rebuilds the config once the window crosses a minute boundary', () => { const { rerender } = renderHook( ({ dateRange }) => - useDeploymentAnnotations(dateRange, true, { source: logSource }), + useReleaseAnnotations(dateRange, true, { source: logSource }), { initialProps: { dateRange: liveRange } }, ); const first = mockedUseQueriedChartConfig.mock.calls[0][0]; diff --git a/packages/app/src/hooks/useDeploymentAnnotations.tsx b/packages/app/src/hooks/useReleaseAnnotations.tsx similarity index 87% rename from packages/app/src/hooks/useDeploymentAnnotations.tsx rename to packages/app/src/hooks/useReleaseAnnotations.tsx index 76405376d2..d51aa096c9 100644 --- a/packages/app/src/hooks/useDeploymentAnnotations.tsx +++ b/packages/app/src/hooks/useReleaseAnnotations.tsx @@ -20,10 +20,10 @@ import { getChartColorInfo } from '@/utils'; export const DEFAULT_VERSION_EXPRESSION = "ResourceAttributes['service.version']"; -const DEPLOY_EMPTY_NOTIFICATION_ID = 'deployment-markers-empty'; +const RELEASE_EMPTY_NOTIFICATION_ID = 'release-markers-empty'; /** Distinct versions fetched per window. Far above any real release cadence. */ -const MAX_DEPLOY_ROWS = 500; +const MAX_RELEASE_ROWS = 500; /** * Query bounds are floored/ceiled to this, so a live-tailing dashboard reuses @@ -33,19 +33,19 @@ const MAX_DEPLOY_ROWS = 500; const BUCKET_MS = 60_000; // How far back before the visible window we look for the already-running -// version. See `deploymentRowsToAnnotations` for why. +// version. See `releaseRowsToAnnotations` for why. const MIN_LOOKBACK_MS = 30 * 60_000; const LOOKBACK_RATIO = 0.1; -/** A row of the deployments query. Values arrive as strings from ClickHouse. */ -export type DeploymentRow = { +/** A row of the releases query. Values arrive as strings from ClickHouse. */ +export type ReleaseRow = { firstSeen?: string | number | null; version?: string | null; service?: string | null; }; /** Narrows the tile's filters to the ones worth sending. */ -type DeploymentScope = { +type ReleaseScope = { where?: string; whereLanguage?: SearchConditionLanguage; filters?: Filter[]; @@ -54,13 +54,13 @@ type DeploymentScope = { /** * Whether releases can be derived from this source. * - * Only log and trace sources qualify: the deployments query runs against the + * Only log and trace sources qualify: the releases query runs against the * *same table* the tile charts, which is what makes the tile's own filters * meaningful against it. Metric sources resolve their table from `metricTables` * per metric type, so there is no single table to re-aggregate, and a tile * filter written against metric columns would not apply to a log table. */ -export function canDeriveDeployments( +export function canDeriveReleases( source: TSource | undefined, ): source is TSource { return ( @@ -99,11 +99,11 @@ export function resolveVersionExpression(source: TSource | undefined): string { * `SelectListSchema` accepts a raw string for exactly that case — which also * keeps `min()` over a `DateTime64` out of the aggregate-function machinery. */ -export function buildDeploymentChartConfig( +export function buildReleaseChartConfig( source: TSource, versionExpression: string, dateRange: [Date, Date], - scope: DeploymentScope = {}, + scope: ReleaseScope = {}, ): BuilderChartConfigWithDateRange { const timestampExpression = getFirstTimestampValueExpression( source.timestampValueExpression, @@ -162,7 +162,7 @@ export function buildDeploymentChartConfig( // the renderer appends them a second time. selectGroupBy: false, orderBy: 'firstSeen ASC', - limit: { limit: MAX_DEPLOY_ROWS }, + limit: { limit: MAX_RELEASE_ROWS }, dateRange, }; } @@ -186,18 +186,18 @@ const NO_SOURCE_CONFIG: BuilderChartConfigWithDateRange = { * Maps query rows to markers, keeping only versions whose first appearance * lands inside the visible window. * - * The query range is widened backwards (see `useDeploymentAnnotations`) so the + * The query range is widened backwards (see `useReleaseAnnotations`) so the * version that was *already running* when the window opened also comes back — * its `min(timestamp)` would otherwise sit at the left edge and read as a - * deploy that never happened. Anything first seen before `windowStart` is that + * release that never happened. Anything first seen before `windowStart` is that * incumbent, so it is dropped. * * Residual artifact: a service idle for longer than the lookback has no rows in - * the widened prefix, so its first post-idle row still reads as a deploy at the + * the widened prefix, so its first post-idle row still reads as a release at the * left edge. */ -export function deploymentRowsToAnnotations( - rows: DeploymentRow[], +export function releaseRowsToAnnotations( + rows: ReleaseRow[], { windowStart }: { windowStart: Date }, ): ChartAnnotation[] { // Resolve the theme color once (it reads computed styles). @@ -219,10 +219,10 @@ export function deploymentRowsToAnnotations( // Fallback only: the chart re-tints the marker to its service's series // color when that service is charted (see `getSeriesColorForGroup`). color, - kind: 'deployment', - groupNoun: 'deploys', + kind: 'release', + groupNoun: 'releases', group: row.service ?? undefined, - key: `deploy-annotation-${firstSeenMs}-${row.version}-${row.service ?? ''}`, + key: `release-annotation-${firstSeenMs}-${row.version}-${row.service ?? ''}`, }); } @@ -230,7 +230,7 @@ export function deploymentRowsToAnnotations( } /** - * Returns deployment markers for a tile, derived from changes in the + * Returns release markers for a tile, derived from changes in the * `service.version` resource attribute. * * Scoped to the tile: the query runs against the tile's own source with the @@ -242,17 +242,17 @@ export function deploymentRowsToAnnotations( * collapsing need the chart's x-axis domain). The query stays idle unless * `enabled` is true and the source can provide releases. */ -export function useDeploymentAnnotations( +export function useReleaseAnnotations( dateRange: [Date, Date], enabled: boolean = false, - options?: DeploymentScope & { + options?: ReleaseScope & { source?: TSource; /** Overrides the source's own expression. Mainly a testing seam. */ versionExpression?: string; }, ): ChartAnnotation[] | undefined { const source = options?.source; - const isSupported = canDeriveDeployments(source); + const isSupported = canDeriveReleases(source); const versionExpression = options?.versionExpression || resolveVersionExpression(isSupported ? source : undefined); @@ -279,7 +279,7 @@ export function useDeploymentAnnotations( MIN_LOOKBACK_MS, (windowEndMs - windowStartMs) * LOOKBACK_RATIO, ); - return buildDeploymentChartConfig( + return buildReleaseChartConfig( source, versionExpression, [new Date(windowStartMs - lookbackMs), new Date(windowEndMs)], @@ -302,7 +302,7 @@ export function useDeploymentAnnotations( if (!enabled || !data?.data?.length) { return undefined; } - const mapped = deploymentRowsToAnnotations(data.data, { + const mapped = releaseRowsToAnnotations(data.data, { windowStart: new Date(windowStartMs), }); return mapped.length ? mapped : undefined; @@ -325,11 +325,11 @@ export function useDeploymentAnnotations( } hasWarnedRef.current = true; notifications.show({ - id: DEPLOY_EMPTY_NOTIFICATION_ID, + id: RELEASE_EMPTY_NOTIFICATION_ID, color: 'yellow', - title: 'No deployments found', + title: 'No releases found', message: - 'Deployment markers are derived from the OpenTelemetry `service.version` resource attribute. No version changes were found in this time range.', + 'Release markers come from changes in the version attribute configured on this source. No version changes were found in this time range - if you deploy without changing the version, there is nothing to mark.', }); }, [enabled, isFetching, data, annotations]); diff --git a/packages/app/tests/e2e/features/deployment-markers.spec.ts b/packages/app/tests/e2e/features/release-markers.spec.ts similarity index 50% rename from packages/app/tests/e2e/features/deployment-markers.spec.ts rename to packages/app/tests/e2e/features/release-markers.spec.ts index 66f3d38eb1..8962793e84 100644 --- a/packages/app/tests/e2e/features/deployment-markers.spec.ts +++ b/packages/app/tests/e2e/features/release-markers.spec.ts @@ -1,5 +1,5 @@ /** - * Deployment markers: releases show up as dashed vertical lines on dashboard + * Release markers: releases show up as dashed vertical lines on dashboard * tile charts, labelled with the version. * * Markers are derived from `ResourceAttributes['service.version']` — one marker @@ -44,7 +44,7 @@ async function clickhouseQuery(sql: string): Promise { } // Spread across the dashboard's default "Past 1h" window with ~20 minutes -// between each. Labels collapse into "N deploys" once neighbours are closer +// between each. Labels collapse into "N releases" once neighbours are closer // than roughly half their combined width, and a dashboard tile's plot is only // a few hundred pixels wide — keep these generously spaced (and the version // strings short) so the assertions below aren't sitting on that threshold. @@ -54,18 +54,18 @@ const NEW_VERSION_AGO_MS = 8 * 60 * 1000; // Versions are fixed rather than per-run unique, which makes seeding // idempotent: a Playwright retry would otherwise add more versions a few // seconds from the first attempt's — close enough to collapse into a -// "2 deploys" label and break the assertions. Re-inserting the same versions +// "2 releases" label and break the assertions. Re-inserting the same versions // still groups to the same markers, since the query is // `min(Timestamp) GROUP BY version, service`. The global seed truncates the // table between suite runs, so nothing accumulates. const OLD_VERSION = 'e2e-1.0.0'; const NEW_VERSION = 'e2e-2.0.0'; -const DEPLOY_SERVICE = 'deploy_markers_e2e'; +const RELEASE_SERVICE = 'release_markers_e2e'; // A second service releasing in the same window. Markers are scoped to the data -// a tile is charting, so filtering to DEPLOY_SERVICE must hide this one. +// a tile is charting, so filtering to RELEASE_SERVICE must hide this one. const OTHER_VERSION = 'oth-9.9.9'; -const OTHER_SERVICE = 'deploy_markers_other_e2e'; +const OTHER_SERVICE = 'release_markers_other_e2e'; const OTHER_VERSION_AGO_MS = 29 * 60 * 1000; /** Seed releases for two services into the shared logs table. */ @@ -82,8 +82,8 @@ async function seedReleases(): Promise { }; const values = [ - row(OLD_VERSION_AGO_MS, OLD_VERSION, DEPLOY_SERVICE), - row(NEW_VERSION_AGO_MS, NEW_VERSION, DEPLOY_SERVICE), + row(OLD_VERSION_AGO_MS, OLD_VERSION, RELEASE_SERVICE), + row(NEW_VERSION_AGO_MS, NEW_VERSION, RELEASE_SERVICE), row(OTHER_VERSION_AGO_MS, OTHER_VERSION, OTHER_SERVICE), ].join(', '); @@ -96,78 +96,74 @@ async function seedReleases(): Promise { `); } -test.describe( - 'Deployment markers', - { tag: ['@full-stack', '@dashboard'] }, - () => { - let dashboardPage: DashboardPage; - - test.beforeEach(async ({ page }) => { - dashboardPage = new DashboardPage(page); - await dashboardPage.goto(); - }); - - test('overlays a labelled marker per release and clears them when toggled off', async ({ - page, - }) => { - await seedReleases(); - - await dashboardPage.createNewDashboard(); - await dashboardPage.addTileWithSource( - 'Deployment markers chart', - DEFAULT_LOGS_SOURCE_NAME, - ); - // Scope the chart to one service so its releases are attributable — see - // the suppression test below for why that matters. - await dashboardPage.setGlobalFilter(`ServiceName:"${DEPLOY_SERVICE}"`); - - const markers = dashboardPage.getAnnotationMarkers(); - const labels = dashboardPage.getAnnotationLabels(); - await expect(markers).toHaveCount(0); - - await dashboardPage.toggleDeployAnnotations(); - - // Ephemeral view state, carried in the URL so a shared link keeps it. - await expect(page).toHaveURL(/deployMarkers=true/); - - // Both releases first appear inside the window and are far enough apart - // to stay individually labelled rather than collapsing. - await expect(markers).toHaveCount(2); - await expect(labels.filter({ hasText: OLD_VERSION })).toBeVisible(); - await expect(labels.filter({ hasText: NEW_VERSION })).toBeVisible(); - - // Regression: markers used to be read from the source globally, so a - // chart filtered to one service was still annotated with every other - // service's releases. - await expect(labels.filter({ hasText: OTHER_VERSION })).toHaveCount(0); - - await dashboardPage.toggleDeployAnnotations(); - - await expect(markers).toHaveCount(0); - await expect(page).not.toHaveURL(/deployMarkers=true/); - }); - - // A marker only aids correlation if the reader can tie it to something on - // the chart. An aggregate line over several services can't do that, so - // rather than draw a wall of markers naming services with no visible line, - // none are drawn at all. - test('suppresses markers it cannot attribute to the chart', async ({ - page, - }) => { - await seedReleases(); - - await dashboardPage.createNewDashboard(); - // No filter and no group by: one line covering both seeded services. - await dashboardPage.addTileWithSource( - 'Unattributable markers', - DEFAULT_LOGS_SOURCE_NAME, - ); - await dashboardPage.toggleDeployAnnotations(); - - await expect(page).toHaveURL(/deployMarkers=true/); - // The releases are found by the query — they just aren't drawn, because - // neither service has its own line here. - await expect(dashboardPage.getAnnotationMarkers()).toHaveCount(0); - }); - }, -); +test.describe('Release markers', { tag: ['@full-stack', '@dashboard'] }, () => { + let dashboardPage: DashboardPage; + + test.beforeEach(async ({ page }) => { + dashboardPage = new DashboardPage(page); + await dashboardPage.goto(); + }); + + test('overlays a labelled marker per release and clears them when toggled off', async ({ + page, + }) => { + await seedReleases(); + + await dashboardPage.createNewDashboard(); + await dashboardPage.addTileWithSource( + 'Release markers chart', + DEFAULT_LOGS_SOURCE_NAME, + ); + // Scope the chart to one service so its releases are attributable — see + // the suppression test below for why that matters. + await dashboardPage.setGlobalFilter(`ServiceName:"${RELEASE_SERVICE}"`); + + const markers = dashboardPage.getAnnotationMarkers(); + const labels = dashboardPage.getAnnotationLabels(); + await expect(markers).toHaveCount(0); + + await dashboardPage.toggleReleaseAnnotations(); + + // Ephemeral view state, carried in the URL so a shared link keeps it. + await expect(page).toHaveURL(/releaseMarkers=true/); + + // Both releases first appear inside the window and are far enough apart + // to stay individually labelled rather than collapsing. + await expect(markers).toHaveCount(2); + await expect(labels.filter({ hasText: OLD_VERSION })).toBeVisible(); + await expect(labels.filter({ hasText: NEW_VERSION })).toBeVisible(); + + // Regression: markers used to be read from the source globally, so a + // chart filtered to one service was still annotated with every other + // service's releases. + await expect(labels.filter({ hasText: OTHER_VERSION })).toHaveCount(0); + + await dashboardPage.toggleReleaseAnnotations(); + + await expect(markers).toHaveCount(0); + await expect(page).not.toHaveURL(/releaseMarkers=true/); + }); + + // A marker only aids correlation if the reader can tie it to something on + // the chart. An aggregate line over several services can't do that, so + // rather than draw a wall of markers naming services with no visible line, + // none are drawn at all. + test('suppresses markers it cannot attribute to the chart', async ({ + page, + }) => { + await seedReleases(); + + await dashboardPage.createNewDashboard(); + // No filter and no group by: one line covering both seeded services. + await dashboardPage.addTileWithSource( + 'Unattributable markers', + DEFAULT_LOGS_SOURCE_NAME, + ); + await dashboardPage.toggleReleaseAnnotations(); + + await expect(page).toHaveURL(/releaseMarkers=true/); + // The releases are found by the query — they just aren't drawn, because + // neither service has its own line here. + await expect(dashboardPage.getAnnotationMarkers()).toHaveCount(0); + }); +}); diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index a7ba8cdc58..a4db8efa30 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -98,7 +98,7 @@ export class DashboardPage { private readonly removeDefaultQueryAndFiltersMenuItem: Locator; private readonly exportDashboardMenuItem: Locator; private readonly enterKioskModeMenuItem: Locator; - private readonly toggleDeployAnnotationsMenuItem: Locator; + private readonly toggleReleaseAnnotationsMenuItem: Locator; private readonly exitKioskModeBtn: Locator; private readonly kioskHeaderContainer: Locator; private readonly kioskLiveStatusBadge: Locator; @@ -163,8 +163,8 @@ export class DashboardPage { this.enterKioskModeMenuItem = page.getByTestId( 'enter-kiosk-mode-menu-item', ); - this.toggleDeployAnnotationsMenuItem = page.getByTestId( - 'toggle-deploy-annotations-menu-item', + this.toggleReleaseAnnotationsMenuItem = page.getByTestId( + 'toggle-release-annotations-menu-item', ); this.exitKioskModeBtn = page.getByTestId('exit-kiosk-mode-button'); this.kioskHeaderContainer = page.getByTestId('kiosk-header'); @@ -1306,12 +1306,12 @@ export class DashboardPage { /** * Open the dashboard overflow menu and toggle deployment markers on tile * charts. Expects the menu item with - * data-testid="toggle-deploy-annotations-menu-item", which only renders once + * data-testid="toggle-release-annotations-menu-item", which only renders once * the dashboard has at least one tile. */ - async toggleDeployAnnotations() { + async toggleReleaseAnnotations() { await this.dashboardMenuButton.click(); - await this.toggleDeployAnnotationsMenuItem.click(); + await this.toggleReleaseAnnotationsMenuItem.click(); } /** From 3e220c3be45be2ebd9e34a2aed9e7dc511ecc1c6 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Wed, 12 Aug 2026 10:51:20 -0400 Subject: [PATCH 6/7] feat(charts): name the service on release marker hover Colour was the only thing tying a marker to a service, and that lookup breaks down exactly when it matters. The legend caps at 4 entries, so on a chart with more series than that a marker can be tinted to match a line whose legend entry is hidden behind "+N more" - there is nothing on screen to resolve the colour against. Collapsed clusters were worse still: "6 releases" named nobody at all. Hovering a marker now lists every release in its cluster with the service that shipped it, its version, and the time. Implementation notes: - The hit targets live in a ZIndexLayer above every other chart layer. Without that the series areas (zIndex 100) and the marker lines (400) receive the pointer first. The lines and labels are now pointer-transparent for the same reason; they are decoration and the hit layer owns the interaction. - Targets are confined to the label headroom rather than the full plot height. Covering the plot made the series tooltip fire alongside this one, and the label is the natural thing to aim at anyway. Events are never stopped, so drag-to-zoom still works underneath. - One target per cluster, spanning it, so the muted lines inside a cluster are covered by their anchor rather than intercepting it. - The tooltip is portaled and fixed-positioned, matching the series tooltip: a dashboard tile clips its overflow, so an absolutely positioned tooltip is cut off at the tile edge. Layout is now shared between the rendered lines and the hit layer via `layoutAnnotations`, so the hover bands can't drift from the markers they belong to. --- packages/app/src/HDXMultiSeriesTimeChart.tsx | 45 ++++++++- .../components/charts/AnnotationHitLayer.tsx | 98 +++++++++++++++++++ .../components/charts/AnnotationTooltip.tsx | 90 +++++++++++++++++ .../__tests__/chartAnnotations.test.tsx | 60 ++++++++++++ .../components/charts/chartAnnotations.tsx | 40 ++++++-- .../e2e/features/release-markers.spec.ts | 25 +++++ .../tests/e2e/page-objects/DashboardPage.ts | 10 ++ 7 files changed, 359 insertions(+), 9 deletions(-) create mode 100644 packages/app/src/components/charts/AnnotationHitLayer.tsx create mode 100644 packages/app/src/components/charts/AnnotationTooltip.tsx diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index f5584f72ef..eb5b1ce2c5 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -17,6 +17,7 @@ import { BarChart, BarProps, CartesianGrid, + Customized, Legend, ReferenceArea, ReferenceLine, @@ -33,9 +34,15 @@ import { Popover } from '@mantine/core'; import type { NumberFormat } from '@/types'; import { COLORS, formatNumber, truncateMiddle } from '@/utils'; +import { + AnnotationHitLayer, + type HoveredAnnotation, +} from './components/charts/AnnotationHitLayer'; +import { AnnotationTooltip } from './components/charts/AnnotationTooltip'; import { ChartAnnotation, getAnnotationElements, + layoutAnnotations, resolveAnnotationSeries, } from './components/charts/chartAnnotations'; import { ChartOverlayControls } from './components/charts/ChartOverlayControls'; @@ -1016,7 +1023,9 @@ export const MemoChart = memo(function MemoChart({ return map; }, [lineData]); - const xAxisDomain: AxisDomain = useMemo(() => { + // Typed as the tuple it actually is (assignable to AxisDomain) so the + // annotation helpers can take it without an unsafe cast. + const xAxisDomain: [number, number] = useMemo(() => { let startTime = toStartOfInterval(dateRange[0], granularity); let endTime = toStartOfInterval(dateRange[1], granularity); const endTimeIsBoundaryAligned = isSameSecond(dateRange[1], endTime); @@ -1052,13 +1061,27 @@ export const MemoChart = memo(function MemoChart({ ); }, [annotations, lineData]); + // Same geometry the hit layer positions against, so the hover bands can't + // drift from the lines they belong to. + const laidOutAnnotations = useMemo(() => { + if (!coloredAnnotations?.length) { + return null; + } + return layoutAnnotations(coloredAnnotations, { + domain: xAxisDomain, + plotWidth: Math.max(0, containerWidth - Y_AXIS_WIDTH), + }); + }, [coloredAnnotations, xAxisDomain, containerWidth]); + + const [hoveredAnnotation, setHoveredAnnotation] = + useState(null); + const annotationElements = useMemo(() => { if (!coloredAnnotations?.length) { return null; } - // xAxisDomain is a [min, max] tuple at runtime (declared as AxisDomain). return getAnnotationElements(coloredAnnotations, { - domain: xAxisDomain as [number, number], + domain: xAxisDomain, // Drawable width, so markers too close together share one label. Zero on // the first paint (before ResponsiveContainer measures), which the // renderer treats as "label everything". @@ -1071,6 +1094,12 @@ export const MemoChart = memo(function MemoChart({ ref={containerRef} style={{ position: 'relative', width: '100%', height: '100%' }} > + {hoveredAnnotation != null && ( + + )} + } + /> + )} {highlightStart && highlightEnd ? ( ` so it can read the + * real plot geometry (`usePlotArea`, `useXAxisScale`) instead of re-deriving it + * from the container width. Handlers deliberately do not stop propagation: + * events still bubble to the chart wrapper, which is what keeps drag-to-zoom + * and the series tooltip working underneath the bands. + */ +export function AnnotationHitLayer({ + annotations, + onHover, +}: { + annotations: PositionedAnnotation[]; + onHover: (hovered: HoveredAnnotation | null) => void; +}) { + const plot = usePlotArea(); + const scale = useXAxisScale(); + + if (plot == null || scale == null) { + return null; + } + + // One target per cluster, not per line. Anchors carry `members`; the muted + // lines inside a cluster are already covered by their anchor's band, and + // giving them their own rects would just have them intercept each other. + const anchors = annotations.filter(annotation => annotation.members != null); + + return ( + // Above every other chart layer, otherwise the series areas (zIndex 100) + // and the marker lines (400) receive the pointer instead of these bands. + + + {anchors.map((annotation, i) => { + const members = annotation.members ?? [annotation]; + // Span the whole cluster so hovering any of its lines — including the + // muted ones, which carry no label — surfaces the same tooltip. + const xs = members + .map(member => scale(member.x)) + .filter((px): px is number => px != null && Number.isFinite(px)); + if (xs.length === 0) { + return null; + } + const left = Math.min(...xs); + const right = Math.max(...xs); + + const width = Math.max(right - left, MIN_HIT_WIDTH_PX); + const x = left - (width - (right - left)) / 2; + const centre = (left + right) / 2; + + return ( + + onHover({ annotation, point: { x: centre, y: plot.y } }) + } + onMouseLeave={() => onHover(null)} + /> + ); + })} + + + ); +} diff --git a/packages/app/src/components/charts/AnnotationTooltip.tsx b/packages/app/src/components/charts/AnnotationTooltip.tsx new file mode 100644 index 0000000000..fedad4af11 --- /dev/null +++ b/packages/app/src/components/charts/AnnotationTooltip.tsx @@ -0,0 +1,90 @@ +import type { MutableRefObject } from 'react'; +import { createPortal } from 'react-dom'; +import { Group, Stack, Text } from '@mantine/core'; + +import type { HoveredAnnotation } from './AnnotationHitLayer'; +import { + ChartTooltipContainer, + ChartTooltipHeader, + toViewportPoint, + useChartTooltipZIndex, +} from './ChartTooltip'; + +/** Gap below the marker so the tooltip does not sit on top of its own label. */ +const TOOLTIP_OFFSET_PX = 6; + +/** + * Hover tooltip for a release marker: one row per release in the hovered + * cluster, each naming the service it belongs to. + * + * The service name is the whole point. On the chart the only carrier of that is + * the marker's colour, which stops being resolvable once the legend overflows + * past `MAX_LEGEND_ITEMS` and the matching entry hides behind "+N more". + * + * Portaled to the body and positioned fixed, matching the series tooltip: a + * dashboard tile clips its overflow, so an absolutely-positioned tooltip gets + * cut off at the tile edge. + */ +export function AnnotationTooltip({ + hovered, + containerRef, +}: { + hovered: HoveredAnnotation; + containerRef: MutableRefObject; +}) { + const zIndex = useChartTooltipZIndex(); + const members = hovered.annotation.members ?? [hovered.annotation]; + + // eslint-disable-next-line react-hooks/refs + const containerRect = containerRef.current?.getBoundingClientRect(); + if (containerRect == null || typeof document === 'undefined') { + return null; + } + const anchor = toViewportPoint(containerRect, hovered.point); + + return createPortal( +
+ } + > + + {members.map((member, i) => ( + +
+ {member.group != null && ( + + {member.group} + + )} + + {member.label} + + + ))} + + +
, + document.body, + ); +} diff --git a/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx b/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx index af5ff334d2..01c5210c10 100644 --- a/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx +++ b/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx @@ -5,6 +5,7 @@ import { type ChartAnnotation, getAnnotationElements, labelSeparationPx, + layoutAnnotations, MAX_ANNOTATION_MARKERS, mergeAnnotations, resolveAnnotationSeries, @@ -428,3 +429,62 @@ describe('resolveAnnotationSeries', () => { expect(input.color).toBe('#fallback'); }); }); + +describe('layoutAnnotations', () => { + const collapsing = { + domain: [0, 1000] as [number, number], + plotWidth: 1000, + }; + const release = (seconds: number, label: string, group: string) => ({ + time: seconds * 1000, + label, + group, + kind: 'release', + groupNoun: 'releases', + }); + + // The hover tooltip reads `members` so a cluster shown as "N releases" can + // still name every service inside it — which colour alone cannot do once the + // legend overflows. + it('gives a collapsed cluster the members it swallowed', () => { + const laidOut = layoutAnnotations( + [ + release(0, '1.0.0', 'checkout'), + release(10, '2.0.0', 'payments'), + release(600, '3.0.0', 'search'), + ], + collapsing, + ); + + const anchors = laidOut.filter(a => a.members != null); + expect(anchors).toHaveLength(2); + expect(anchors[0].label).toBe('2 releases'); + expect(anchors[0].members?.map(m => m.group)).toEqual([ + 'checkout', + 'payments', + ]); + // Members keep their own labels, so the tooltip shows real versions. + expect(anchors[0].members?.map(m => m.label)).toEqual(['1.0.0', '2.0.0']); + expect(anchors[1].members?.map(m => m.group)).toEqual(['search']); + }); + + it('gives a lone marker itself as its only member', () => { + const [only] = layoutAnnotations( + [release(500, '1.0.0', 'checkout')], + collapsing, + ); + + expect(only.members?.map(m => m.label)).toEqual(['1.0.0']); + }); + + // Before the plot is measured there is no collapsing, but hover still needs + // members to render a row. + it('populates members even when the geometry is unknown', () => { + const laidOut = layoutAnnotations( + [release(0, '1.0.0', 'checkout'), release(10, '2.0.0', 'payments')], + { domain: collapsing.domain }, + ); + + expect(laidOut.every(a => a.members?.length === 1)).toBe(true); + }); +}); diff --git a/packages/app/src/components/charts/chartAnnotations.tsx b/packages/app/src/components/charts/chartAnnotations.tsx index 934de4c8e8..67edbf9165 100644 --- a/packages/app/src/components/charts/chartAnnotations.tsx +++ b/packages/app/src/components/charts/chartAnnotations.tsx @@ -73,10 +73,16 @@ const STROKE_OPACITY = 0.9; const MUTED_STROKE_OPACITY = 0.35; /** A marker resolved to its clamped x position, in unix seconds. */ -type PositionedAnnotation = ChartAnnotation & { +export type PositionedAnnotation = ChartAnnotation & { x: number; /** Label suppressed — a neighbour carries the group label for this cluster. */ muted?: boolean; + /** + * Every marker in this one's cluster, itself included, in time order. Only + * set on the labelled anchor. Hover surfaces read this so a cluster shown as + * "3 releases" can name all three. + */ + members?: PositionedAnnotation[]; }; /** @@ -213,8 +219,9 @@ function collapseLabels( } const size = end - start; + const members = sorted.slice(start, end); if (size === 1) { - collapsed.push(anchor); + collapsed.push({ ...anchor, members }); } else { // A cluster covering several series can't wear one of their colors // without claiming the others' events as that series'. Go neutral so @@ -227,6 +234,7 @@ function collapseLabels( ...anchor, label: `${size} ${noun}`, color: spansSeries ? MIXED_SERIES_COLOR : anchor.color, + members, }); } for (let i = start + 1; i < end; i++) { @@ -256,10 +264,10 @@ function collapseLabels( * Generic over source — feature hooks map their events to `ChartAnnotation[]`. * Capped at `MAX_ANNOTATION_MARKERS` to protect against pathological inputs. */ -export function getAnnotationElements( +export function layoutAnnotations( annotations: ChartAnnotation[], opts: { domain: [number, number]; plotWidth?: number }, -): ReactElement[] { +): PositionedAnnotation[] { const [minX, maxX] = opts.domain; const positioned = positionAnnotations(annotations, [minX, maxX]); @@ -272,9 +280,24 @@ export function getAnnotationElements( const laidOut = plotWidth > 0 && spanSeconds > 0 ? collapseLabels(positioned, plotWidth / spanSeconds) - : positioned; + : positioned.map(annotation => ({ + ...annotation, + members: [annotation], + })); + + return laidOut.slice(0, MAX_ANNOTATION_MARKERS); +} - return laidOut.slice(0, MAX_ANNOTATION_MARKERS).map((annotation, i) => { +/** + * Renders the laid-out markers as dashed vertical reference lines. Takes the + * output of `layoutAnnotations` so the hover hit layer can position itself + * against exactly the same geometry. + */ +export function getAnnotationElements( + annotations: ChartAnnotation[], + opts: { domain: [number, number]; plotWidth?: number }, +): ReactElement[] { + return layoutAnnotations(annotations, opts).map((annotation, i) => { const color = annotation.color ?? 'var(--color-border)'; return ( ) : undefined diff --git a/packages/app/tests/e2e/features/release-markers.spec.ts b/packages/app/tests/e2e/features/release-markers.spec.ts index 8962793e84..01e1450fe7 100644 --- a/packages/app/tests/e2e/features/release-markers.spec.ts +++ b/packages/app/tests/e2e/features/release-markers.spec.ts @@ -144,6 +144,31 @@ test.describe('Release markers', { tag: ['@full-stack', '@dashboard'] }, () => { await expect(page).not.toHaveURL(/releaseMarkers=true/); }); + // Colour alone can't identify a service once the legend overflows its 4-item + // cap and the matching entry hides behind "+N more". Hover has to name the + // service outright. + test('names the service on hover', async ({ page }) => { + await seedReleases(); + + await dashboardPage.createNewDashboard(); + await dashboardPage.addTileWithSource( + 'Release marker hover', + DEFAULT_LOGS_SOURCE_NAME, + ); + await dashboardPage.setGlobalFilter(`ServiceName:"${RELEASE_SERVICE}"`); + await dashboardPage.toggleReleaseAnnotations(); + + await expect(dashboardPage.getAnnotationMarkers()).toHaveCount(2); + + const targets = dashboardPage.getAnnotationHitTargets(); + await expect(targets).toHaveCount(2); + await targets.first().hover(); + + // Service name and version together, so no colour lookup is needed. + await expect(page.getByText(RELEASE_SERVICE).last()).toBeVisible(); + await expect(page.getByText(OLD_VERSION).last()).toBeVisible(); + }); + // A marker only aids correlation if the reader can tie it to something on // the chart. An aggregate line over several services can't do that, so // rather than draw a wall of markers naming services with no visible line, diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index a4db8efa30..1012c5424c 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -1322,6 +1322,16 @@ export class DashboardPage { return this.getTile(tileIndex).locator('.recharts-reference-line'); } + /** + * Transparent hover targets over the annotation markers, one per cluster. + * Hovering one opens a tooltip naming the services that released. + */ + getAnnotationHitTargets(tileIndex = 0): Locator { + return this.getTile(tileIndex).locator( + '.recharts-annotation-hit-layer rect', + ); + } + /** * Text labels for the annotation markers. Recharts hoists reference-line * labels into a separate z-index layer, so they are NOT descendants of the From f05da93ffb2c62a8a872c60a26c2be1b09f6fb4a Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Wed, 12 Aug 2026 16:14:50 -0400 Subject: [PATCH 7/7] test(api): use a provisioned connection in the trace source fixture POST /sources now rejects a connection that does not exist for the team (added upstream alongside the same check on the external API). The suite registers a real Connection for MOCK_SOURCE.connection in its login helper, and every other test in this block inherits it by spreading MOCK_SOURCE. The trace fixture minted its own ObjectId instead, so it started 400ing once that check landed. --- packages/api/src/routers/api/__tests__/sources.int.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/api/src/routers/api/__tests__/sources.int.test.ts b/packages/api/src/routers/api/__tests__/sources.int.test.ts index 281c5055a2..85ad0ed75c 100644 --- a/packages/api/src/routers/api/__tests__/sources.int.test.ts +++ b/packages/api/src/routers/api/__tests__/sources.int.test.ts @@ -560,7 +560,9 @@ describe('sources router', () => { const traceSource: Omit, 'id'> = { kind: SourceKind.Trace, name: 'Test Trace Source', - connection: new Types.ObjectId().toString(), + // The suite provisions a real Connection for this id; POST now 400s on + // one that doesn't exist for the team. + connection: MOCK_SOURCE.connection, from: { databaseName: 'test_db', tableName: 'otel_traces' }, timestampValueExpression: 'Timestamp', defaultTableSelectExpression: 'Timestamp, SpanName',