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:
Félix Malfait
2026-03-23 10:28:23 +01:00
committed by GitHub
parent 49af539032
commit 77d4bd9158
154 changed files with 2246 additions and 565 deletions
@@ -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 ?? '')