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
@@ -4,6 +4,9 @@ import { OBJECT_RECORD_UPDATED_EVENT } from 'src/engine/core-modules/audit/utils
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
import { type GenericTrackEvent } from 'src/engine/core-modules/audit/utils/events/workspace-event/track';
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
export type ObjectEventFixture = GenericTrackEvent & {
recordId: string;
@@ -11,6 +14,20 @@ export type ObjectEventFixture = GenericTrackEvent & {
isCustom?: boolean;
};
export type UsageEventFixture = {
timestamp: string;
workspaceId: string;
userWorkspaceId: string;
resourceType: string;
operationType: string;
quantity: number;
unit: string;
creditsUsedMicro: number;
resourceId: string;
resourceContext: string;
metadata: Record<string, never>;
};
export const workspaceEventFixtures: Array<GenericTrackEvent> = [
{
type: 'track',
@@ -18,7 +35,7 @@ export const workspaceEventFixtures: Array<GenericTrackEvent> = [
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
},
{
@@ -27,7 +44,7 @@ export const workspaceEventFixtures: Array<GenericTrackEvent> = [
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
},
];
@@ -39,7 +56,7 @@ export const objectEventFixtures: Array<ObjectEventFixture> = [
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
recordId: '20202020-c21e-4ec2-873b-de4264d89025',
objectMetadataId: '20202020-1f76-4e46-b33b-58a70e007ba0',
@@ -50,7 +67,7 @@ export const objectEventFixtures: Array<ObjectEventFixture> = [
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
recordId: '20202020-c21e-4ec2-873b-de4264d89025',
objectMetadataId: '20202020-1f76-4e46-b33b-58a70e007ba0',
@@ -61,9 +78,155 @@ export const objectEventFixtures: Array<ObjectEventFixture> = [
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
recordId: '20202020-c21e-4ec2-873b-de4264d89025',
objectMetadataId: '20202020-1f76-4e46-b33b-58a70e007ba0',
},
];
const buildUsageEventFixtures = (): UsageEventFixture[] => {
const now = new Date();
const fixtures: UsageEventFixture[] = [];
const users = [
USER_WORKSPACE_DATA_SEED_IDS.TIM,
USER_WORKSPACE_DATA_SEED_IDS.JANE,
USER_WORKSPACE_DATA_SEED_IDS.JONY,
USER_WORKSPACE_DATA_SEED_IDS.PHIL,
];
// Weight per user so the breakdown isn't uniform
const userWeights = [1.0, 0.6, 0.3, 0.15];
const aiModelIds = [
'anthropic/claude-opus-4-6',
'openai/gpt-5.4',
'openai/gpt-5.4-mini',
'google/gemini-2.5-pro',
];
const operations: {
resourceType: string;
operationType: string;
baseCreditsMicro: number;
baseQuantity: number;
unit: string;
modelIds?: string[];
}[] = [
{
resourceType: 'AI',
operationType: 'AI_CHAT_TOKEN',
baseCreditsMicro: 5000,
baseQuantity: 1200,
unit: 'TOKEN',
modelIds: aiModelIds,
},
{
resourceType: 'AI',
operationType: 'AI_WORKFLOW_TOKEN',
baseCreditsMicro: 3500,
baseQuantity: 800,
unit: 'TOKEN',
modelIds: aiModelIds,
},
{
resourceType: 'WORKFLOW',
operationType: 'WORKFLOW_EXECUTION',
baseCreditsMicro: 12000,
baseQuantity: 1,
unit: 'INVOCATION',
},
{
resourceType: 'WORKFLOW',
operationType: 'CODE_EXECUTION',
baseCreditsMicro: 3000,
baseQuantity: 1,
unit: 'INVOCATION',
},
];
// Pseudo-random using a seed for reproducibility across runs
let rngState = 42;
const nextRandom = () => {
rngState = (rngState * 1664525 + 1013904223) & 0x7fffffff;
return rngState / 0x7fffffff;
};
for (let daysAgo = 34; daysAgo >= 0; daysAgo--) {
const day = new Date(now);
day.setDate(day.getDate() - daysAgo);
day.setHours(0, 0, 0, 0);
// Weekdays have more activity than weekends
const dayOfWeek = day.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const dayMultiplier = isWeekend ? 0.3 : 1.0;
// Gradual ramp-up over the month (more recent = more usage)
const recencyMultiplier = 0.5 + 0.5 * ((35 - daysAgo) / 35);
for (let userIdx = 0; userIdx < users.length; userIdx++) {
const userWeight = userWeights[userIdx];
for (const op of operations) {
// Skip some user/operation combos randomly for variety
if (nextRandom() < 0.25) {
continue;
}
const eventsCount = Math.max(
1,
Math.round(
(1 + nextRandom() * 4) *
dayMultiplier *
recencyMultiplier *
userWeight,
),
);
for (let eventIdx = 0; eventIdx < eventsCount; eventIdx++) {
const hour = Math.floor(9 + nextRandom() * 9); // 9am6pm
const minute = Math.floor(nextRandom() * 60);
const second = Math.floor(nextRandom() * 60);
const eventDate = new Date(day);
eventDate.setHours(
hour,
minute,
second,
Math.floor(nextRandom() * 1000),
);
const jitter = 0.5 + nextRandom();
const resourceContext = op.modelIds
? op.modelIds[Math.floor(nextRandom() * op.modelIds.length)]
: '';
fixtures.push({
timestamp: formatDateForClickHouse(eventDate),
workspaceId: SEED_APPLE_WORKSPACE_ID,
userWorkspaceId: users[userIdx],
resourceType: op.resourceType,
operationType: op.operationType,
quantity: Math.round(op.baseQuantity * jitter),
unit: op.unit,
creditsUsedMicro: Math.round(op.baseCreditsMicro * jitter),
resourceId: '',
resourceContext,
metadata: {},
});
}
}
}
}
return fixtures;
};
export const usageEventFixtures: UsageEventFixture[] =
buildUsageEventFixtures();
@@ -2,7 +2,11 @@
import { createClient, ClickHouseLogLevel } from '@clickhouse/client';
import { config } from 'dotenv';
import { objectEventFixtures, workspaceEventFixtures } from './fixtures';
import {
objectEventFixtures,
usageEventFixtures,
workspaceEventFixtures,
} from './fixtures';
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
@@ -34,6 +38,14 @@ async function seedEvents() {
format: 'JSONEachRow',
});
console.log(`⚡ Seeding ${usageEventFixtures.length} usage events...`);
await client.insert({
table: 'usageEvent',
values: usageEventFixtures,
format: 'JSONEachRow',
});
console.log('✅ All events seeded successfully');
} catch (error) {
console.error('Error seeding events:', error);