Add billing usage analytics dashboard with ClickHouse integration (#18592)
## Summary This PR adds a comprehensive billing usage analytics feature that provides detailed breakdowns of credit consumption across execution types, users, resources, and time periods. The implementation includes a new ClickHouse-backed analytics service, GraphQL API endpoint, and a frontend dashboard component. ## Key Changes ### Backend - **New BillingAnalyticsService**: Queries ClickHouse for usage breakdowns by user, resource, execution type, and time series data - **BillingEventWriterService**: Writes billing events to ClickHouse for analytics while maintaining best-effort semantics (never blocks Stripe billing) - **ClickHouse Schema**: Added `billingEvent` table with 3-year TTL for storing detailed billing event data - **GraphQL Resolver**: New `getBillingAnalytics` query that aggregates usage data for the current billing period, protected by feature flag and billing permissions - **Enhanced BillingUsageEvent**: Added `userWorkspaceId` field to track per-user credit consumption - **AI Billing Integration**: Updated AI billing service to pass `userWorkspaceId` when recording usage events ### Frontend - **SettingsBillingAnalyticsSection**: New component displaying: - Usage breakdown by execution type with progress bars - Daily usage time series chart (28-day view) - Per-user credit consumption breakdown - Per-resource (agent/workflow) credit consumption breakdown - **SettingsUsage Page**: Dedicated page for viewing usage analytics - **GraphQL Query**: `GetBillingAnalytics` query with generated hooks - **Navigation**: Added Usage menu item in settings (feature-flagged) - **Mock Data**: Included screenshot mock data for preview/testing ### Feature Flag - Added `IS_USAGE_ANALYTICS_ENABLED` feature flag to control visibility and access to analytics features ## Implementation Details - Analytics data is queried in parallel for performance - ClickHouse writes are non-blocking to ensure billing operations never fail - Progress bars use dynamic coloring from a predefined palette - Time series visualization normalizes bar heights relative to max value - Empty state handling when no analytics data is available - Responsive UI with proper text truncation for long names https://claude.ai/code/session_01Y1EqrX6PFq3EJxJq89h7DF --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+1
@@ -11,6 +11,7 @@ const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
|
||||
[EventLogTable.WORKSPACE_EVENT]: 'workspaceEvent',
|
||||
[EventLogTable.PAGEVIEW]: 'pageview',
|
||||
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
|
||||
[EventLogTable.USAGE_EVENT]: 'usageEvent',
|
||||
};
|
||||
|
||||
export type EventLogCleanupParams = {
|
||||
|
||||
@@ -36,6 +36,19 @@ type ClickHouseEventRecord = {
|
||||
isCustom?: boolean;
|
||||
};
|
||||
|
||||
type ClickHouseUsageEventRecord = {
|
||||
timestamp: string;
|
||||
userWorkspaceId?: string;
|
||||
resourceType?: string;
|
||||
operationType?: string;
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
creditsUsedMicro?: number;
|
||||
resourceId?: string;
|
||||
resourceContext?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const ALLOWED_TABLES = Object.values(EventLogTable);
|
||||
const MAX_LIMIT = 10000;
|
||||
|
||||
@@ -43,6 +56,7 @@ const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
|
||||
[EventLogTable.WORKSPACE_EVENT]: 'workspaceEvent',
|
||||
[EventLogTable.PAGEVIEW]: 'pageview',
|
||||
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
|
||||
[EventLogTable.USAGE_EVENT]: 'usageEvent',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -67,7 +81,11 @@ export class EventLogsService {
|
||||
const limit = Math.min(input.first ?? 100, MAX_LIMIT);
|
||||
const tableName = CLICKHOUSE_TABLE_NAMES[input.table];
|
||||
const eventFieldName =
|
||||
input.table === EventLogTable.PAGEVIEW ? 'name' : 'event';
|
||||
input.table === EventLogTable.USAGE_EVENT
|
||||
? 'resourceType'
|
||||
: input.table === EventLogTable.PAGEVIEW
|
||||
? 'name'
|
||||
: 'event';
|
||||
|
||||
const whereClauses: string[] = ['"workspaceId" = {workspaceId:String}'];
|
||||
const params: Record<string, unknown> = { workspaceId };
|
||||
@@ -178,15 +196,24 @@ export class EventLogsService {
|
||||
params.eventTypePattern = `%${filters.eventType.toLowerCase()}%`;
|
||||
}
|
||||
|
||||
// TODO: Legacy event tables (workspaceEvent, pageview, objectEvent) use
|
||||
// userId because some actions are logged out. Usage events use
|
||||
// userWorkspaceId directly which is more relevant in a workspace context.
|
||||
// Consider migrating all event tables to userWorkspaceId for consistency.
|
||||
if (isDefined(filters.userWorkspaceId)) {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: filters.userWorkspaceId },
|
||||
select: ['userId'],
|
||||
});
|
||||
if (table === EventLogTable.USAGE_EVENT) {
|
||||
whereClauses.push('"userWorkspaceId" = {userWorkspaceId:String}');
|
||||
params.userWorkspaceId = filters.userWorkspaceId;
|
||||
} else {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: filters.userWorkspaceId },
|
||||
select: ['userId'],
|
||||
});
|
||||
|
||||
if (isDefined(userWorkspace)) {
|
||||
whereClauses.push('"userId" = {userId:String}');
|
||||
params.userId = userWorkspace.userId;
|
||||
if (isDefined(userWorkspace)) {
|
||||
whereClauses.push('"userId" = {userId:String}');
|
||||
params.userId = userWorkspace.userId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,10 +249,27 @@ export class EventLogsService {
|
||||
}
|
||||
|
||||
private normalizeRecords(
|
||||
records: ClickHouseEventRecord[],
|
||||
records: ClickHouseEventRecord[] | ClickHouseUsageEventRecord[],
|
||||
table: EventLogTable,
|
||||
): EventLogRecord[] {
|
||||
return records.map((record) => {
|
||||
if (table === EventLogTable.USAGE_EVENT) {
|
||||
return (records as ClickHouseUsageEventRecord[]).map((record) => ({
|
||||
event: record.resourceType ?? '',
|
||||
timestamp: new Date(record.timestamp),
|
||||
userId: record.userWorkspaceId,
|
||||
properties: {
|
||||
operationType: record.operationType,
|
||||
quantity: record.quantity,
|
||||
unit: record.unit,
|
||||
creditsUsedMicro: record.creditsUsedMicro,
|
||||
resourceId: record.resourceId,
|
||||
resourceContext: record.resourceContext,
|
||||
...(record.metadata ?? {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
return (records as ClickHouseEventRecord[]).map((record) => {
|
||||
const eventName =
|
||||
table === EventLogTable.PAGEVIEW
|
||||
? (record.name ?? '')
|
||||
|
||||
Reference in New Issue
Block a user