fix: resolve settings/usage chart crash and add ClickHouse usage event seeds (#19039)

## Summary

- **Fix settings/usage page crash**: The `GraphWidgetLineChart`
component used on `settings/usage` was crashing with "Instance id is not
provided and cannot be found in context" because it requires
`WidgetComponentInstanceContext` (for tooltip/crosshair component
states) which is only provided inside the widget system. Wraps the
standalone chart usages with the required context provider.
- **Avoid mounting `GraphWidgetLegend` when hidden**: The legend
component calls `useIsPageLayoutInEditMode()` which requires
`PageLayoutEditModeProviderContext` — another context only available
inside the widget system. Since the settings page passes
`showLegend={false}`, the fix conditionally unmounts the legend instead
of always mounting it with a `show` prop. Applied consistently across
all four chart types (line, bar, pie, gauge).
- **Add ClickHouse usage event seeds**: Generates ~400 realistic
`usageEvent` rows spanning the past 35 days with weighted user activity,
weekday/weekend patterns, and gradual ramp-up. Enables developers to see
the usage analytics page with data locally.

## Test plan

- [ ] Navigate to `settings/usage` — page should render without errors
- [ ] Verify the daily usage line chart displays correctly
- [ ] Navigate to a user detail page from the usage list
- [ ] Verify the user detail chart renders without errors
- [ ] Run `npx nx clickhouse:seed twenty-server` and confirm usage
events are seeded
- [ ] Verify chart legend still works correctly on dashboard widgets (no
regression)


Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-29 07:31:36 +02:00
committed by GitHub
parent 6efd9ad26e
commit a51b5ed589
54 changed files with 2072 additions and 1049 deletions
@@ -2,6 +2,8 @@
import { Field, InputType } from '@nestjs/graphql';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
@InputType()
export class UsageAnalyticsInput {
@Field(() => Date, { nullable: true })
@@ -12,4 +14,7 @@ export class UsageAnalyticsInput {
@Field(() => String, { nullable: true })
userWorkspaceId?: string;
@Field(() => [UsageOperationType], { nullable: true })
operationTypes?: UsageOperationType[];
}
@@ -14,6 +14,9 @@ export class UsageAnalyticsDTO {
@Field(() => [UsageBreakdownItemDTO])
usageByOperationType: UsageBreakdownItemDTO[];
@Field(() => [UsageBreakdownItemDTO])
usageByModel: UsageBreakdownItemDTO[];
@Field(() => [UsageTimeSeriesDTO])
timeSeries: UsageTimeSeriesDTO[];
@@ -3,7 +3,8 @@
import { registerEnumType } from '@nestjs/graphql';
export enum UsageOperationType {
AI_TOKEN = 'AI_TOKEN',
AI_CHAT_TOKEN = 'AI_CHAT_TOKEN',
AI_WORKFLOW_TOKEN = 'AI_WORKFLOW_TOKEN',
WORKFLOW_EXECUTION = 'WORKFLOW_EXECUTION',
CODE_EXECUTION = 'CODE_EXECUTION',
}
@@ -5,6 +5,7 @@ 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';
import { toDollars } from 'src/engine/core-modules/usage/utils/to-dollars.util';
export type UsageBreakdownItem = {
key: string;
@@ -31,6 +32,8 @@ type PeriodParams = {
workspaceId: string;
periodStart: Date;
periodEnd: Date;
operationTypes?: string[];
useDollarMode?: boolean;
};
const ALLOWED_GROUP_BY_FIELDS = [
@@ -38,6 +41,7 @@ const ALLOWED_GROUP_BY_FIELDS = [
'resourceId',
'operationType',
'resourceType',
'resourceContext',
] as const;
type GroupByField = (typeof ALLOWED_GROUP_BY_FIELDS)[number];
@@ -48,6 +52,40 @@ const BREAKDOWN_QUERY_LIMIT = 50;
export class UsageAnalyticsService {
constructor(private readonly clickHouseService: ClickHouseService) {}
async getAdminAiUsageByWorkspace(params: {
periodStart: Date;
periodEnd: Date;
useDollarMode?: boolean;
}): Promise<UsageBreakdownItem[]> {
const aiOperationTypes = ['AI_CHAT_TOKEN', 'AI_WORKFLOW_TOKEN'];
const convert = params.useDollarMode ? toDollars : toDisplayCredits;
const query = `
SELECT
workspaceId AS key,
sum(creditsUsedMicro) AS creditsUsedMicro
FROM usageEvent
WHERE timestamp >= {periodStart:String}
AND timestamp < {periodEnd:String}
AND operationType IN ({operationTypes:Array(String)})
GROUP BY workspaceId
ORDER BY creditsUsedMicro DESC
LIMIT ${BREAKDOWN_QUERY_LIMIT}
`;
const rows = await this.clickHouseService.select<BreakdownRowMicro>(query, {
periodStart: formatDateForClickHouse(params.periodStart),
periodEnd: formatDateForClickHouse(params.periodEnd),
operationTypes: aiOperationTypes,
});
return rows.map((row) => ({
key: row.key,
creditsUsed: convert(row.creditsUsedMicro),
}));
}
async getUsageByUser(params: PeriodParams): Promise<UsageBreakdownItem[]> {
return this.queryBreakdown({
...params,
@@ -56,6 +94,14 @@ export class UsageAnalyticsService {
});
}
async getUsageByModel(params: PeriodParams): Promise<UsageBreakdownItem[]> {
return this.queryBreakdown({
...params,
groupByField: 'resourceContext',
extraWhere: "AND resourceContext != ''",
});
}
async getUsageByOperationType(
params: PeriodParams & { userWorkspaceId?: string },
): Promise<UsageBreakdownItem[]> {
@@ -90,6 +136,8 @@ export class UsageAnalyticsService {
periodStart,
periodEnd,
groupByField,
operationTypes,
useDollarMode = false,
extraWhere = '',
extraParams,
}: PeriodParams & {
@@ -97,6 +145,19 @@ export class UsageAnalyticsService {
extraWhere?: string;
extraParams?: Record<string, unknown>;
}): Promise<UsageBreakdownItem[]> {
if (
!ALLOWED_GROUP_BY_FIELDS.includes(
groupByField as (typeof ALLOWED_GROUP_BY_FIELDS)[number],
)
) {
throw new Error(`Invalid groupByField: ${groupByField}`);
}
const opTypeFilter =
operationTypes && operationTypes.length > 0
? 'AND operationType IN ({operationTypes:Array(String)})'
: '';
const query = `
SELECT
${groupByField} AS key,
@@ -105,22 +166,28 @@ export class UsageAnalyticsService {
WHERE workspaceId = {workspaceId:String}
AND timestamp >= {periodStart:String}
AND timestamp < {periodEnd:String}
${opTypeFilter}
${extraWhere}
GROUP BY ${groupByField}
ORDER BY creditsUsedMicro DESC
LIMIT ${BREAKDOWN_QUERY_LIMIT}
`;
const convert = useDollarMode ? toDollars : toDisplayCredits;
const rows = await this.clickHouseService.select<BreakdownRowMicro>(query, {
workspaceId,
periodStart: formatDateForClickHouse(periodStart),
periodEnd: formatDateForClickHouse(periodEnd),
...(operationTypes && operationTypes.length > 0
? { operationTypes }
: {}),
...(extraParams ?? {}),
});
return rows.map((row) => ({
key: row.key,
creditsUsed: toDisplayCredits(row.creditsUsedMicro),
creditsUsed: convert(row.creditsUsedMicro),
}));
}
@@ -128,12 +195,19 @@ export class UsageAnalyticsService {
workspaceId,
periodStart,
periodEnd,
operationTypes,
useDollarMode = false,
extraWhere = '',
extraParams,
}: PeriodParams & {
extraWhere?: string;
extraParams?: Record<string, unknown>;
}): Promise<UsageTimeSeriesPoint[]> {
const opTypeFilter =
operationTypes && operationTypes.length > 0
? 'AND operationType IN ({operationTypes:Array(String)})'
: '';
const query = `
SELECT
formatDateTime(timestamp, '%Y-%m-%d') AS date,
@@ -142,24 +216,30 @@ export class UsageAnalyticsService {
WHERE workspaceId = {workspaceId:String}
AND timestamp >= {periodStart:String}
AND timestamp < {periodEnd:String}
${opTypeFilter}
${extraWhere}
GROUP BY date
ORDER BY date ASC
`;
const convert = useDollarMode ? toDollars : toDisplayCredits;
const rows = await this.clickHouseService.select<TimeSeriesRowMicro>(
query,
{
workspaceId,
periodStart: formatDateForClickHouse(periodStart),
periodEnd: formatDateForClickHouse(periodEnd),
...(operationTypes && operationTypes.length > 0
? { operationTypes }
: {}),
...(extraParams ?? {}),
},
);
return rows.map((row) => ({
date: row.date,
creditsUsed: toDisplayCredits(row.creditsUsedMicro),
creditsUsed: convert(row.creditsUsedMicro),
}));
}
}
@@ -17,6 +17,7 @@ import {
} 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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
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';
@@ -34,6 +35,7 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato
export class UsageResolver {
constructor(
private readonly usageAnalyticsService: UsageAnalyticsService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
@@ -56,21 +58,26 @@ export class UsageResolver {
const periodStart = input?.periodStart ?? defaultPeriodStart;
const periodEnd = input?.periodEnd ?? defaultPeriodEnd;
const useDollarMode = !this.twentyConfigService.get('IS_BILLING_ENABLED');
const periodParams = {
workspaceId: workspace.id,
periodStart,
periodEnd,
operationTypes: input?.operationTypes ?? undefined,
useDollarMode,
};
const [usageByUser, usageByOperationType, timeSeries] = await Promise.all([
this.usageAnalyticsService.getUsageByUser(periodParams),
this.usageAnalyticsService.getUsageByOperationType({
...periodParams,
userWorkspaceId: input?.userWorkspaceId ?? undefined,
}),
this.usageAnalyticsService.getUsageTimeSeries(periodParams),
]);
const [usageByUser, usageByOperationType, usageByModel, timeSeries] =
await Promise.all([
this.usageAnalyticsService.getUsageByUser(periodParams),
this.usageAnalyticsService.getUsageByOperationType({
...periodParams,
userWorkspaceId: input?.userWorkspaceId ?? undefined,
}),
this.usageAnalyticsService.getUsageByModel(periodParams),
this.usageAnalyticsService.getUsageTimeSeries(periodParams),
]);
const resolvedUsageByUser = await this.resolveBreakdownKeys(
usageByUser,
@@ -80,6 +87,7 @@ export class UsageResolver {
const result: UsageAnalyticsDTO = {
usageByUser: resolvedUsageByUser,
usageByOperationType,
usageByModel,
timeSeries,
periodStart,
periodEnd,
@@ -0,0 +1,6 @@
import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/metadata-modules/ai/ai-billing/constants/dollar-to-credit-multiplier';
// Converts internal micro-credits to dollars.
// Rounds to 2 decimal places (e.g. 7500 → 0.01).
export const toDollars = (internalCredits: number): number =>
Math.round((internalCredits / DOLLAR_TO_CREDIT_MULTIPLIER) * 100) / 100;