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
@@ -73,6 +73,11 @@ export class AiAgentWorkflowAction implements WorkflowAction {
const executionContext =
await this.workflowExecutionContextService.getExecutionContext(runInfo);
const userWorkspaceId =
executionContext.authContext.type === 'user'
? executionContext.authContext.userWorkspaceId
: null;
const { result, usage, cacheCreationTokens } =
await this.aiAgentExecutionService.executeAgent({
agent,
@@ -89,6 +94,7 @@ export class AiAgentWorkflowAction implements WorkflowAction {
{ usage, cacheCreationTokens },
workspaceId,
agent?.id || null,
userWorkspaceId,
);
return {
@@ -2,9 +2,11 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { getWorkflowRunContext, StepStatus } from 'twenty-shared/workflow';
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
import { BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE } from 'src/engine/core-modules/billing/constants/billing-workflow-execution-error-message.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 { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@@ -190,16 +192,15 @@ describe('WorkflowExecutorWorkspaceService', () => {
});
expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith(
BILLING_FEATURE_USED,
USAGE_RECORDED,
[
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: 1,
dimensions: {
execution_type: 'workflow_execution',
resource_id: 'workflow-id',
execution_context_1: null,
},
resourceType: UsageResourceType.WORKFLOW,
operationType: UsageOperationType.WORKFLOW_EXECUTION,
creditsUsedMicro: 1,
quantity: 1,
unit: UsageUnit.INVOCATION,
resourceId: 'workflow-id',
},
],
'workspace-id',
@@ -648,16 +649,15 @@ describe('WorkflowExecutorWorkspaceService', () => {
service['sendWorkflowNodeRunEvent']('workspace-id', 'workflow-id');
expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith(
BILLING_FEATURE_USED,
USAGE_RECORDED,
[
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: 1,
dimensions: {
execution_type: 'workflow_execution',
resource_id: 'workflow-id',
execution_context_1: null,
},
resourceType: UsageResourceType.WORKFLOW,
operationType: UsageOperationType.WORKFLOW_EXECUTION,
creditsUsedMicro: 1,
quantity: 1,
unit: UsageUnit.INVOCATION,
resourceId: 'workflow-id',
},
],
'workspace-id',
@@ -9,12 +9,14 @@ import {
WorkflowRunStepInfos,
} from 'twenty-shared/workflow';
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
import { BILLING_WORKFLOW_EXECUTION_ERROR_MESSAGE } from 'src/engine/core-modules/billing/constants/billing-workflow-execution-error-message.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 { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
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 { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@@ -353,17 +355,16 @@ export class WorkflowExecutorWorkspaceService {
}
private sendWorkflowNodeRunEvent(workspaceId: string, workflowId: string) {
this.workspaceEventEmitter.emitCustomBatchEvent<BillingUsageEvent>(
BILLING_FEATURE_USED,
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
USAGE_RECORDED,
[
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: 1,
dimensions: {
execution_type: 'workflow_execution',
resource_id: workflowId,
execution_context_1: null,
},
resourceType: UsageResourceType.WORKFLOW,
operationType: UsageOperationType.WORKFLOW_EXECUTION,
creditsUsedMicro: 1,
quantity: 1,
unit: UsageUnit.INVOCATION,
resourceId: workflowId,
},
],
workspaceId,