Skip to content

Commit 497fba8

Browse files
authored
feat(metrics): Source support for gauge metrics (#598)
Adds the ability to configure a gauge metric database source. Like logs and traces, it can infer the field configurations from a table if the column names match the otel collector schema. <img width="804" alt="Screenshot 2025-02-05 at 5 40 29 PM" src="https://github.com/user-attachments/assets/85ceeafd-ec6b-48ca-bce5-4ec603815000" /> <img width="796" alt="Screenshot 2025-02-05 at 5 40 58 PM" src="https://github.com/user-attachments/assets/c20969f1-18ee-466c-b287-707029e08ba1" /> <img width="1448" alt="Screenshot 2025-02-04 at 5 18 53 PM" src="https://github.com/user-attachments/assets/b03dc416-2ac9-4b37-8d30-c69d999dfe3f" /> Ref: HDX-1338
1 parent 3ba934d commit 497fba8

File tree

5 files changed

+295
-20
lines changed

5 files changed

+295
-20
lines changed

.changeset/gentle-coins-dress.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/common-utils": minor
3+
---
4+
5+
Added support for querying gauge metric table with default detection for OTEL collector schema.

packages/api/src/models/source.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { TSource } from '@hyperdx/common-utils/dist/types';
1+
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
22
import mongoose, { Schema } from 'mongoose';
33

44
type ObjectId = mongoose.Types.ObjectId;
@@ -14,7 +14,7 @@ export const Source = mongoose.model<ISource>(
1414
{
1515
kind: {
1616
type: String,
17-
enum: ['log', 'trace'],
17+
enum: Object.values(SourceKind),
1818
required: true,
1919
},
2020
team: {
@@ -58,6 +58,12 @@ export const Source = mongoose.model<ISource>(
5858
spanKindExpression: String,
5959
statusCodeExpression: String,
6060
statusMessageExpression: String,
61+
62+
metricDiscriminator: String,
63+
metricNameExpression: String,
64+
metricUnitExpression: String,
65+
flagsExpression: String,
66+
valueExpression: String,
6167
},
6268
{
6369
toJSON: { virtuals: true },

packages/app/src/components/SourceForm.tsx

+246-18
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
Menu,
1818
Radio,
1919
SegmentedControl,
20+
Select,
2021
Slider,
2122
Stack,
2223
Switch,
@@ -44,6 +45,8 @@ import { DBTableSelectControlled } from './DBTableSelect';
4445
import { InputControlled } from './InputControlled';
4546
import { SQLInlineEditorControlled } from './SQLInlineEditor';
4647

48+
const DEFAULT_DATABASE = 'default';
49+
4750
function FormRow({
4851
label,
4952
children,
@@ -112,7 +115,6 @@ export function LogTableModelForm({
112115
watch: UseFormWatch<TSource>;
113116
setValue: UseFormSetValue<TSource>;
114117
}) {
115-
const DEFAULT_DATABASE = 'default';
116118
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
117119
const tableName = watch(`from.tableName`);
118120
const connectionId = watch(`connection`);
@@ -319,7 +321,6 @@ export function TraceTableModelForm({
319321
watch: UseFormWatch<TSource>;
320322
setValue: UseFormSetValue<TSource>;
321323
}) {
322-
const DEFAULT_DATABASE = 'default';
323324
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
324325
const tableName = watch(`from.tableName`);
325326
const connectionId = watch(`connection`);
@@ -536,6 +537,242 @@ export function TraceTableModelForm({
536537
);
537538
}
538539

540+
export function MetricTableModelForm({
541+
control,
542+
watch,
543+
setValue,
544+
}: {
545+
control: Control<TSource>;
546+
watch: UseFormWatch<TSource>;
547+
setValue: UseFormSetValue<TSource>;
548+
}) {
549+
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
550+
const tableName = watch(`from.tableName`);
551+
const connectionId = watch(`connection`);
552+
553+
const [showOptionalFields, setShowOptionalFields] = useState(false);
554+
555+
return (
556+
<>
557+
<Stack gap="sm">
558+
<FormRow label={'Server Connection'}>
559+
<ConnectionSelectControlled control={control} name={`connection`} />
560+
</FormRow>
561+
<FormRow label={'Database'}>
562+
<DatabaseSelectControlled
563+
connectionId={connectionId}
564+
control={control}
565+
name={`from.databaseName`}
566+
/>
567+
</FormRow>
568+
<Divider />
569+
<FormRow label={'Metric Type'}>
570+
<Select
571+
data={[{ value: 'gauge', label: 'Gauge' }]}
572+
defaultValue="gauge"
573+
placeholder="Select metric type"
574+
allowDeselect={false}
575+
/>
576+
</FormRow>
577+
<FormRow label={'Table'}>
578+
<DBTableSelectControlled
579+
connectionId={connectionId}
580+
database={databaseName}
581+
control={control}
582+
name={`from.tableName`}
583+
rules={{ required: 'Table is required' }}
584+
/>
585+
</FormRow>
586+
<FormRow label={'Timestamp Column'}>
587+
<SQLInlineEditorControlled
588+
connectionId={connectionId}
589+
database={databaseName}
590+
table={tableName}
591+
control={control}
592+
name="timestampValueExpression"
593+
placeholder="TimeUnix"
594+
disableKeywordAutocomplete
595+
/>
596+
</FormRow>
597+
<FormRow
598+
label={'Default Select'}
599+
helpText="Default columns selected in search results (this can be customized per search later)"
600+
>
601+
<SQLInlineEditorControlled
602+
database={databaseName}
603+
table={tableName}
604+
control={control}
605+
name="defaultTableSelectExpression"
606+
placeholder="TimeUnix, MetricName, Value, ServiceName, Attributes"
607+
connectionId={connectionId}
608+
/>
609+
</FormRow>
610+
<FormRow
611+
label={'Metric Name Column'}
612+
helpText="Column containing the name of the metric being measured"
613+
>
614+
<SQLInlineEditorControlled
615+
connectionId={connectionId}
616+
database={databaseName}
617+
table={tableName}
618+
control={control}
619+
name="metricNameExpression"
620+
placeholder="MetricName"
621+
/>
622+
</FormRow>
623+
<FormRow label={'Gauge Value Column'}>
624+
<SQLInlineEditorControlled
625+
connectionId={connectionId}
626+
database={databaseName}
627+
table={tableName}
628+
control={control}
629+
name="valueExpression"
630+
placeholder="Value"
631+
/>
632+
</FormRow>
633+
<Box>
634+
{!showOptionalFields && (
635+
<Anchor
636+
underline="always"
637+
onClick={() => setShowOptionalFields(true)}
638+
size="xs"
639+
c="gray.4"
640+
>
641+
<Text me="sm" span>
642+
<i className="bi bi-gear" />
643+
</Text>
644+
Configure Optional Fields
645+
</Anchor>
646+
)}
647+
{showOptionalFields && (
648+
<Button
649+
onClick={() => setShowOptionalFields(false)}
650+
size="xs"
651+
variant="subtle"
652+
color="gray.4"
653+
>
654+
Hide Optional Fields
655+
</Button>
656+
)}
657+
</Box>
658+
</Stack>
659+
<Stack
660+
gap="sm"
661+
style={{
662+
display: showOptionalFields ? 'flex' : 'none',
663+
}}
664+
>
665+
<Divider />
666+
<FormRow
667+
label={'Service Name Column'}
668+
helpText="Column containing the service name associated with the metric"
669+
>
670+
<SQLInlineEditorControlled
671+
connectionId={connectionId}
672+
database={databaseName}
673+
table={tableName}
674+
control={control}
675+
name="serviceNameExpression"
676+
placeholder="ServiceName"
677+
/>
678+
</FormRow>
679+
<FormRow
680+
label={'Resource Attributes Column'}
681+
helpText="Column containing resource attributes/tags associated with the metric"
682+
>
683+
<SQLInlineEditorControlled
684+
connectionId={connectionId}
685+
database={databaseName}
686+
table={tableName}
687+
control={control}
688+
name="resourceAttributesExpression"
689+
placeholder="ResourceAttributes"
690+
/>
691+
</FormRow>
692+
<FormRow
693+
label={'Metric Unit Column'}
694+
helpText="Column containing the unit of measurement for the metric"
695+
>
696+
<SQLInlineEditorControlled
697+
connectionId={connectionId}
698+
database={databaseName}
699+
table={tableName}
700+
control={control}
701+
name="metricUnitExpression"
702+
placeholder="MetricUnit"
703+
/>
704+
</FormRow>
705+
<FormRow
706+
label={'Metric Flag Column'}
707+
helpText="Column containing flags or markers associated with the metric"
708+
>
709+
<SQLInlineEditorControlled
710+
connectionId={connectionId}
711+
database={databaseName}
712+
table={tableName}
713+
control={control}
714+
name="flagsExpression"
715+
placeholder="Flags"
716+
/>
717+
</FormRow>
718+
<FormRow
719+
label={'Event Attributes Expression'}
720+
helpText="Column containing additional attributes/dimensions for the metric"
721+
>
722+
<SQLInlineEditorControlled
723+
connectionId={connectionId}
724+
database={databaseName}
725+
table={tableName}
726+
control={control}
727+
name="eventAttributesExpression"
728+
placeholder="Attributes"
729+
/>
730+
</FormRow>
731+
</Stack>
732+
</>
733+
);
734+
}
735+
736+
function TableModelForm({
737+
control,
738+
watch,
739+
setValue,
740+
kind,
741+
}: {
742+
control: Control<TSource>;
743+
watch: UseFormWatch<TSource>;
744+
setValue: UseFormSetValue<TSource>;
745+
kind: SourceKind;
746+
}) {
747+
switch (kind) {
748+
case SourceKind.Log:
749+
case SourceKind.Session:
750+
return (
751+
<LogTableModelForm
752+
control={control}
753+
watch={watch}
754+
setValue={setValue}
755+
/>
756+
);
757+
case SourceKind.Trace:
758+
return (
759+
<TraceTableModelForm
760+
control={control}
761+
watch={watch}
762+
setValue={setValue}
763+
/>
764+
);
765+
case SourceKind.Metric:
766+
return (
767+
<MetricTableModelForm
768+
control={control}
769+
watch={watch}
770+
setValue={setValue}
771+
/>
772+
);
773+
}
774+
}
775+
539776
export function TableSourceForm({
540777
sourceId,
541778
onSave,
@@ -737,6 +974,7 @@ export function TableSourceForm({
737974
<Group>
738975
<Radio value={SourceKind.Log} label="Log" />
739976
<Radio value={SourceKind.Trace} label="Trace" />
977+
<Radio value={SourceKind.Metric} label="Metric" />
740978
{IS_SESSIONS_ENABLED && (
741979
<Radio value={SourceKind.Session} label="Session" />
742980
)}
@@ -746,22 +984,12 @@ export function TableSourceForm({
746984
/>
747985
</FormRow>
748986
</Stack>
749-
{kind === SourceKind.Trace ? (
750-
<TraceTableModelForm
751-
// @ts-ignore
752-
control={control}
753-
// @ts-ignore
754-
watch={watch}
755-
// @ts-ignore
756-
setValue={setValue}
757-
/>
758-
) : (
759-
<LogTableModelForm
760-
control={control}
761-
watch={watch}
762-
setValue={setValue}
763-
/>
764-
)}
987+
<TableModelForm
988+
control={control}
989+
watch={watch}
990+
setValue={setValue}
991+
kind={kind}
992+
/>
765993
</div>
766994
);
767995
}

packages/app/src/source.ts

+26
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,18 @@ export async function inferTableSourceConfig({
242242
'StatusMessage',
243243
]);
244244

245+
const isOtelMetricSchema = hasAllColumns(columns, [
246+
'TimeUnix',
247+
'MetricName',
248+
'MetricDescription',
249+
'MetricUnit',
250+
'Value',
251+
'Flags',
252+
'ResourceAttributes',
253+
'Attributes',
254+
'ResourceAttributes',
255+
]);
256+
245257
const timestampColumns = filterColumnMetaByType(columns, [JSDataType.Date]);
246258
const primaryKeyTimestampColumn = timestampColumns?.find(c =>
247259
keys.find(
@@ -297,6 +309,20 @@ export async function inferTableSourceConfig({
297309
statusMessageExpression: 'StatusMessage',
298310
}
299311
: {}),
312+
...(isOtelMetricSchema
313+
? {
314+
serviceNameExpression: 'ServiceName',
315+
timestampValueExpression: 'TimeUnix',
316+
defaultTableSelectExpression:
317+
'TimeUnix, ServiceName, MetricName, Value, Attributes',
318+
metricNameExpression: 'MetricName',
319+
metricUnitExpression: 'MetricUnit',
320+
flagsExpression: 'Flags',
321+
valueExpression: 'Value',
322+
eventAttributesExpression: 'Attributes',
323+
resourceAttributesExpression: 'ResourceAttributes',
324+
}
325+
: {}),
300326
};
301327
}
302328

0 commit comments

Comments
 (0)