Skip to content

Commit bb37520

Browse files
authored
feat: Auto-link correlated sources bidirectionally (#990)
Automatically maintain bidirectional relationships between correlated sources. When a user selects a correlated source (e.g., Log → Metric), the target source is updated to link back (Metric → Log) if not already linked. - Works for both new and existing sources - Preserves existing correlations (no overwriting) - Improves data consistency across the application
1 parent 4ce81d4 commit bb37520

File tree

3 files changed

+361
-4
lines changed

3 files changed

+361
-4
lines changed

.changeset/chilly-owls-know.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": minor
3+
---
4+
5+
Correlated source field links are bidirectional by default and no link exists.

packages/app/src/components/SourceForm.tsx

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
useCreateSource,
3939
useDeleteSource,
4040
useSource,
41+
useSources,
4142
useUpdateSource,
4243
} from '@/source';
4344

@@ -56,6 +57,39 @@ const OTEL_CLICKHOUSE_EXPRESSIONS = {
5657
resourceAttributesExpression: 'ResourceAttributes',
5758
};
5859

60+
const CORRELATION_FIELD_MAP: Record<
61+
SourceKind,
62+
Record<string, { targetKind: SourceKind; targetField: keyof TSource }[]>
63+
> = {
64+
[SourceKind.Log]: {
65+
metricSourceId: [
66+
{ targetKind: SourceKind.Metric, targetField: 'logSourceId' },
67+
],
68+
traceSourceId: [
69+
{ targetKind: SourceKind.Trace, targetField: 'logSourceId' },
70+
],
71+
},
72+
[SourceKind.Trace]: {
73+
logSourceId: [{ targetKind: SourceKind.Log, targetField: 'traceSourceId' }],
74+
sessionSourceId: [
75+
{ targetKind: SourceKind.Session, targetField: 'traceSourceId' },
76+
],
77+
metricSourceId: [
78+
{ targetKind: SourceKind.Metric, targetField: 'logSourceId' },
79+
],
80+
},
81+
[SourceKind.Session]: {
82+
traceSourceId: [
83+
{ targetKind: SourceKind.Trace, targetField: 'sessionSourceId' },
84+
],
85+
},
86+
[SourceKind.Metric]: {
87+
logSourceId: [
88+
{ targetKind: SourceKind.Log, targetField: 'metricSourceId' },
89+
],
90+
},
91+
};
92+
5993
function FormRow({
6094
label,
6195
children,
@@ -906,6 +940,62 @@ export function TableSourceForm({
906940
const updateSource = useUpdateSource();
907941
const deleteSource = useDeleteSource();
908942

943+
// Bidirectional source linking
944+
const { data: sources } = useSources();
945+
const currentSourceId = watch('id');
946+
947+
useEffect(() => {
948+
const { unsubscribe } = watch(async (_value, { name, type }) => {
949+
const value = _value as TSourceUnion;
950+
if (!currentSourceId || !sources || type !== 'change') return;
951+
952+
const correlationFields = CORRELATION_FIELD_MAP[kind];
953+
if (!correlationFields || !name || !(name in correlationFields)) return;
954+
955+
const fieldName = name as keyof TSourceUnion;
956+
const newTargetSourceId = value[fieldName] as string | undefined;
957+
const targetConfigs = correlationFields[fieldName];
958+
959+
for (const { targetKind, targetField } of targetConfigs) {
960+
// Find the previously linked source if any
961+
const previouslyLinkedSource = sources.find(
962+
s => s.kind === targetKind && s[targetField] === currentSourceId,
963+
);
964+
965+
// If there was a previously linked source and it's different from the new one, unlink it
966+
if (
967+
previouslyLinkedSource &&
968+
previouslyLinkedSource.id !== newTargetSourceId
969+
) {
970+
await updateSource.mutateAsync({
971+
source: {
972+
...previouslyLinkedSource,
973+
[targetField]: undefined,
974+
} as TSource,
975+
});
976+
}
977+
978+
// If a new source is selected, link it back
979+
if (newTargetSourceId) {
980+
const targetSource = sources.find(s => s.id === newTargetSourceId);
981+
if (targetSource && targetSource.kind === targetKind) {
982+
// Only update if the target field is empty to avoid overwriting existing correlations
983+
if (!targetSource[targetField]) {
984+
await updateSource.mutateAsync({
985+
source: {
986+
...targetSource,
987+
[targetField]: currentSourceId,
988+
} as TSource,
989+
});
990+
}
991+
}
992+
}
993+
}
994+
});
995+
996+
return () => unsubscribe();
997+
}, [watch, kind, currentSourceId, sources, updateSource]);
998+
909999
const sourceFormSchema = sourceSchemaWithout({ id: true });
9101000
const handleError = (error: z.ZodError<TSourceUnion>) => {
9111001
const errors = error.errors;
@@ -933,18 +1023,47 @@ export function TableSourceForm({
9331023

9341024
const _onCreate = useCallback(() => {
9351025
clearErrors();
936-
handleSubmit(data => {
1026+
handleSubmit(async data => {
9371027
const parseResult = sourceFormSchema.safeParse(data);
9381028
if (parseResult.error) {
9391029
handleError(parseResult.error);
9401030
return;
9411031
}
1032+
9421033
createSource.mutate(
9431034
// TODO: HDX-1768 get rid of this type assertion
9441035
{ source: data as TSource },
9451036
{
946-
onSuccess: data => {
947-
onCreate?.(data);
1037+
onSuccess: async newSource => {
1038+
// Handle bidirectional linking for new sources
1039+
const correlationFields = CORRELATION_FIELD_MAP[newSource.kind];
1040+
if (correlationFields && sources) {
1041+
for (const [fieldName, targetConfigs] of Object.entries(
1042+
correlationFields,
1043+
)) {
1044+
const targetSourceId = (newSource as any)[fieldName];
1045+
if (targetSourceId) {
1046+
for (const { targetKind, targetField } of targetConfigs) {
1047+
const targetSource = sources.find(
1048+
s => s.id === targetSourceId,
1049+
);
1050+
if (targetSource && targetSource.kind === targetKind) {
1051+
// Only update if the target field is empty to avoid overwriting existing correlations
1052+
if (!targetSource[targetField]) {
1053+
await updateSource.mutateAsync({
1054+
source: {
1055+
...targetSource,
1056+
[targetField]: newSource.id,
1057+
} as TSource,
1058+
});
1059+
}
1060+
}
1061+
}
1062+
}
1063+
}
1064+
}
1065+
1066+
onCreate?.(newSource);
9481067
notifications.show({
9491068
color: 'green',
9501069
message: 'Source created',
@@ -959,7 +1078,15 @@ export function TableSourceForm({
9591078
},
9601079
);
9611080
})();
962-
}, [handleSubmit, createSource, onCreate, kind, formState]);
1081+
}, [
1082+
handleSubmit,
1083+
createSource,
1084+
onCreate,
1085+
kind,
1086+
formState,
1087+
sources,
1088+
updateSource,
1089+
]);
9631090

9641091
const _onSave = useCallback(() => {
9651092
clearErrors();

0 commit comments

Comments
 (0)