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
@@ -16,7 +16,7 @@ import { BillingProductEntity } from 'src/engine/core-modules/billing/entities/b
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
import { BillingFeatureUsedListener } from 'src/engine/core-modules/billing/listeners/billing-feature-used.listener';
import { BillingUsageEventListener } from 'src/engine/core-modules/billing/listeners/billing-usage-event.listener';
import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener';
import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
@@ -79,7 +79,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
BillingResolver,
BillingPlanService,
BillingWorkspaceMemberListener,
BillingFeatureUsedListener,
BillingUsageEventListener,
BillingService,
BillingRestApiExceptionFilter,
BillingSyncCustomerDataCommand,
@@ -26,7 +26,7 @@ import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-module
import {
INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
toDisplayCredits,
} from 'src/engine/core-modules/billing/utils/to-display-credits.util';
} from 'src/engine/core-modules/usage/utils/to-display-credits.util';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
@@ -374,7 +374,5 @@ export class BillingResolver {
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
return;
}
}
@@ -1,3 +0,0 @@
/* @license Enterprise */
export const BILLING_FEATURE_USED = 'BILLING_FEATURE_USED';
@@ -1,5 +0,0 @@
/* @license Enterprise */
export enum BillingMeterEventName {
WORKFLOW_NODE_RUN = 'WORKFLOW_NODE_RUN',
}
@@ -5,22 +5,22 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { OnCustomBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-custom-batch-event.decorator';
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { CustomWorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/custom-workspace-batch-event.type';
@Injectable()
export class BillingFeatureUsedListener {
export class BillingUsageEventListener {
constructor(
private readonly billingUsageService: BillingUsageService,
private readonly twentyConfigService: TwentyConfigService,
) {}
@OnCustomBatchEvent(BILLING_FEATURE_USED)
async handleBillingFeatureUsedEvent(
payload: CustomWorkspaceEventBatch<BillingUsageEvent>,
@OnCustomBatchEvent(USAGE_RECORDED)
async handleUsageRecordedEvent(
payload: CustomWorkspaceEventBatch<UsageEvent>,
) {
if (!isDefined(payload.workspaceId)) {
return;
@@ -40,7 +40,7 @@ export class BillingFeatureUsedListener {
await this.billingUsageService.billUsage({
workspaceId: payload.workspaceId,
billingEvents: payload.events,
usageEvents: payload.events,
});
}
}
@@ -18,7 +18,7 @@ import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
import { StripeCreditGrantService } from 'src/engine/core-modules/billing/stripe/services/stripe-credit-grant.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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -50,10 +50,10 @@ export class BillingUsageService {
async billUsage({
workspaceId,
billingEvents,
usageEvents,
}: {
workspaceId: string;
billingEvents: BillingUsageEvent[];
usageEvents: UsageEvent[];
}) {
const workspaceStripeCustomer =
await this.billingCustomerRepository.findOne({
@@ -71,12 +71,10 @@ export class BillingUsageService {
try {
await Promise.all(
billingEvents.map((event) =>
usageEvents.map((usageEvent) =>
this.stripeBillingMeterEventService.sendBillingMeterEvent({
eventName: event.eventName,
value: event.value,
usageEvent,
stripeCustomerId: workspaceStripeCustomer.stripeCustomerId,
dimensions: event.dimensions,
}),
),
);
@@ -0,0 +1 @@
export const STRIPE_BILLING_METER_EVENT_NAME = 'WORKFLOW_NODE_RUN';
@@ -4,11 +4,11 @@ import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import type Stripe from 'stripe';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
import { STRIPE_BILLING_METER_EVENT_NAME } from 'src/engine/core-modules/billing/stripe/constants/stripe-billing-meter-event-name.constant';
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class StripeBillingAlertService {
@@ -36,7 +36,7 @@ export class StripeBillingAlertService {
): Promise<void> {
const meter = (await this.stripeBillingMeterService.getAllMeters()).find(
(meterItem) => {
return meterItem.event_name === BillingMeterEventName.WORKFLOW_NODE_RUN;
return meterItem.event_name === STRIPE_BILLING_METER_EVENT_NAME;
},
);
@@ -4,9 +4,9 @@ import { Injectable, Logger } from '@nestjs/common';
import type Stripe from 'stripe';
import { type BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { STRIPE_BILLING_METER_EVENT_NAME } from 'src/engine/core-modules/billing/stripe/constants/stripe-billing-meter-event-name.constant';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { type BillingDimensions } from 'src/engine/core-modules/billing/types/billing-dimensions.type';
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
@@ -27,35 +27,28 @@ export class StripeBillingMeterEventService {
}
async sendBillingMeterEvent({
eventName,
value,
usageEvent,
stripeCustomerId,
dimensions,
}: {
eventName: BillingMeterEventName;
value: number;
usageEvent: UsageEvent;
stripeCustomerId: string;
dimensions?: BillingDimensions;
}) {
const payload: Record<string, string> = {
value: value.toString(),
value: usageEvent.creditsUsedMicro.toString(),
stripe_customer_id: stripeCustomerId,
execution_type: usageEvent.operationType.toLowerCase(),
};
if (dimensions) {
payload.execution_type = dimensions.execution_type;
if (usageEvent.resourceId) {
payload.resource_id = usageEvent.resourceId;
}
if (dimensions.resource_id !== undefined) {
payload.resource_id = dimensions.resource_id || 'none';
}
if (dimensions.execution_context_1 !== undefined) {
payload.execution_context_1 = dimensions.execution_context_1 || 'none';
}
if (usageEvent.resourceContext) {
payload.execution_context_1 = usageEvent.resourceContext;
}
await this.stripe.billing.meterEvents.create({
event_name: eventName,
event_name: STRIPE_BILLING_METER_EVENT_NAME,
payload,
});
}
@@ -1,12 +0,0 @@
/* @license Enterprise */
export type BillingExecutionType =
| 'workflow_execution'
| 'code_execution'
| 'ai_token';
export type BillingDimensions = {
execution_type: BillingExecutionType;
resource_id?: string | null;
execution_context_1?: string | null;
};
@@ -1,12 +0,0 @@
/* @license Enterprise */
import { type NonNegative } from 'type-fest';
import { type BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { type BillingDimensions } from 'src/engine/core-modules/billing/types/billing-dimensions.type';
export type BillingUsageEvent = {
eventName: BillingMeterEventName;
value: NonNegative<number>;
dimensions?: BillingDimensions;
};
@@ -7,7 +7,7 @@ import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entitie
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { type BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
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';
export const formatBillingDatabaseProductToGraphqlDTO = (
plan: BillingGetPlanResult,
@@ -1,9 +0,0 @@
// Internal credits use micro-precision: $1 = 1,000,000 internal credits
// Display credits are 1000x coarser: $1 = 1,000 display credits
// This mirrors the "micro" pattern in payment systems (e.g. microdollars → dollars)
export const INTERNAL_CREDITS_PER_DISPLAY_CREDIT = 1000;
// Converts internal (high-precision) credits to user-facing display credits.
// Rounds to 1 decimal place for clean display (e.g. 7500 → 7.5).
export const toDisplayCredits = (internalCredits: number): number =>
Math.round((internalCredits / INTERNAL_CREDITS_PER_DISPLAY_CREDIT) * 10) / 10;