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
34 changes: 34 additions & 0 deletions apps/backend/src/queries/project.queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,15 @@ export const listProjectChats = async (

const toolErrorCountExpr = countToolState('output-error');
const toolAvailableCountExpr = countToolState('output-available');
const sourceExpr = sql<string | null>`(
select source_message.source
from ${s.chatMessage} as source_message
where source_message.chat_id = ${s.chat.id}
and source_message.role = 'user'
and source_message.superseded_at is null
order by source_message.created_at desc
limit 1
)`;

const baseWhereClauses = [eq(s.chat.projectId, projectId)];

Expand Down Expand Up @@ -444,6 +453,11 @@ export const listProjectChats = async (
if (expr) {
filterWhereClauses.push(expr);
}
} else if (filter.id === 'source') {
const expr = or(...filter.values.map((source) => eq(sourceExpr, source)));
if (expr) {
filterWhereClauses.push(expr);
}
} else if (filter.id === 'toolState') {
const exprs: SQL<unknown>[] = [];
for (const v of filter.values) {
Expand All @@ -465,6 +479,24 @@ export const listProjectChats = async (
if (expr) {
filterWhereClauses.push(expr);
}
} else if (filter.id === 'feedback') {
const exprs: SQL<unknown>[] = [];
for (const v of filter.values) {
if (v === 'noVotes') {
const e = and(eq(upvotesExpr, 0), eq(downvotesExpr, 0));
if (e) {
exprs.push(e);
}
} else if (v === 'upvotes') {
exprs.push(gt(upvotesExpr, 0));
} else if (v === 'downvotes') {
exprs.push(gt(downvotesExpr, 0));
}
}
const expr = or(...exprs);
if (expr) {
filterWhereClauses.push(expr);
}
}
}

Expand All @@ -490,6 +522,7 @@ export const listProjectChats = async (
userName: s.user.name,
userRole: sql<UserRole | null>`coalesce(${s.projectMember.role}, 'Former member')`.as('userRole'),
title: s.chat.title,
source: sourceExpr.as('source'),
numberOfMessages: numberOfMessagesExpr.as('numberOfMessages'),
totalTokens: totalTokensExpr.as('totalTokens'),
feedbackText: feedbackTextExpr.as('feedbackText'),
Expand Down Expand Up @@ -530,6 +563,7 @@ export const listProjectChats = async (
userName: row.userName,
userRole: row.userRole,
title: row.title,
source: row.source,
numberOfMessages: Number(row.numberOfMessages ?? 0),
totalTokens: Number(row.totalTokens ?? 0),
feedbackText: row.feedbackText ?? '',
Expand Down
116 changes: 107 additions & 9 deletions apps/backend/src/queries/usage.queries.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { LlmProvider } from '@nao/shared/types';
import { and, eq, isNotNull, SQL, sql, SQLWrapper, sum } from 'drizzle-orm';
import { and, eq, isNotNull, or, SQL, sql, SQLWrapper, sum } from 'drizzle-orm';

import { LLM_PROVIDERS } from '../agents/providers';
import s from '../db/abstractSchema';
import { db } from '../db/db';
import dbConfig, { Dialect } from '../db/dbConfig';
import type { Granularity, UsageFilter, UsageRecord } from '../types/usage';
import type { Granularity, TotalUsageRecord, UsageFilter, UsageRecord, UsageSource } from '../types/usage';
import { fillMissingDates, getLookbackTimestamp } from '../utils/date';
import * as projectLlmConfigQueries from './project-llm-config.queries';

Expand Down Expand Up @@ -54,6 +54,41 @@ export async function createCostLookup(projectId: string) {
return { table, joinCondition };
}

const MESSAGE_USAGE_PROVIDER_EXPR = sql<LlmProvider | null>`case
when ${s.chatMessage.role} = 'user' then (
select next_message.llm_provider
from chat_message as next_message
where next_message.chat_id = ${s.chatMessage.chatId}
and next_message.role = 'assistant'
and next_message.llm_provider is not null
and next_message.created_at > ${s.chatMessage.createdAt}
and not exists (
select 1
from chat_message as next_user_message
where next_user_message.chat_id = ${s.chatMessage.chatId}
and next_user_message.role = 'user'
and next_user_message.created_at > ${s.chatMessage.createdAt}
and next_user_message.created_at < next_message.created_at
)
order by next_message.created_at asc
limit 1
)
else ${s.chatMessage.llmProvider}
end`;

const MESSAGE_USAGE_SOURCE_EXPR = sql<UsageSource | null>`case
when ${s.chatMessage.role} = 'assistant' then (
select source_message.source
from chat_message as source_message
where source_message.chat_id = ${s.chatMessage.chatId}
and source_message.role = 'user'
and source_message.created_at <= ${s.chatMessage.createdAt}
Comment thread
Bl3f marked this conversation as resolved.
order by source_message.created_at desc
limit 1
)
else ${s.chatMessage.source}
end`;

export const getMessagesUsage = async (projectId: string, filter: UsageFilter): Promise<UsageRecord[]> => {
const { granularity, provider } = filter;
const dateExpr = getDateExpr(s.chatMessage.createdAt, granularity);
Expand All @@ -65,8 +100,10 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter):

const whereConditions = [eq(s.chat.projectId, projectId), lookbackFilter];
if (provider) {
whereConditions.push(eq(s.chatMessage.llmProvider, provider));
whereConditions.push(sql`${MESSAGE_USAGE_PROVIDER_EXPR} = ${provider}`);
}
addUserNameFilter(whereConditions, filter.userNames);
addSourceFilter(whereConditions, filter.sources);

const costLookup = await createCostLookup(projectId);

Expand All @@ -79,6 +116,9 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter):
teamsMessageCount: sql<number>`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'teams' then ${s.chatMessage.id} end)`,
telegramMessageCount: sql<number>`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'telegram' then ${s.chatMessage.id} end)`,
whatsappMessageCount: sql<number>`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'whatsapp' then ${s.chatMessage.id} end)`,
adminMessageCount: sql<number>`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'admin' then ${s.chatMessage.id} end)`,
mcpMessageCount: sql<number>`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'mcp' then ${s.chatMessage.id} end)`,
contextRecommendationsMessageCount: sql<number>`count(distinct case when ${s.chatMessage.role} = 'user' and ${s.chatMessage.source} = 'contextRecommendations' then ${s.chatMessage.id} end)`,
inputNoCacheTokens: sum(s.chatMessage.inputNoCacheTokens),
inputCacheReadTokens: sum(s.chatMessage.inputCacheReadTokens),
inputCacheWriteTokens: sum(s.chatMessage.inputCacheWriteTokens),
Expand All @@ -91,19 +131,23 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter):
})
.from(s.chatMessage)
.innerJoin(s.chat, eq(s.chatMessage.chatId, s.chat.id))
.innerJoin(s.user, eq(s.chat.userId, s.user.id))
.leftJoin(costLookup.table, costLookup.joinCondition)
.where(and(...whereConditions))
.groupBy(dateExpr);

