Show individual usage stats to members (#70)

* Show individual usage stats to members

* Tighter security

* PR feedback
This commit is contained in:
Matt Rubens 2025-06-02 21:17:52 -04:00 committed by GitHub
parent 9b6ad19db0
commit 25f003c459
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 286 additions and 83 deletions

View file

@ -1,6 +1,7 @@
'use server';
import { z } from 'zod';
import { auth } from '@clerk/nextjs/server';
import {
TelemetryEventName,
@ -14,6 +15,56 @@ import { type User, getUsersById } from '@/db/server';
type Table = 'events' | 'messages';
/**
* Validates authentication and authorization for analytics functions
*/
async function validateAnalyticsAccess({
requestedOrgId,
requestedUserId,
requireAdmin = false,
}: {
requestedOrgId?: string | null;
requestedUserId?: string | null;
requireAdmin?: boolean;
}): Promise<{
authOrgId: string;
authUserId: string;
orgRole: string;
effectiveUserId: string | null;
}> {
const { orgId: authOrgId, orgRole, userId: authUserId } = await auth();
// Ensure user is authenticated and belongs to the organization
if (!authOrgId || !authUserId || authOrgId !== requestedOrgId) {
throw new Error('Unauthorized: Invalid organization access');
}
// Check if admin access is required
if (requireAdmin && orgRole !== 'org:admin') {
throw new Error('Unauthorized: Administrator access required');
}
// If user is not an admin and trying to access data other than their own
if (
orgRole !== 'org:admin' &&
requestedUserId &&
requestedUserId !== authUserId
) {
throw new Error('Unauthorized: Members can only access their own data');
}
// For non-admin users, force userId filter to their own ID
const effectiveUserId =
orgRole !== 'org:admin' ? authUserId : requestedUserId || null;
return {
authOrgId,
authUserId,
orgRole: orgRole || 'unknown',
effectiveUserId,
};
}
/**
* captureEvent
*/
@ -78,14 +129,30 @@ type UsageRecord = Partial<Record<TelemetryEventName, Usage>>;
export const getUsage = async ({
orgId,
timePeriod = 90,
userId,
}: {
orgId?: string | null;
timePeriod?: AnyTimePeriod;
userId?: string | null;
}): Promise<UsageRecord> => {
const { effectiveUserId } = await validateAnalyticsAccess({
requestedOrgId: orgId,
requestedUserId: userId,
});
if (!orgId) {
return {};
}
const userFilter = effectiveUserId ? 'AND userId = {userId: String}' : '';
const queryParams: Record<string, string | number> = {
orgId: orgId!,
timePeriod,
};
if (effectiveUserId) {
queryParams.userId = effectiveUserId;
}
const results = await analytics.query({
query: `
SELECT
@ -98,10 +165,11 @@ export const getUsage = async ({
WHERE
orgId = {orgId: String}
AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY)
${userFilter}
GROUP BY 1
`,
format: 'JSONEachRow',
query_params: { orgId, timePeriod },
query_params: queryParams,
});
return z
@ -132,14 +200,36 @@ export type DeveloperUsage = z.infer<typeof developerUsageSchema> & {
export const getDeveloperUsage = async ({
orgId,
timePeriod = 90,
userId,
}: {
orgId?: string | null;
timePeriod?: AnyTimePeriod;
userId?: string | null;
}): Promise<DeveloperUsage[]> => {
await validateAnalyticsAccess({
requestedOrgId: orgId,
requestedUserId: userId,
requireAdmin: true,
});
if (!orgId) {
return [];
}
const userFilter = userId ? 'AND userId = {userId: String}' : '';
const queryParams: Record<string, string | number | string[]> = {
orgId: orgId!,
timePeriod,
types: [
TelemetryEventName.TASK_CREATED,
TelemetryEventName.TASK_COMPLETED,
TelemetryEventName.LLM_COMPLETION,
],
};
if (userId) {
queryParams.userId = userId;
}
const results = await analytics.query({
query: `
SELECT
@ -152,18 +242,11 @@ export const getDeveloperUsage = async ({
WHERE orgId = {orgId: String}
AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY)
AND type IN ({types: Array(String)})
${userFilter}
GROUP BY 1
`,
format: 'JSONEachRow',
query_params: {
orgId,
timePeriod,
types: [
TelemetryEventName.TASK_CREATED,
TelemetryEventName.TASK_COMPLETED,
TelemetryEventName.LLM_COMPLETION,
],
},
query_params: queryParams,
});
const developerUsages = z
@ -194,14 +277,36 @@ export type ModelUsage = z.infer<typeof modelUsageSchema>;
export const getModelUsage = async ({
orgId,
timePeriod = 90,
userId,
}: {
orgId?: string | null;
timePeriod?: AnyTimePeriod;
userId?: string | null;
}): Promise<ModelUsage[]> => {
await validateAnalyticsAccess({
requestedOrgId: orgId,
requestedUserId: userId,
requireAdmin: true,
});
if (!orgId) {
return [];
}
const userFilter = userId ? 'AND userId = {userId: String}' : '';
const queryParams: Record<string, string | number | string[]> = {
orgId: orgId!,
timePeriod,
types: [
TelemetryEventName.TASK_CREATED,
TelemetryEventName.TASK_COMPLETED,
TelemetryEventName.LLM_COMPLETION,
],
};
if (userId) {
queryParams.userId = userId;
}
const results = await analytics.query({
query: `
SELECT
@ -215,18 +320,11 @@ export const getModelUsage = async ({
orgId = {orgId: String}
AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY)
AND type IN ({types: Array(String)})
${userFilter}
GROUP BY 1, 2
`,
format: 'JSONEachRow',
query_params: {
orgId,
timePeriod,
types: [
TelemetryEventName.TASK_CREATED,
TelemetryEventName.TASK_COMPLETED,
TelemetryEventName.LLM_COMPLETION,
],
},
query_params: queryParams,
});
return z.array(modelUsageSchema).parse(await results.json());
@ -246,13 +344,36 @@ export type TaskWithUser = TaskWithTitle & { user: User };
export const getTasks = async ({
orgId,
userId,
}: {
orgId?: string | null;
userId?: string | null;
}): Promise<TaskWithUser[]> => {
const { effectiveUserId } = await validateAnalyticsAccess({
requestedOrgId: orgId,
requestedUserId: userId,
});
if (!orgId) {
return [];
}
const userFilter = effectiveUserId ? 'AND e.userId = {userId: String}' : '';
const messageUserFilter = effectiveUserId
? 'AND userId = {userId: String}'
: '';
const queryParams: Record<string, string | string[]> = {
orgId: orgId!,
types: [
TelemetryEventName.TASK_CREATED,
TelemetryEventName.TASK_COMPLETED,
TelemetryEventName.LLM_COMPLETION,
],
};
if (effectiveUserId) {
queryParams.userId = effectiveUserId;
}
const results = await analytics.query({
query: `
WITH first_messages AS (
@ -262,6 +383,7 @@ export const getTasks = async ({
argMin(mode, ts) as mode
FROM messages
WHERE orgId = {orgId: String}
${messageUserFilter}
GROUP BY taskId
)
SELECT
@ -280,18 +402,12 @@ export const getTasks = async ({
WHERE
e.orgId = {orgId: String}
AND e.type IN ({types: Array(String)})
${userFilter}
GROUP BY 1, 2
ORDER BY timestamp DESC
`,
format: 'JSONEachRow',
query_params: {
orgId,
types: [
TelemetryEventName.TASK_CREATED,
TelemetryEventName.TASK_COMPLETED,
TelemetryEventName.LLM_COMPLETION,
],
},
query_params: queryParams,
});
const tasks = z.array(taskWithTitleSchema).parse(await results.json());
@ -322,14 +438,35 @@ export type HourlyUsageByUser = z.infer<typeof hourlyUsageByUserSchema> & {
export const getHourlyUsageByUser = async ({
orgId,
timePeriod = 90,
userId,
}: {
orgId?: string | null;
timePeriod?: AnyTimePeriod;
userId?: string | null;
}): Promise<HourlyUsageByUser[]> => {
const { effectiveUserId } = await validateAnalyticsAccess({
requestedOrgId: orgId,
requestedUserId: userId,
});
if (!orgId) {
return [];
}
const userFilter = effectiveUserId ? 'AND userId = {userId: String}' : '';
const queryParams: Record<string, string | number | string[]> = {
orgId: orgId!,
timePeriod,
types: [
TelemetryEventName.TASK_CREATED,
TelemetryEventName.TASK_COMPLETED,
TelemetryEventName.LLM_COMPLETION,
],
};
if (effectiveUserId) {
queryParams.userId = effectiveUserId;
}
const results = await analytics.query({
query: `
SELECT
@ -343,19 +480,12 @@ export const getHourlyUsageByUser = async ({
orgId = {orgId: String}
AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY)
AND type IN ({types: Array(String)})
${userFilter}
GROUP BY 1, 2
ORDER BY hour_utc DESC, userId
`,
format: 'JSONEachRow',
query_params: {
orgId,
timePeriod,
types: [
TelemetryEventName.TASK_CREATED,
TelemetryEventName.TASK_COMPLETED,
TelemetryEventName.LLM_COMPLETION,
],
},
query_params: queryParams,
});
const hourlyUsages = z

View file

@ -1,33 +1,39 @@
import { redirect } from 'next/navigation';
import { auth } from '@clerk/nextjs/server';
import {
NavbarHeader,
NavbarMenu,
Section,
Connected,
} from '@/components/layout';
import { NavbarHeader, NavbarMenu, Section } from '@/components/layout';
import { Usage } from './usage/Usage';
export default async function AuthenticatedLayout({
children,
}: {
children: React.ReactNode;
}) {
const { orgId, orgRole } = await auth();
const { orgId, orgRole, userId } = await auth();
if (!orgId) {
redirect('/select-org');
}
// For now, a non-admin just get a "You're connected" message.
if (orgRole !== 'org:admin') {
return <Connected />;
// Members get access to usage page only, filtered to their own data
if (orgRole === 'org:member' || (orgRole && orgRole !== 'org:admin')) {
return (
<>
<NavbarHeader className="h-[72px]" />
<NavbarMenu className="h-[72px]" userRole="member" />
<Section divider={false} className="min-h-[calc(100vh-72px-72px-2px)]">
<div className="flex flex-col gap-8 py-8">
<Usage userRole="member" currentUserId={userId} />
</div>
</Section>
</>
);
}
return (
<>
<NavbarHeader className="h-[72px]" />
<NavbarMenu className="h-[72px]" />
<NavbarMenu className="h-[72px]" userRole="admin" />
<Section divider={false} className="min-h-[calc(100vh-72px-72px-2px)]">
<div className="flex flex-col gap-8 py-8">{children}</div>
</Section>

View file

@ -13,16 +13,24 @@ export const Tasks = ({
filter,
onFilter,
onTaskSelected,
userRole = 'admin',
currentUserId,
}: {
filter: Filter | null;
onFilter: (filter: Filter) => void;
onTaskSelected: (task: TaskWithUser) => void;
userRole?: 'admin' | 'member';
currentUserId?: string | null;
}) => {
const { orgId } = useAuth();
const { data = [], isPending } = useQuery({
queryKey: ['getTasks', orgId],
queryFn: () => getTasks({ orgId }),
queryKey: ['getTasks', orgId, userRole === 'member' ? currentUserId : null],
queryFn: () =>
getTasks({
orgId,
userId: userRole === 'member' ? currentUserId : undefined,
}),
enabled: !!orgId,
});
@ -64,7 +72,7 @@ export const Tasks = ({
<TaskCard
key={task.taskId}
task={task}
onFilter={onFilter}
onFilter={userRole === 'member' ? undefined : onFilter}
onTaskSelected={onTaskSelected}
/>
))}

View file

@ -14,7 +14,12 @@ import { Models } from './Models';
import { Tasks } from './Tasks';
import { TaskDrawer } from './TaskDrawer';
export const Usage = () => {
type UsageProps = {
userRole?: 'admin' | 'member';
currentUserId?: string | null;
};
export const Usage = ({ userRole = 'admin', currentUserId }: UsageProps) => {
const t = useTranslations('Analytics');
const [viewMode, setViewMode] = useState<ViewMode>('tasks');
const [filter, setFilter] = useState<Filter | null>(null);
@ -25,24 +30,36 @@ export const Usage = () => {
setViewMode('tasks');
}, []);
// For members, automatically set filter to their user ID and hide other tabs
const isMember = userRole === 'member';
const availableViewModes = isMember ? (['tasks'] as const) : viewModes;
// Auto-apply user filter for members
const effectiveFilter =
isMember && currentUserId
? { type: 'userId' as const, value: currentUserId, label: 'Your Tasks' }
: filter;
return (
<>
<div className="flex flex-col gap-4 sm:gap-6">
<UsageCard />
<div className="flex flex-wrap gap-2">
{viewModes.map((mode) => (
<Button
key={mode}
variant={viewMode === mode ? 'default' : 'secondary'}
size="sm"
onClick={() => setViewMode(mode)}
className="min-w-[80px]"
>
{t(`view_mode_${mode}`)}
</Button>
))}
</div>
{filter && (
<UsageCard userRole={userRole} currentUserId={currentUserId} />
{!isMember && (
<div className="flex flex-wrap gap-2">
{availableViewModes.map((mode) => (
<Button
key={mode}
variant={viewMode === mode ? 'default' : 'secondary'}
size="sm"
onClick={() => setViewMode(mode)}
className="min-w-[80px]"
>
{t(`view_mode_${mode}`)}
</Button>
))}
</div>
)}
{filter && !isMember && (
<div className="flex flex-row">
<Badge variant="outline">
<span className="text-sm">
@ -60,9 +77,11 @@ export const Usage = () => {
)}
{viewMode === 'tasks' ? (
<Tasks
filter={filter}
onFilter={onFilter}
filter={effectiveFilter}
onFilter={isMember ? () => {} : onFilter}
onTaskSelected={(task: TaskWithUser) => setTask(task)}
userRole={userRole}
currentUserId={currentUserId}
/>
) : viewMode === 'developers' ? (
<Developers onFilter={onFilter} />

View file

@ -1,5 +1,8 @@
import { auth } from '@clerk/nextjs/server';
import { Usage } from './Usage';
export default async function Page() {
return <Usage />;
const { userId } = await auth();
return <Usage userRole="admin" currentUserId={userId} />;
}

View file

@ -7,7 +7,12 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/ecosystem';
import { Section } from './Section';
type NavbarMenuProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>;
type NavbarMenuProps = Omit<
React.HTMLAttributes<HTMLDivElement>,
'children'
> & {
userRole?: 'admin' | 'member';
};
const tabValues = [
'/usage',
@ -23,7 +28,10 @@ type TabValue = (typeof tabValues)[number];
const isTabValue = (value: string): value is TabValue =>
tabValues.includes(value as TabValue);
export const NavbarMenu = (props: NavbarMenuProps) => {
export const NavbarMenu = ({
userRole = 'admin',
...props
}: NavbarMenuProps) => {
const router = useRouter();
const pathname = usePathname();
const [tabValue, setTabValue] = useState<TabValue | undefined>(undefined);
@ -50,10 +58,14 @@ export const NavbarMenu = (props: NavbarMenuProps) => {
>
<TabsList>
<TabsTrigger value="/usage">Usage</TabsTrigger>
<TabsTrigger value="/audit-logs">Audit Logs</TabsTrigger>
<TabsTrigger value="/providers">Providers</TabsTrigger>
<TabsTrigger value="/telemetry">Telemetry</TabsTrigger>
<TabsTrigger value="/org">Organization</TabsTrigger>
{userRole === 'admin' && (
<>
<TabsTrigger value="/audit-logs">Audit Logs</TabsTrigger>
<TabsTrigger value="/providers">Providers</TabsTrigger>
<TabsTrigger value="/telemetry">Telemetry</TabsTrigger>
<TabsTrigger value="/org">Organization</TabsTrigger>
</>
)}
<TabsTrigger value="/hidden" className="hidden" />
</TabsList>
</Tabs>

View file

@ -20,7 +20,7 @@ import { Status } from '@/app/(authenticated)/usage/Status';
type TaskCardProps = {
task: TaskWithUser;
onFilter: (filter: Filter) => void;
onFilter?: (filter: Filter) => void;
onTaskSelected: (task: TaskWithUser) => void;
};
@ -82,13 +82,17 @@ export const TaskCard = ({ task, onFilter, onTaskSelected }: TaskCardProps) => {
size="sm"
onClick={(e) => {
e.stopPropagation();
onFilter({
type: 'userId',
value: task.userId,
label: task.user.name,
});
// Only allow filtering if onFilter is provided (disabled for members)
if (onFilter) {
onFilter({
type: 'userId',
value: task.userId,
label: task.user.name,
});
}
}}
className="px-0 h-auto text-xs font-normal text-muted-foreground hover:text-foreground"
disabled={!onFilter}
>
{task.user.name}
</Button>

View file

@ -27,7 +27,15 @@ import { UsageChart } from './UsageChart';
type MetricType = 'tasks' | 'tokens' | 'cost';
export const UsageCard = () => {
type UsageCardProps = {
userRole?: 'admin' | 'member';
currentUserId?: string | null;
};
export const UsageCard = ({
userRole = 'admin',
currentUserId,
}: UsageCardProps) => {
const t = useTranslations('DashboardIndex');
const { orgId } = useAuth();
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriodConfig>(
@ -43,6 +51,7 @@ export const UsageCard = () => {
orgId,
selectedPeriod.value,
selectedPeriod.granularity,
userRole === 'member' ? currentUserId : null,
],
queryFn: () =>
getUsage({
@ -51,6 +60,7 @@ export const UsageCard = () => {
selectedPeriod.granularity === 'daily'
? (selectedPeriod.value as 7 | 30 | 90)
: (selectedPeriod.value as 1), // Use 1 day for 24h view
userId: userRole === 'member' ? currentUserId : undefined,
}),
enabled: !!orgId,
});
@ -169,6 +179,8 @@ export const UsageCard = () => {
<UsageChart
timePeriodConfig={selectedPeriod}
selectedMetric={selectedMetric}
userRole={userRole}
currentUserId={currentUserId}
/>
)}
</div>

View file

@ -283,6 +283,8 @@ const processDailyDataForChart = (
interface UsageChartProps {
timePeriodConfig: TimePeriodConfig;
selectedMetric?: MetricType;
userRole?: 'admin' | 'member';
currentUserId?: string | null;
}
// Custom tick components for theme-aware labels
@ -432,6 +434,8 @@ const CustomTooltip = ({
export const UsageChart = ({
timePeriodConfig,
selectedMetric = 'tasks',
userRole = 'admin',
currentUserId,
}: UsageChartProps) => {
const { orgId } = useAuth();
const [isClient, setIsClient] = useState(false);
@ -447,9 +451,14 @@ export const UsageChart = ({
orgId,
timePeriodConfig.value,
timePeriodConfig.granularity,
userRole === 'member' ? currentUserId : null,
],
queryFn: () =>
getHourlyUsageByUser({ orgId, timePeriod: timePeriodConfig.value }),
getHourlyUsageByUser({
orgId,
timePeriod: timePeriodConfig.value,
userId: userRole === 'member' ? currentUserId : undefined,
}),
enabled: !!orgId,
});