Skip to content
Open
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
8 changes: 8 additions & 0 deletions apps/api/src/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ export function buildAbsoluteDateFilter(
return `toDate(${column}) >= toDate({${startParamName}:String}) AND toDate(${column}) <= toDate({${endParamName}:String})`;
}

export function buildInclusiveDateRangeFilter(
startParamName: string,
endParamName: string,
column = "session_date",
): string {
return buildAbsoluteDateFilter(startParamName, endParamName, column);
}

export async function queryClickhouse<T>(
statement: ClickHouseStatement,
): Promise<T[]> {
Expand Down
36 changes: 36 additions & 0 deletions apps/api/src/handlers/analytics/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import {
getModelTokensTrend,
getOverviewInsights,
getOverviewKPIs,
getRepositoriesDailyTrend,
getSuccessRateMetrics,
getTeamSummaryWithComparison,
getUsageTrendDetailed,
getUsersDailyTrend,
getUsersTokenUsage,
} from "../../services/overview.service.js";

const kpis = os.analytics.overview.kpis
Expand Down Expand Up @@ -38,6 +41,36 @@ const modelTokensTrend = os.analytics.overview.modelTokensTrend
);
});

const usersTokenUsage = os.analytics.overview.usersTokenUsage
.use(orgMiddleware)
.handler(async ({ input, context }) => {
return getUsersTokenUsage(
context.organizationId,
input.startDate,
input.endDate,
);
});

const usersDailyTrend = os.analytics.overview.usersDailyTrend
.use(orgMiddleware)
.handler(async ({ input, context }) => {
return getUsersDailyTrend(
context.organizationId,
input.startDate,
input.endDate,
);
});

const repositoriesDailyTrend = os.analytics.overview.repositoriesDailyTrend
.use(orgMiddleware)
.handler(async ({ input, context }) => {
return getRepositoriesDailyTrend(
context.organizationId,
input.startDate,
input.endDate,
);
});

