Skip to content
Open
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
14 changes: 14 additions & 0 deletions static/app/components/workflowEngine/form/getFormFieldValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type FormModel from 'sentry/components/forms/model';
import type {FieldValue} from 'sentry/components/forms/types';

/**
* The form values returned by form.getValue are not typed, so this is a
* convenient helper to do a type assertion while getting the value.
*/
export function getFormFieldValue<T extends FieldValue = FieldValue>(
form: FormModel,
field: string
): T {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
return form.getValue(field) as FieldValue;
Comment on lines +8 to +13

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge Return helper as wrong generic type

The new getFormFieldValue helper is declared to return the caller‑specified generic type T, but the implementation always casts to FieldValue. Because FieldValue is only the constraint for T, this expression is not assignable to T and TypeScript will fail to compile with Type 'FieldValue' is not assignable to type 'T'. To preserve the generic typing and satisfy the compiler the value should be asserted to T instead of FieldValue.

Useful? React with 👍 / 👎.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[DataTypeCheck]

Type mismatch in function return type vs implementation:

export function getFormFieldValue<T extends FieldValue = FieldValue>(
  form: FormModel,
  field: string
): T {
  return form.getValue(field) as FieldValue; // ❌ Returns FieldValue, not T
}

This function promises to return type T but actually returns FieldValue, breaking type safety.

Suggested Change
Suggested change
return form.getValue(field) as FieldValue;
export function getFormFieldValue<T extends FieldValue = FieldValue>(
form: FormModel,
field: string
): T {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
return form.getValue(field) as T;
}

Committable suggestion

Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Context for Agents
[**DataTypeCheck**]

Type mismatch in function return type vs implementation:

```typescript
export function getFormFieldValue<T extends FieldValue = FieldValue>(
  form: FormModel,
  field: string
): T {
  return form.getValue(field) as FieldValue; // ❌ Returns FieldValue, not T
}
```

This function promises to return type `T` but actually returns `FieldValue`, breaking type safety.

<details>
<summary>Suggested Change</summary>

```suggestion
export function getFormFieldValue<T extends FieldValue = FieldValue>(
  form: FormModel,
  field: string
): T {
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
  return form.getValue(field) as T;
}
```

⚡ **Committable suggestion**

Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

</details>

File: static/app/components/workflowEngine/form/getFormFieldValue.tsx
Line: 13

}
4 changes: 2 additions & 2 deletions static/app/components/workflowEngine/form/useFormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {observe} from 'mobx';

import FormContext from 'sentry/components/forms/formContext';
import type {FieldValue} from 'sentry/components/forms/types';
import {getFormFieldValue} from 'sentry/components/workflowEngine/form/getFormFieldValue';

export function useFormField<Value extends FieldValue = FieldValue>(
field: string
Expand Down Expand Up @@ -42,8 +43,7 @@ export function useFormField<Value extends FieldValue = FieldValue>(
return undefined;
}

// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
return context.form.getValue(field) as Value;
return getFormFieldValue<Value>(context.form, field);
}, [context.form, field]);

return useSyncExternalStore(subscribe, getSnapshot);
Expand Down
3 changes: 3 additions & 0 deletions static/app/views/detectors/components/forms/metric/metric.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
} from 'sentry/views/detectors/components/forms/metric/metricFormData';
import {MetricDetectorPreviewChart} from 'sentry/views/detectors/components/forms/metric/previewChart';
import {ResolveSection} from 'sentry/views/detectors/components/forms/metric/resolveSection';
import {useAutoMetricDetectorName} from 'sentry/views/detectors/components/forms/metric/useAutoMetricDetectorName';
import {useInitialMetricDetectorFormData} from 'sentry/views/detectors/components/forms/metric/useInitialMetricDetectorFormData';
import {useIntervalChoices} from 'sentry/views/detectors/components/forms/metric/useIntervalChoices';
import {Visualize} from 'sentry/views/detectors/components/forms/metric/visualize';
Expand All @@ -60,6 +61,8 @@ import {getStaticDetectorThresholdSuffix} from 'sentry/views/detectors/utils/met
import {deprecateTransactionAlerts} from 'sentry/views/insights/common/utils/hasEAPAlerts';

