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:
+5
@@ -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[];
|
||||
|
||||
|
||||
+2
-1
@@ -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',
|
||||
}
|
||||
|
||||
+82
-2
@@ -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;
|
||||
Reference in New Issue
Block a user