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:
+13
-10
@@ -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',
|
||||
|
||||
+34
-16
@@ -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,
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+1
@@ -247,6 +247,7 @@ export class ChatExecutionService {
|
||||
{ usage, cacheCreationTokens },
|
||||
workspace.id,
|
||||
null,
|
||||
userWorkspaceId,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
Reference in New Issue
Block a user