diff --git a/apps/web/src/app/api/docs/route.test.ts b/apps/web/src/app/api/docs/route.test.ts
index e0081f7c0d..b07a0a0144 100644
--- a/apps/web/src/app/api/docs/route.test.ts
+++ b/apps/web/src/app/api/docs/route.test.ts
@@ -13,5 +13,7 @@ describe('GET /api/docs', () => {
expect(body).toContain('.swagger-ui .auth-wrapper');
expect(body).toContain('.swagger-ui .authorization__btn');
expect(body).toContain('display: none !important');
+ expect(body).toContain('Swagger UI generated from the Kilo Code OpenAPI document.');
+ expect(body).not.toContain('tRPC OpenAPI document');
});
});
diff --git a/apps/web/src/app/api/docs/route.ts b/apps/web/src/app/api/docs/route.ts
index 6e98255c98..9592662d4b 100644
--- a/apps/web/src/app/api/docs/route.ts
+++ b/apps/web/src/app/api/docs/route.ts
@@ -77,7 +77,7 @@ function swaggerUiHtml(nonce: string) {
diff --git a/apps/web/src/app/api/v1/organizations/[id]/members/route.test.ts b/apps/web/src/app/api/v1/organizations/[id]/members/route.test.ts
new file mode 100644
index 0000000000..de2734db58
--- /dev/null
+++ b/apps/web/src/app/api/v1/organizations/[id]/members/route.test.ts
@@ -0,0 +1,91 @@
+import { beforeAll, describe, expect, it, jest } from '@jest/globals';
+import { NextRequest } from 'next/server';
+import type { handleTRPCRequest } from '@/lib/trpc-route-handler';
+import type { GET as routeGET } from './route';
+
+jest.mock('@/lib/trpc-route-handler', () => ({
+ handleTRPCRequest: jest.fn(),
+}));
+
+const { handleTRPCRequest: mockedHandleTRPCRequest } = jest.requireMock(
+ '@/lib/trpc-route-handler'
+) as {
+ handleTRPCRequest: jest.MockedFunction;
+};
+
+let GET: typeof routeGET;
+
+beforeAll(async () => {
+ ({ GET } = await import('./route'));
+});
+
+describe('GET /api/v1/organizations/[id]/members', () => {
+ it('returns public organization members without invited-member invite fields', async () => {
+ const invitedMember = {
+ email: 'invited@example.com',
+ role: 'member' as const,
+ inviteDate: '2026-06-22T00:00:00.000Z',
+ inviteToken: 'secret-token',
+ inviteId: 'invite-id',
+ status: 'invited' as const,
+ inviteUrl: 'https://example.com/users/accept-invite/secret-token',
+ dailyUsageLimitUsd: null,
+ currentDailyUsageUsd: null,
+ };
+ const withMembers = jest.fn(async (_input: { organizationId: string }) => ({
+ id: 'org-id',
+ name: 'Test Org',
+ members: [
+ {
+ id: 'user-id',
+ name: 'Active User',
+ email: 'active@example.com',
+ role: 'owner' as const,
+ status: 'active' as const,
+ inviteDate: null,
+ dailyUsageLimitUsd: null,
+ currentDailyUsageUsd: null,
+ },
+ invitedMember,
+ ],
+ }));
+ const caller = {
+ organizations: {
+ withMembers,
+ },
+ };
+
+ mockedHandleTRPCRequest.mockImplementationOnce(async (_request, handler) => {
+ const result = await handler(caller as never);
+ return Response.json(result) as never;
+ });
+
+ const response = await GET(new NextRequest('http://localhost:3000'), {
+ params: Promise.resolve({ id: 'org-id' }),
+ });
+
+ expect(withMembers).toHaveBeenCalledWith({ organizationId: 'org-id' });
+ expect(invitedMember).toHaveProperty('inviteToken', 'secret-token');
+ expect(response.status).toBe(200);
+ await expect(response.json()).resolves.toEqual([
+ {
+ id: 'user-id',
+ name: 'Active User',
+ email: 'active@example.com',
+ role: 'owner',
+ status: 'active',
+ inviteDate: null,
+ dailyUsageLimitUsd: null,
+ currentDailyUsageUsd: null,
+ },
+ {
+ email: 'invited@example.com',
+ role: 'member',
+ inviteDate: '2026-06-22T00:00:00.000Z',
+ status: 'invited',
+ dailyUsageLimitUsd: null,
+ currentDailyUsageUsd: null,
+ },
+ ]);
+ });
+});
diff --git a/apps/web/src/app/api/v1/organizations/[id]/members/route.ts b/apps/web/src/app/api/v1/organizations/[id]/members/route.ts
new file mode 100644
index 0000000000..6dbf1745bf
--- /dev/null
+++ b/apps/web/src/app/api/v1/organizations/[id]/members/route.ts
@@ -0,0 +1,12 @@
+import type { NextRequest } from 'next/server';
+import { PublicOrganizationMembersSchema } from '@/lib/organizations/organization-types';
+import { handleTRPCRequest } from '@/lib/trpc-route-handler';
+
+export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const organizationId = (await params).id;
+
+ return handleTRPCRequest(request, async caller => {
+ const org = await caller.organizations.withMembers({ organizationId });
+ return PublicOrganizationMembersSchema.parse(org.members);
+ });
+}
diff --git a/apps/web/src/lib/openapi/trpc-openapi.test.ts b/apps/web/src/lib/openapi/trpc-openapi.test.ts
index 55da2672f2..491e887f5a 100644
--- a/apps/web/src/lib/openapi/trpc-openapi.test.ts
+++ b/apps/web/src/lib/openapi/trpc-openapi.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from '@jest/globals';
import { TRPCError, initTRPC } from '@trpc/server';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import * as z from 'zod';
-import { publicTrpcOpenApiProcedures } from '@/lib/openapi/trpc-registry';
+import { publicRestOpenApiRoutes, publicTrpcOpenApiProcedures } from '@/lib/openapi/trpc-registry';
import { generateTrpcOpenApiDocument } from '@/lib/openapi/trpc-openapi';
import {
TrpcErrorResponseSchema,
@@ -41,11 +41,14 @@ async function callVerificationProcedure(path: string, input?: unknown): Promise
}
describe('generateTrpcOpenApiDocument', () => {
- it('documents only the allowlisted tRPC procedures', () => {
+ it('documents only the allowlisted API paths', () => {
const document = generateTrpcOpenApiDocument();
expect(Object.keys(document.paths).sort()).toEqual(
- publicTrpcOpenApiProcedures.map(procedure => `/api/trpc/${procedure.procedurePath}`).sort()
+ [
+ ...publicTrpcOpenApiProcedures.map(procedure => `/api/trpc/${procedure.procedurePath}`),
+ ...publicRestOpenApiRoutes.map(route => route.path),
+ ].sort()
);
expect(document.paths['/api/trpc/usageAnalytics.getTable']?.get).toMatchObject({
operationId: 'usageAnalytics_getTable',
@@ -55,6 +58,58 @@ describe('generateTrpcOpenApiDocument', () => {
expect(document.paths).not.toHaveProperty('/api/trpc/admin');
});
+ it('generates the REST organization members endpoint', () => {
+ const document = generateTrpcOpenApiDocument();
+ const operation = document.paths['/api/v1/organizations/{id}/members']?.get as {
+ responses: Record }> }>;
+ };
+ const responseSchema = operation?.responses['200'].content['application/json'].schema;
+
+ expect(document.info.title).toBe('Kilo Code API');
+ expect(operation).toMatchObject({
+ operationId: 'organizations_getMembers',
+ summary: 'Return organization members',
+ tags: ['Organizations'],
+ parameters: [
+ {
+ name: 'id',
+ in: 'path',
+ required: true,
+ schema: { type: 'string' },
+ },
+ ],
+ responses: {
+ '200': {
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'array',
+ },
+ },
+ },
+ },
+ },
+ });
+ expect(responseSchema.items.oneOf).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ required: expect.arrayContaining(['id', 'name', 'email', 'status']),
+ properties: expect.objectContaining({
+ status: { type: 'string', const: 'active' },
+ }),
+ }),
+ expect.objectContaining({
+ required: expect.arrayContaining(['email', 'status']),
+ properties: expect.objectContaining({
+ status: { type: 'string', const: 'invited' },
+ }),
+ }),
+ ])
+ );
+ expect(JSON.stringify(responseSchema)).not.toContain('inviteToken');
+ expect(JSON.stringify(responseSchema)).not.toContain('inviteUrl');
+ });
+
it('documents bearer auth metadata for protected procedures', () => {
const document = generateTrpcOpenApiDocument();
diff --git a/apps/web/src/lib/openapi/trpc-openapi.ts b/apps/web/src/lib/openapi/trpc-openapi.ts
index 66ddcc20b1..6194aaf19e 100644
--- a/apps/web/src/lib/openapi/trpc-openapi.ts
+++ b/apps/web/src/lib/openapi/trpc-openapi.ts
@@ -1,10 +1,17 @@
import * as z from 'zod';
import {
+ publicRestOpenApiRoutes,
publicTrpcOpenApiProcedures,
+ type RestOpenApiRoute,
type TrpcOpenApiProcedure,
} from '@/lib/openapi/trpc-registry';
import { TrpcErrorResponseSchema, trpcSuccessResponseJsonSchema } from '@/lib/trpc/transport';
+const RestErrorResponseSchema = z.object({
+ error: z.string(),
+ message: z.string().optional(),
+});
+
type JsonSchema = Record;
type OpenApiDocument = {
@@ -38,6 +45,10 @@ function successResponseSchema(data: JsonSchema): JsonSchema {
return trpcSuccessResponseJsonSchema(data);
}
+function jsonResponseSchema(data: JsonSchema): JsonSchema {
+ return data;
+}
+
function errorResponse(description: string) {
return {
description,
@@ -82,6 +93,20 @@ function requestShapeForProcedure(procedure: TrpcOpenApiProcedure) {
};
}
+function pathParametersForRoute(route: RestOpenApiRoute) {
+ if (!route.pathParameters || route.pathParameters.length === 0) return {};
+
+ return {
+ parameters: route.pathParameters.map(parameter => ({
+ name: parameter.name,
+ in: 'path',
+ required: true,
+ description: parameter.description,
+ schema: zodToJsonSchema(parameter.schema),
+ })),
+ };
+}
+
function operationForProcedure(procedure: TrpcOpenApiProcedure) {
return {
operationId: procedure.procedurePath.replaceAll('.', '_'),
@@ -107,6 +132,59 @@ function operationForProcedure(procedure: TrpcOpenApiProcedure) {
};
}
+function operationForRoute(route: RestOpenApiRoute) {
+ return {
+ operationId: route.operationId,
+ tags: route.tags,
+ summary: route.summary,
+ description: route.description,
+ security: [{ bearerAuth: [] }],
+ ...pathParametersForRoute(route),
+ responses: {
+ '200': {
+ description: 'Successful response',
+ content: {
+ 'application/json': {
+ schema: jsonResponseSchema(zodToJsonSchema(route.output)),
+ },
+ },
+ },
+ '400': {
+ ...errorResponse('Invalid request'),
+ content: {
+ 'application/json': {
+ schema: zodToJsonSchema(RestErrorResponseSchema),
+ },
+ },
+ },
+ '401': {
+ ...errorResponse('Authentication required'),
+ content: {
+ 'application/json': {
+ schema: zodToJsonSchema(RestErrorResponseSchema),
+ },
+ },
+ },
+ '403': {
+ ...errorResponse('Access denied'),
+ content: {
+ 'application/json': {
+ schema: zodToJsonSchema(RestErrorResponseSchema),
+ },
+ },
+ },
+ '500': {
+ ...errorResponse('Unexpected server error'),
+ content: {
+ 'application/json': {
+ schema: zodToJsonSchema(RestErrorResponseSchema),
+ },
+ },
+ },
+ },
+ };
+}
+
export function generateTrpcOpenApiDocument(): OpenApiDocument {
const paths: OpenApiDocument['paths'] = {};
const tagNames = new Set();
@@ -120,10 +198,18 @@ export function generateTrpcOpenApiDocument(): OpenApiDocument {
};
}
+ for (const route of publicRestOpenApiRoutes) {
+ for (const tag of route.tags) tagNames.add(tag);
+ paths[route.path] = {
+ ...paths[route.path],
+ [route.method]: operationForRoute(route),
+ };
+ }
+
return {
openapi: '3.1.0',
info: {
- title: 'Kilo Code tRPC API',
+ title: 'Kilo Code API',
version: '1.0.0',
},
servers: [{ url: '/' }],
diff --git a/apps/web/src/lib/openapi/trpc-registry.ts b/apps/web/src/lib/openapi/trpc-registry.ts
index 0770b36ee7..0134fef778 100644
--- a/apps/web/src/lib/openapi/trpc-registry.ts
+++ b/apps/web/src/lib/openapi/trpc-registry.ts
@@ -1,5 +1,6 @@
import type { inferRouterInputs } from '@trpc/server';
-import type * as z from 'zod';
+import * as z from 'zod';
+import { PublicOrganizationMembersSchema } from '@/lib/organizations/organization-types';
import type { usageAnalyticsRouter } from '@/routers/usage-analytics-router';
import {
BreakdownInputSchema,
@@ -22,6 +23,21 @@ export type TrpcOpenApiProcedure = {
output: z.ZodType;
};
+export type RestOpenApiRoute = {
+ path: string;
+ method: 'get' | 'post';
+ operationId: string;
+ tags: string[];
+ summary: string;
+ description?: string;
+ pathParameters?: Array<{
+ name: string;
+ description: string;
+ schema: z.ZodType;
+ }>;
+ output: z.ZodType;
+};
+
type UsageAnalyticsProcedureKey = Extract<
keyof inferRouterInputs,
string
@@ -79,3 +95,23 @@ export const publicTrpcOpenApiProcedures = [
output: TableOutputSchema,
}),
];
+
+export const publicRestOpenApiRoutes = [
+ {
+ path: '/api/v1/organizations/{id}/members',
+ method: 'get',
+ operationId: 'organizations_getMembers',
+ tags: ['Organizations'],
+ summary: 'Return organization members',
+ description:
+ 'Returns active and invited members for an organization the authenticated user can access. Invite tokens and invite URLs are omitted from the response.',
+ pathParameters: [
+ {
+ name: 'id',
+ description: 'Organization ID.',
+ schema: z.string(),
+ },
+ ],
+ output: PublicOrganizationMembersSchema,
+ },
+] satisfies RestOpenApiRoute[];
diff --git a/apps/web/src/lib/organizations/organization-types.ts b/apps/web/src/lib/organizations/organization-types.ts
index c1dde9b444..9f1e77b083 100644
--- a/apps/web/src/lib/organizations/organization-types.ts
+++ b/apps/web/src/lib/organizations/organization-types.ts
@@ -21,6 +21,8 @@ import type { OrganizationRole, OrganizationPlan } from './organization-base-typ
import { OrganizationPlanSchema, OrganizationSettingsSchema } from './organization-base-types';
import { OpenCodeSettingsSchema } from '@kilocode/db/schema-types';
+export const OrganizationRoleSchema = z.enum(['owner', 'member', 'billing_manager']);
+
// API-facing billing cycle values: 'monthly' | 'annual'
// The DB stores 'yearly' instead of 'annual'; Stripe uses 'year'/'month'.
export const BillingCycleSchema = z.enum(['monthly', 'annual']);
@@ -90,6 +92,18 @@ export type UserOrganizationWithSeats = {
};
};
+export const InvitedOrganizationMemberSchema = z.object({
+ email: z.string(),
+ role: OrganizationRoleSchema,
+ inviteDate: z.string().nullable(),
+ inviteToken: z.string(),
+ inviteId: z.string(),
+ status: z.literal('invited'),
+ inviteUrl: z.string(),
+ dailyUsageLimitUsd: z.number().nullable(),
+ currentDailyUsageUsd: z.number().nullable(),
+});
+
type InvitedMember = {
email: string;
role: OrganizationRole;
@@ -102,6 +116,17 @@ type InvitedMember = {
currentDailyUsageUsd: number | null;
};
+export const ActiveOrganizationMemberSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ role: OrganizationRoleSchema,
+ status: z.literal('active'),
+ inviteDate: z.string().nullable(),
+ dailyUsageLimitUsd: z.number().nullable(),
+ currentDailyUsageUsd: z.number().nullable(),
+});
+
type ActiveMember = {
id: string;
name: string;
@@ -115,6 +140,28 @@ type ActiveMember = {
export type OrganizationMember = InvitedMember | ActiveMember;
+export const OrganizationMemberSchema = z.discriminatedUnion('status', [
+ ActiveOrganizationMemberSchema,
+ InvitedOrganizationMemberSchema,
+]);
+
+export const PublicInvitedOrganizationMemberSchema = InvitedOrganizationMemberSchema.omit({
+ inviteToken: true,
+ inviteId: true,
+ inviteUrl: true,
+});
+
+export const PublicOrganizationMemberSchema = z.discriminatedUnion('status', [
+ ActiveOrganizationMemberSchema,
+ PublicInvitedOrganizationMemberSchema,
+]);
+
+export const PublicOrganizationMembersSchema = z.array(PublicOrganizationMemberSchema);
+
+export const OrganizationWithMembersSchema = OrganizationSchema.extend({
+ members: z.array(OrganizationMemberSchema),
+});
+
export type OrganizationSsoPolicyView = {
required: boolean;
source: 'self' | 'direct_parent' | null;
diff --git a/apps/web/src/routers/usage-analytics-router.test.ts b/apps/web/src/routers/usage-analytics-router.test.ts
index efe2113446..c1c4225101 100644
--- a/apps/web/src/routers/usage-analytics-router.test.ts
+++ b/apps/web/src/routers/usage-analytics-router.test.ts
@@ -1,10 +1,33 @@
jest.mock('@/lib/redis', () => ({ redisClient: {} }));
+jest.mock('@/lib/snowflake', () => ({
+ executeSnowflakeStatement: jest.fn(),
+ resolveSnowflakeConfig: jest.fn(() => ({ account: 'test-account' })),
+}));
+
+jest.mock('@/lib/drizzle', () => ({
+ readDb: { select: jest.fn() },
+}));
+
+import type { User } from '@kilocode/db/schema';
+import { readDb } from '@/lib/drizzle';
+import { executeSnowflakeStatement } from '@/lib/snowflake';
+import { createCallerFactory } from '@/lib/trpc/init';
import {
CostSourceSchema,
UsageAnalyticsFiltersSchema,
+ applySelfEmailExclusion,
+ buildWhereClause,
+ buildScopedUserEmailMaps,
costColumnFor,
costSumExprSql,
+ dimensionDisplayValue,
+ displayBreakdownValues,
+ scopedUserEmailBreakdownIds,
+ shouldLoadFullOrgWideUserEmailMap,
+ userEmailMapValuesSql,
+ userDisplayValue,
+ usageAnalyticsRouter,
} from './usage-analytics-router';
const baseFilters = {
@@ -13,7 +36,37 @@ const baseFilters = {
granularity: 'day' as const,
};
+const createCaller = createCallerFactory(usageAnalyticsRouter);
+const mockExecuteSnowflakeStatement = jest.mocked(executeSnowflakeStatement);
+const mockReadDbSelect = jest.mocked(readDb.select);
+const mockAuthProviderRows: Array<{
+ userId: string;
+ provider: 'github' | 'google';
+ providerAccountId: string;
+}> = [];
+
+function createUsageAnalyticsCaller() {
+ return createCaller({
+ user: {
+ id: 'user_1',
+ google_user_email: 'person@example.com',
+ is_admin: false,
+ } as User,
+ });
+}
+
describe('usage analytics cost source', () => {
+ beforeEach(() => {
+ mockExecuteSnowflakeStatement.mockReset();
+ mockExecuteSnowflakeStatement.mockResolvedValue([['person@example.com', '110']]);
+ mockAuthProviderRows.splice(0, mockAuthProviderRows.length);
+ mockReadDbSelect.mockReturnValue({
+ from: jest.fn(() => ({
+ where: jest.fn(async () => mockAuthProviderRows),
+ })),
+ } as never);
+ });
+
it('defaults to billable cost for existing clients', () => {
expect(UsageAnalyticsFiltersSchema.parse(baseFilters).costSource).toBe('cost');
expect(costColumnFor('cost')).toBe('total_cost_microdollars');
@@ -33,4 +86,264 @@ describe('usage analytics cost source', () => {
CostSourceSchema.safeParse('total_cost_microdollars); DROP TABLE usage; --').success
).toBe(false);
});
+
+ it('accepts email-based user filtering and email display mode', () => {
+ const filters = UsageAnalyticsFiltersSchema.parse({
+ ...baseFilters,
+ userEmails: ['person@example.com'],
+ excludedUserEmails: ['excluded@example.com'],
+ userDisplay: 'email',
+ });
+
+ expect(filters.userEmails).toEqual(['person@example.com']);
+ expect(filters.excludedUserEmails).toEqual(['excluded@example.com']);
+ expect(filters.userDisplay).toBe('email');
+ });
+
+ it('builds scoped email maps with canonical and legacy OAuth user ids', () => {
+ const maps = buildScopedUserEmailMaps(
+ [
+ { id: 'user_1', email: 'person@example.com' },
+ { id: 'user_2', email: 'other@example.com' },
+ { id: 'user_without_email', email: null },
+ ],
+ [
+ { userId: 'user_1', provider: 'github', providerAccountId: '123' },
+ { userId: 'user_1', provider: 'google', providerAccountId: 'abc' },
+ { userId: 'missing_user', provider: 'github', providerAccountId: 'ignored' },
+ ]
+ );
+
+ expect(maps.idsByEmail.get('person@example.com')).toEqual([
+ 'user_1',
+ 'oauth/github:123',
+ 'oauth/google:abc',
+ ]);
+ expect(maps.idsByEmail.get('other@example.com')).toEqual(['user_2']);
+ expect(maps.emailsById.get('user_1')).toBe('person@example.com');
+ expect(maps.emailsById.get('oauth/github:123')).toBe('person@example.com');
+ expect(maps.emailsById.has('oauth/github:ignored')).toBe(false);
+ expect(maps.emailsById.has('user_without_email')).toBe(false);
+ });
+
+ it('translates self-scope excluded user emails to excluded user ids', () => {
+ const filters = UsageAnalyticsFiltersSchema.parse({
+ ...baseFilters,
+ excludedUserEmails: ['person@example.com'],
+ });
+
+ expect(
+ applySelfEmailExclusion(filters, 'user_1', 'person@example.com').excludedUserIds
+ ).toEqual(['user_1']);
+ expect(applySelfEmailExclusion(filters, 'user_1', 'other@example.com').excludedUserIds).toBe(
+ undefined
+ );
+ });
+
+ it('loads full org email maps only for email display requests', () => {
+ const filters = UsageAnalyticsFiltersSchema.parse({
+ ...baseFilters,
+ organizationId: '00000000-0000-4000-8000-000000000001',
+ viewAs: 'org-wide',
+ userEmails: ['person@example.com'],
+ });
+
+ expect(shouldLoadFullOrgWideUserEmailMap(filters, false)).toBe(false);
+ expect(shouldLoadFullOrgWideUserEmailMap(filters, true)).toBe(false);
+ expect(shouldLoadFullOrgWideUserEmailMap({ ...filters, userDisplay: 'email' }, true)).toBe(
+ true
+ );
+ });
+
+ it('does not fall back to raw user ids for unmapped email display values', () => {
+ const userEmailsById = new Map([['user_1', 'person@example.com']]);
+
+ expect(userDisplayValue({ userDisplay: 'email' }, userEmailsById, 'user_1')).toBe(
+ 'person@example.com'
+ );
+ expect(userDisplayValue({ userDisplay: 'email' }, userEmailsById, 'missing_user')).toBe('');
+ expect(userDisplayValue({ userDisplay: 'id' }, userEmailsById, 'missing_user')).toBe(
+ 'missing_user'
+ );
+ expect(dimensionDisplayValue('model', { userDisplay: 'email' }, userEmailsById, 'claude')).toBe(
+ 'claude'
+ );
+ });
+
+ it('aggregates breakdown user ids by email before applying the display limit', () => {
+ const userEmailsById = new Map([
+ ['user_1', 'person@example.com'],
+ ['oauth/github:123', 'person@example.com'],
+ ['user_2', 'other@example.com'],
+ ]);
+
+ expect(
+ displayBreakdownValues(
+ 'user',
+ { userDisplay: 'email' },
+ userEmailsById,
+ [
+ { key: 'user_2', label: 'user_2', value: 80 },
+ { key: 'user_1', label: 'user_1', value: 60 },
+ { key: 'oauth/github:123', label: 'oauth/github:123', value: 50 },
+ { key: 'missing_user_1', label: 'missing_user_1', value: 6 },
+ { key: 'missing_user_2', label: 'missing_user_2', value: 4 },
+ ],
+ 2
+ )
+ ).toEqual([
+ { key: 'person@example.com', label: 'person@example.com', value: 110 },
+ { key: 'other@example.com', label: 'other@example.com', value: 80 },
+ ]);
+ });
+
+ it('builds bound SQL values for email-display user aggregation', () => {
+ const maps = buildScopedUserEmailMaps(
+ [
+ { id: 'user_1', email: 'person@example.com' },
+ { id: 'user_2', email: 'other@example.com' },
+ ],
+ [{ userId: 'user_1', provider: 'github', providerAccountId: '123' }]
+ );
+
+ const values = userEmailMapValuesSql(maps);
+
+ expect(values?.valuesSql).toBe('(?, ?), (?, ?), (?, ?)');
+ expect(values?.valuesSql).not.toContain('person@example.com');
+ expect(values?.valuesSql).not.toContain('oauth/github:123');
+ expect(values?.bindings).toEqual([
+ { type: 'TEXT', value: 'user_1' },
+ { type: 'TEXT', value: 'person@example.com' },
+ { type: 'TEXT', value: 'user_2' },
+ { type: 'TEXT', value: 'other@example.com' },
+ { type: 'TEXT', value: 'oauth/github:123' },
+ { type: 'TEXT', value: 'person@example.com' },
+ ]);
+ });
+
+ it('getBreakdown aggregates email-display user buckets in SQL before limiting', async () => {
+ mockAuthProviderRows.push({
+ userId: 'user_1',
+ provider: 'github',
+ providerAccountId: '123',
+ });
+
+ const caller = createUsageAnalyticsCaller();
+
+ await caller.getBreakdown({
+ ...baseFilters,
+ dimension: 'user',
+ metric: 'cost',
+ userDisplay: 'email',
+ limit: 2,
+ });
+
+ expect(mockExecuteSnowflakeStatement).toHaveBeenCalledTimes(1);
+ const statement = mockExecuteSnowflakeStatement.mock.calls[0][0].statement as string;
+ expect(statement).toContain('WITH user_email_map(mapped_user_id, mapped_email) AS');
+ expect(statement).toContain('FROM VALUES (?, ?), (?, ?)');
+ expect(statement).toContain('JOIN user_email_map ON kilo_user_id = mapped_user_id');
+ expect(statement).toContain('mapped_email AS key');
+ expect(statement).toContain('GROUP BY 1');
+ expect(statement).toContain('ORDER BY 2 DESC');
+ expect(statement).toContain('LIMIT 2');
+ expect(statement).not.toContain('person@example.com');
+ expect(statement).not.toContain('oauth/github:123');
+ });
+
+ it('getBreakdown wires scoped self email identities into the self-scope predicate', async () => {
+ mockAuthProviderRows.push({
+ userId: 'user_1',
+ provider: 'github',
+ providerAccountId: '123',
+ });
+
+ const caller = createUsageAnalyticsCaller();
+
+ await caller.getBreakdown({
+ ...baseFilters,
+ dimension: 'user',
+ metric: 'cost',
+ userDisplay: 'email',
+ limit: 2,
+ });
+
+ const call = mockExecuteSnowflakeStatement.mock.calls[0][0];
+ const statement = call.statement as string;
+ expect(statement).toContain('kilo_user_id IN (?, ?)');
+ expect(statement).not.toContain('kilo_user_id = ?');
+ expect(call.bindings).toEqual([
+ { type: 'TEXT', value: 'user_1' },
+ { type: 'TEXT', value: 'person@example.com' },
+ { type: 'TEXT', value: 'oauth/github:123' },
+ { type: 'TEXT', value: 'person@example.com' },
+ { type: 'TEXT', value: '2026-06-04' },
+ { type: 'TEXT', value: '2026-06-05' },
+ { type: 'TEXT', value: 'user_1' },
+ { type: 'TEXT', value: 'oauth/github:123' },
+ { type: 'TEXT', value: '' },
+ ]);
+ });
+
+ it('uses scoped self ids instead of intersecting email breakdowns with the canonical user id', () => {
+ const filters = UsageAnalyticsFiltersSchema.parse({
+ ...baseFilters,
+ userDisplay: 'email',
+ });
+
+ const where = buildWhereClause('daily', filters, 'user_1', true, [
+ 'user_1',
+ 'oauth/github:123',
+ ]);
+
+ expect(where.sql()).toContain('kilo_user_id IN (?, ?)');
+ expect(where.sql()).not.toContain('kilo_user_id = ?');
+ expect(where.bindings.map(binding => binding.value)).toEqual(
+ expect.arrayContaining(['user_1', 'oauth/github:123'])
+ );
+ });
+
+ it('uses scoped self ids for organization self-scope email breakdowns', () => {
+ const filters = UsageAnalyticsFiltersSchema.parse({
+ ...baseFilters,
+ organizationId: '00000000-0000-4000-8000-000000000001',
+ viewAs: 'self',
+ userDisplay: 'email',
+ });
+
+ const where = buildWhereClause('daily', filters, 'user_1', true, [
+ 'user_1',
+ 'oauth/github:123',
+ ]);
+
+ expect(where.sql()).toContain('organization_id = ?');
+ expect(where.sql()).toContain('kilo_user_id IN (?, ?)');
+ expect(where.sql()).not.toContain('kilo_user_id = ?');
+ expect(where.bindings.map(binding => binding.value)).toEqual(
+ expect.arrayContaining(['00000000-0000-4000-8000-000000000001', 'user_1', 'oauth/github:123'])
+ );
+ });
+
+ it('scopes email-display user breakdown queries to mapped user identities', () => {
+ const maps = buildScopedUserEmailMaps(
+ [
+ { id: 'user_1', email: 'person@example.com' },
+ { id: 'user_2', email: 'other@example.com' },
+ ],
+ [
+ { userId: 'user_1', provider: 'github', providerAccountId: '123' },
+ { userId: 'user_1', provider: 'github', providerAccountId: '123' },
+ { userId: 'user_2', provider: 'google', providerAccountId: 'abc' },
+ ]
+ );
+
+ expect(scopedUserEmailBreakdownIds('user', { userDisplay: 'email' }, maps)).toEqual([
+ 'user_1',
+ 'oauth/github:123',
+ 'user_2',
+ 'oauth/google:abc',
+ ]);
+ expect(scopedUserEmailBreakdownIds('user', { userDisplay: 'id' }, maps)).toBe(undefined);
+ expect(scopedUserEmailBreakdownIds('model', { userDisplay: 'email' }, maps)).toBe(undefined);
+ });
});
diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts
index 35858b2b13..f9e485ef8d 100644
--- a/apps/web/src/routers/usage-analytics-router.ts
+++ b/apps/web/src/routers/usage-analytics-router.ts
@@ -137,7 +137,7 @@ function ceilIsoToUtcMonthExclusive(iso: string): string {
* Accumulates SQL WHERE clauses with positional `?` bindings.
* Callers push conditions in any order; `sql()` joins them with AND.
*/
-class WhereBuilder {
+export class WhereBuilder {
readonly clauses: string[] = [];
readonly bindings: SnowflakeBinding[] = [];
@@ -197,6 +197,7 @@ class WhereBuilder {
async function ensureScopeAccess(ctx: TRPCContext, filters: UsageAnalyticsFilters): Promise {
const userId = ctx.user.id;
+ const userEmail = ctx.user.google_user_email;
if (filters.organizationId) {
const requiredRoles =
filters.viewAs === 'org-wide' ? (['owner', 'billing_manager'] as const) : undefined;
@@ -214,6 +215,16 @@ async function ensureScopeAccess(ctx: TRPCContext, filters: UsageAnalyticsFilter
message: 'Self-scope analytics can only filter to own user.',
});
}
+ const allUserEmailFilterValues = [
+ ...(filters.userEmails ?? []),
+ ...(filters.excludedUserEmails ?? []),
+ ];
+ if (allUserEmailFilterValues.some(v => v !== userEmail)) {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ message: 'Self-scope analytics can only filter to own user.',
+ });
+ }
}
return;
}
@@ -225,6 +236,329 @@ async function ensureScopeAccess(ctx: TRPCContext, filters: UsageAnalyticsFilter
message: 'Personal analytics can only filter to own user.',
});
}
+ const allUserEmailFilterValues = [
+ ...(filters.userEmails ?? []),
+ ...(filters.excludedUserEmails ?? []),
+ ];
+ if (allUserEmailFilterValues.some(v => v !== userEmail)) {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ message: 'Personal analytics can only filter to own user.',
+ });
+ }
+}
+
+const NO_MATCHING_USER_EMAIL_ID = '__no_matching_kilo_user_email__';
+
+type ScopedUserEmailMaps = {
+ idsByEmail: Map;
+ emailsById: Map;
+};
+
+type ScopedUserEmailMapUser = {
+ id: string;
+ email: string | null;
+};
+
+type ScopedUserEmailMapAuthProvider = {
+ userId: string;
+ provider: AuthProviderId;
+ providerAccountId: string;
+};
+
+function uniqueStrings(values: string[]): string[] {
+ return Array.from(new Set(values));
+}
+
+function addScopedUserId(maps: ScopedUserEmailMaps, email: string, userId: string): void {
+ maps.emailsById.set(userId, email);
+ maps.idsByEmail.set(email, [...(maps.idsByEmail.get(email) ?? []), userId]);
+}
+
+export function buildScopedUserEmailMaps(
+ users: ScopedUserEmailMapUser[],
+ authProviders: ScopedUserEmailMapAuthProvider[]
+): ScopedUserEmailMaps {
+ const maps: ScopedUserEmailMaps = { idsByEmail: new Map(), emailsById: new Map() };
+
+ for (const user of users) {
+ if (!user.email) continue;
+ addScopedUserId(maps, user.email, user.id);
+ }
+
+ for (const authProvider of authProviders) {
+ const email = maps.emailsById.get(authProvider.userId);
+ if (!email) continue;
+ addScopedUserId(
+ maps,
+ email,
+ `oauth/${authProvider.provider}:${authProvider.providerAccountId}`
+ );
+ }
+
+ for (const [email, ids] of maps.idsByEmail) {
+ maps.idsByEmail.set(email, uniqueStrings(ids));
+ }
+
+ return maps;
+}
+
+async function loadOrgWideUserEmailMaps(organizationId: string): Promise {
+ const users = await readDb
+ .select({ id: kilocode_users.id, email: kilocode_users.google_user_email })
+ .from(kilocode_users)
+ .innerJoin(
+ organization_memberships,
+ and(
+ eq(organization_memberships.kilo_user_id, kilocode_users.id),
+ eq(organization_memberships.organization_id, organizationId)
+ )
+ );
+
+ const userIds = users.map(user => user.id);
+ if (userIds.length === 0) return buildScopedUserEmailMaps(users, []);
+
+ const authProviders = await readDb
+ .select({
+ userId: user_auth_provider.kilo_user_id,
+ provider: user_auth_provider.provider,
+ providerAccountId: user_auth_provider.provider_account_id,
+ })
+ .from(user_auth_provider)
+ .where(inArray(user_auth_provider.kilo_user_id, userIds));
+
+ return buildScopedUserEmailMaps(users, authProviders);
+}
+
+async function loadOrgWideUserEmailFilterMaps(
+ organizationId: string,
+ emails: string[]
+): Promise {
+ const uniqueEmails = uniqueStrings(emails);
+ if (uniqueEmails.length === 0) return buildScopedUserEmailMaps([], []);
+
+ const users = await readDb
+ .select({ id: kilocode_users.id, email: kilocode_users.google_user_email })
+ .from(kilocode_users)
+ .innerJoin(
+ organization_memberships,
+ and(
+ eq(organization_memberships.kilo_user_id, kilocode_users.id),
+ eq(organization_memberships.organization_id, organizationId)
+ )
+ )
+ .where(inArray(kilocode_users.google_user_email, uniqueEmails));
+
+ const userIds = users.map(user => user.id);
+ if (userIds.length === 0) return buildScopedUserEmailMaps(users, []);
+
+ const authProviders = await readDb
+ .select({
+ userId: user_auth_provider.kilo_user_id,
+ provider: user_auth_provider.provider,
+ providerAccountId: user_auth_provider.provider_account_id,
+ })
+ .from(user_auth_provider)
+ .where(inArray(user_auth_provider.kilo_user_id, userIds));
+
+ return buildScopedUserEmailMaps(users, authProviders);
+}
+
+function needsOrgWideUserEmailMaps(
+ filters: UsageAnalyticsFilters,
+ includeUserEmailDisplay: boolean
+): filters is UsageAnalyticsFilters & {
+ organizationId: string;
+ viewAs: 'org-wide';
+} {
+ return Boolean(
+ filters.organizationId &&
+ filters.viewAs === 'org-wide' &&
+ ((includeUserEmailDisplay && filters.userDisplay === 'email') ||
+ (filters.userEmails && filters.userEmails.length > 0) ||
+ (filters.excludedUserEmails && filters.excludedUserEmails.length > 0))
+ );
+}
+
+export function shouldLoadFullOrgWideUserEmailMap(
+ filters: UsageAnalyticsFilters,
+ includeUserEmailDisplay: boolean
+): boolean {
+ return Boolean(
+ filters.organizationId &&
+ filters.viewAs === 'org-wide' &&
+ includeUserEmailDisplay &&
+ filters.userDisplay === 'email'
+ );
+}
+
+function lookupScopedUserIdsByEmails(maps: ScopedUserEmailMaps, emails: string[] = []): string[] {
+ return uniqueStrings(emails.flatMap(email => maps.idsByEmail.get(email) ?? []));
+}
+
+function emailFilterValues(filters: UsageAnalyticsFilters): string[] {
+ return uniqueStrings([...(filters.userEmails ?? []), ...(filters.excludedUserEmails ?? [])]);
+}
+
+export function applySelfEmailExclusion(
+ filters: T,
+ userId: string,
+ userEmail: string | null
+): T {
+ if (!userEmail || !filters.excludedUserEmails?.includes(userEmail)) return filters;
+ return {
+ ...filters,
+ excludedUserIds: uniqueStrings([...(filters.excludedUserIds ?? []), userId]),
+ };
+}
+
+async function loadSelfUserEmailMaps(ctx: TRPCContext): Promise {
+ const authProviders = await readDb
+ .select({
+ userId: user_auth_provider.kilo_user_id,
+ provider: user_auth_provider.provider,
+ providerAccountId: user_auth_provider.provider_account_id,
+ })
+ .from(user_auth_provider)
+ .where(eq(user_auth_provider.kilo_user_id, ctx.user.id));
+
+ return buildScopedUserEmailMaps(
+ [{ id: ctx.user.id, email: ctx.user.google_user_email }],
+ authProviders
+ );
+}
+
+async function prepareUsageFilters(
+ ctx: TRPCContext,
+ filters: T,
+ includeUserEmailDisplay = false
+): Promise<{ filters: T; scopedUserEmailMaps?: ScopedUserEmailMaps }> {
+ await ensureScopeAccess(ctx, filters);
+ const needsOrgWideMaps = needsOrgWideUserEmailMaps(filters, includeUserEmailDisplay);
+ const needsUserEmailDisplay = includeUserEmailDisplay && filters.userDisplay === 'email';
+ const prepared = { ...filters };
+
+ if (needsUserEmailDisplay && !needsOrgWideMaps) {
+ return {
+ filters: applySelfEmailExclusion(prepared, ctx.user.id, ctx.user.google_user_email),
+ scopedUserEmailMaps: await loadSelfUserEmailMaps(ctx),
+ };
+ }
+
+ if (!needsOrgWideMaps) {
+ return { filters: applySelfEmailExclusion(prepared, ctx.user.id, ctx.user.google_user_email) };
+ }
+
+ const scopedUserEmailMaps = shouldLoadFullOrgWideUserEmailMap(filters, includeUserEmailDisplay)
+ ? await loadOrgWideUserEmailMaps(filters.organizationId)
+ : await loadOrgWideUserEmailFilterMaps(filters.organizationId, emailFilterValues(filters));
+ const userIdsFromEmails = lookupScopedUserIdsByEmails(scopedUserEmailMaps, filters.userEmails);
+ const excludedUserIdsFromEmails = lookupScopedUserIdsByEmails(
+ scopedUserEmailMaps,
+ filters.excludedUserEmails
+ );
+
+ if (filters.userEmails && filters.userEmails.length > 0) {
+ prepared.userIds = uniqueStrings([
+ ...(filters.userIds ?? []),
+ ...userIdsFromEmails,
+ NO_MATCHING_USER_EMAIL_ID,
+ ]);
+ }
+ if (filters.excludedUserEmails && filters.excludedUserEmails.length > 0) {
+ prepared.excludedUserIds = uniqueStrings([
+ ...(filters.excludedUserIds ?? []),
+ ...excludedUserIdsFromEmails,
+ ]);
+ }
+ return { filters: prepared, scopedUserEmailMaps };
+}
+
+function userEmailsForDisplay(
+ filters: UsageAnalyticsFilters,
+ scopedUserEmailMaps: ScopedUserEmailMaps | undefined
+): Map {
+ return filters.userDisplay === 'email'
+ ? (scopedUserEmailMaps?.emailsById ?? new Map())
+ : new Map();
+}
+
+export function userDisplayValue(
+ filters: Pick,
+ userEmailsById: Map,
+ rawUserId: string
+): string {
+ return filters.userDisplay === 'email' ? (userEmailsById.get(rawUserId) ?? '') : rawUserId;
+}
+
+export function dimensionDisplayValue(
+ dimension: Dimension,
+ filters: Pick,
+ userEmailsById: Map,
+ rawValue: string
+): string {
+ return dimension === 'user' ? userDisplayValue(filters, userEmailsById, rawValue) : rawValue;
+}
+
+type BreakdownValue = {
+ key: string;
+ label: string;
+ value: number;
+};
+
+export function displayBreakdownValues(
+ dimension: Dimension,
+ filters: Pick,
+ userEmailsById: Map,
+ values: BreakdownValue[],
+ limit: number
+): BreakdownValue[] {
+ const valuesByDisplayKey = new Map();
+
+ for (const value of values) {
+ const label = dimensionDisplayValue(dimension, filters, userEmailsById, value.key);
+ const displayKey = dimension === 'user' && filters.userDisplay === 'email' ? label : value.key;
+ const existing = valuesByDisplayKey.get(displayKey);
+ if (existing) {
+ existing.value += value.value;
+ continue;
+ }
+ valuesByDisplayKey.set(displayKey, {
+ key: displayKey,
+ label,
+ value: value.value,
+ });
+ }
+
+ return Array.from(valuesByDisplayKey.values())
+ .sort((a, b) => b.value - a.value)
+ .slice(0, limit);
+}
+
+export function scopedUserEmailBreakdownIds(
+ dimension: Dimension,
+ filters: Pick,
+ scopedUserEmailMaps: ScopedUserEmailMaps | undefined
+): string[] | undefined {
+ if (dimension !== 'user' || filters.userDisplay !== 'email') return undefined;
+ return uniqueStrings(Array.from(scopedUserEmailMaps?.idsByEmail.values() ?? []).flat());
+}
+
+export function userEmailMapValuesSql(
+ scopedUserEmailMaps: ScopedUserEmailMaps | undefined
+): { valuesSql: string; bindings: SnowflakeBinding[] } | undefined {
+ const entries = Array.from(scopedUserEmailMaps?.emailsById.entries() ?? []);
+ if (entries.length === 0) return undefined;
+
+ const bindings: SnowflakeBinding[] = [];
+ for (const [userId, email] of entries) {
+ bindings.push({ type: 'TEXT', value: userId }, { type: 'TEXT', value: email });
+ }
+
+ return {
+ valuesSql: entries.map(() => '(?, ?)').join(', '),
+ bindings,
+ };
}
// ---------------------------------------------------------------------------
@@ -259,12 +593,26 @@ function buildDateConditions(
function buildScopeConditions(
where: WhereBuilder,
filters: UsageAnalyticsFilters,
- ctxUserId: string
+ ctxUserId: string,
+ scopedSelfUserIds?: string[]
): void {
+ const selfUserIds =
+ scopedSelfUserIds && scopedSelfUserIds.length > 0 ? scopedSelfUserIds : undefined;
+ const addSelfScope = () => {
+ if (selfUserIds) {
+ where.addIn('kilo_user_id', selfUserIds);
+ } else {
+ where.addEq('kilo_user_id', ctxUserId);
+ }
+ if (filters.excludedUserIds?.includes(ctxUserId)) {
+ where.addNotIn('kilo_user_id', selfUserIds ?? [ctxUserId]);
+ }
+ };
+
if (filters.organizationId) {
where.addEq('organization_id', filters.organizationId);
if (filters.viewAs === 'self') {
- where.addEq('kilo_user_id', ctxUserId);
+ addSelfScope();
} else {
if (filters.userIds && filters.userIds.length > 0) {
where.addIn('kilo_user_id', filters.userIds);
@@ -274,7 +622,7 @@ function buildScopeConditions(
}
}
} else {
- where.addEq('kilo_user_id', ctxUserId);
+ addSelfScope();
if (filters.personalScope === 'personal-only') {
// DBT coalesces personal Snowflake usage rollups to an empty-string sentinel
// so incremental merges can match on organization_id.
@@ -303,15 +651,16 @@ function buildDimensionConditions(where: WhereBuilder, filters: UsageAnalyticsFi
addNotInIfNonEmpty('project_id', filters.excludedProjects);
}
-function buildWhereClause(
+export function buildWhereClause(
tier: GranularityTier,
filters: UsageAnalyticsFilters,
ctxUserId: string,
- includeDimensions: boolean
+ includeDimensions: boolean,
+ scopedSelfUserIds?: string[]
): WhereBuilder {
const where = new WhereBuilder();
buildDateConditions(where, tier, filters);
- buildScopeConditions(where, filters, ctxUserId);
+ buildScopeConditions(where, filters, ctxUserId, scopedSelfUserIds);
if (includeDimensions) {
buildDimensionConditions(where, filters);
}
@@ -530,6 +879,10 @@ function toSafeNumber(value: unknown): number {
return n;
}
+function toStringValue(value: unknown): string {
+ return typeof value === 'string' ? value : '';
+}
+
// ---------------------------------------------------------------------------
// User list (for org context)
// ---------------------------------------------------------------------------
@@ -591,10 +944,10 @@ export const usageAnalyticsRouter = createTRPCRouter({
.input(UsageAnalyticsFiltersSchema)
.output(SummaryOutputSchema)
.query(async ({ input, ctx }): Promise => {
- await ensureScopeAccess(ctx, input);
+ const { filters } = await prepareUsageFilters(ctx, input);
const config = resolveSnowflakeConfig();
- const meta = resolveTier(input.granularity, input.startDate);
+ const meta = resolveTier(filters.granularity, filters.startDate);
if (!config) {
return {
costMicrodollars: 0,
@@ -624,9 +977,9 @@ export const usageAnalyticsRouter = createTRPCRouter({
};
}
const table = getTableName(meta.tier);
- const where = buildWhereClause(meta.tier, input, ctx.user.id, true);
+ const where = buildWhereClause(meta.tier, filters, ctx.user.id, true);
const generationTimeCountExpr = generationTimeCountExprSql(meta.tier);
- const costSumExpr = costSumExprSql(input.costSource);
+ const costSumExpr = costSumExprSql(filters.costSource);
const statement = `
SELECT
@@ -654,8 +1007,8 @@ export const usageAnalyticsRouter = createTRPCRouter({
{
route: 'usageAnalytics.getSummary',
queryLabel: `summary_${meta.tier}`,
- scope: input.organizationId ? 'org' : 'user',
- period: `${input.startDate}/${input.endDate}`,
+ scope: filters.organizationId ? 'org' : 'user',
+ period: `${filters.startDate}/${filters.endDate}`,
},
signal =>
executeSnowflakeStatement({
@@ -663,7 +1016,7 @@ export const usageAnalyticsRouter = createTRPCRouter({
statement,
bindings: where.bindings,
timeoutSeconds: Math.ceil(
- defaultTimeoutForScope(input.organizationId ? 'org' : 'user') / 1000
+ defaultTimeoutForScope(filters.organizationId ? 'org' : 'user') / 1000
),
signal,
})
@@ -720,17 +1073,21 @@ export const usageAnalyticsRouter = createTRPCRouter({
.input(TimeseriesInputSchema)
.output(TimeseriesOutputSchema)
.query(async ({ input, ctx }) => {
- await ensureScopeAccess(ctx, input);
+ const { filters, scopedUserEmailMaps } = await prepareUsageFilters(
+ ctx,
+ input,
+ input.splitBy === 'user'
+ );
const config = resolveSnowflakeConfig();
- const meta = resolveTier(input.granularity, input.startDate);
+ const meta = resolveTier(filters.granularity, filters.startDate);
if (!config) {
return { timeseries: [], effectiveGranularity: meta.effectiveGranularity };
}
const table = getTableName(meta.tier);
const bucketExpr = bucketExprSql(meta.effectiveGranularity, meta.tier);
- const metricExpr = metricExprSql(input.metric, meta.tier, input.costSource);
- const where = buildWhereClause(meta.tier, input, ctx.user.id, true);
+ const metricExpr = metricExprSql(filters.metric, meta.tier, filters.costSource);
+ const where = buildWhereClause(meta.tier, filters, ctx.user.id, true);
let statement: string;
if (input.splitBy) {
@@ -761,8 +1118,8 @@ export const usageAnalyticsRouter = createTRPCRouter({
{
route: 'usageAnalytics.getTimeseries',
queryLabel: `timeseries_${meta.tier}${input.splitBy ? `_split_${input.splitBy}` : ''}`,
- scope: input.organizationId ? 'org' : 'user',
- period: `${input.startDate}/${input.endDate}`,
+ scope: filters.organizationId ? 'org' : 'user',
+ period: `${filters.startDate}/${filters.endDate}`,
},
signal =>
executeSnowflakeStatement({
@@ -770,18 +1127,26 @@ export const usageAnalyticsRouter = createTRPCRouter({
statement,
bindings: where.bindings,
timeoutSeconds: Math.ceil(
- defaultTimeoutForScope(input.organizationId ? 'org' : 'user') / 1000
+ defaultTimeoutForScope(filters.organizationId ? 'org' : 'user') / 1000
),
signal,
})
);
+ const userEmailsById = userEmailsForDisplay(filters, scopedUserEmailMaps);
+
return {
- timeseries: rows.map(row => ({
- datetime: row[0] ?? '',
- value: toSafeNumber(row[1]),
- label: input.splitBy ? (row[2] ?? undefined) : undefined,
- })),
+ timeseries: rows.map(row => {
+ const rawLabel = toStringValue(row[2]);
+ const label = input.splitBy
+ ? dimensionDisplayValue(input.splitBy, filters, userEmailsById, rawLabel)
+ : undefined;
+ return {
+ datetime: toStringValue(row[0]),
+ value: toSafeNumber(row[1]),
+ label: label || undefined,
+ };
+ }),
effectiveGranularity: meta.effectiveGranularity,
};
}),
@@ -790,64 +1155,119 @@ export const usageAnalyticsRouter = createTRPCRouter({
.input(BreakdownInputSchema)
.output(BreakdownOutputSchema)
.query(async ({ input, ctx }) => {
- await ensureScopeAccess(ctx, input);
+ const { filters, scopedUserEmailMaps } = await prepareUsageFilters(
+ ctx,
+ input,
+ input.dimension === 'user'
+ );
const config = resolveSnowflakeConfig();
- const meta = resolveTier(input.granularity, input.startDate);
+ const meta = resolveTier(filters.granularity, filters.startDate);
if (!config) {
return { breakdown: [], totalValue: 0, effectiveGranularity: meta.effectiveGranularity };
}
const table = getTableName(meta.tier);
const dimCol = dimensionColumn(input.dimension);
- const metricExpr = metricExprSql(input.metric, meta.tier, input.costSource);
- const where = buildWhereClause(meta.tier, input, ctx.user.id, true);
+ const metricExpr = metricExprSql(input.metric, meta.tier, filters.costSource);
+ const scopedBreakdownUserIds = scopedUserEmailBreakdownIds(
+ input.dimension,
+ filters,
+ scopedUserEmailMaps
+ );
+ const scopedSelfUserIds =
+ scopedBreakdownUserIds && (filters.viewAs === 'self' || !filters.organizationId)
+ ? scopedBreakdownUserIds
+ : undefined;
+ const where = buildWhereClause(meta.tier, filters, ctx.user.id, true, scopedSelfUserIds);
+ const emailMapValues =
+ input.dimension === 'user' && filters.userDisplay === 'email'
+ ? userEmailMapValuesSql(scopedUserEmailMaps)
+ : undefined;
+
+ if (input.dimension === 'user' && filters.userDisplay === 'email' && !emailMapValues) {
+ return { breakdown: [], totalValue: 0, effectiveGranularity: meta.effectiveGranularity };
+ }
- const statement = `
- SELECT
- ${dimCol} AS key,
- ${metricExpr} AS value
- FROM ${table}
- WHERE ${where.sql()}
- GROUP BY 1
- ORDER BY 2 DESC
- LIMIT ${Number(input.limit)}
- `;
+ if (scopedBreakdownUserIds && !emailMapValues && !scopedSelfUserIds) {
+ if (scopedBreakdownUserIds.length > 0) {
+ where.addIn('kilo_user_id', scopedBreakdownUserIds);
+ } else {
+ where.addEq('kilo_user_id', NO_MATCHING_USER_EMAIL_ID);
+ }
+ }
+
+ const statement = emailMapValues
+ ? `
+ WITH user_email_map(mapped_user_id, mapped_email) AS (
+ SELECT column1, column2
+ FROM VALUES ${emailMapValues.valuesSql}
+ )
+ SELECT
+ mapped_email AS key,
+ ${metricExpr} AS value
+ FROM ${table}
+ JOIN user_email_map ON kilo_user_id = mapped_user_id
+ WHERE ${where.sql()}
+ GROUP BY 1
+ ORDER BY 2 DESC
+ LIMIT ${Number(input.limit)}
+ `
+ : `
+ SELECT
+ ${dimCol} AS key,
+ ${metricExpr} AS value
+ FROM ${table}
+ WHERE ${where.sql()}
+ GROUP BY 1
+ ORDER BY 2 DESC
+ LIMIT ${Number(input.limit)}
+ `;
- // SAFETY: LIMIT value is interpolated directly into SQL but is
- // validated by Zod above: `z.number().int().min(1).max(10_000)`.
+ // SAFETY: LIMIT value is interpolated directly into SQL when present but
+ // is validated by Zod above: `z.number().int().min(1).max(100)`.
// Snowflake's SQL API v2 does not support parameter binding for LIMIT.
const rows = await timedSnowflakeQuery(
{
route: 'usageAnalytics.getBreakdown',
queryLabel: `breakdown_${meta.tier}_by_${input.dimension}`,
- scope: input.organizationId ? 'org' : 'user',
- period: `${input.startDate}/${input.endDate}`,
+ scope: filters.organizationId ? 'org' : 'user',
+ period: `${filters.startDate}/${filters.endDate}`,
},
signal =>
executeSnowflakeStatement({
config,
statement,
- bindings: where.bindings,
+ bindings: [...(emailMapValues?.bindings ?? []), ...where.bindings],
timeoutSeconds: Math.ceil(
- defaultTimeoutForScope(input.organizationId ? 'org' : 'user') / 1000
+ defaultTimeoutForScope(filters.organizationId ? 'org' : 'user') / 1000
),
signal,
})
);
- const values = rows.map(row => ({ key: row[0] ?? '', value: toSafeNumber(row[1]) }));
+ const rawValues = rows.map(row => ({
+ key: toStringValue(row[0]),
+ label: toStringValue(row[0]),
+ value: toSafeNumber(row[1]),
+ }));
+ const userEmailsById = userEmailsForDisplay(filters, scopedUserEmailMaps);
+ const values = emailMapValues
+ ? rawValues
+ : displayBreakdownValues(input.dimension, filters, userEmailsById, rawValues, input.limit);
// Percentages are relative to the *returned* rows (limited by input.limit).
// They will not reflect the true share when the result set is capped.
const totalValue = values.reduce((s, r) => s + r.value, 0);
return {
- breakdown: values.map(r => ({
- key: r.key,
- label: r.key,
- value: r.value,
- percentage: totalValue > 0 ? (r.value / totalValue) * 100 : 0,
- })),
+ breakdown: values.map(r => {
+ return {
+ key: r.key,
+ label: r.label,
+ value: r.value,
+ percentage: totalValue > 0 ? (r.value / totalValue) * 100 : 0,
+ };
+ }),
totalValue,
effectiveGranularity: meta.effectiveGranularity,
};
@@ -857,17 +1277,21 @@ export const usageAnalyticsRouter = createTRPCRouter({
.input(TableInputSchema)
.output(TableOutputSchema)
.query(async ({ input, ctx }) => {
- await ensureScopeAccess(ctx, input);
+ const { filters, scopedUserEmailMaps } = await prepareUsageFilters(
+ ctx,
+ input,
+ input.groupBy.includes('user')
+ );
const config = resolveSnowflakeConfig();
- const meta = resolveTier(input.granularity, input.startDate);
+ const meta = resolveTier(filters.granularity, filters.startDate);
if (!config) {
return { rows: [], effectiveGranularity: meta.effectiveGranularity };
}
const table = getTableName(meta.tier);
const bucketExpr = bucketExprSql(meta.effectiveGranularity, meta.tier);
- const where = buildWhereClause(meta.tier, input, ctx.user.id, true);
- const costSumExpr = costSumExprSql(input.costSource);
+ const where = buildWhereClause(meta.tier, filters, ctx.user.id, true);
+ const costSumExpr = costSumExprSql(filters.costSource);
const requestedDims = input.groupBy;
@@ -913,8 +1337,8 @@ export const usageAnalyticsRouter = createTRPCRouter({
{
route: 'usageAnalytics.getTable',
queryLabel: `table_${meta.tier}_groupby_${requestedDims.join('+') || 'none'}`,
- scope: input.organizationId ? 'org' : 'user',
- period: `${input.startDate}/${input.endDate}`,
+ scope: filters.organizationId ? 'org' : 'user',
+ period: `${filters.startDate}/${filters.endDate}`,
},
signal =>
executeSnowflakeStatement({
@@ -922,7 +1346,7 @@ export const usageAnalyticsRouter = createTRPCRouter({
statement,
bindings: where.bindings,
timeoutSeconds: Math.ceil(
- defaultTimeoutForScope(input.organizationId ? 'org' : 'user') / 1000
+ defaultTimeoutForScope(filters.organizationId ? 'org' : 'user') / 1000
),
signal,
})
@@ -937,15 +1361,18 @@ export const usageAnalyticsRouter = createTRPCRouter({
project: 6,
};
+ const userEmailsById = userEmailsForDisplay(filters, scopedUserEmailMaps);
+
return {
rows: rows.map(row => {
const dimensions: Record = {};
for (const d of requestedDims) {
const raw = row[dimIndexMap[d]];
- dimensions[d] = typeof raw === 'string' ? raw : '';
+ const value = toStringValue(raw);
+ dimensions[d] = dimensionDisplayValue(d, filters, userEmailsById, value);
}
return {
- datetime: row[0] ?? '',
+ datetime: toStringValue(row[0]),
dimensions,
costMicrodollars: toSafeNumber(row[7]),
requestCount: toSafeNumber(row[8]),
diff --git a/apps/web/src/routers/usage-analytics-schemas.ts b/apps/web/src/routers/usage-analytics-schemas.ts
index 0c38fb1f06..021870cf5a 100644
--- a/apps/web/src/routers/usage-analytics-schemas.ts
+++ b/apps/web/src/routers/usage-analytics-schemas.ts
@@ -37,14 +37,17 @@ const FiltersShape = {
models: z.array(z.string()).optional(),
modes: z.array(z.string()).optional(),
userIds: z.array(z.string()).optional(),
+ userEmails: z.array(z.string()).optional(),
providers: z.array(z.string()).optional(),
projects: z.array(z.string()).optional(),
excludedFeatures: z.array(z.string()).optional(),
excludedModels: z.array(z.string()).optional(),
excludedModes: z.array(z.string()).optional(),
excludedUserIds: z.array(z.string()).optional(),
+ excludedUserEmails: z.array(z.string()).optional(),
excludedProviders: z.array(z.string()).optional(),
excludedProjects: z.array(z.string()).optional(),
+ userDisplay: z.enum(['id', 'email']).default('id'),
} as const;
export const UsageAnalyticsFiltersSchema = z.object(FiltersShape);