Skip to content
73 changes: 49 additions & 24 deletions static/app/views/detectors/components/forms/metric/visualize.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,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 @@ -93,18 +88,47 @@ function renderTag(kind: FieldValueKind): React.ReactNode {
return <Tag type={tagType}>{text}</Tag>;
}

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

const ADDITIONAL_EAP_AGGREGATES = [AggregationKey.APDEX];

/**
* Locks the primary dropdown to the single option
*/
const LOCKED_SPAN_AGGREGATES = {
[AggregationKey.APDEX]: {
value: DEFAULT_VISUALIZATION_FIELD,
label: 'span.duration',
},
[AggregationKey.COUNT]: {
value: DEFAULT_VISUALIZATION_FIELD,
label: '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.filter(aggregate => {
if (dataset === DetectorDataset.LOGS) {
return !LOGS_NOT_ALLOWED_AGGREGATES.includes(aggregate);
}
return true;
}).map(aggregate => [aggregate, aggregate]);
return [...ALLOWED_EXPLORE_VISUALIZE_AGGREGATES, ...ADDITIONAL_EAP_AGGREGATES]
.filter(aggregate => {
if (dataset === DetectorDataset.LOGS) {
return !LOGS_EXCLUDED_AGGREGATES.includes(aggregate);
}
return true;
})
.map(aggregate => [aggregate, aggregate]);
}

function getAggregateOptions(
Expand Down Expand Up @@ -226,10 +250,9 @@ 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
Expand Down Expand Up @@ -334,7 +357,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 @@ -368,22 +394,20 @@ 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 || ''
}
onChange={option => {
handleParameterChange(index, String(option.value));
}}
disabled={isTransactionsDataset || lockSpanOptions}
disabled={isTransactionsDataset}
/>
) : param.kind === 'dropdown' && param.options ? (
<StyledVisualizeSelect
Expand All @@ -404,6 +428,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 'number',
required: param.required,
defaultValue: param.defaultValue,
placeholder: param.placeholder,
};
}
return {
kind: 'column' as const,
columnTypes: Array.isArray(param.columnTypes)
? param.columnTypes
: [param.columnTypes],
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
11 changes: 8 additions & 3 deletions static/app/views/detectors/edit.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ describe('DetectorEdit', () => {
expect(screen.queryByText('Dynamic')).not.toBeInTheDocument();
});

it('disables column select when spans + count()', async () => {
it('limited options when selecting spans + count()', async () => {
const spansDetector = MetricDetectorFixture({
dataSources: [
SnubaQueryDataSourceFixture({
Expand Down Expand Up @@ -606,9 +606,14 @@ describe('DetectorEdit', () => {
await screen.findByRole('link', {name: spansDetector.name})
).toBeInTheDocument();

// Column parameter should be locked to "spans" and disabled
// Column parameter should be locked to "spans" - verify only "spans" option is available
const button = screen.getByRole('button', {name: 'spans'});
expect(button).toBeDisabled();
await userEvent.click(button);

// Verify only "spans" option exists in the dropdown
const options = screen.getAllByRole('option');
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('spans');
});

it('resets 1 day interval to 15 minutes when switching to dynamic detection', async () => {
Expand Down
77 changes: 77 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,83 @@ 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'}));

// 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