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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export const BILLING_FEATURE_USED = 'BILLING_FEATURE_USED';
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export enum BillingMeterEventName {
|
||||
WORKFLOW_NODE_RUN = 'WORKFLOW_NODE_RUN',
|
||||
}
|
||||
+7
-7
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const STRIPE_BILLING_METER_EVENT_NAME = 'WORKFLOW_NODE_RUN';
|
||||
+5
-5
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+12
-19
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
-12
@@ -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;
|
||||
};
|
||||
-12
@@ -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;
|
||||
};
|
||||
+1
-1
@@ -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,
|
||||
|
||||
-9
@@ -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;
|
||||
Reference in New Issue
Block a user