Skip to content

Commit c11a4c0

Browse files
authored
feat(analytics): show user names in usage charts (#269)
* docs: design usage analytics user labels * feat: support formatted insight group labels * feat: show names in usage analytics user charts * fix: keep usage analytics series keys stable
1 parent 191a38d commit c11a4c0

7 files changed

Lines changed: 206 additions & 5 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Usage Analytics User Labels Design
2+
3+
## Goal
4+
5+
Display users in all three AI Gateway Usage Analytics user charts as
6+
`name (userId)` instead of showing only the raw user ID.
7+
8+
## Name Resolution
9+
10+
The client will load the current workspace members and resolve each chart
11+
group's `userId` with this priority:
12+
13+
1. The member's non-empty `nickname`.
14+
2. The member's `username`.
15+
3. The original `userId` when the member cannot be resolved.
16+
17+
Resolved users are rendered as `name (userId)`. The fallback remains the raw
18+
ID without additional punctuation so historical data for removed users stays
19+
readable.
20+
21+
## Architecture and Data Flow
22+
23+
The existing insights query continues to group AI Gateway logs by `userId`.
24+
`AIGatewayAnalytics` uses the existing `workspace.members` query to build a
25+
user-ID-to-display-label map and passes a stable formatter to each of the three
26+
user charts.
27+
28+
`InsightQueryChart` forwards an optional group-value formatter to
29+
`useInsightsData`. Chart data keeps its raw, stable series keys, while the
30+
formatter changes only the corresponding chart configuration labels. It does
31+
not change the query, grouping, metric values, or raw response.
32+
33+
The formatter is optional, so every existing chart without it preserves its
34+
current behavior.
35+
36+
## Scope
37+
38+
The formatter is applied to:
39+
40+
- Request Count by User
41+
- Cost by User
42+
- Token Usage by User
43+
44+
No locale JSON files, AI Gateway log records, insights SQL, or unrelated chart
45+
labels are changed.
46+
47+
## Error and Loading Behavior
48+
49+
Charts may render before workspace-member data is available. Until resolution
50+
is possible, labels fall back to raw IDs. Once member data arrives, memoized
51+
chart processing reruns and replaces known IDs with resolved labels.
52+
53+
An unknown or removed user never produces an empty label.
54+
55+
## Testing
56+
57+
Focused unit tests for insights data processing will verify:
58+
59+
- a supplied formatter changes grouped series labels;
60+
- formatted labels do not change the underlying series keys;
61+
- grouped data values remain unchanged;
62+
- processing without a formatter remains backward compatible;
63+
- an unresolved ID stays visible through the formatter's fallback.
64+
65+
Type checking and the production build will provide integration-level
66+
verification after the focused tests pass.

src/client/components/aiGateway/AIGatewayAnalytics.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { LoadingView } from '../LoadingView';
1919
import { DateUnit, getDateArray } from '@tianji/shared';
2020
import type { InsightsRawData } from '@/hooks/useInsightsData';
2121
import type { ChartConfig } from '../ui/chart';
22+
import { createAIGatewayUserLabelFormatter } from './AIGatewayUserLabel';
2223

2324
// Status color tokens used in the Errors tab.
2425
// Keep the success/failure colors aligned with the rest of the app
@@ -38,6 +39,13 @@ export const AIGatewayAnalytics: React.FC<AIGatewayAnalyticsProps> = React.memo(
3839
const { gatewayId } = props;
3940
const workspaceId = useCurrentWorkspaceId();
4041
const { startDate, endDate, unit, refresh } = useGlobalRangeDate();
42+
const { data: workspaceMembers = [] } = trpc.workspace.members.useQuery({
43+
workspaceId,
44+
});
45+
const userLabelFormatter = useMemo(
46+
() => createAIGatewayUserLabelFormatter(workspaceMembers),
47+
[workspaceMembers]
48+
);
4149

4250
const trpcUtils = trpc.useUtils();
4351
const handleRefresh = useEvent(async () => {
@@ -473,6 +481,7 @@ export const AIGatewayAnalytics: React.FC<AIGatewayAnalyticsProps> = React.memo(
473481
time={timeConfig}
474482
chartType="bar"
475483
valueProcessor={defaultValueProcessor.alwaysPositive}
484+
groupValueFormatter={userLabelFormatter}
476485
/>
477486
</CardContent>
478487
</Card>
@@ -498,6 +507,7 @@ export const AIGatewayAnalytics: React.FC<AIGatewayAnalyticsProps> = React.memo(
498507
time={timeConfig}
499508
chartType="bar"
500509
valueProcessor={defaultValueProcessor.alwaysPositive}
510+
groupValueFormatter={userLabelFormatter}
501511
/>
502512
</CardContent>
503513
</Card>
@@ -537,6 +547,7 @@ export const AIGatewayAnalytics: React.FC<AIGatewayAnalyticsProps> = React.memo(
537547
time={timeConfig}
538548
chartType="bar"
539549
valueProcessor={defaultValueProcessor.alwaysPositive}
550+
groupValueFormatter={userLabelFormatter}
540551
/>
541552
</CardContent>
542553
</Card>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { createAIGatewayUserLabelFormatter } from './AIGatewayUserLabel';
3+
4+
describe('createAIGatewayUserLabelFormatter', () => {
5+
it('prefers a non-empty nickname', () => {
6+
const format = createAIGatewayUserLabelFormatter([
7+
{ userId: 'user-1', user: { nickname: 'Ada', username: 'ada' } },
8+
]);
9+
10+
expect(format('user-1')).toBe('Ada (user-1)');
11+
});
12+
13+
it('falls back to username when nickname is blank', () => {
14+
const format = createAIGatewayUserLabelFormatter([
15+
{ userId: 'user-2', user: { nickname: ' ', username: 'grace' } },
16+
]);
17+
18+
expect(format('user-2')).toBe('grace (user-2)');
19+
});
20+
21+
it('keeps the raw ID for an unknown user', () => {
22+
const format = createAIGatewayUserLabelFormatter([]);
23+
24+
expect(format('removed-user')).toBe('removed-user');
25+
});
26+
27+
it('keeps the raw ID when both nickname and username are blank', () => {
28+
const format = createAIGatewayUserLabelFormatter([
29+
{ userId: 'user-3', user: { nickname: null, username: ' ' } },
30+
]);
31+
32+
expect(format('user-3')).toBe('user-3');
33+
});
34+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
interface WorkspaceMemberForLabel {
2+
userId: string;
3+
user: {
4+
nickname: string | null;
5+
username: string;
6+
};
7+
}
8+
9+
export function createAIGatewayUserLabelFormatter(
10+
members: WorkspaceMemberForLabel[]
11+
) {
12+
const labels = new Map(
13+
members.map((member) => {
14+
const name =
15+
member.user.nickname?.trim() || member.user.username.trim() || null;
16+
17+
return [
18+
member.userId,
19+
name ? `${name} (${member.userId})` : member.userId,
20+
] as const;
21+
})
22+
);
23+
24+
return (userId: string) => labels.get(userId) ?? userId;
25+
}

src/client/components/insights/InsightQueryChart.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import { InsightType } from '@/store/insights';
1010
import { DateUnit, FilterInfo, GroupInfo, MetricsInfo } from '@tianji/shared';
1111
import { getUserTimezone } from '@/api/model/user';
1212
import { ChartConfig } from '../ui/chart';
13-
import { InsightsRawData, useInsightsData } from '@/hooks/useInsightsData';
13+
import {
14+
GroupValueFormatter,
15+
InsightsRawData,
16+
useInsightsData,
17+
} from '@/hooks/useInsightsData';
1418

1519
interface InsightQueryChartProps {
1620
className?: string;
@@ -33,6 +37,7 @@ interface InsightQueryChartProps {
3337
chartConfig?: ChartConfig;
3438

3539
valueProcessor?: (value: number) => number;
40+
groupValueFormatter?: GroupValueFormatter;
3641
}
3742
export const InsightQueryChart: React.FC<InsightQueryChartProps> = React.memo(
3843
(props) => {
@@ -49,6 +54,7 @@ export const InsightQueryChart: React.FC<InsightQueryChartProps> = React.memo(
4954
chartType,
5055
chartConfig,
5156
valueProcessor,
57+
groupValueFormatter,
5258
} = props;
5359

5460
const { data: rawData = [], isLoading } = trpc.insights.query.useQuery(
@@ -83,6 +89,7 @@ export const InsightQueryChart: React.FC<InsightQueryChartProps> = React.memo(
8389
groups,
8490
time,
8591
valueProcessor,
92+
groupValueFormatter,
8693
});
8794

8895
// Use simple data for backward compatibility when it's a single series
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { processInsightsData } from '@/hooks/useInsightsData';
3+
4+
const options = {
5+
data: [
6+
{
7+
name: '$all_event',
8+
userId: 'user-1',
9+
data: [{ date: '2026-07-14', value: 3 }],
10+
},
11+
],
12+
groups: [{ value: 'userId' }],
13+
time: {
14+
startAt: new Date('2026-07-14T00:00:00Z').valueOf(),
15+
endAt: new Date('2026-07-14T23:59:59Z').valueOf(),
16+
unit: 'day' as const,
17+
},
18+
};
19+
20+
describe('processInsightsData group labels', () => {
21+
it('formats grouped series labels without changing stable data keys', () => {
22+
const result = processInsightsData({
23+
...options,
24+
groupValueFormatter: (value) => `Ada (${value})`,
25+
});
26+
27+
expect(result.chartData).toEqual([
28+
{ date: '2026-07-14', '$all_event-user-1': 3 },
29+
]);
30+
expect(result.chartConfig['$all_event-user-1']?.label).toBe(
31+
'$all_event-Ada (user-1)'
32+
);
33+
});
34+
35+
it('keeps existing grouped series labels without a formatter', () => {
36+
const result = processInsightsData(options);
37+
38+
expect(result.chartData).toEqual([
39+
{ date: '2026-07-14', '$all_event-user-1': 3 },
40+
]);
41+
});
42+
});

src/client/hooks/useInsightsData.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,20 @@ import { ChartConfig } from '@/components/ui/chart';
1212
*/
1313
function generateSeriesName(
1414
item: InsightsRawData,
15-
groups: Pick<GroupInfo, 'value'>[]
15+
groups: Pick<GroupInfo, 'value'>[],
16+
groupValueFormatter?: GroupValueFormatter
1617
): string {
1718
// Use alias if available, otherwise use name
1819
let name = item.alias ?? item.name;
1920

2021
if (groups.length > 0) {
2122
const groupSuffixes = groups
2223
.map((group) => {
23-
return get(item, group.value);
24+
const value = get(item, group.value);
25+
26+
return value && groupValueFormatter
27+
? groupValueFormatter(String(value), group)
28+
: value;
2429
})
2530
.filter(Boolean) // Remove null/undefined values
2631
.join('-');
@@ -48,6 +53,11 @@ export interface ProcessedChartData {
4853
[key: string]: string | number;
4954
}
5055

56+
export type GroupValueFormatter = (
57+
value: string,
58+
group: Pick<GroupInfo, 'value'>
59+
) => string;
60+
5161
export interface UseInsightsDataOptions {
5262
data: InsightsRawData[];
5363
groups: Pick<GroupInfo, 'value'>[];
@@ -57,6 +67,7 @@ export interface UseInsightsDataOptions {
5767
unit: DateUnit;
5868
};
5969
valueProcessor?: (value: number) => number;
70+
groupValueFormatter?: GroupValueFormatter;
6071
}
6172

6273
/**
@@ -73,7 +84,7 @@ export function processInsightsData(options: UseInsightsDataOptions): {
7384
isMultiSeries: boolean;
7485
seriesCount: number;
7586
} {
76-
const { data, groups, time, valueProcessor } = options;
87+
const { data, groups, time, valueProcessor, groupValueFormatter } = options;
7788

7889
if (!data || data.length === 0) {
7990
return {
@@ -117,6 +128,7 @@ export function processInsightsData(options: UseInsightsDataOptions): {
117128

118129
// For complex case (groups or multiple metrics), process full chart data
119130
const res: { date: string }[] = [];
131+
const seriesLabels = new Map<string, string>();
120132

121133
// Collect all unique dates from all data series
122134
const allDates = new Set<string>();
@@ -131,6 +143,9 @@ export function processInsightsData(options: UseInsightsDataOptions): {
131143
const value = dataPoint?.value ?? 0;
132144
const processedValue = valueProcessor?.(value) ?? value;
133145
const name = generateSeriesName(item, groups);
146+
const label = generateSeriesName(item, groups, groupValueFormatter);
147+
148+
seriesLabels.set(name, label);
134149

135150
res.push({
136151
date,
@@ -151,7 +166,7 @@ export function processInsightsData(options: UseInsightsDataOptions): {
151166
return {
152167
...prev,
153168
[curr]: {
154-
label: curr,
169+
label: seriesLabels.get(curr) ?? curr,
155170
color: pickColorWithNum(i),
156171
},
157172
};
@@ -193,6 +208,7 @@ export function useInsightsData(options: UseInsightsDataOptions) {
193208
options.time.endAt,
194209
options.time.unit,
195210
options.valueProcessor,
211+
options.groupValueFormatter,
196212
]
197213
);
198214
}

0 commit comments

Comments
 (0)