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
@@ -0,0 +1,3 @@
/* @license Enterprise */
export const USAGE_RECORDED = 'USAGE_RECORDED';
@@ -0,0 +1,15 @@
/* @license Enterprise */
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class UsageAnalyticsInput {
@Field(() => Date, { nullable: true })
periodStart?: Date;
@Field(() => Date, { nullable: true })
periodEnd?: Date;
@Field(() => String, { nullable: true })
userWorkspaceId?: string;
}
@@ -0,0 +1,28 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
import { UsageBreakdownItemDTO } from 'src/engine/core-modules/usage/dtos/usage-breakdown-item.dto';
import { UsageTimeSeriesDTO } from 'src/engine/core-modules/usage/dtos/usage-time-series.dto';
import { UsageUserDailyDTO } from 'src/engine/core-modules/usage/dtos/usage-user-daily.dto';
@ObjectType('UsageAnalytics')
export class UsageAnalyticsDTO {
@Field(() => [UsageBreakdownItemDTO])
usageByUser: UsageBreakdownItemDTO[];
@Field(() => [UsageBreakdownItemDTO])
usageByOperationType: UsageBreakdownItemDTO[];
@Field(() => [UsageTimeSeriesDTO])
timeSeries: UsageTimeSeriesDTO[];
@Field(() => Date)
periodStart: Date;
@Field(() => Date)
periodEnd: Date;
@Field(() => UsageUserDailyDTO, { nullable: true })
userDailyUsage?: UsageUserDailyDTO;
}
@@ -0,0 +1,15 @@
/* @license Enterprise */
import { Field, Float, ObjectType } from '@nestjs/graphql';
@ObjectType('UsageBreakdownItem')
export class UsageBreakdownItemDTO {
@Field(() => String)
key: string;
@Field(() => String, { nullable: true })
label?: string;
@Field(() => Float)
creditsUsed: number;
}
@@ -0,0 +1,12 @@
/* @license Enterprise */
import { Field, Float, ObjectType } from '@nestjs/graphql';
@ObjectType('UsageTimeSeries')
export class UsageTimeSeriesDTO {
@Field(() => String)
date: string;
@Field(() => Float)
creditsUsed: number;
}
@@ -0,0 +1,14 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
import { UsageTimeSeriesDTO } from 'src/engine/core-modules/usage/dtos/usage-time-series.dto';
@ObjectType('UsageUserDaily')
export class UsageUserDailyDTO {
@Field(() => String)
userWorkspaceId: string;
@Field(() => [UsageTimeSeriesDTO])
dailyUsage: UsageTimeSeriesDTO[];
}
@@ -0,0 +1,13 @@
/* @license Enterprise */
import { registerEnumType } from '@nestjs/graphql';
export enum UsageOperationType {
AI_TOKEN = 'AI_TOKEN',
WORKFLOW_EXECUTION = 'WORKFLOW_EXECUTION',
CODE_EXECUTION = 'CODE_EXECUTION',
}
registerEnumType(UsageOperationType, {
name: 'UsageOperationType',
});
@@ -0,0 +1,9 @@
/* @license Enterprise */
export enum UsageResourceType {
AI = 'AI',
WORKFLOW = 'WORKFLOW',
APP = 'APP',
STORAGE = 'STORAGE',
API = 'API',
}
@@ -0,0 +1,9 @@
/* @license Enterprise */
export enum UsageUnit {
CREDIT = 'CREDIT',
TOKEN = 'TOKEN',
INVOCATION = 'INVOCATION',
MINUTE = 'MINUTE',
BYTE = 'BYTE',
}
@@ -0,0 +1,30 @@
/* @license Enterprise */
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 { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
import { UsageEventWriterService } from 'src/engine/core-modules/usage/services/usage-event-writer.service';
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
import { CustomWorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/custom-workspace-batch-event.type';
@Injectable()
export class UsageEventListener {
constructor(
private readonly usageEventWriterService: UsageEventWriterService,
) {}
@OnCustomBatchEvent(USAGE_RECORDED)
handleUsageRecordedEvent(payload: CustomWorkspaceEventBatch<UsageEvent>) {
if (!isDefined(payload.workspaceId)) {
return;
}
this.usageEventWriterService.writeToClickHouse(
payload.workspaceId,
payload.events,
);
}
}
@@ -0,0 +1,165 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
export type UsageBreakdownItem = {
key: string;
label?: string;
creditsUsed: number;
};
export type UsageTimeSeriesPoint = {
date: string;
creditsUsed: number;
};
type BreakdownRowMicro = {
key: string;
creditsUsedMicro: number;
};
type TimeSeriesRowMicro = {
date: string;
creditsUsedMicro: number;
};
type PeriodParams = {
workspaceId: string;
periodStart: Date;
periodEnd: Date;
};
const ALLOWED_GROUP_BY_FIELDS = [
'userWorkspaceId',
'resourceId',
'operationType',
'resourceType',
] as const;
type GroupByField = (typeof ALLOWED_GROUP_BY_FIELDS)[number];
const BREAKDOWN_QUERY_LIMIT = 50;
@Injectable()
export class UsageAnalyticsService {
constructor(private readonly clickHouseService: ClickHouseService) {}
async getUsageByUser(params: PeriodParams): Promise<UsageBreakdownItem[]> {
return this.queryBreakdown({
...params,
groupByField: 'userWorkspaceId',
extraWhere: "AND userWorkspaceId != ''",
});
}
async getUsageByOperationType(
params: PeriodParams & { userWorkspaceId?: string },
): Promise<UsageBreakdownItem[]> {
return this.queryBreakdown({
...params,
groupByField: 'operationType',
...(params.userWorkspaceId && {
extraWhere: 'AND userWorkspaceId = {userWorkspaceId:String}',
extraParams: { userWorkspaceId: params.userWorkspaceId },
}),
});
}
async getUsageByUserTimeSeries(
params: PeriodParams & { userWorkspaceId: string },
): Promise<UsageTimeSeriesPoint[]> {
return this.queryTimeSeries({
...params,
extraWhere: 'AND userWorkspaceId = {userWorkspaceId:String}',
extraParams: { userWorkspaceId: params.userWorkspaceId },
});
}
async getUsageTimeSeries(
params: PeriodParams,
): Promise<UsageTimeSeriesPoint[]> {
return this.queryTimeSeries(params);
}
private async queryBreakdown({
workspaceId,
periodStart,
periodEnd,
groupByField,
extraWhere = '',
extraParams,
}: PeriodParams & {
groupByField: GroupByField;
extraWhere?: string;
extraParams?: Record<string, unknown>;
}): Promise<UsageBreakdownItem[]> {
const query = `
SELECT
${groupByField} AS key,
sum(creditsUsedMicro) AS creditsUsedMicro
FROM usageEvent
WHERE workspaceId = {workspaceId:String}
AND timestamp >= {periodStart:String}
AND timestamp < {periodEnd:String}
${extraWhere}
GROUP BY ${groupByField}
ORDER BY creditsUsedMicro DESC
LIMIT ${BREAKDOWN_QUERY_LIMIT}
`;
const rows = await this.clickHouseService.select<BreakdownRowMicro>(query, {
workspaceId,
periodStart: formatDateForClickHouse(periodStart),
periodEnd: formatDateForClickHouse(periodEnd),
...(extraParams ?? {}),
});
return rows.map((row) => ({
key: row.key,
creditsUsed: toDisplayCredits(row.creditsUsedMicro),
}));
}
private async queryTimeSeries({
workspaceId,
periodStart,
periodEnd,
extraWhere = '',
extraParams,
}: PeriodParams & {
extraWhere?: string;
extraParams?: Record<string, unknown>;
}): Promise<UsageTimeSeriesPoint[]> {
const query = `
SELECT
formatDateTime(timestamp, '%Y-%m-%d') AS date,
sum(creditsUsedMicro) AS creditsUsedMicro
FROM usageEvent
WHERE workspaceId = {workspaceId:String}
AND timestamp >= {periodStart:String}
AND timestamp < {periodEnd:String}
${extraWhere}
GROUP BY date
ORDER BY date ASC
`;
const rows = await this.clickHouseService.select<TimeSeriesRowMicro>(
query,
{
workspaceId,
periodStart: formatDateForClickHouse(periodStart),
periodEnd: formatDateForClickHouse(periodEnd),
...(extraParams ?? {}),
},
);
return rows.map((row) => ({
date: row.date,
creditsUsed: toDisplayCredits(row.creditsUsedMicro),
}));
}
}
@@ -0,0 +1,44 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
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()
export class UsageEventWriterService {
private readonly logger = new Logger(UsageEventWriterService.name);
constructor(
private readonly clickHouseService: ClickHouseService,
private readonly twentyConfigService: TwentyConfigService,
) {}
writeToClickHouse(workspaceId: string, usageEvents: UsageEvent[]): void {
if (!this.twentyConfigService.get('CLICKHOUSE_URL')) {
return;
}
const now = formatDateForClickHouse(new Date());
const rows = usageEvents.map((usageEvent) => ({
timestamp: now,
workspaceId,
userWorkspaceId: usageEvent.userWorkspaceId ?? '',
resourceType: usageEvent.resourceType,
operationType: usageEvent.operationType,
quantity: usageEvent.quantity,
unit: usageEvent.unit,
creditsUsedMicro: usageEvent.creditsUsedMicro,
resourceId: usageEvent.resourceId ?? '',
resourceContext: usageEvent.resourceContext ?? '',
metadata: '{}',
}));
this.clickHouseService.insert('usageEvent', rows).catch((error) => {
this.logger.error('Failed to write usage events to ClickHouse', error);
});
}
}
@@ -0,0 +1,16 @@
/* @license Enterprise */
import { type UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum';
import { type UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { type UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
export type UsageEvent = {
resourceType: UsageResourceType;
operationType: UsageOperationType;
creditsUsedMicro: number;
quantity: number;
unit: UsageUnit;
resourceId?: string | null;
resourceContext?: string | null;
userWorkspaceId?: string | null;
};
@@ -0,0 +1,30 @@
/* @license Enterprise */
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UsageResolver } from 'src/engine/core-modules/usage/usage.resolver';
import { UsageAnalyticsService } from 'src/engine/core-modules/usage/services/usage-analytics.service';
import { UsageEventWriterService } from 'src/engine/core-modules/usage/services/usage-event-writer.service';
import { UsageEventListener } from 'src/engine/core-modules/usage/listeners/usage-event.listener';
@Module({
imports: [
ClickHouseModule,
FeatureFlagModule,
PermissionsModule,
TypeOrmModule.forFeature([UserWorkspaceEntity]),
],
providers: [
UsageResolver,
UsageAnalyticsService,
UsageEventWriterService,
UsageEventListener,
],
exports: [UsageEventWriterService, UsageAnalyticsService],
})
export class UsageModule {}
@@ -0,0 +1,160 @@
/* @license Enterprise */
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Query } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, type Repository } from 'typeorm';
import { UsageAnalyticsDTO } from 'src/engine/core-modules/usage/dtos/usage-analytics.dto';
import { UsageAnalyticsInput } from 'src/engine/core-modules/usage/dtos/inputs/usage-analytics.input';
import {
type UsageBreakdownItem,
UsageAnalyticsService,
} from 'src/engine/core-modules/usage/services/usage-analytics.service';
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 { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
@MetadataResolver()
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
@UsePipes(ResolverValidationPipe)
export class UsageResolver {
constructor(
private readonly usageAnalyticsService: UsageAnalyticsService,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
@Query(() => UsageAnalyticsDTO)
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
)
@RequireFeatureFlag(FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED)
async getUsageAnalytics(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('input', { nullable: true }) input?: UsageAnalyticsInput,
): Promise<UsageAnalyticsDTO> {
const defaultPeriodEnd = new Date();
const defaultPeriodStart = new Date();
defaultPeriodStart.setDate(defaultPeriodStart.getDate() - 30);
const periodStart = input?.periodStart ?? defaultPeriodStart;
const periodEnd = input?.periodEnd ?? defaultPeriodEnd;
const periodParams = {
workspaceId: workspace.id,
periodStart,
periodEnd,
};
const [usageByUser, usageByOperationType, timeSeries] = await Promise.all([
this.usageAnalyticsService.getUsageByUser(periodParams),
this.usageAnalyticsService.getUsageByOperationType({
...periodParams,
userWorkspaceId: input?.userWorkspaceId ?? undefined,
}),
this.usageAnalyticsService.getUsageTimeSeries(periodParams),
]);
const resolvedUsageByUser = await this.resolveBreakdownKeys(
usageByUser,
(ids) => this.resolveUserNames(ids, workspace.id),
);
const result: UsageAnalyticsDTO = {
usageByUser: resolvedUsageByUser,
usageByOperationType,
timeSeries,
periodStart,
periodEnd,
};
if (input?.userWorkspaceId) {
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { id: input.userWorkspaceId, workspaceId: workspace.id },
select: { id: true },
});
if (isDefined(userWorkspace)) {
const dailyUsage =
await this.usageAnalyticsService.getUsageByUserTimeSeries({
...periodParams,
userWorkspaceId: input.userWorkspaceId,
});
result.userDailyUsage = {
userWorkspaceId: input.userWorkspaceId,
dailyUsage,
};
}
}
return result;
}
private async resolveBreakdownKeys(
items: UsageBreakdownItem[],
resolveNames: (ids: string[]) => Promise<Map<string, string>>,
): Promise<UsageBreakdownItem[]> {
if (items.length === 0) {
return items;
}
const ids = items.map((item) => item.key);
const nameMap = await resolveNames(ids);
return items.map((item) => ({
...item,
label: nameMap.get(item.key),
}));
}
private async resolveUserNames(
userWorkspaceIds: string[],
workspaceId: string,
): Promise<Map<string, string>> {
const nameMap = new Map<string, string>();
if (userWorkspaceIds.length === 0) {
return nameMap;
}
const userWorkspaces = await this.userWorkspaceRepository.find({
where: { id: In(userWorkspaceIds), workspaceId },
relations: ['user'],
select: {
id: true,
user: { firstName: true, lastName: true, email: true },
},
});
for (const userWorkspace of userWorkspaces) {
if (!isDefined(userWorkspace.user)) {
continue;
}
const { firstName, lastName, email } = userWorkspace.user;
const fullName = `${firstName} ${lastName}`.trim();
nameMap.set(userWorkspace.id, fullName || email);
}
return nameMap;
}
}
@@ -0,0 +1,9 @@
// 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;