diff --git a/apps/api/src/agents/tools/base.ts b/apps/api/src/agents/tools/base.ts
index 576ae2970..22039d31e 100644
--- a/apps/api/src/agents/tools/base.ts
+++ b/apps/api/src/agents/tools/base.ts
@@ -258,7 +258,15 @@ export const generateReport = chatTool(
)
.default([])
.optional(),
- metric: z.enum(['sum', 'count', 'average']).default('sum').optional(),
+ // No `.default()` here on purpose: it would make an omitted metric
+ // indistinguishable from an explicit `sum`, and the fallback depends on
+ // the chart type (see below).
+ metric: z
+ .enum(['sum', 'count', 'average', 'min', 'max'])
+ .optional()
+ .describe(
+ 'How a series is aggregated for display. Only the metric and map chart types read this; `count` is unique profiles. Omit it unless the user asked for a specific aggregation — metric cards then default to unique profiles.',
+ ),
previous: z
.boolean()
.optional()
@@ -338,7 +346,11 @@ export const generateReport = chatTool(
}),
),
range: 'custom' as const,
- metric: input.metric ?? 'sum',
+ // A metric card has always shown the total unique count, so an
+ // unspecified metric must stay `count` there — `sum` would silently turn
+ // "1.2k users" into "45k events".
+ metric:
+ input.metric ?? (input.chartType === 'metric' ? 'count' : 'sum'),
previous: input.previous ?? false,
...(input.lineType ? { lineType: input.lineType } : {}),
...(input.limit ? { limit: input.limit } : {}),
diff --git a/apps/start/src/components/report-chart/metric/chart.tsx b/apps/start/src/components/report-chart/metric/chart.tsx
index 8d5a96c1b..c37dca3eb 100644
--- a/apps/start/src/components/report-chart/metric/chart.tsx
+++ b/apps/start/src/components/report-chart/metric/chart.tsx
@@ -12,7 +12,7 @@ interface Props {
export function Chart({ data }: Props) {
const {
isEditMode,
- report: { unit },
+ report: { metric, unit },
} = useReportChartContext();
const { series } = useVisibleSeries(data, { limit: isEditMode ? 20 : 4 });
return (
@@ -27,7 +27,7 @@ export function Chart({ data }: Props) {
);
diff --git a/apps/start/src/components/report-chart/metric/metric-card.tsx b/apps/start/src/components/report-chart/metric/metric-card.tsx
index 334091bfc..e98b1d63d 100644
--- a/apps/start/src/components/report-chart/metric/metric-card.tsx
+++ b/apps/start/src/components/report-chart/metric/metric-card.tsx
@@ -58,7 +58,11 @@ export function MetricCard({
const number = useNumber();
const renderValue = (value: number | undefined, unitClassName?: string) => {
- if (!value) {
+ // A genuine 0 is a real value, not a missing one — `min` is 0 whenever the
+ // range contains an empty bucket. Only absent metrics render as N/A, which
+ // still matters: getAggregateChartSql never selects total_count, so `count`
+ // really is undefined for bar/pie series.
+ if (value === undefined || value === null) {
return
N/A
;
}
diff --git a/apps/start/src/components/report/reportSlice.ts b/apps/start/src/components/report/reportSlice.ts
index 9f88a1eb8..87df79de7 100644
--- a/apps/start/src/components/report/reportSlice.ts
+++ b/apps/start/src/components/report/reportSlice.ts
@@ -13,6 +13,7 @@ import type {
IChartEventFilter,
IChartEventItem,
IChartLineType,
+ IChartMetric,
IChartRange,
IChartType,
IInterval,
@@ -209,6 +210,14 @@ export const reportSlice = createSlice({
state.dirty = true;
state.chartType = action.payload;
+ // The Metric card has always shown the total unique count. Existing
+ // reports are backfilled to 'count' by migration, so default a newly
+ // switched one the same way rather than leaving old and new metric
+ // reports showing different aggregations. The picker overrides it.
+ if (action.payload === 'metric') {
+ state.metric = 'count';
+ }
+
// Initialize sankey options if switching to sankey
if (action.payload === 'sankey' && !state.options) {
state.options = {
@@ -301,6 +310,11 @@ export const reportSlice = createSlice({
state.unit = action.payload || undefined;
},
+ changeMetric(state, action: PayloadAction) {
+ state.dirty = true;
+ state.metric = action.payload;
+ },
+
changeFunnelGroup(state, action: PayloadAction) {
state.dirty = true;
if (!state.options || state.options.type !== 'funnel') {
@@ -445,6 +459,7 @@ export const {
changePrevious,
changeCriteria,
changeUnit,
+ changeMetric,
changeFunnelGroup,
changeFunnelWindow,
changeOptions,
diff --git a/apps/start/src/components/report/sidebar/ReportSettings.tsx b/apps/start/src/components/report/sidebar/ReportSettings.tsx
index 7f12ce348..777a93d28 100644
--- a/apps/start/src/components/report/sidebar/ReportSettings.tsx
+++ b/apps/start/src/components/report/sidebar/ReportSettings.tsx
@@ -7,11 +7,13 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventNames } from '@/hooks/use-event-names';
+import type { IChartMetric } from '@openpanel/validation';
import { useMemo } from 'react';
import {
changeCriteria,
changeFunnelGroup,
changeFunnelWindow,
+ changeMetric,
changePrevious,
changeSankeyExclude,
changeSankeyInclude,
@@ -25,6 +27,7 @@ export function ReportSettings() {
const chartType = useSelector((state) => state.report.chartType);
const previous = useSelector((state) => state.report.previous);
const unit = useSelector((state) => state.report.unit);
+ const metric = useSelector((state) => state.report.metric);
const options = useSelector((state) => state.report.options);
const retentionOptions = options?.type === 'retention' ? options : undefined;
@@ -69,6 +72,11 @@ export function ReportSettings() {
fields.push('stacked');
}
+ // `map` already reads report.metric; it just never had a way to set it.
+ if (chartType === 'metric' || chartType === 'map') {
+ fields.push('metric');
+ }
+
return fields;
}, [chartType]);
@@ -137,6 +145,27 @@ export function ReportSettings() {
/>
)}
+ {fields.includes('metric') && (
+