Skip to content
115 changes: 88 additions & 27 deletions static/app/views/detectors/components/forms/metric/visualize.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {parseFunction} from 'sentry/utils/discover/fields';
import {
AggregationKey,
ALLOWED_EXPLORE_VISUALIZE_AGGREGATES,
FieldValueType,
getFieldDefinition,
prettifyTagKey,
} from 'sentry/utils/fields';
import {unreachable} from 'sentry/utils/unreachable';
Expand All @@ -37,11 +39,6 @@ import {FieldValueKind} from 'sentry/views/discover/table/types';
import {DEFAULT_VISUALIZATION_FIELD} from 'sentry/views/explore/contexts/pageParamsContext/visualizes';
import {TraceItemDataset} from 'sentry/views/explore/types';

const LOCKED_SPAN_COUNT_OPTION = {
value: DEFAULT_VISUALIZATION_FIELD,
label: t('spans'),
};

/**
* Render a tag badge for field types, similar to dashboard widget builder
*/
Expand Down Expand Up @@ -91,13 +88,59 @@ function renderTag(kind: FieldValueKind): React.ReactNode {
return <Tag type={tagType}>{text}</Tag>;
}

/**
* Aggregate options not allowed for the logs dataset
*/
const LOGS_NOT_ALLOWED_AGGREGATES = [
AggregationKey.FAILURE_RATE,
AggregationKey.FAILURE_COUNT,
AggregationKey.APDEX,
];

/**
* Additional aggregate options for the spans dataset
*/
const EXTRA_AGGREGATES = [AggregationKey.APDEX];

/**
* Locks the options because they usually need to count something specific
*/
const LOCKED_SPAN_AGGREGATES = {
[AggregationKey.APDEX]: {
value: DEFAULT_VISUALIZATION_FIELD,
label: t('span.duration'),
Copy link
Member

Choose a reason for hiding this comment

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

might be cleaner if you just set span.duration to be the single allowable type for this parameter

Copy link
Member Author

@scttcper scttcper Nov 21, 2025

Choose a reason for hiding this comment

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

that is what this is doing, i've removed the disabled

},
[AggregationKey.COUNT]: {
value: DEFAULT_VISUALIZATION_FIELD,
label: t('spans'),
},
};

// Type guard for locked span aggregates
const isLockedSpanAggregate = (
agg: string
): agg is keyof typeof LOCKED_SPAN_AGGREGATES => {
return agg in LOCKED_SPAN_AGGREGATES;
};

function getEAPAllowedAggregates(dataset: DetectorDataset): Array<[string, string]> {
return [...ALLOWED_EXPLORE_VISUALIZE_AGGREGATES, ...EXTRA_AGGREGATES]
.filter(aggregate => {
if (dataset === DetectorDataset.LOGS) {
return !LOGS_NOT_ALLOWED_AGGREGATES.includes(aggregate);
}
return true;
})
.map(aggregate => [aggregate, aggregate]);
}

