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
29 changes: 29 additions & 0 deletions static/app/utils/analytics/metricsAnalyticsEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@ import type {Organization} from 'sentry/types/organization';
import type {PlatformKey} from 'sentry/types/project';

export type MetricsAnalyticsEventParameters = {
'metrics.explorer.metadata': {
datetime_selection: string;
environment_count: number;
has_exceeded_performance_usage_limit: boolean | null;
interval: string;
metric_panels_with_filters_count: number;
metric_panels_with_group_bys_count: number;
metric_queries_count: number;
project_count: number;
};
'metrics.explorer.panel.metadata': {
columns: readonly string[];
columns_count: number;
confidences: string[];
dataScanned: string;
dataset: string;
empty_buckets_percentage: number[];
interval: string;
query_status: 'success' | 'error' | 'pending';
sample_counts: number[];
table_result_length: number;
table_result_missing_root: number;
table_result_mode: 'metric samples' | 'aggregates';
table_result_sort: string[];
user_queries: string;
user_queries_count: number;
};
'metrics.explorer.setup_button_clicked': {
organization: Organization;
platform: PlatformKey | 'unknown';
Expand Down Expand Up @@ -32,6 +59,8 @@ export type MetricsAnalyticsEventParameters = {
type MetricsAnalyticsEventKey = keyof MetricsAnalyticsEventParameters;

export const metricsAnalyticsEventMap: Record<MetricsAnalyticsEventKey, string | null> = {
'metrics.explorer.metadata': 'Metric Explorer Pageload Metadata',
'metrics.explorer.panel.metadata': 'Metric Explorer Panel Metadata',
'metrics.explorer.setup_button_clicked': 'Metrics Setup Button Clicked',
'metrics.onboarding': 'Metrics Explore Empty State (Onboarding)',
'metrics.onboarding_platform_docs_viewed':
Expand Down
258 changes: 258 additions & 0 deletions static/app/views/explore/hooks/useAnalytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {Sort} from 'sentry/utils/discover/fields';
import {DiscoverDatasets} from 'sentry/utils/discover/types';
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
import useOrganization from 'sentry/utils/useOrganization';
import usePageFilters from 'sentry/utils/usePageFilters';
import type {TimeSeries} from 'sentry/views/dashboards/widgets/common/types';
import {useLogsAutoRefreshEnabled} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext';
import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode';
Expand All @@ -19,6 +20,8 @@ import type {TracesTableResult} from 'sentry/views/explore/hooks/useExploreTrace
import {useTopEvents} from 'sentry/views/explore/hooks/useTopEvents';
import {type useLogsAggregatesTable} from 'sentry/views/explore/logs/useLogsAggregatesTable';
import type {UseInfiniteLogsQueryResult} from 'sentry/views/explore/logs/useLogsQuery';
import {useMetricAggregatesTable} from 'sentry/views/explore/metrics/hooks/useMetricAggregatesTable';
import {useMetricSamplesTable} from 'sentry/views/explore/metrics/hooks/useMetricSamplesTable';
import type {ReadableExploreQueryParts} from 'sentry/views/explore/multiQueryMode/locationUtils';
import {
useQueryParamsFields,
Expand All @@ -27,6 +30,7 @@ import {
useQueryParamsTitle,
useQueryParamsVisualizes,
} from 'sentry/views/explore/queryParams/context';
import type {ReadableQueryParams} from 'sentry/views/explore/queryParams/readableQueryParams';
import {Visualize} from 'sentry/views/explore/queryParams/visualize';
import {useSpansDataset} from 'sentry/views/explore/spans/spansQueryParams';
import {
Expand Down Expand Up @@ -685,6 +689,260 @@ function computeEmptyBuckets(
});
}

export function useMetricsPanelAnalytics({
interval,
isTopN,
metricAggregatesTableResult,
metricSamplesTableResult,
metricTimeseriesResult,
mode,
yAxis,
sortBys,
aggregateSortBys,
}: {
aggregateSortBys: readonly Sort[];
interval: string;
isTopN: boolean;
metricAggregatesTableResult: ReturnType<typeof useMetricAggregatesTable>;
metricSamplesTableResult: ReturnType<typeof useMetricSamplesTable>;
metricTimeseriesResult: ReturnType<typeof useSortedTimeSeries>;
mode: Mode;
sortBys: readonly Sort[];
yAxis: string;
}) {
const organization = useOrganization();

const dataset = DiscoverDatasets.METRICS;
const dataScanned = metricSamplesTableResult.result.meta?.dataScanned ?? '';
const search = useQueryParamsSearch();
const query = useQueryParamsQuery();
const fields = useQueryParamsFields();

const tableError =
mode === Mode.AGGREGATE
? (metricAggregatesTableResult.result.error?.message ?? '')
: (metricSamplesTableResult.error?.message ?? '');
const query_status = tableError ? 'error' : 'success';

const aggregatesResultLengthBox = useBox(
metricAggregatesTableResult.result.data?.length || 0
);
const resultLengthBox = useBox(metricSamplesTableResult.result.data?.length || 0);
const fieldsBox = useBox(fields);
const yAxesBox = useBox([yAxis]);
const sortBysBox = useBox(sortBys.map(formatSort));
const aggregateSortBysBox = useBox(aggregateSortBys.map(formatSort));

const timeseriesData = useBox(metricTimeseriesResult.data);

useEffect(() => {
if (mode !== Mode.SAMPLES) {
return;
}

if (metricSamplesTableResult.result.isFetching) {
return;
}

trackAnalytics('metrics.explorer.panel.metadata', {
organization,
dataset,
dataScanned,
columns: fieldsBox.current,
columns_count: fieldsBox.current.length,
confidences: computeConfidence(yAxesBox.current, timeseriesData.current),
empty_buckets_percentage: computeEmptyBuckets(
yAxesBox.current,
timeseriesData.current
),
interval,
query_status,
sample_counts: computeVisualizeSampleTotals(
yAxesBox.current,
timeseriesData.current,
isTopN
),
table_result_length: resultLengthBox.current,
table_result_missing_root: 0,
table_result_mode: 'metric samples',
table_result_sort: sortBysBox.current,
user_queries: search.formatString(),
user_queries_count: search.tokens.length,
});

info(
fmt`metric.explorer.panel.metadata:
organization: ${organization.slug}
dataScanned: ${dataScanned}
dataset: ${dataset}
query: ${query}
fields: ${fieldsBox.current}
query_status: ${query_status}
result_length: ${String(resultLengthBox.current)}
user_queries: ${search.formatString()}
user_queries_count: ${String(search.tokens.length)}
`,
{isAnalytics: true}
);
}, [
organization,
dataset,
dataScanned,
query,
fieldsBox,
interval,
query_status,
isTopN,
metricSamplesTableResult.result.isFetching,
search,
timeseriesData,
mode,
resultLengthBox,
sortBysBox,
yAxesBox,
]);

useEffect(() => {
if (mode !== Mode.AGGREGATE) {
return;
}

if (metricAggregatesTableResult.result.isPending) {
return;
}

trackAnalytics('metrics.explorer.panel.metadata', {
organization,
dataset,
dataScanned,
columns: fieldsBox.current,
columns_count: fieldsBox.current.length,
confidences: computeConfidence([yAxis], timeseriesData.current),
empty_buckets_percentage: computeEmptyBuckets([yAxis], timeseriesData.current),
interval,
query_status,
sample_counts: computeVisualizeSampleTotals(
[yAxis],
timeseriesData.current,
isTopN
),
table_result_length: aggregatesResultLengthBox.current,
table_result_missing_root: 0,
table_result_mode: 'aggregates',
table_result_sort: aggregateSortBysBox.current,
user_queries: search.formatString(),
user_queries_count: search.tokens.length,
});

info(
fmt`metric.explorer.panel.metadata:
organization: ${organization.slug}
dataScanned: ${dataScanned}
dataset: ${dataset}
query: ${query}
fields: ${fieldsBox.current}
query_status: ${query_status}
result_length: ${String(aggregatesResultLengthBox.current)}
user_queries: ${search.formatString()}
user_queries_count: ${String(search.tokens.length)}
`,
{isAnalytics: true}
);
}, [
aggregateSortBysBox,
aggregatesResultLengthBox,
dataScanned,
dataset,
fieldsBox,
interval,
isTopN,
metricAggregatesTableResult.result.isPending,
timeseriesData,
mode,
organization,
query,
query_status,
search,
yAxis,
]);
}

export function useMetricsAnalytics({
interval,
metricQueries,
}: {
interval: string;
metricQueries: Array<{queryParams: ReadableQueryParams}>;
}) {
const organization = useOrganization();
const {selection} = usePageFilters();
Copy link

Choose a reason for hiding this comment

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

Bug: useMetricsAnalytics uses traceItemDataset: 'default' instead of 'metrics', leading to incorrect usage limit checks.
Severity: MEDIUM | Confidence: 0.90

🔍 Detailed Analysis

The useMetricsAnalytics hook incorrectly invokes usePerformanceSubscriptionDetails with traceItemDataset: 'default'. This configuration causes the system to evaluate usage limits against transactions.usageExceeded or spans.usageExceeded instead of the intended traceMetrics.usageExceeded. As a result, the metrics.explorer.metadata analytics event will report an inaccurate value for has_exceeded_performance_usage_limit concerning metrics usage.

💡 Suggested Fix

Modify the call to usePerformanceSubscriptionDetails within useMetricsAnalytics to use traceItemDataset: 'metrics' instead of traceItemDataset: 'default' to ensure accurate usage limit evaluation.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: static/app/views/explore/hooks/useAnalytics.tsx#L878

Potential issue: The `useMetricsAnalytics` hook incorrectly invokes
`usePerformanceSubscriptionDetails` with `traceItemDataset: 'default'`. This
configuration causes the system to evaluate usage limits against
`transactions.usageExceeded` or `spans.usageExceeded` instead of the intended
`traceMetrics.usageExceeded`. As a result, the `metrics.explorer.metadata` analytics
event will report an inaccurate value for `has_exceeded_performance_usage_limit`
concerning metrics usage.

Did we get this right? 👍 / 👎 to inform future reviews.


const {
data: {hasExceededPerformanceUsageLimit},
isLoading: isLoadingSubscriptionDetails,
} = usePerformanceSubscriptionDetails({traceItemDataset: 'default'});
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Use Metrics Dataset for Trace Usage Checks

The useMetricsAnalytics function incorrectly passes traceItemDataset: 'default' to usePerformanceSubscriptionDetails. Since this function is tracking metrics analytics and the PR adds support for the 'metrics' dataset (including the traceMetrics category in the subscription type), it should use traceItemDataset: 'metrics' instead to check the correct usage limit (traceMetrics.usageExceeded) rather than defaulting to transactions/spans limits.

Fix in Cursor Fix in Web


const metricQueriesCount = useBox(metricQueries.length);
const metricPanelsWithGroupBysCount = useBox(
metricQueries.filter(mq =>
mq.queryParams.groupBys.some((gb: string) => gb.trim().length > 0)
).length
);
const metricPanelsWithFiltersCount = useBox(
metricQueries.filter(mq => mq.queryParams.query.trim().length > 0).length
);

useEffect(() => {
if (isLoadingSubscriptionDetails) {
return;
}

const datetimeSelection = `${selection.datetime.start || ''}-${selection.datetime.end || ''}-${selection.datetime.period || ''}`;
const projectCount = selection.projects.length;
const environmentCount = selection.environments.length;

trackAnalytics('metrics.explorer.metadata', {
organization,
datetime_selection: datetimeSelection,
environment_count: environmentCount,
has_exceeded_performance_usage_limit: hasExceededPerformanceUsageLimit,
interval,
metric_panels_with_filters_count: metricPanelsWithFiltersCount.current,
metric_panels_with_group_bys_count: metricPanelsWithGroupBysCount.current,
metric_queries_count: metricQueriesCount.current,
project_count: projectCount,
});

info(
fmt`metrics.explorer.metadata:
organization: ${organization.slug}
datetime_selection: ${datetimeSelection}
environment_count: ${String(environmentCount)}
interval: ${interval}
metric_queries_count: ${String(metricQueriesCount.current)}
metric_panels_with_group_bys_count: ${String(metricPanelsWithGroupBysCount.current)}
metric_panels_with_filters_count: ${String(metricPanelsWithFiltersCount.current)}
project_count: ${String(projectCount)}
has_exceeded_performance_usage_limit: ${String(hasExceededPerformanceUsageLimit)}
`,
{isAnalytics: true}
);
}, [
hasExceededPerformanceUsageLimit,
interval,
isLoadingSubscriptionDetails,
metricQueriesCount,
metricPanelsWithGroupBysCount,
metricPanelsWithFiltersCount,
organization,
selection.datetime.end,
selection.datetime.period,
selection.datetime.start,
selection.environments.length,
selection.projects.length,
]);
}

function useBox<T>(value: T): RefObject<T> {
const box = useRef(value);
box.current = value;
Expand Down
50 changes: 50 additions & 0 deletions static/app/views/explore/metrics/metricPanel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,26 @@ import {useState} from 'react';

import Panel from 'sentry/components/panels/panel';
import PanelBody from 'sentry/components/panels/panelBody';
import {useMetricsPanelAnalytics} from 'sentry/views/explore/hooks/useAnalytics';
import {useChartInterval} from 'sentry/views/explore/hooks/useChartInterval';
import {useTopEvents} from 'sentry/views/explore/hooks/useTopEvents';
import {TraceSamplesTableColumns} from 'sentry/views/explore/metrics/constants';
import {useMetricAggregatesTable} from 'sentry/views/explore/metrics/hooks/useMetricAggregatesTable';
import {useMetricSamplesTable} from 'sentry/views/explore/metrics/hooks/useMetricSamplesTable';
import {useMetricTimeseries} from 'sentry/views/explore/metrics/hooks/useMetricTimeseries';
import {useTableOrientationControl} from 'sentry/views/explore/metrics/hooks/useOrientationControl';
import {SideBySideOrientation} from 'sentry/views/explore/metrics/metricPanel/sideBySideOrientation';
import {StackedOrientation} from 'sentry/views/explore/metrics/metricPanel/stackedOrientation';
import {type TraceMetric} from 'sentry/views/explore/metrics/metricQuery';
import {getMetricTableColumnType} from 'sentry/views/explore/metrics/utils';
import {
useQueryParamsAggregateSortBys,
useQueryParamsMode,
useQueryParamsSortBys,
} from 'sentry/views/explore/queryParams/context';

const RESULT_LIMIT = 50;
const TWO_MINUTE_DELAY = 120;

interface MetricPanelProps {
queryIndex: number;
Expand All @@ -25,6 +40,41 @@ export function MetricPanel({traceMetric, queryIndex}: MetricPanelProps) {
enabled: Boolean(traceMetric.name),
});

const columns = TraceSamplesTableColumns;
const fields = columns.filter(c => getMetricTableColumnType(c) !== 'stat');

const metricSamplesTableResult = useMetricSamplesTable({
disabled: !traceMetric?.name,
limit: RESULT_LIMIT,
traceMetric,
fields,
ingestionDelaySeconds: TWO_MINUTE_DELAY,
});

const metricAggregatesTableResult = useMetricAggregatesTable({
enabled: Boolean(traceMetric.name),
limit: RESULT_LIMIT,
traceMetric,
});

const mode = useQueryParamsMode();
const sortBys = useQueryParamsSortBys();
const aggregateSortBys = useQueryParamsAggregateSortBys();
const [interval] = useChartInterval();
const topEvents = useTopEvents();

useMetricsPanelAnalytics({
Copy link
Member

Choose a reason for hiding this comment

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

This is going to fire once per panel and again when the query changes for the panel. Is this the intended behaviour?

interval,
isTopN: !!topEvents,
metricAggregatesTableResult,
metricSamplesTableResult,
metricTimeseriesResult: timeseriesResult,
mode,
yAxis: traceMetric.name || '',
sortBys,
aggregateSortBys,
});

return (
<Panel>
<PanelBody>
Expand Down
Loading
Loading