Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/metric-table-autofill-on-database-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': patch
---

fix: Only auto-detect metric tables when the database selection changes
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { useWatch } from 'react-hook-form';
import { MetricsDataType } from '@hyperdx/common-utils/dist/types';
import { MetricsDataType, SourceKind } from '@hyperdx/common-utils/dist/types';
import { Stack, Text } from '@mantine/core';
import { notifications } from '@mantine/notifications';

Expand All @@ -10,7 +10,7 @@ import { DBTableSelectControlled } from '@/components/DBTableSelect';
import { SourceSelectControlled } from '@/components/SourceSelect';
import { useMetadataWithSettings } from '@/hooks/useMetadata';
import { useMetricsSeriesTableAvailability } from '@/hooks/useMetricsSeriesTableAvailability';
import { isValidMetricTable } from '@/source';
import { isValidMetricTable, useSource } from '@/source';
import { useBrandDisplayName } from '@/theme/ThemeProvider';
import {
matchMetricTables,
Expand All @@ -21,7 +21,11 @@ import { DEFAULT_DATABASE, OTEL_CLICKHOUSE_EXPRESSIONS } from './constants';
import { FormRow } from './FormRow';
import { TableModelProps } from './types';

export function MetricTableModelForm({ control, setValue }: TableModelProps) {
export function MetricTableModelForm({
control,
setValue,
sourceId,
}: TableModelProps) {
const brandName = useBrandDisplayName();
const { data: team } = api.useTeam();
const isMetricsSeriesTableEnabled = !!team?.isMetricsSeriesTableEnabled;
Expand Down Expand Up @@ -143,10 +147,31 @@ export function MetricTableModelForm({ control, setValue }: TableModelProps) {
{ enabled: !!databaseName && !!connectionId },
);

const { data: savedSource } = useSource({ id: sourceId });
const savedDatabaseName = savedSource?.from?.databaseName;
const wasSavedAsMetricSource = savedSource?.kind === SourceKind.Metric;
const isExistingSource = !!sourceId;

const lastAutofillKeyRef = useRef('');
const hasLoadedSavedSourceRef = useRef(false);

useEffect(() => {
const key = `${databaseName}:${connectionId}`;

if (isExistingSource && !hasLoadedSavedSourceRef.current) {
// Until the saved source lands in the form the watched values are still
// the new-source defaults, so nothing here reflects a user choice yet.
if (!savedSource) return;
if (savedDatabaseName && databaseName !== savedDatabaseName) return;
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
hasLoadedSavedSourceRef.current = true;
if (wasSavedAsMetricSource) {
// Treat the saved database as already handled — only a later switch to
// a different database triggers autofill.
lastAutofillKeyRef.current = key;
return;
}
}

if (key === lastAutofillKeyRef.current) return; // already ran for this db

const tableNames = tablesData?.data?.map((t: { name: string }) => t.name);
Expand Down Expand Up @@ -230,6 +255,10 @@ export function MetricTableModelForm({ control, setValue }: TableModelProps) {
connectionId,
metadata,
isMetricsSeriesTableEnabled,
isExistingSource,
savedSource,
savedDatabaseName,
wasSavedAsMetricSource,
]);

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,12 @@ export function TableSourceForm({
</Button>
</FormRow>
</Stack>
<TableModelForm control={control} setValue={setValue} kind={kind} />
<TableModelForm
control={control}
setValue={setValue}
kind={kind}
sourceId={sourceId}
/>
<Group justify="flex-end" mt="lg">
{onCancel && (
<Button variant="secondary" onClick={onCancel} size="xs">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ export function TableModelForm({
control,
setValue,
kind,
sourceId,
}: {
control: Control<TSource>;
setValue: UseFormSetValue<TSource>;
kind: SourceKind;
sourceId?: string;
}) {
switch (kind) {
case SourceKind.Log:
Expand All @@ -31,7 +33,13 @@ export function TableModelForm({
case SourceKind.Session:
return <SessionTableModelForm control={control} setValue={setValue} />;
case SourceKind.Metric:
return <MetricTableModelForm control={control} setValue={setValue} />;
return (
<MetricTableModelForm
control={control}
setValue={setValue}
sourceId={sourceId}
/>
);
case SourceKind.Promql:
return <PromqlTableModelForm control={control} setValue={setValue} />;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/components/Sources/SourceForm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ import { TSource } from '@hyperdx/common-utils/dist/types';
export interface TableModelProps {
control: Control<TSource>;
setValue: UseFormSetValue<TSource>;
// Set when editing an existing source
sourceId?: string;
}
261 changes: 261 additions & 0 deletions packages/app/src/components/__tests__/MetricTableModelForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
import { MantineProvider } from '@mantine/core';
import { render, waitFor } from '@testing-library/react';

import { MetricTableModelForm } from '@/components/Sources/SourceForm/MetricTableModelForm';

import '@testing-library/jest-dom';

// Table lists per database, used by the mocked `useTablesDirect`.
const TABLES_BY_DB: Record<string, string[]> = {
default: ['otel_metrics_gauge', 'otel_metrics_sum'],
otel_v2: ['otel_metrics_gauge', 'otel_metrics_sum', 'otel_metrics_histogram'],
other_db: [
'other_metrics_gauge',
'other_metrics_sum',
'other_metrics_histogram',
],
};

let savedSource: TSource | undefined;

jest.mock('@/api', () => ({
useTeam: () => ({ data: { isMetricsSeriesTableEnabled: false } }),
}));

jest.mock('@/clickhouse', () => ({
useTablesDirect: ({ database }: { database: string }) => ({
data: {
data: (TABLES_BY_DB[database] ?? []).map(name => ({ name })),
},
}),
}));

jest.mock('@/hooks/useMetadata', () => ({
useMetadataWithSettings: () => ({}),
}));

jest.mock('@/hooks/useMetricsSeriesTableAvailability', () => ({
useMetricsSeriesTableAvailability: () => ({
status: 'disabled',
missingSeriesHashTables: [],
}),
}));

jest.mock('@/source', () => ({
isValidMetricTable: jest.fn(() => Promise.resolve(true)),
useSource: ({ id }: { id?: string }) => ({
data: id ? savedSource : undefined,
}),
}));

jest.mock('@/theme/ThemeProvider', () => ({
useBrandDisplayName: () => 'HyperDX',
}));

jest.mock('@mantine/notifications', () => ({
notifications: { show: jest.fn() },
}));

jest.mock('@/components/DBTableSelect', () => ({
DBTableSelectControlled: ({ name }: { name: string }) => <div>{name}</div>,
}));

jest.mock('@/components/SourceSelect', () => ({
SourceSelectControlled: () => <div />,
}));

const mockSetValue = jest.fn();

// Rendered with a wrapper rather than the global `renderWithMantine` helper so
// that `rerender` keeps the provider (and the component instance) in place.
function renderHarness(ui: React.ReactElement) {
return render(ui, {
wrapper: ({ children }) => <MantineProvider>{children}</MantineProvider>,
});
}

function Harness({
databaseName,
metricTables,
sourceId,
}: {
databaseName: string;
metricTables?: Record<string, string>;
sourceId?: string;
}) {
const { control, setValue } = useForm<TSource>({
defaultValues: {
kind: SourceKind.Metric,
connection: 'conn-1',
from: { databaseName, tableName: '' },
metricTables,
},
});

// Mirrors how SourceForm's watched database changes — either from the user
// picking one, or from the saved source landing in the form.
useEffect(() => {
setValue('from.databaseName', databaseName);
}, [databaseName, setValue]);

return (
<MetricTableModelForm
control={control}
setValue={mockSetValue}
sourceId={sourceId}
/>
);
}

// The component also pushes the fixed OTEL expressions through setValue; only
// the table fields are autofill.
function autofilledTables() {
return mockSetValue.mock.calls
.filter(
([path]) =>
typeof path === 'string' &&
(path.startsWith('metricTables.') || path === 'seriesTable'),
)
.map(([path, value]) => [path, value]);
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const SAVED_SOURCE: TSource = {
id: 'metric-source-1',
kind: SourceKind.Metric,
name: 'Metrics',
connection: 'conn-1',
from: { databaseName: 'otel_v2', tableName: '' },
timestampValueExpression: 'TimeUnix',
metricTables: {
gauge: 'otel_metrics_gauge',
sum: 'otel_metrics_sum',
},
} as any;

describe('MetricTableModelForm metric table autofill', () => {
beforeEach(() => {
mockSetValue.mockClear();
savedSource = undefined;
});

it('autofills tables for a new source', async () => {
renderHarness(<Harness databaseName="default" />);

await waitFor(() => {
expect(autofilledTables()).toEqual(
expect.arrayContaining([
['metricTables.gauge', 'otel_metrics_gauge'],
['metricTables.sum', 'otel_metrics_sum'],
]),
);
});
});

it('autofills when a new source switches database', async () => {
const { rerender } = renderHarness(<Harness databaseName="default" />);

await waitFor(() => {
expect(autofilledTables().length).toBeGreaterThan(0);
});
mockSetValue.mockClear();

rerender(<Harness databaseName="otel_v2" />);

await waitFor(() => {
expect(autofilledTables()).toEqual(
expect.arrayContaining([
['metricTables.histogram', 'otel_metrics_histogram'],
]),
);
});
});

it('does not autofill when opening an existing configured source', async () => {
// Saved source hasn't loaded yet, so the form still shows the new-source
// default database — autofill must not fire against it.
const { rerender } = renderHarness(
<Harness databaseName="default" sourceId="metric-source-1" />,
);
await waitFor(() => {
expect(mockSetValue).toHaveBeenCalled(); // OTEL expressions applied
});
expect(autofilledTables()).toEqual([]);

// Saved source arrives and the form resets to its saved values.
savedSource = SAVED_SOURCE;
rerender(
<Harness
databaseName="otel_v2"
metricTables={{ gauge: 'otel_metrics_gauge', sum: 'otel_metrics_sum' }}
sourceId="metric-source-1"
/>,
);

// `otel_v2` has an unconfigured histogram table, but opening the source
// must leave the form untouched.
await waitFor(() => {
expect(autofilledTables()).toEqual([]);
});
});

it('autofills when an existing configured source switches database', async () => {
savedSource = SAVED_SOURCE;
const { rerender } = renderHarness(
<Harness
databaseName="otel_v2"
metricTables={{ gauge: 'otel_metrics_gauge', sum: 'otel_metrics_sum' }}
sourceId="metric-source-1"
/>,
);

await waitFor(() => {
expect(mockSetValue).toHaveBeenCalled();
});
expect(autofilledTables()).toEqual([]);

// Switching database fills the metric types that are still unset. Types
// the user already configured are never overwritten.
rerender(
<Harness
databaseName="other_db"
metricTables={{ gauge: 'otel_metrics_gauge', sum: 'otel_metrics_sum' }}
sourceId="metric-source-1"
/>,
);

await waitFor(() => {
expect(autofilledTables()).toEqual([
['metricTables.histogram', 'other_metrics_histogram'],
]);
});
});

// A saved source of another kind switched over to OTEL Metrics has no metric
// tables to preserve, so it autofills like a new source.
it('autofills for an existing source switched to the metrics kind', async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
savedSource = {
...SAVED_SOURCE,
kind: SourceKind.Log,
metricTables: undefined,
} as any;

renderHarness(
<Harness databaseName="otel_v2" sourceId="metric-source-1" />,
);

await waitFor(() => {
expect(autofilledTables()).toEqual(
expect.arrayContaining([
['metricTables.gauge', 'otel_metrics_gauge'],
['metricTables.histogram', 'otel_metrics_histogram'],
['metricTables.sum', 'otel_metrics_sum'],
]),
);
});
});
});
Loading
Loading