Skip to content
Closed
14 changes: 14 additions & 0 deletions .changeset/release-markers-on-dashboard-charts.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3821,6 +3821,12 @@
"nullable": true,
"example": "ServiceName"
},
"serviceVersionExpression": {
"type": "string",
"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": {
"type": "string",
"description": "Expression to extract the severity/log level text.",
Expand Down Expand Up @@ -4095,6 +4101,12 @@
"nullable": true,
"example": "ServiceName"
},
"serviceVersionExpression": {
"type": "string",
"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": {
"type": "string",
"description": "Expression to extract resource-level attributes.",
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/models/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export const LogSource = Source.discriminator<ILogSource>(
new Schema<ILogSource>({
defaultTableSelectExpression: String,
serviceNameExpression: String,
serviceVersionExpression: String,
severityTextExpression: String,
bodyExpression: String,
eventAttributesExpression: String,
Expand Down Expand Up @@ -199,6 +200,7 @@ export const TraceSource = Source.discriminator<ITraceSource>(
statusCodeExpression: String,
statusMessageExpression: String,
serviceNameExpression: String,
serviceVersionExpression: String,
resourceAttributesExpression: String,
eventAttributesExpression: String,
spanEventsValueExpression: String,
Expand Down
106 changes: 106 additions & 0 deletions packages/api/src/routers/api/__tests__/sources.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,112 @@ 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<Extract<TSource, { kind: 'trace' }>, 'id'> = {
kind: SourceKind.Trace,
name: 'Test Trace Source',
// 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',
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);
Expand Down
10 changes: 10 additions & 0 deletions packages/api/src/routers/external-api/v2/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,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 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:
* type: string
* description: Expression to extract the severity/log level text.
Expand Down Expand Up @@ -616,6 +621,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 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:
* type: string
* description: Expression to extract resource-level attributes.
Expand Down
27 changes: 27 additions & 0 deletions packages/app/src/ChartUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,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 (release 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 :(
Expand Down
70 changes: 59 additions & 11 deletions packages/app/src/DBDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import {
IconPlus,
IconPresentation,
IconRefresh,
IconRocket,
IconSearch,
IconSquaresDiagonal,
IconTags,
Expand All @@ -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';
Expand Down Expand Up @@ -149,6 +151,7 @@ import useDashboardContainers, {
TabDeleteAction,
} from '@/hooks/useDashboardContainers';
import { useDashboardKioskMode } from '@/hooks/useDashboardKioskMode';
import { useReleaseAnnotations } from '@/hooks/useReleaseAnnotations';
import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning';

import ChartContainer, {
Expand Down Expand Up @@ -387,6 +390,7 @@ const Tile = forwardRef(
onTimeRangeSelect,
filters,
showAlertAnnotations,
showReleaseAnnotations,
isLive,
readOnly,

Expand Down Expand Up @@ -416,6 +420,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.
showReleaseAnnotations?: boolean;
isLive?: boolean;
readOnly?: boolean;

Expand Down Expand Up @@ -657,6 +663,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 releaseAnnotations = useReleaseAnnotations(
isFullscreen ? fullscreenDateRange : dateRange,
showReleaseAnnotations,
{
source,
where: isBuilderSavedChartConfig(chart.config)
? chart.config.where
: undefined,
whereLanguage: isBuilderSavedChartConfig(chart.config)
? chart.config.whereLanguage
: undefined,
filters,
},
);

const annotations = useMemo(
() => mergeAnnotations(alertAnnotations, releaseAnnotations),
[alertAnnotations, releaseAnnotations],
);

const filterWarning = useMemo(() => {
const doFiltersExist = !!filters?.filter(
f => (f.type === 'lucene' || f.type === 'sql') && f.condition.trim(),
Expand Down Expand Up @@ -1145,7 +1175,7 @@ const Tile = forwardRef(
showDisplaySwitcher={!readOnly}
enabled={chartEnabled}
config={effectiveQueriedConfig}
annotations={alertAnnotations}
annotations={annotations}
onTimeRangeSelect={
readOnly
? undefined
Expand Down Expand Up @@ -1357,7 +1387,7 @@ const Tile = forwardRef(
isSourceMissing,
isSourceUnset,
hasBeenVisible,
alertAnnotations,
annotations,
isLive,
readOnly,
],
Expand Down Expand Up @@ -1770,6 +1800,11 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
'alertAnnotations',
parseAsBoolean.withDefault(false),
);
// Same for release markers, derived from `service.version` changes.
const [showReleaseAnnotations, setShowReleaseAnnotations] = useQueryState(
'releaseMarkers',
parseAsBoolean.withDefault(false),
);

// Track if we've initialized query for this dashboard
const initializedDashboardRef = useRef<string>(undefined);
Expand Down Expand Up @@ -2236,6 +2271,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
]}
onTimeRangeSelect={onTimeRangeSelect}
showAlertAnnotations={showAlertAnnotations}
showReleaseAnnotations={showReleaseAnnotations}
isHighlighted={highlightedTileId === chart.id}
onUpdateChart={
isKioskMode
Expand Down Expand Up @@ -2333,6 +2369,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
whereLanguage,
onTimeRangeSelect,
showAlertAnnotations,
showReleaseAnnotations,
getFilterQueriesForSource,
moveTargetContainers,
handleMoveTileToGroup,
Expand Down Expand Up @@ -2775,15 +2812,26 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
{(hasTiles || containers.length > 0) && (
<>
{hasTiles && (
<Menu.Item
leftSection={<IconTimelineEvent size={16} />}
onClick={() => setShowAlertAnnotations(v => !v)}
data-testid="toggle-alert-annotations-menu-item"
>
{showAlertAnnotations
? 'Hide alert annotations'
: 'Show alert annotations'}
</Menu.Item>
<>
<Menu.Item
leftSection={<IconTimelineEvent size={16} />}
onClick={() => setShowAlertAnnotations(v => !v)}
data-testid="toggle-alert-annotations-menu-item"
>
{showAlertAnnotations
? 'Hide alert annotations'
: 'Show alert annotations'}
</Menu.Item>
<Menu.Item
leftSection={<IconRocket size={16} />}
onClick={() => setShowReleaseAnnotations(v => !v)}
data-testid="toggle-release-annotations-menu-item"
>
{showReleaseAnnotations
? 'Hide release markers'
: 'Show release markers'}
</Menu.Item>
</>
)}
{containers.length > 0 && (
<>
Expand Down
Loading
Loading