function MetricDetectorForm() {
useAutoMetricDetectorName();

return (
<FormStack>
<TransactionsDatasetWarningListener />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type FormModel from 'sentry/components/forms/model';
import {getFormFieldValue} from 'sentry/components/workflowEngine/form/getFormFieldValue';
import {t} from 'sentry/locale';
import {DataConditionType} from 'sentry/types/workflowEngine/dataConditions';
import getDuration from 'sentry/utils/duration/getDuration';
import {unreachable} from 'sentry/utils/unreachable';
import {useSetAutomaticName} from 'sentry/views/detectors/components/forms/common/useSetAutomaticName';
import {
METRIC_DETECTOR_FORM_FIELDS,
type MetricDetectorFormData,
} from 'sentry/views/detectors/components/forms/metric/metricFormData';
import {getDatasetConfig} from 'sentry/views/detectors/datasetConfig/getDatasetConfig';
import {getStaticDetectorThresholdSuffix} from 'sentry/views/detectors/utils/metricDetectorSuffix';

/**
* Automatically generates a name for the metric detector form based on the values:
* - Dataset
* - Aggregate function
* - Detection type
* - Direction (above or below)
* - Threshold value
* - Interval
*
* e.g. "p95(span.duration) above 100ms compared to past 1 hour"
*/
export function useAutoMetricDetectorName() {
useSetAutomaticName((form: FormModel): string | null => {
const detectionType = getFormFieldValue<MetricDetectorFormData['detectionType']>(
form,
METRIC_DETECTOR_FORM_FIELDS.detectionType
);
const aggregate = getFormFieldValue<MetricDetectorFormData['aggregateFunction']>(
form,
METRIC_DETECTOR_FORM_FIELDS.aggregateFunction
);
const interval = getFormFieldValue<MetricDetectorFormData['interval']>(
form,
METRIC_DETECTOR_FORM_FIELDS.interval
);
const dataset = getFormFieldValue<MetricDetectorFormData['dataset']>(
form,
METRIC_DETECTOR_FORM_FIELDS.dataset
);
const datasetConfig = getDatasetConfig(dataset);

if (!aggregate || !interval || !detectionType || !dataset) {
return null;
}

switch (detectionType) {
case 'static': {
const conditionType = getFormFieldValue<MetricDetectorFormData['conditionType']>(
form,
METRIC_DETECTOR_FORM_FIELDS.conditionType
);
const conditionValue = getFormFieldValue<
MetricDetectorFormData['conditionValue']
>(form, METRIC_DETECTOR_FORM_FIELDS.conditionValue);

if (!conditionType || !conditionValue) {
return null;
}

const suffix = getStaticDetectorThresholdSuffix(aggregate);
const direction =
conditionType === DataConditionType.GREATER ? t('above') : t('below');

return t(
'%(aggregate)s %(aboveOrBelow)s %(value)s%(unit)s over past %(interval)s',
{
aggregate: datasetConfig.formatAggregateForTitle?.(aggregate) ?? aggregate,
aboveOrBelow: direction,
value: conditionValue,
unit: suffix,
interval: getDuration(interval),
}
);
}
case 'percent': {
const conditionType = getFormFieldValue<MetricDetectorFormData['conditionType']>(
form,
METRIC_DETECTOR_FORM_FIELDS.conditionType
);
const conditionValue = getFormFieldValue<
MetricDetectorFormData['conditionValue']
>(form, METRIC_DETECTOR_FORM_FIELDS.conditionValue);

if (!conditionType || !conditionValue) {
return null;
}

const direction =
conditionType === DataConditionType.GREATER ? t('higher') : t('lower');

return t(
'%(aggregate)s %(aboveOrBelow)s by %(value)s%% compared to past %(interval)s',
{
aggregate: datasetConfig.formatAggregateForTitle?.(aggregate) ?? aggregate,
aboveOrBelow: direction,
value: conditionValue,
interval: getDuration(interval),
}
);
}
case 'dynamic':
return t('%(aggregate)s is anomalous', {
aggregate: datasetConfig.formatAggregateForTitle?.(aggregate) ?? aggregate,
});
default:
unreachable(detectionType);
return null;
}
});
}
8 changes: 8 additions & 0 deletions static/app/views/detectors/datasetConfig/base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,12 @@ export interface DetectorDatasetConfig<SeriesResponse> {
data: SeriesResponse | undefined,
aggregate: string
) => Series[];

/**
* When automatically generating a detector name, this function will be called to format the aggregate function.
* If this function is not provided, the aggregate function will be used as is.
*
* e.g. For the errors dataset, count() will be formatted as 'Number of errors'
*/
formatAggregateForTitle?: (aggregate: string) => string;
}
9 changes: 9 additions & 0 deletions static/app/views/detectors/datasetConfig/errors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,13 @@ export const DetectorErrorsConfig: DetectorDatasetConfig<ErrorsSeriesResponse> =
parseEventTypesFromQuery(query, DEFAULT_EVENT_TYPES),
// TODO: This should use the discover dataset unless `is:unresolved` is in the query
getDiscoverDataset: () => DiscoverDatasets.ERRORS,
formatAggregateForTitle: aggregate => {
if (aggregate === 'count()') {
return t('Number of errors');
}
if (aggregate === 'count_unique(user)') {
return t('Users experiencing errors');
}
return aggregate;
},
};
6 changes: 6 additions & 0 deletions static/app/views/detectors/datasetConfig/logs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,10 @@ export const DetectorLogsConfig: DetectorDatasetConfig<LogsSeriesRepsonse> = {
},
supportedDetectionTypes: ['static', 'percent', 'dynamic'],
getDiscoverDataset: () => DiscoverDatasets.OURLOGS,
formatAggregateForTitle: aggregate => {
if (aggregate === 'count()') {
return t('Number of logs');
}
return aggregate;
},
};
6 changes: 6 additions & 0 deletions static/app/views/detectors/datasetConfig/spans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,10 @@ export const DetectorSpansConfig: DetectorDatasetConfig<SpansSeriesResponse> = {
},
supportedDetectionTypes: ['static', 'percent', 'dynamic'],
getDiscoverDataset: () => DiscoverDatasets.SPANS,
formatAggregateForTitle: aggregate => {
if (aggregate.startsWith('count(span.duration)')) {
return t('Number of spans');
}
return aggregate;
},
};
50 changes: 48 additions & 2 deletions static/app/views/detectors/new-setting.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import DetectorNewSettings from 'sentry/views/detectors/new-settings';

