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
@@ -1,7 +1,9 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum';
import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@@ -334,16 +336,17 @@ describe('AiBillingService', () => {
expect(
mockWorkspaceEventEmitter.emitCustomBatchEvent,
).toHaveBeenCalledWith(
BILLING_FEATURE_USED,
USAGE_RECORDED,
[
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: 7500,
dimensions: {
execution_type: 'ai_token',
resource_id: 'agent-id-123',
execution_context_1: 'gpt-4o',
},
resourceType: UsageResourceType.AI,
operationType: UsageOperationType.AI_TOKEN,
creditsUsedMicro: 7500,
quantity: 1500,
unit: UsageUnit.TOKEN,
resourceId: 'agent-id-123',
resourceContext: 'gpt-4o',
userWorkspaceId: null,
},
],
'workspace-1',
@@ -2,9 +2,11 @@ import { Injectable, Logger } from '@nestjs/common';
import { type LanguageModelUsage } from 'ai';
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum';
import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/utils/compute-cost-breakdown.util';
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
@@ -54,32 +56,48 @@ export class AiBillingService {
billingInput: BillingUsageInput,
workspaceId: string,
agentId?: string | null,
userWorkspaceId?: string | null,
): void {
const costInDollars = this.calculateCost(modelId, billingInput);
const creditsUsed = Math.round(
const creditsUsedMicro = Math.round(
convertDollarsToBillingCredits(costInDollars),
);
this.sendAiTokenUsageEvent(workspaceId, creditsUsed, modelId, agentId);
const totalTokens =
(billingInput.usage.inputTokens ?? 0) +
(billingInput.usage.outputTokens ?? 0) +
(billingInput.cacheCreationTokens ?? 0);
this.emitAiTokenUsageEvent(
workspaceId,
creditsUsedMicro,
totalTokens,
modelId,
agentId,
userWorkspaceId,
);
}
private sendAiTokenUsageEvent(
private emitAiTokenUsageEvent(
workspaceId: string,
creditsUsed: number,
creditsUsedMicro: number,
totalTokens: number,
modelId: ModelId,
agentId?: string | null,
userWorkspaceId?: string | null,
): void {
this.workspaceEventEmitter.emitCustomBatchEvent<BillingUsageEvent>(
BILLING_FEATURE_USED,
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: creditsUsed,
dimensions: {
execution_type: 'ai_token',
resource_id: agentId || null,
execution_context_1: modelId,
},
resourceType: UsageResourceType.AI,
operationType: UsageOperationType.AI_TOKEN,
creditsUsedMicro,
quantity: totalTokens,
unit: UsageUnit.TOKEN,
resourceId: agentId || null,
resourceContext: modelId,
userWorkspaceId: userWorkspaceId || null,
},
],
workspaceId,
@@ -13,7 +13,7 @@ import { FeatureFlagKey } from 'twenty-shared/types';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-display-credits.util';
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
@@ -19,7 +19,7 @@ import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agen
import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/utils/compute-cost-breakdown.util';
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
import { extractCacheCreationTokens } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-display-credits.util';
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
@@ -247,6 +247,7 @@ export class ChatExecutionService {
{ usage, cacheCreationTokens },
workspace.id,
null,
userWorkspaceId,
);
})
.catch((error) => {