Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7acd044
wip
narsaynorath Nov 3, 2025
0e0d522
wip
narsaynorath Nov 4, 2025
9e838d8
wip
narsaynorath Nov 6, 2025
3d0552e
Merge branch 'master' into nar/feat/tracemetrics-add-dataset-to-dashb…
narsaynorath Dec 2, 2025
49be8d1
Merge branch 'master' into nar/feat/tracemetrics-add-dataset-to-dashb…
narsaynorath Dec 3, 2025
5d9c904
Add metrics dataset and enable saving/editing
narsaynorath Dec 4, 2025
d15c6c1
disable loading until aggregate is filled in
narsaynorath Dec 4, 2025
53e9d24
Ensure aggregate is always a valid option, change it if not
narsaynorath Dec 4, 2025
3473331
Simplify dataset config
narsaynorath Dec 4, 2025
8052033
Merge branch 'master' into nar/feat/tracemetrics-add-dataset-to-dashb…
narsaynorath Dec 5, 2025
d47950e
Cleanup
narsaynorath Dec 5, 2025
32ca634
cleanup comments
narsaynorath Dec 5, 2025
94f644d
Use empty const
narsaynorath Dec 5, 2025
dbefbf1
reorder hook args to avoid diff
narsaynorath Dec 5, 2025
fd6ee36
feat(tracemetrics): Add dataset to dashboards
narsaynorath Dec 8, 2025
f88be44
re-add select wrapper
narsaynorath Dec 8, 2025
28e9c9d
Small fixes
narsaynorath Dec 8, 2025
613a468
only encode tracemetrics if applicable
narsaynorath Dec 8, 2025
5a56f44
fix test
narsaynorath Dec 8, 2025
dd95ed1
Allow grouping by aggregate metric
narsaynorath Dec 8, 2025
1251e59
fix orderby when adding
narsaynorath Dec 9, 2025
442dc37
Update comment for timeseries sort options
narsaynorath Dec 9, 2025
2f022cb
Remove duplication and use flex component
narsaynorath Dec 9, 2025
434cdb0
Re-add timeseries sort options
narsaynorath Dec 9, 2025
fddc171
fix visualize deletion for tracemetric
narsaynorath Dec 9, 2025
09534d1
cleanup
narsaynorath Dec 10, 2025
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
2 changes: 1 addition & 1 deletion static/app/actionCreators/events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ type Options = {
period?: string | null;
project?: readonly number[];
query?: string;
queryExtras?: Record<string, string | boolean | number>;
queryExtras?: Record<string, string | boolean | number | string[]>;
referrer?: string;
sampling?: SamplingMode;
start?: DateString;
Expand Down
2 changes: 1 addition & 1 deletion static/app/utils/timeSeries/useFetchEventsTimeSeries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export function useFetchEventsTimeSeries<YAxis extends string, Attribute extends
);
}