function getAggregateOptions(
dataset: DetectorDataset,
tableFieldOptions: Record<string, SelectValue<FieldValue>>
): Array<[string, string]> {
// For spans dataset, use the predefined aggregates
if (dataset === DetectorDataset.SPANS || dataset === DetectorDataset.LOGS) {
return ALLOWED_EXPLORE_VISUALIZE_AGGREGATES.map(aggregate => [aggregate, aggregate]);
return getEAPAllowedAggregates(dataset);
}

// For other datasets, extract function-type options from tableFieldOptions
Expand All @@ -107,7 +150,7 @@ function getAggregateOptions(

// If no function options available, fall back to the predefined aggregates
if (functionOptions.length === 0) {
return ALLOWED_EXPLORE_VISUALIZE_AGGREGATES.map(aggregate => [aggregate, aggregate]);
return getEAPAllowedAggregates(dataset);
}

return functionOptions.sort((a, b) => a[1].localeCompare(b[1]));
Expand Down Expand Up @@ -210,23 +253,39 @@ export function Visualize() {

const datasetConfig = useMemo(() => getDatasetConfig(dataset), [dataset]);

const aggregateOptions = useMemo(
() => datasetConfig.getAggregateOptions(organization, tags, customMeasurements),
[organization, tags, datasetConfig, customMeasurements]
);
const aggregateOptions = useMemo(() => {
return datasetConfig.getAggregateOptions(organization, tags, customMeasurements);
}, [organization, tags, datasetConfig, customMeasurements]);

const fieldOptions = useMemo(() => {
// For Spans dataset, use span-specific options from the provider
if (dataset === DetectorDataset.SPANS || dataset === DetectorDataset.LOGS) {
// Use field definition to determine what options should be displayed
const fieldDefinition = getFieldDefinition(
aggregate,
dataset === DetectorDataset.SPANS ? 'span' : 'log'
);
let isTypeAllowed = (_valueType: FieldValueType) => true;
if (fieldDefinition?.parameters?.[0]?.kind === 'column') {
const columnTypes = fieldDefinition?.parameters[0]?.columnTypes;
isTypeAllowed = (valueType: FieldValueType) =>
typeof columnTypes === 'function'
? columnTypes({key: '', valueType})
: columnTypes.includes(valueType);
}
const spanColumnOptions: Array<[string, string]> = [
...Object.values(stringSpanTags).map((tag): [string, string] => [
tag.key,
prettifyTagKey(tag.name),
]),
...Object.values(numericSpanTags).map((tag): [string, string] => [
tag.key,
prettifyTagKey(tag.name),
]),
...(isTypeAllowed(FieldValueType.STRING)
? Object.values(stringSpanTags).map((tag): [string, string] => [
tag.key,
prettifyTagKey(tag.name),
])
: []),
...(isTypeAllowed(FieldValueType.NUMBER)
? Object.values(numericSpanTags).map((tag): [string, string] => [
tag.key,
prettifyTagKey(tag.name),
])
: []),
];
return spanColumnOptions.sort((a, b) => a[1].localeCompare(b[1]));
}
Expand All @@ -239,7 +298,7 @@ export function Visualize() {
)
.map((option): [string, string] => [option.value.meta.name, option.value.meta.name])
.sort((a, b) => a[1].localeCompare(b[1]));
}, [dataset, stringSpanTags, numericSpanTags, aggregateOptions]);
}, [dataset, stringSpanTags, numericSpanTags, aggregateOptions, aggregate]);

const fieldOptionsDropdown = useMemo(() => {
return fieldOptions.map(([value, label]) => ({
Expand Down Expand Up @@ -301,7 +360,10 @@ export function Visualize() {
};

const lockSpanOptions =
dataset === DetectorDataset.SPANS && aggregate === AggregationKey.COUNT;
dataset === DetectorDataset.SPANS && isLockedSpanAggregate(aggregate);

// Get locked option if applicable, with proper type narrowing
const lockedOption = lockSpanOptions ? LOCKED_SPAN_AGGREGATES[aggregate] : null;

return (
<Flex direction="column" gap="md">
Expand Down Expand Up @@ -335,15 +397,13 @@ export function Visualize() {
<StyledVisualizeSelect
searchable
triggerProps={{
children: lockSpanOptions
? LOCKED_SPAN_COUNT_OPTION.label
children: lockedOption
? lockedOption.label
: parameters[index] || param.defaultValue || t('Select metric'),
}}
options={
lockSpanOptions ? [LOCKED_SPAN_COUNT_OPTION] : fieldOptionsDropdown
}
options={lockedOption ? [lockedOption] : fieldOptionsDropdown}
value={
lockSpanOptions
lockedOption
? DEFAULT_VISUALIZATION_FIELD
: parameters[index] || param.defaultValue || ''
}
Expand Down Expand Up @@ -371,6 +431,7 @@ export function Visualize() {
/>
) : (
<StyledParameterInput
size="md"
placeholder={param.defaultValue || t('Enter value')}
value={parameters[index] || ''}
onChange={e => {
Expand Down
59 changes: 57 additions & 2 deletions static/app/views/detectors/datasetConfig/spans.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import {t} from 'sentry/locale';
import type {EventsStats} from 'sentry/types/organization';
import type {SelectValue} from 'sentry/types/core';
import type {TagCollection} from 'sentry/types/group';
import type {EventsStats, Organization} from 'sentry/types/organization';
import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements';
import type {AggregateParameter} from 'sentry/utils/discover/fields';
import {DiscoverDatasets} from 'sentry/utils/discover/types';
import {AggregationKey, getFieldDefinition} from 'sentry/utils/fields';
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
import {EventTypes} from 'sentry/views/alerts/rules/metric/types';
import {SpansConfig} from 'sentry/views/dashboards/datasetConfig/spans';
Expand All @@ -20,19 +25,69 @@ import {
translateAggregateTag,
translateAggregateTagBack,
} from 'sentry/views/detectors/datasetConfig/utils/translateAggregateTag';
import type {FieldValue} from 'sentry/views/discover/table/types';
import {FieldValueKind} from 'sentry/views/discover/table/types';

import type {DetectorDatasetConfig} from './base';

type SpansSeriesResponse = EventsStats;

const DEFAULT_EVENT_TYPES = [EventTypes.TRACE_ITEM_SPAN];

function getAggregateOptions(
organization: Organization,
tags?: TagCollection,
customMeasurements?: CustomMeasurementCollection
): Record<string, SelectValue<FieldValue>> {
const base = SpansConfig.getTableFieldOptions(organization, tags, customMeasurements);

const apdexDefinition = getFieldDefinition(AggregationKey.APDEX, 'span');

if (apdexDefinition?.parameters) {
// Convert field definition parameters to discover field format
const convertedParameters = apdexDefinition.parameters.map<AggregateParameter>(
param => {
if (param.kind === 'value') {
return {
kind: 'value' as const,
dataType: param.dataType as any,
required: param.required,
defaultValue: param.defaultValue,
placeholder: param.placeholder,
};
}
return {
kind: 'column' as const,
columnTypes: Array.isArray(param.columnTypes)
? (param.columnTypes as any)
: ([param.columnTypes] as any),
required: param.required,
defaultValue: param.defaultValue,
};
}
);

base['function:apdex'] = {
label: 'apdex',
value: {
kind: FieldValueKind.FUNCTION,
meta: {
name: 'apdex',
parameters: convertedParameters,
},
},
};
}

return base;
}

export const DetectorSpansConfig: DetectorDatasetConfig<SpansSeriesResponse> = {
name: t('Spans'),
SearchBar: TraceSearchBar,
defaultEventTypes: DEFAULT_EVENT_TYPES,
defaultField: SpansConfig.defaultField,
getAggregateOptions: SpansConfig.getTableFieldOptions,
getAggregateOptions,
getSeriesQueryOptions: options => {
return getDiscoverSeriesQueryOptions({
...options,
Expand Down
79 changes: 79 additions & 0 deletions static/app/views/detectors/new-setting.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,85 @@ describe('DetectorEdit', () => {
);
});
});

it('can submit a new metric detector with apdex aggregate', async () => {
const mockCreateDetector = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/detectors/`,
method: 'POST',
body: MetricDetectorFixture({id: '789'}),
});

render(<DetectorNewSettings />, {
organization,
initialRouterConfig: metricRouterConfig,
});

const title = await screen.findByText('New Monitor');
await userEvent.click(title);
await userEvent.keyboard('Apdex{enter}');

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

// The first parameter (span.duration) should be locked and not editable
expect(await screen.findByText('span.duration')).toBeInTheDocument();
// Change to apdex(100)
await userEvent.clear(screen.getByPlaceholderText('300'));
await userEvent.type(screen.getByPlaceholderText('300'), '100');

// Set the high threshold for alerting
await userEvent.type(
screen.getByRole('spinbutton', {name: 'High threshold'}),
'100'
);

await userEvent.click(screen.getByRole('button', {name: 'Create Monitor'}));

await waitFor(() => {
expect(mockCreateDetector).toHaveBeenCalledWith(
`/organizations/${organization.slug}/detectors/`,
expect.objectContaining({
data: expect.objectContaining({
name: 'Apdex',
type: 'metric_issue',
projectId: project.id,
owner: null,
workflowIds: [],
conditionGroup: {
conditions: [
{
comparison: 100,
conditionResult: 75,
type: 'gt',
},
{
comparison: 100,
conditionResult: 0,
type: 'lte',
},
],
logicType: 'any',
},
config: {
detectionType: 'static',
},
dataSources: [
{
aggregate: 'apdex(span.duration,100)',
dataset: 'events_analytics_platform',
eventTypes: ['trace_item_span'],
query: '',
queryType: 1,
timeWindow: 3600,
environment: null,
},
],
}),
})
);
});
});
});

describe('Uptime Detector', () => {
Expand Down
Loading