return fillMissingDates(
rows.map((row) => ({
date: row.date,
messageCount: row.messageCount,
webMessageCount: row.webMessageCount,
slackMessageCount: row.slackMessageCount,
teamsMessageCount: row.teamsMessageCount,
telegramMessageCount: row.telegramMessageCount,
whatsappMessageCount: row.whatsappMessageCount,
messageCount: Number(row.messageCount ?? 0),
webMessageCount: Number(row.webMessageCount ?? 0),
slackMessageCount: Number(row.slackMessageCount ?? 0),
teamsMessageCount: Number(row.teamsMessageCount ?? 0),
telegramMessageCount: Number(row.telegramMessageCount ?? 0),
whatsappMessageCount: Number(row.whatsappMessageCount ?? 0),
adminMessageCount: Number(row.adminMessageCount ?? 0),
mcpMessageCount: Number(row.mcpMessageCount ?? 0),
contextRecommendationsMessageCount: Number(row.contextRecommendationsMessageCount ?? 0),
inputNoCacheTokens: Number(row.inputNoCacheTokens ?? 0),
inputCacheReadTokens: Number(row.inputCacheReadTokens ?? 0),
inputCacheWriteTokens: Number(row.inputCacheWriteTokens ?? 0),
Expand All @@ -123,6 +167,37 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter):
);
};

export const getTotalUsage = async (projectId: string, filter: UsageFilter): Promise<TotalUsageRecord> => {
const { granularity, provider } = filter;
const lookbackTs = getLookbackTimestamp(granularity);
const lookbackFilter =
dbConfig.dialect === Dialect.Postgres
? sql`${s.chatMessage.createdAt} >= ${new Date(lookbackTs).toISOString()}`
: sql`${s.chatMessage.createdAt} >= ${lookbackTs}`;

const whereConditions = [eq(s.chat.projectId, projectId), lookbackFilter];
if (provider) {
whereConditions.push(sql`${MESSAGE_USAGE_PROVIDER_EXPR} = ${provider}`);
}
addUserNameFilter(whereConditions, filter.userNames);
addSourceFilter(whereConditions, filter.sources);

const rows = await db
.select({
totalMessages: sql<number>`count(distinct case when ${s.chatMessage.role} = 'user' then ${s.chatMessage.id} end)`,
uniqueUsers: sql<number>`count(distinct ${s.chat.userId})`,
})
.from(s.chatMessage)
.innerJoin(s.chat, eq(s.chatMessage.chatId, s.chat.id))
.innerJoin(s.user, eq(s.chat.userId, s.user.id))
.where(and(...whereConditions));

return {
totalMessages: Number(rows[0]?.totalMessages ?? 0),
uniqueUsers: Number(rows[0]?.uniqueUsers ?? 0),
};
};

export const getUsedProviders = async (projectId: string): Promise<LlmProvider[]> => {
const rows = await db
.selectDistinct({ provider: s.chatMessage.llmProvider })
Expand All @@ -134,6 +209,29 @@ export const getUsedProviders = async (projectId: string): Promise<LlmProvider[]
return rows.map((row) => row.provider).filter((p): p is LlmProvider => p !== null);
};

function addUserNameFilter(whereConditions: SQL<unknown>[], userNames: string[] | undefined) {
const names = userNames?.filter(Boolean) ?? [];
if (names.length === 0) {
return;
}

const expr = or(...names.map((name) => eq(s.user.name, name)));
if (expr) {
whereConditions.push(expr);
}
}

function addSourceFilter(whereConditions: SQL<unknown>[], sources: UsageSource[] | undefined) {
if (!sources?.length) {
return;
}

const expr = or(...sources.map((source) => eq(MESSAGE_USAGE_SOURCE_EXPR, source)));
if (expr) {
whereConditions.push(expr);
}
}

function getDateExpr(field: SQLWrapper, granularity: Granularity): SQL<string> {
if (dbConfig.dialect === Dialect.Postgres) {
const format = sql.raw(`'${pgFormats[granularity]}'`);
Expand Down
9 changes: 7 additions & 2 deletions apps/backend/src/trpc/project.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,7 +837,7 @@ export const projectRoutes = {
filters: z
.array(
z.object({
id: z.enum(['userName', 'userRole', 'toolState']),
id: z.enum(['userName', 'userRole', 'toolState', 'feedback', 'source']),
values: z.array(z.string()).default([]),
}),
)
Expand Down Expand Up @@ -876,7 +876,12 @@ export const projectRoutes = {
}

const ownerName = ownerId ? await userQueries.getUserName(ownerId) : null;
return { ...chat, ownerId: ownerId ?? null, ownerName };
return {
...chat,
ownerId: ownerId ?? null,
ownerName,
chatOwnerId: ownerId ?? null,
};
}),

getEnvVars: adminProtectedProcedure.query(async ({ ctx }) => {
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/trpc/usage.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export const usageRoutes = {
return usageQueries.getMessagesUsage(ctx.project.id, input);
}),

getTotalUsage: adminProtectedProcedure.input(usageFilterSchema).query(async ({ ctx, input }) => {
return usageQueries.getTotalUsage(ctx.project.id, input);
}),

getUsedProviders: adminProtectedProcedure.query(async ({ ctx }) => {
return usageQueries.getUsedProviders(ctx.project.id);
}),
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/types/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface UserWithRole {
messagingProviderCode: string | null;
}

export type ProjectChatsFacetKey = 'userName' | 'userRole' | 'toolState';
export type ProjectChatsFacetKey = 'userName' | 'userRole' | 'toolState' | 'feedback' | 'source';

export interface ListProjectChatsResponse {
chats: ProjectChatListItem[];
Expand Down
14 changes: 14 additions & 0 deletions apps/backend/src/types/usage.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { z } from 'zod/v4';

import { MESSAGE_SOURCES } from './chat';
import { llmProviderSchema } from './llm';

export const granularitySchema = z.enum(['hour', 'day', 'month']);
export type Granularity = z.infer<typeof granularitySchema>;

export const USAGE_SOURCES = MESSAGE_SOURCES;
export type UsageSource = (typeof USAGE_SOURCES)[number];

export const usageFilterSchema = z.object({
granularity: granularitySchema.default('day'),
provider: llmProviderSchema.optional(),
userNames: z.array(z.string()).optional(),
sources: z.array(z.enum(USAGE_SOURCES)).optional(),
});
export type UsageFilter = z.infer<typeof usageFilterSchema>;

Expand All @@ -19,6 +25,9 @@ export interface UsageRecord {
teamsMessageCount: number;
telegramMessageCount: number;
whatsappMessageCount: number;
adminMessageCount: number;
mcpMessageCount: number;
contextRecommendationsMessageCount: number;
inputNoCacheTokens: number;
inputCacheReadTokens: number;
inputCacheWriteTokens: number;
Expand All @@ -31,3 +40,8 @@ export interface UsageRecord {
outputCost: number;
totalCost: number;
}

export interface TotalUsageRecord {
totalMessages: number;
uniqueUsers: number;
}
7 changes: 5 additions & 2 deletions apps/backend/src/utils/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export function isValidIsoDateString(s: string): boolean {

export const lookbackPeriods = {
hour: 24,
day: 30,
month: 12,
day: 15,
month: 6,
};

export function getLookbackTimestamp(granularity: Granularity): number {
Expand Down Expand Up @@ -111,6 +111,9 @@ export function fillMissingDates(records: UsageRecord[], granularity: Granularit
teamsMessageCount: 0,
telegramMessageCount: 0,
whatsappMessageCount: 0,
adminMessageCount: 0,
mcpMessageCount: 0,
contextRecommendationsMessageCount: 0,
inputNoCacheTokens: 0,
inputCacheReadTokens: 0,
inputCacheWriteTokens: 0,
Expand Down
Loading
Loading