describe('DetectorEdit', () => {
const organization = OrganizationFixture({
features: ['workflow-engine-ui', 'visibility-explore-view'],
features: ['workflow-engine-ui', 'visibility-explore-view', 'performance-view'],
});
const project = ProjectFixture({organization, environments: ['production']});
const initialRouterConfig = {
Expand Down Expand Up @@ -98,6 +98,52 @@ describe('DetectorEdit', () => {
},
};

it('auto-generates name', async () => {
render(<DetectorNewSettings />, {
organization,
initialRouterConfig: metricRouterConfig,
});
await screen.findByText('New Monitor');

// Enter threshold value
await userEvent.type(screen.getByRole('spinbutton', {name: 'Threshold'}), '100');

// Name should be auto-generated from defaults (Spans + count(span.duration))
expect(await screen.findByTestId('editable-text-label')).toHaveTextContent(
'Number of spans above 100 over past 1 hour'
);

// Change aggregate from count() to p75(span.duration)
await userEvent.click(screen.getByRole('button', {name: 'count'}));
await userEvent.click(await screen.findByRole('option', {name: 'p75'}));

await waitFor(() => {
expect(screen.getByTestId('editable-text-label')).toHaveTextContent(
'p75(span.duration) above 100ms over past 1 hour'
);
});

// Change dataset from Spans to Errors
await userEvent.click(screen.getByText('Spans'));
await userEvent.click(await screen.findByRole('menuitemradio', {name: 'Errors'}));

await waitFor(() => {
expect(screen.getByTestId('editable-text-label')).toHaveTextContent(
'Number of errors above 100 over past 1 hour'
);
});

// Change interval from 1 hour to 4 hours
await userEvent.click(screen.getByText('1 hour'));
await userEvent.click(screen.getByRole('menuitemradio', {name: '4 hours'}));

await waitFor(() => {
expect(screen.getByTestId('editable-text-label')).toHaveTextContent(
'Number of errors above 100 over past 4 hours'
);
});
});

it('can submit a new metric detector', async () => {
const mockCreateDetector = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/detectors/`,
Expand Down Expand Up @@ -200,7 +246,7 @@ describe('DetectorEdit', () => {
`/organizations/${organization.slug}/detectors/`,
expect.objectContaining({
data: expect.objectContaining({
name: 'My Monitor',
name: 'Users experiencing errors above 100 over past 1 hour',
type: 'metric_issue',
projectId: project.id,
owner: null,
Expand Down