type EventsTimeSeriesResponse = {
export type EventsTimeSeriesResponse = {
timeSeries: TimeSeries[];
meta?: {
dataset: DiscoverDatasets;
Expand Down
11 changes: 9 additions & 2 deletions static/app/views/dashboards/datasetConfig/base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {IssuesConfig} from './issues';
import {LogsConfig} from './logs';
import {ReleasesConfig} from './releases';
import {SpansConfig} from './spans';
import {TraceMetricsConfig} from './traceMetrics';
import {TransactionsConfig} from './transactions';

export type WidgetBuilderSearchBarProps = {
Expand All @@ -43,6 +44,7 @@ export type WidgetBuilderSearchBarProps = {
widgetQuery: WidgetQuery;
dataset?: DiscoverDatasets;
disabled?: boolean;
index?: number;
portalTarget?: HTMLElement | null;
};

Expand Down Expand Up @@ -275,7 +277,9 @@ export function getDatasetConfig<T extends WidgetType | undefined>(
? typeof LogsConfig
: T extends WidgetType.SPANS
? typeof SpansConfig
: typeof ErrorsAndTransactionsConfig;
: T extends WidgetType.TRACEMETRICS
? typeof TraceMetricsConfig
: typeof ErrorsAndTransactionsConfig;

export function getDatasetConfig(
widgetType?: WidgetType
Expand All @@ -286,7 +290,8 @@ export function getDatasetConfig(
| typeof ErrorsConfig
| typeof TransactionsConfig
| typeof LogsConfig
| typeof SpansConfig {
| typeof SpansConfig
| typeof TraceMetricsConfig {
switch (widgetType) {
case WidgetType.ISSUE:
return IssuesConfig;
Expand All @@ -300,6 +305,8 @@ export function getDatasetConfig(
return LogsConfig;
case WidgetType.SPANS:
return SpansConfig;
case WidgetType.TRACEMETRICS:
return TraceMetricsConfig;
case WidgetType.DISCOVER:
default:
return ErrorsAndTransactionsConfig;
Expand Down
30 changes: 2 additions & 28 deletions static/app/views/dashboards/datasetConfig/spans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,14 @@ import {
renderTraceAsLinkable,
transformEventsResponseToTable,
} from 'sentry/views/dashboards/datasetConfig/errorsAndTransactions';
import {combineBaseFieldsWithTags} from 'sentry/views/dashboards/datasetConfig/utils/combineBaseFieldsWithEapTags';
import {getSeriesRequestData} from 'sentry/views/dashboards/datasetConfig/utils/getSeriesRequestData';
import {DisplayType, type Widget, type WidgetQuery} from 'sentry/views/dashboards/types';
import {eventViewFromWidget} from 'sentry/views/dashboards/utils';
import {transformEventsResponseToSeries} from 'sentry/views/dashboards/utils/transformEventsResponseToSeries';
import SpansSearchBar from 'sentry/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar';
import type {FieldValueOption} from 'sentry/views/discover/table/queryField';
import {FieldValueKind} from 'sentry/views/discover/table/types';
import {generateFieldOptions} from 'sentry/views/discover/utils';
import {useSearchQueryBuilderProps} from 'sentry/views/explore/components/traceItemSearchQueryBuilder';
import {useTraceItemAttributesWithConfig} from 'sentry/views/explore/contexts/traceItemAttributeContext';
import type {SamplingMode} from 'sentry/views/explore/hooks/useProgressiveQuery';
Expand Down Expand Up @@ -241,33 +241,7 @@ function getPrimaryFieldOptions(
tags?: TagCollection,
_customMeasurements?: CustomMeasurementCollection
): Record<string, FieldValueOption> {
const baseFieldOptions = generateFieldOptions({
organization,
tagKeys: [],
fieldKeys: [],
aggregations: EAP_AGGREGATIONS,
});

const spanTags = Object.values(tags ?? {}).reduce(
function combineTag(acc, tag) {
acc[`${tag.kind}:${tag.key}`] = {
label: tag.name,
value: {
kind: FieldValueKind.TAG,

// We have numeric and string tags which have the same
// display name, but one is used for aggregates and the other
// is used for grouping.
meta: {name: tag.key, dataType: tag.kind === 'tag' ? 'string' : 'number'},
},
};

return acc;
},
{} as Record<string, FieldValueOption>
);

return {...baseFieldOptions, ...spanTags};
return combineBaseFieldsWithTags(organization, tags, EAP_AGGREGATIONS);
}

function filterAggregateParams(option: FieldValueOption, fieldValue?: QueryFieldValue) {
Expand Down
270 changes: 270 additions & 0 deletions static/app/views/dashboards/datasetConfig/traceMetrics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import pickBy from 'lodash/pickBy';

import {doEventsRequest} from 'sentry/actionCreators/events';
import type {ApiResult, Client} from 'sentry/api';
import type {PageFilters, SelectValue} from 'sentry/types/core';
import type {TagCollection} from 'sentry/types/group';
import type {Organization} from 'sentry/types/organization';
import type {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements';
import {parseFunction, type QueryFieldValue} from 'sentry/utils/discover/fields';
import {DiscoverDatasets} from 'sentry/utils/discover/types';
import type {MEPState} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
import type {OnDemandControlContext} from 'sentry/utils/performance/contexts/onDemandControl';
import type {EventsTimeSeriesResponse} from 'sentry/utils/timeSeries/useFetchEventsTimeSeries';
import usePageFilters from 'sentry/utils/usePageFilters';
import {
type DatasetConfig,
type SearchBarData,
type SearchBarDataProviderProps,
type WidgetBuilderSearchBarProps,
} from 'sentry/views/dashboards/datasetConfig/base';
import {
getTableSortOptions,
getTimeseriesSortOptions,
} from 'sentry/views/dashboards/datasetConfig/errorsAndTransactions';
import {combineBaseFieldsWithTags} from 'sentry/views/dashboards/datasetConfig/utils/combineBaseFieldsWithEapTags';
import {getSeriesRequestData} from 'sentry/views/dashboards/datasetConfig/utils/getSeriesRequestData';
import {useHasTraceMetricsDashboards} from 'sentry/views/dashboards/hooks/useHasTraceMetricsDashboards';
import {DisplayType, type Widget, type WidgetQuery} from 'sentry/views/dashboards/types';
import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext';
import {formatTimeSeriesLabel} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesLabel';
import type {FieldValueOption} from 'sentry/views/discover/table/queryField';
import {FieldValueKind} from 'sentry/views/discover/table/types';
import {
TraceItemSearchQueryBuilder,
useSearchQueryBuilderProps,
} from 'sentry/views/explore/components/traceItemSearchQueryBuilder';
import {useTraceItemAttributesWithConfig} from 'sentry/views/explore/contexts/traceItemAttributeContext';
import type {SamplingMode} from 'sentry/views/explore/hooks/useProgressiveQuery';
import {TraceItemDataset} from 'sentry/views/explore/types';

// This is a placeholder that currently signals that no metric is selected
// When the metrics are loaded up, the first metric is selected and this will be filled out
export const EMPTY_METRIC_SELECTION = 'avg(value,,,-)';

const DEFAULT_WIDGET_QUERY: WidgetQuery = {
name: '',
fields: [],
columns: [],
fieldAliases: [],
aggregates: [EMPTY_METRIC_SELECTION],
conditions: '',
orderby: '',
};

const DEFAULT_FIELD: QueryFieldValue = {
function: ['avg', 'value', undefined, undefined],
kind: FieldValueKind.FUNCTION,
};

function TraceMetricsSearchBar({
widgetQuery,
onSearch,
portalTarget,
onClose,
}: Pick<
WidgetBuilderSearchBarProps,
'widgetQuery' | 'onSearch' | 'portalTarget' | 'onClose'
>) {
const {
selection: {projects},
} = usePageFilters();
const hasTraceMetricsDashboards = useHasTraceMetricsDashboards();
const {state: widgetBuilderState} = useWidgetBuilderContext();

// TODO: Make decision on how filtering works with multiple trace metrics
// We should probably limit it to only one metric for now because there's no way
// to filter by multiple metrics at the same time, unless the filter _ONLY_ includes
// tags for both metrics.
const traceMetric = widgetBuilderState.traceMetrics?.[0];

const traceItemAttributeConfig = {
traceItemType: TraceItemDataset.TRACEMETRICS,
enabled: hasTraceMetricsDashboards,
};

const {attributes: stringAttributes, secondaryAliases: stringSecondaryAliases} =
useTraceItemAttributesWithConfig(traceItemAttributeConfig, 'string');
const {attributes: numberAttributes, secondaryAliases: numberSecondaryAliases} =
useTraceItemAttributesWithConfig(traceItemAttributeConfig, 'number');

return (
<TraceItemSearchQueryBuilder
initialQuery={widgetQuery.conditions}
onSearch={onSearch}
itemType={TraceItemDataset.TRACEMETRICS}
numberAttributes={numberAttributes}
stringAttributes={stringAttributes}
numberSecondaryAliases={numberSecondaryAliases}
stringSecondaryAliases={stringSecondaryAliases}
searchSource="dashboards"
projects={projects}
portalTarget={portalTarget}
onChange={(query, state) => {
onClose?.(query, {validSearch: state.queryIsValid});
}}
namespace={traceMetric?.name}
/>
);
}

function useTraceMetricsSearchBarDataProvider(
props: SearchBarDataProviderProps
): SearchBarData {
const {pageFilters, widgetQuery} = props;
const hasTraceMetricsDashboards = useHasTraceMetricsDashboards();

const traceItemAttributeConfig = {
traceItemType: TraceItemDataset.TRACEMETRICS,
enabled: hasTraceMetricsDashboards,
};

const {attributes: stringAttributes, secondaryAliases: stringSecondaryAliases} =
useTraceItemAttributesWithConfig(traceItemAttributeConfig, 'string');
const {attributes: numberAttributes, secondaryAliases: numberSecondaryAliases} =
useTraceItemAttributesWithConfig(traceItemAttributeConfig, 'number');

const {filterKeys, filterKeySections, getTagValues} = useSearchQueryBuilderProps({
itemType: TraceItemDataset.TRACEMETRICS,
numberAttributes,
stringAttributes,
numberSecondaryAliases,
stringSecondaryAliases,
searchSource: 'dashboards',
initialQuery: widgetQuery?.conditions ?? '',
projects: pageFilters.projects,
});

return {
getFilterKeySections: () => filterKeySections,
getFilterKeys: () => filterKeys,
getTagValues,
};
}

function prettifySortOption(option: SelectValue<string>) {
const parsedFunction = parseFunction(option.value);
if (parsedFunction) {
return `${parsedFunction.name}(${parsedFunction.arguments[1] ?? '…'})`;
}
return option.label;
}

export const TraceMetricsConfig: DatasetConfig<EventsTimeSeriesResponse, never> = {
defaultField: DEFAULT_FIELD,
defaultWidgetQuery: DEFAULT_WIDGET_QUERY,
enableEquations: false,
SearchBar: TraceMetricsSearchBar,
useSearchBarDataProvider: useTraceMetricsSearchBarDataProvider,
filterSeriesSortOptions,
getTableFieldOptions: getPrimaryFieldOptions,
getTimeseriesSortOptions: (organization, widgetQuery, tags) =>
getTimeseriesSortOptions(organization, widgetQuery, tags, getPrimaryFieldOptions),
// We've forced the sort options to use the table sort options UI because
// we only want to allow sorting by selected aggregates.
getTableSortOptions: (organization, widgetQuery) =>
getTableSortOptions(organization, widgetQuery).map(option => ({
label: prettifySortOption(option),
value: option.value,
})),
getGroupByFieldOptions,
supportedDisplayTypes: [DisplayType.AREA, DisplayType.BAR, DisplayType.LINE],
getSeriesRequest,
transformTable: () => ({data: []}),
transformSeries: (data, _widgetQuery) =>
data.timeSeries.map(timeSeries => {
const func = parseFunction(timeSeries.yAxis);
if (func) {
timeSeries.yAxis = `${func.name}(${func.arguments[1] ?? '…'})`;
}
return {
data: timeSeries.values.map(value => ({
name: value.timestamp / 1000, // Account for microseconds to milliseconds precision
value: value.value ?? 0,
})),
seriesName: formatTimeSeriesLabel(timeSeries),
};
}),
};

function getPrimaryFieldOptions(
organization: Organization,
tags?: TagCollection,
_customMeasurements?: CustomMeasurementCollection
): Record<string, FieldValueOption> {
return combineBaseFieldsWithTags(organization, tags, {});
}

function filterYAxisOptions() {
return function (option: FieldValueOption) {
return option.value.kind === FieldValueKind.FUNCTION;
};
}

function getGroupByFieldOptions(
organization: Organization,
tags?: TagCollection,
customMeasurements?: CustomMeasurementCollection
) {
const primaryFieldOptions = getPrimaryFieldOptions(
organization,
tags,
customMeasurements
);
const yAxisFilter = filterYAxisOptions();
const filterGroupByOptions = (option: FieldValueOption) => !yAxisFilter(option);

return pickBy(primaryFieldOptions, filterGroupByOptions);
}

function getSeriesRequest(
api: Client,
widget: Widget,
queryIndex: number,
organization: Organization,
pageFilters: PageFilters,
_onDemandControlContext?: OnDemandControlContext,
referrer?: string,
_mepSetting?: MEPState | null,
samplingMode?: SamplingMode
) {
const requestData = getSeriesRequestData(
widget,
queryIndex,
organization,
pageFilters,
DiscoverDatasets.TRACEMETRICS,
referrer
);

requestData.generatePathname = () =>
`/organizations/${organization.slug}/events-timeseries/`;

if (
[DisplayType.LINE, DisplayType.AREA, DisplayType.BAR].includes(widget.displayType) &&
(widget.queries[0]?.columns?.length ?? 0) > 0
) {
requestData.queryExtras = {
...requestData.queryExtras,
groupBy: widget.queries[0]!.columns,
};
}

if (samplingMode) {
requestData.sampling = samplingMode;
}

return doEventsRequest<true>(api, requestData) as unknown as Promise<
ApiResult<EventsTimeSeriesResponse>
>;
}

function filterSeriesSortOptions(columns: Set<string>) {
return (option: FieldValueOption) => {
if (option.value.kind === FieldValueKind.FUNCTION) {
return true;
}

return columns.has(option.value.meta.name);
};
}
Loading
Loading