const insights = os.analytics.overview.insights
.use(orgMiddleware)
.handler(async ({ input, context }) => {
Expand Down Expand Up @@ -72,6 +105,9 @@ export const overviewRouter = os.analytics.overview.router({
kpis,
usageTrend,
modelTokensTrend,
usersTokenUsage,
usersDailyTrend,
repositoriesDailyTrend,
insights,
teamSummaryComparison,
successRate,
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/handlers/analytics/roi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@ import { orgMiddleware, os } from "../../middleware.js";
import {
getDeveloperCostBreakdown,
getProjectCostBreakdown,
getROIDashboard,
getROIMetrics,
getROITrends,
} from "../../services/roi.service.js";

const dashboard = os.analytics.roi.dashboard
.use(orgMiddleware)
.handler(async ({ input, context }) => {
return getROIDashboard(context.organizationId, {
start_date: input.startDate,
end_date: input.endDate,
});
});

const metrics = os.analytics.roi.metrics
.use(orgMiddleware)
.handler(async ({ input, context }) => {
Expand All @@ -31,6 +41,7 @@ const breakdownProjects = os.analytics.roi.breakdownProjects
});

export const roiRouter = os.analytics.roi.router({
dashboard,
metrics,
trends,
breakdownDevelopers,
Expand Down
199 changes: 199 additions & 0 deletions apps/api/src/services/overview.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type {
ModelTokensTrendData,
OverviewKPIs,
RepositoryDailyTrendData,
UsageTrendData,
UserDailyTrendData,
UserTokenUsageData,
} from "@rudel/api-routes";
import { user } from "@rudel/sql-schema";
import { eq } from "drizzle-orm";
Expand All @@ -11,6 +14,7 @@ import {
queryClickhouse,
} from "../clickhouse.js";
import { db } from "../db.js";
import { buildEstimatedCostSql } from "./pricing.service.js";

export interface Insight {
type: "trend" | "performer" | "alert" | "info";
Expand All @@ -19,6 +23,14 @@ export interface Insight {
link: string;
}

const PER_SESSION_COST_SQL = buildEstimatedCostSql({
modelExpr: "model_used",
inputExpr: "ifNull(input_tokens, 0)",
outputExpr: "ifNull(output_tokens, 0)",
cacheReadInputExpr: "ifNull(cache_read_input_tokens, 0)",
cacheCreationInputExpr: "ifNull(cache_creation_input_tokens, 0)",
});

/**
* Get overview KPI counts: distinct users, sessions, projects, subagents, skills, slash commands
*/
Expand Down Expand Up @@ -155,6 +167,193 @@ export async function getModelTokensTrend(
});
}

export async function getUsersTokenUsage(
orgId: string,
startDate: string,
endDate: string,
): Promise<UserTokenUsageData[]> {
const dateFilter = buildAbsoluteDateFilter("startDate", "endDate");
const rows = await queryClickhouse<{
models_used: string[];
repositories_touched: string[];
user_id: string;
total_commits: number;
total_tokens: number;
input_tokens: number;
output_tokens: number;
cost: number;
total_sessions: number;
total_duration_min: number;
success_rate: number;
distinct_skills: number;
distinct_slash_commands: number;
}>({
query: `
SELECT
user_id,
arrayFilter(
x -> x != '',
topK(3)(if(model_used != '' AND model_used != 'unknown', model_used, ''))
) as models_used,
arraySort(
arrayDistinct(
arrayFilter(
x -> x != '',
groupArray(
if(
git_remote != '',
replaceRegexpOne(arrayElement(splitByChar('/', git_remote), -1), '\\\\.git$', ''),
if(
package_name != '',
package_name,
arrayElement(splitByChar('/', replaceAll(project_path, '\\\\', '/')), -1)
)
)
)
)
)
) as repositories_touched,
sum(has_commit) as total_commits,
sum(ifNull(total_tokens, 0)) as total_tokens,
sum(ifNull(input_tokens, 0)) as input_tokens,
sum(ifNull(output_tokens, 0)) as output_tokens,
round(sum(${PER_SESSION_COST_SQL}), 4) as cost,
count() as total_sessions,
round(sum(actual_duration_min), 2) as total_duration_min,
round(avg(success_score), 2) as success_rate,
length(arrayDistinct(arrayFilter(x -> x != '', arrayFlatten(groupArray(skills))))) as distinct_skills,
length(arrayDistinct(arrayFilter(x -> x != '', arrayFlatten(groupArray(slash_commands))))) as distinct_slash_commands
FROM rudel.session_analytics
WHERE ${dateFilter}
AND organization_id = {orgId:String}
AND user_id != ''
GROUP BY user_id
ORDER BY total_tokens DESC
`,
query_params: {
startDate,
endDate,
orgId,
},
});

if (rows.length === 0) {
return [];
}

return rows.map((row) => ({
models_used: row.models_used ?? [],
repositories_touched: row.repositories_touched ?? [],
user_id: row.user_id,
user_label: row.user_id,
total_commits: Number(row.total_commits),
total_tokens: Number(row.total_tokens),
input_tokens: Number(row.input_tokens),
output_tokens: Number(row.output_tokens),
cost: Number(row.cost),
total_sessions: Number(row.total_sessions),
total_duration_min: Number(row.total_duration_min),
success_rate: Number(row.success_rate),
distinct_skills: Number(row.distinct_skills),
distinct_slash_commands: Number(row.distinct_slash_commands),
}));
}

export async function getUsersDailyTrend(
orgId: string,
startDate: string,
endDate: string,
): Promise<UserDailyTrendData[]> {
const dateFilter = buildAbsoluteDateFilter("startDate", "endDate");

return queryClickhouse<UserDailyTrendData>({
query: `
SELECT
toString(toDate(session_date)) as date,
user_id,
count() as sessions,
sum(has_commit) as total_commits,
round(sum(actual_duration_min) / 60, 2) as total_hours,
sum(ifNull(total_tokens, 0)) as total_tokens,
sum(ifNull(input_tokens, 0)) as input_tokens,
sum(ifNull(output_tokens, 0)) as output_tokens,
round(avg(success_score), 2) as avg_success_rate,
length(arrayDistinct(arrayFilter(x -> x != '', arrayFlatten(groupArray(skills))))) as distinct_skills,
length(arrayDistinct(arrayFilter(x -> x != '', arrayFlatten(groupArray(slash_commands))))) as distinct_slash_commands,
arrayFilter(
x -> x != '',
arrayDistinct(groupArray(if(model_used != '' AND model_used != 'unknown', model_used, '')))
) as models_used,
arraySort(
arrayDistinct(
arrayFilter(
x -> x != '',
groupArray(
if(
git_remote != '',
replaceRegexpOne(arrayElement(splitByChar('/', git_remote), -1), '\\\\.git$', ''),
if(
package_name != '',
package_name,
arrayElement(splitByChar('/', replaceAll(project_path, '\\\\', '/')), -1)
)
)
)
)
)
) as repositories_touched
FROM rudel.session_analytics
WHERE ${dateFilter}
AND organization_id = {orgId:String}
AND user_id != ''
GROUP BY date, user_id
ORDER BY date ASC, user_id ASC
`,
query_params: {
startDate,
endDate,
orgId,
},
});
}

export async function getRepositoriesDailyTrend(
orgId: string,
startDate: string,
endDate: string,
): Promise<RepositoryDailyTrendData[]> {
const dateFilter = buildAbsoluteDateFilter("startDate", "endDate");

return queryClickhouse<RepositoryDailyTrendData>({
query: `
SELECT
toString(toDate(session_date)) as date,
if(
git_remote != '',
replaceRegexpOne(arrayElement(splitByChar('/', git_remote), -1), '\\\\.git$', ''),
if(
package_name != '',
package_name,
arrayElement(splitByChar('/', replaceAll(project_path, '\\\\', '/')), -1)
)
) as repository,
count() as sessions,
sum(has_commit) as total_commits
FROM rudel.session_analytics
WHERE ${dateFilter}
AND organization_id = {orgId:String}
GROUP BY date, repository
HAVING repository != ''
ORDER BY date ASC, repository ASC
`,
query_params: {
startDate,
endDate,
orgId,
},
});
}

/**
* Get detailed usage trend data aggregated by day
*/
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/services/pricing.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export {
buildEstimatedCostSql,
calculateEstimatedCost,
ESTIMATED_PRICING_MODE,
FALLBACK_MODEL_PRICING,
getModelPricingCatalog,
MODEL_PRICING_CATALOG_VERSION,
resolveModelPricing,
} from "@rudel/api-routes";
Loading
Loading