Skip to content

Commit d201b71

Browse files
authored
feat(sources): configurable service version expression (#2893)
1 parent 4176b75 commit d201b71

11 files changed

Lines changed: 191 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@hyperdx/app': minor
3+
'@hyperdx/api': minor
4+
---
5+
6+
Add an optional `serviceVersionExpression` to log and trace sources, identifying
7+
the running release of a service. Defaults to the OpenTelemetry
8+
`service.version` resource attribute; teams whose release identifier lives
9+
elsewhere, such as a container image tag under GitOps, can point it there
10+
instead of changing instrumentation.

‎packages/api/openapi.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3821,6 +3821,12 @@
38213821
"nullable": true,
38223822
"example": "ServiceName"
38233823
},
3824+
"serviceVersionExpression": {
3825+
"type": "string",
3826+
"description": "Expression identifying the running release of a service. 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, '')).",
3827+
"nullable": true,
3828+
"example": "ResourceAttributes['service.version']"
3829+
},
38243830
"severityTextExpression": {
38253831
"type": "string",
38263832
"description": "Expression to extract the severity/log level text.",
@@ -4095,6 +4101,12 @@
40954101
"nullable": true,
40964102
"example": "ServiceName"
40974103
},
4104+
"serviceVersionExpression": {
4105+
"type": "string",
4106+
"description": "Expression identifying the running release of a service. 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, '')).",
4107+
"nullable": true,
4108+
"example": "ResourceAttributes['service.version']"
4109+
},
40984110
"resourceAttributesExpression": {
40994111
"type": "string",
41004112
"description": "Expression to extract resource-level attributes.",

‎packages/api/src/models/source.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export const LogSource = Source.discriminator<ILogSource>(
139139
new Schema<ILogSource>({
140140
defaultTableSelectExpression: String,
141141
serviceNameExpression: String,
142+
serviceVersionExpression: String,
142143
severityTextExpression: String,
143144
bodyExpression: String,
144145
eventAttributesExpression: String,
@@ -199,6 +200,7 @@ export const TraceSource = Source.discriminator<ITraceSource>(
199200
statusCodeExpression: String,
200201
statusMessageExpression: String,
201202
serviceNameExpression: String,
203+
serviceVersionExpression: String,
202204
resourceAttributesExpression: String,
203205
eventAttributesExpression: String,
204206
spanEventsValueExpression: String,

‎packages/api/src/routers/api/__tests__/sources.int.test.ts‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,112 @@ describe('sources router', () => {
526526
expect(updatedSource.severityTextExpression).toBe('SeverityText');
527527
});
528528

529+
// Regression guard: a field present in the Zod schema but missing from the
530+
// Mongoose discriminator is silently dropped on write, with no error. Only a
531+
// round-trip through the database catches that.
532+
describe('serviceVersionExpression', () => {
533+
const VERSION_EXPR = "ResourceAttributes['container.image.tag']";
534+
535+
/** The single persisted source, narrowed to a kind that carries the field. */
536+
const persisted = async (kind: SourceKind.Log | SourceKind.Trace) => {
537+
const [source] = await Source.find({}).lean();
538+
if (source?.kind !== kind) {
539+
throw new Error(`Expected a ${kind} source, got ${source?.kind}`);
540+
}
541+
return source;
542+
};
543+
544+
it('POST / - persists the expression on a log source', async () => {
545+
const { agent } = await getLoggedInAgent(server);
546+
547+
await agent
548+
.post('/sources')
549+
.send({ ...MOCK_SOURCE, serviceVersionExpression: VERSION_EXPR })
550+
.expect(200);
551+
552+
expect((await persisted(SourceKind.Log)).serviceVersionExpression).toBe(
553+
VERSION_EXPR,
554+
);
555+
});
556+
557+
it('POST / - persists the expression on a trace source', async () => {
558+
const { agent } = await getLoggedInAgent(server);
559+
560+
const traceSource: Omit<Extract<TSource, { kind: 'trace' }>, 'id'> = {
561+
kind: SourceKind.Trace,
562+
name: 'Test Trace Source',
563+
// The suite provisions a real Connection for this id; POST now 400s on
564+
// one that doesn't exist for the team.
565+
connection: MOCK_SOURCE.connection,
566+
from: { databaseName: 'test_db', tableName: 'otel_traces' },
567+
timestampValueExpression: 'Timestamp',
568+
defaultTableSelectExpression: 'Timestamp, SpanName',
569+
durationExpression: 'Duration',
570+
durationPrecision: 9,
571+
traceIdExpression: 'TraceId',
572+
spanIdExpression: 'SpanId',
573+
parentSpanIdExpression: 'ParentSpanId',
574+
spanNameExpression: 'SpanName',
575+
spanKindExpression: 'SpanKind',
576+
serviceVersionExpression: VERSION_EXPR,
577+
};
578+
579+
await agent.post('/sources').send(traceSource).expect(200);
580+
581+
expect((await persisted(SourceKind.Trace)).serviceVersionExpression).toBe(
582+
VERSION_EXPR,
583+
);
584+
});
585+
586+
it('GET / - returns the expression', async () => {
587+
const { agent } = await getLoggedInAgent(server);
588+
await agent
589+
.post('/sources')
590+
.send({ ...MOCK_SOURCE, serviceVersionExpression: VERSION_EXPR })
591+
.expect(200);
592+
593+
const response = await agent.get('/sources').expect(200);
594+
595+
expect(response.body[0]).toMatchObject({
596+
serviceVersionExpression: VERSION_EXPR,
597+
});
598+
});
599+
600+
it('PUT /:id - updates the expression, and clears it when omitted', async () => {
601+
const { agent } = await getLoggedInAgent(server);
602+
const created = await agent
603+
.post('/sources')
604+
.send({ ...MOCK_SOURCE, serviceVersionExpression: VERSION_EXPR })
605+
.expect(200);
606+
const id = created.body.id;
607+
608+
await agent
609+
.put(`/sources/${id}`)
610+
.send({ ...MOCK_SOURCE, id, serviceVersionExpression: 'Version' })
611+
.expect(200);
612+
expect((await persisted(SourceKind.Log)).serviceVersionExpression).toBe(
613+
'Version',
614+
);
615+
616+
await agent
617+
.put(`/sources/${id}`)
618+
.send({ ...MOCK_SOURCE, id })
619+
.expect(200);
620+
expect(
621+
(await persisted(SourceKind.Log)).serviceVersionExpression,
622+
).toBeUndefined();
623+
});
624+
625+
it('POST / - is optional', async () => {
626+
const { agent } = await getLoggedInAgent(server);
627+
await agent.post('/sources').send(MOCK_SOURCE).expect(200);
628+
629+
expect(
630+
(await persisted(SourceKind.Log)).serviceVersionExpression,
631+
).toBeUndefined();
632+
});
633+
});
634+
529635
describe('seriesTable', () => {
530636
it('POST / - creates a metric source with seriesTable and it round-trips', async () => {
531637
const { agent } = await getLoggedInAgent(server);

‎packages/api/src/routers/external-api/v2/sources.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,11 @@ function formatExternalSource(source: SourceDocument) {
398398
* description: Expression to extract the service name from log rows.
399399
* nullable: true
400400
* example: ServiceName
401+
* serviceVersionExpression:
402+
* type: string
403+
* description: Expression identifying the running release of a service. 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, '')).
404+
* nullable: true
405+
* example: ResourceAttributes['service.version']
401406
* severityTextExpression:
402407
* type: string
403408
* description: Expression to extract the severity/log level text.
@@ -616,6 +621,11 @@ function formatExternalSource(source: SourceDocument) {
616621
* description: Expression to extract the service name from trace rows.
617622
* nullable: true
618623
* example: ServiceName
624+
* serviceVersionExpression:
625+
* type: string
626+
* description: Expression identifying the running release of a service. 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, '')).
627+
* nullable: true
628+
* example: ResourceAttributes['service.version']
619629
* resourceAttributesExpression:
620630
* type: string
621631
* description: Expression to extract resource-level attributes.

‎packages/app/src/components/Sources/SourceForm/LogTableModelForm.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useBrandDisplayName } from '@/theme/ThemeProvider';
1212
import {
1313
DEFAULT_DATABASE,
1414
KNOWN_COLUMNS_EXPRESSION_HELP_TEXT,
15+
SERVICE_VERSION_EXPRESSION_HELP_TEXT,
1516
} from './constants';
1617
import { ExpressionFormRow } from './ExpressionFormRow';
1718
import { FormRow } from './FormRow';
@@ -118,6 +119,17 @@ export function LogTableModelForm(props: TableModelProps) {
118119
sourceKind={SourceKind.Log}
119120
tableConnection={tableConnection}
120121
/>
122+
<ExpressionFormRow
123+
control={control}
124+
setValue={setValue}
125+
name="serviceVersionExpression"
126+
label="Service Version Expression"
127+
placeholder="ResourceAttributes['service.version']"
128+
helpText={SERVICE_VERSION_EXPRESSION_HELP_TEXT}
129+
columns={columns}
130+
sourceKind={SourceKind.Log}
131+
tableConnection={tableConnection}
132+
/>
121133
<ExpressionFormRow
122134
control={control}
123135
setValue={setValue}

‎packages/app/src/components/Sources/SourceForm/TraceTableModelForm.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useBrandDisplayName } from '@/theme/ThemeProvider';
1010
import {
1111
DEFAULT_DATABASE,
1212
KNOWN_COLUMNS_EXPRESSION_HELP_TEXT,
13+
SERVICE_VERSION_EXPRESSION_HELP_TEXT,
1314
} from './constants';
1415
import { ExpressionFormRow } from './ExpressionFormRow';
1516
import { FormRow } from './FormRow';
@@ -274,6 +275,17 @@ export function TraceTableModelForm(props: TableModelProps) {
274275
sourceKind={SourceKind.Trace}
275276
tableConnection={tableConnection}
276277
/>
278+
<ExpressionFormRow
279+
control={control}
280+
setValue={setValue}
281+
name="serviceVersionExpression"
282+
label="Service Version Expression"
283+
placeholder="ResourceAttributes['service.version']"
284+
helpText={SERVICE_VERSION_EXPRESSION_HELP_TEXT}
285+
columns={columns}
286+
sourceKind={SourceKind.Trace}
287+
tableConnection={tableConnection}
288+
/>
277289
<ExpressionFormRow
278290
control={control}
279291
setValue={setValue}

‎packages/app/src/components/Sources/SourceForm/constants.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ export const DEFAULT_DATABASE = 'default';
66
export const KNOWN_COLUMNS_EXPRESSION_HELP_TEXT =
77
'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.';
88

9+
export const SERVICE_VERSION_EXPRESSION_HELP_TEXT =
10+
"Identifies the running release of a service. 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'], '')).";
11+
912
// Placeholder written into from.databaseName / from.tableName when the
1013
// selected connection is Prometheus-only.
1114
export const PROMETHEUS_PLACEHOLDER = 'prometheus';

‎packages/app/src/utils/sourceFieldSuggestions.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type SourceFieldKind =
1010
| 'bodyExpression'
1111
| 'implicitColumnExpression'
1212
| 'serviceNameExpression'
13+
| 'serviceVersionExpression'
1314
| 'severityTextExpression'
1415
| 'eventAttributesExpression'
1516
| 'resourceAttributesExpression'
@@ -98,6 +99,18 @@ const FIELD_RULES: Record<SourceFieldKind, FieldRule> = {
9899
typeMatches: isStringy,
99100
recommendStrategy: 'name-only',
100101
},
102+
// Usually a map lookup rather than a column, so these only hit on schemas
103+
// that materialize the version out. No match just leaves the placeholder.
104+
serviceVersionExpression: {
105+
canonicalNames: [
106+
'ServiceVersion',
107+
'service.version',
108+
'service_version',
109+
'version',
110+
],
111+
typeMatches: isStringy,
112+
recommendStrategy: 'name-only',
113+
},
101114
severityTextExpression: {
102115
canonicalNames: [
103116
'SeverityText',

‎packages/app/tests/e2e/features/sources.spec.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const COMMON_FIELDS = [
2222
const LOG_FIELDS = [
2323
...COMMON_FIELDS,
2424
'Service Name Expression',
25+
'Service Version Expression',
2526
'Log Level Expression',
2627
'Body Expression',
2728
'Log Attributes Expression',
@@ -50,6 +51,7 @@ const TRACE_FIELDS = [
5051
'Status Code Expression',
5152
'Status Message Expression',
5253
'Service Name Expression',
54+
'Service Version Expression',
5355
'Resource Attributes Expression',
5456
'Event Attributes Expression',
5557
'Span Events Expression',

0 commit comments

Comments
 (0)