hyperdx/packages/api/src/models/source.ts
Aaron Knudtson ce8506478d
fix: better source validation and refine required source fields (#1895)
## Summary

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

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

### How to test locally or on Vercel

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

### References

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

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

223 lines
6.2 KiB
TypeScript

import {
BaseSourceSchema,
LogSourceSchema,
MetricsDataType,
MetricSourceSchema,
QuerySettings,
SessionSourceSchema,
SourceKind,
TraceSourceSchema,
} from '@hyperdx/common-utils/dist/types';
import mongoose, { Schema } from 'mongoose';
import z from 'zod';
import { objectIdSchema } from '@/utils/zod';
// ISource is a discriminated union (inherits from TSource) with team added
// and connection widened to ObjectId | string for Mongoose.
// Omit and & distribute over the union, preserving the discriminated structure.
export const ISourceSchema = z.discriminatedUnion('kind', [
LogSourceSchema.omit({ connection: true }).extend({
team: objectIdSchema,
connection: objectIdSchema.or(z.string()),
}),
TraceSourceSchema.omit({ connection: true }).extend({
team: objectIdSchema,
connection: objectIdSchema.or(z.string()),
}),
SessionSourceSchema.omit({ connection: true }).extend({
team: objectIdSchema,
connection: objectIdSchema.or(z.string()),
}),
MetricSourceSchema.omit({ connection: true }).extend({
team: objectIdSchema,
connection: objectIdSchema.or(z.string()),
}),
]);
export type ISource = z.infer<typeof ISourceSchema>;
export type ISourceInput = z.input<typeof ISourceSchema>;
export type SourceDocument = mongoose.HydratedDocument<ISource>;
const maxLength =
(max: number) =>
<T>({ length }: Array<T>) =>
length <= max;
const QuerySetting = new Schema<QuerySettings[number]>(
{
setting: {
type: String,
required: true,
minlength: 1,
},
value: {
type: String,
required: true,
minlength: 1,
},
},
{ _id: false },
);
// --------------------------
// Base schema (common fields shared by all source kinds)
// --------------------------
type MongooseSourceBase = Omit<
z.infer<typeof BaseSourceSchema>,
'connection'
> & {
team: mongoose.Types.ObjectId;
connection: mongoose.Types.ObjectId;
};
const sourceBaseSchema = new Schema<MongooseSourceBase>(
{
team: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'Team',
},
connection: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'Connection',
},
name: String,
from: {
databaseName: String,
tableName: String,
},
timestampValueExpression: String,
querySettings: {
type: [QuerySetting],
validate: {
validator: maxLength(10),
message: '{PATH} exceeds the limit of 10',
},
},
},
{
discriminatorKey: 'kind',
toJSON: { virtuals: true },
timestamps: true,
},
);
// Model is typed with the base schema type internally. Consumers use ISource
// (the discriminated union) via the exported type and discriminator models.
const SourceModel = mongoose.model<MongooseSourceBase>(
'Source',
sourceBaseSchema,
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
export const Source = SourceModel as unknown as mongoose.Model<ISource>;
// --------------------------
// Log discriminator
// --------------------------
type ILogSource = Extract<ISource, { kind: SourceKind.Log }>;
export const LogSource = Source.discriminator<ILogSource>(
SourceKind.Log,
new Schema<ILogSource>({
defaultTableSelectExpression: String,
serviceNameExpression: String,
severityTextExpression: String,
bodyExpression: String,
eventAttributesExpression: String,
resourceAttributesExpression: String,
displayedTimestampValueExpression: String,
metricSourceId: String,
traceSourceId: String,
traceIdExpression: String,
spanIdExpression: String,
implicitColumnExpression: String,
uniqueRowIdExpression: String,
tableFilterExpression: String,
highlightedTraceAttributeExpressions: {
type: mongoose.Schema.Types.Array,
},
highlightedRowAttributeExpressions: {
type: mongoose.Schema.Types.Array,
},
materializedViews: {
type: mongoose.Schema.Types.Array,
},
orderByExpression: String,
}),
);
// --------------------------
// Trace discriminator
// --------------------------
type ITraceSource = Extract<ISource, { kind: SourceKind.Trace }>;
export const TraceSource = Source.discriminator<ITraceSource>(
SourceKind.Trace,
new Schema<ITraceSource>({
defaultTableSelectExpression: String,
durationExpression: String,
durationPrecision: Number,
traceIdExpression: String,
spanIdExpression: String,
parentSpanIdExpression: String,
spanNameExpression: String,
spanKindExpression: String,
logSourceId: String,
sessionSourceId: String,
metricSourceId: String,
statusCodeExpression: String,
statusMessageExpression: String,
serviceNameExpression: String,
resourceAttributesExpression: String,
eventAttributesExpression: String,
spanEventsValueExpression: String,
implicitColumnExpression: String,
displayedTimestampValueExpression: String,
highlightedTraceAttributeExpressions: {
type: mongoose.Schema.Types.Array,
},
highlightedRowAttributeExpressions: {
type: mongoose.Schema.Types.Array,
},
materializedViews: {
type: mongoose.Schema.Types.Array,
},
orderByExpression: String,
}),
);
// --------------------------
// Session discriminator
// --------------------------
type ISessionSource = Extract<ISource, { kind: SourceKind.Session }>;
export const SessionSource = Source.discriminator<ISessionSource>(
SourceKind.Session,
new Schema<Extract<ISource, { kind: SourceKind.Session }>>({
traceSourceId: String,
resourceAttributesExpression: String,
}),
);
// --------------------------
// Metric discriminator
// --------------------------
type IMetricSource = Extract<ISource, { kind: SourceKind.Metric }>;
export const MetricSource = Source.discriminator<IMetricSource>(
SourceKind.Metric,
new Schema<Extract<ISource, { kind: SourceKind.Metric }>>({
metricTables: {
type: {
[MetricsDataType.Gauge]: String,
[MetricsDataType.Histogram]: String,
[MetricsDataType.Sum]: String,
[MetricsDataType.Summary]: String,
[MetricsDataType.ExponentialHistogram]: String,
},
default: undefined,
},
resourceAttributesExpression: String,
serviceNameExpression: String,
logSourceId: String,
}),
);