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:
@@ -22,6 +22,7 @@ import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
|
||||
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
|
||||
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
|
||||
import { UsageModule } from 'src/engine/core-modules/usage/usage.module';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
@@ -42,6 +43,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
PermissionsModule,
|
||||
SecureHttpClientModule,
|
||||
ApplicationRegistrationModule,
|
||||
UsageModule,
|
||||
],
|
||||
providers: [
|
||||
AdminPanelResolver,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Int, Mutation, Query } from '@nestjs/graphql';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import { In, type Repository } from 'typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
|
||||
@@ -10,6 +13,8 @@ import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-pan
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { AdminAIModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
|
||||
import { UsageBreakdownItemDTO } from 'src/engine/core-modules/usage/dtos/usage-breakdown-item.dto';
|
||||
import { UsageAnalyticsService } from 'src/engine/core-modules/usage/services/usage-analytics.service';
|
||||
import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum';
|
||||
import { ConfigVariableDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
|
||||
import { ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables.dto';
|
||||
@@ -41,6 +46,7 @@ import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/
|
||||
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
|
||||
import { extractConfigVariableName } from 'src/engine/metadata-modules/ai/ai-models/utils/extract-config-variable-name.util';
|
||||
import { loadDefaultAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-ai-providers.util';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
|
||||
import { ServerLevelImpersonateGuard } from 'src/engine/guards/server-level-impersonate.guard';
|
||||
@@ -75,6 +81,9 @@ export class AdminPanelResolver {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly modelsDevCatalogService: ModelsDevCatalogService,
|
||||
private readonly usageAnalyticsService: UsageAnalyticsService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
) {}
|
||||
|
||||
@UseGuards(ServerLevelImpersonateGuard)
|
||||
@@ -499,4 +508,47 @@ export class AdminPanelResolver {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@UseGuards(AdminPanelGuard)
|
||||
@Query(() => [UsageBreakdownItemDTO])
|
||||
async getAdminAiUsageByWorkspace(
|
||||
@Args('periodStart', { type: () => Date, nullable: true })
|
||||
periodStart?: Date,
|
||||
@Args('periodEnd', { type: () => Date, nullable: true })
|
||||
periodEnd?: Date,
|
||||
): Promise<UsageBreakdownItemDTO[]> {
|
||||
const defaultEnd = new Date();
|
||||
const defaultStart = new Date();
|
||||
|
||||
defaultStart.setDate(defaultStart.getDate() - 30);
|
||||
|
||||
const useDollarMode = !this.twentyConfigService.get('IS_BILLING_ENABLED');
|
||||
|
||||
const items = await this.usageAnalyticsService.getAdminAiUsageByWorkspace({
|
||||
periodStart: periodStart ?? defaultStart,
|
||||
periodEnd: periodEnd ?? defaultEnd,
|
||||
useDollarMode,
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const workspaceIds = items.map((item) => item.key);
|
||||
const workspaces = await this.workspaceRepository.find({
|
||||
where: { id: In(workspaceIds) },
|
||||
select: { id: true, displayName: true },
|
||||
});
|
||||
|
||||
const nameMap = new Map(
|
||||
workspaces
|
||||
.filter((workspace) => isDefined(workspace.displayName))
|
||||
.map((workspace) => [workspace.id, workspace.displayName!]),
|
||||
);
|
||||
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
label: nameMap.get(item.key),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+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