Files
twenty/packages/twenty-server/test/integration/audit/suites/clickHouse-usage-event-writer.integration-spec.ts
T
Félix Malfait a51b5ed589 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)
2026-03-29 07:31:36 +02:00

139 lines
4.1 KiB
TypeScript

import process from 'process';
import {
type ClickHouseClient,
ClickHouseLogLevel,
createClient,
} from '@clickhouse/client';
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum';
import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
const buildUsageEventRow = (
workspaceId: string,
overrides: Partial<{
userWorkspaceId: string;
resourceType: UsageResourceType;
operationType: UsageOperationType;
quantity: number;
unit: UsageUnit;
creditsUsedMicro: number;
resourceId: string;
resourceContext: string;
}> = {},
) => ({
timestamp: formatDateForClickHouse(new Date()),
workspaceId,
userWorkspaceId: overrides.userWorkspaceId ?? '',
resourceType: overrides.resourceType ?? UsageResourceType.AI,
operationType: overrides.operationType ?? UsageOperationType.AI_CHAT_TOKEN,
quantity: overrides.quantity ?? 0,
unit: overrides.unit ?? UsageUnit.TOKEN,
creditsUsedMicro: overrides.creditsUsedMicro ?? 0,
resourceId: overrides.resourceId ?? '',
resourceContext: overrides.resourceContext ?? '',
metadata: {},
});
describe('ClickHouse Usage Event Writer (integration)', () => {
let clickHouseClient: ClickHouseClient;
beforeAll(async () => {
jest.useRealTimers();
clickHouseClient = createClient({
url: process.env.CLICKHOUSE_URL,
log: { level: ClickHouseLogLevel.OFF },
});
await clickHouseClient.query({
query: 'TRUNCATE TABLE usageEvent',
format: 'JSONEachRow',
});
});
afterAll(async () => {
if (clickHouseClient) {
await clickHouseClient.close();
}
});
it('should insert a usage event row matching the schema produced by UsageEventWriterService', async () => {
const workspaceId = '00000000-0000-0000-0000-000000000001';
const row = buildUsageEventRow(workspaceId, {
userWorkspaceId: '00000000-0000-0000-0000-000000000002',
resourceType: UsageResourceType.AI,
operationType: UsageOperationType.AI_CHAT_TOKEN,
quantity: 1500,
unit: UsageUnit.TOKEN,
creditsUsedMicro: 7500,
resourceId: 'agent-123',
resourceContext: 'gpt-4o',
});
await clickHouseClient.insert({
table: 'usageEvent',
values: [row],
format: 'JSONEachRow',
});
const queryResult = await clickHouseClient.query({
query: `
SELECT *
FROM usageEvent
WHERE workspaceId = '${workspaceId}'
`,
format: 'JSONEachRow',
});
const rows = await queryResult.json<Record<string, unknown>>();
expect(rows).toHaveLength(1);
expect(rows[0].workspaceId).toBe(workspaceId);
expect(rows[0].resourceType).toBe(UsageResourceType.AI);
expect(rows[0].operationType).toBe(UsageOperationType.AI_CHAT_TOKEN);
expect(rows[0].quantity).toBe(1500);
expect(rows[0].unit).toBe(UsageUnit.TOKEN);
expect(rows[0].creditsUsedMicro).toBe(7500);
expect(rows[0].resourceId).toBe('agent-123');
expect(rows[0].resourceContext).toBe('gpt-4o');
});
it('should insert a usage event with empty optional fields', async () => {
const workspaceId = '00000000-0000-0000-0000-000000000003';
const row = buildUsageEventRow(workspaceId, {
resourceType: UsageResourceType.WORKFLOW,
operationType: UsageOperationType.WORKFLOW_EXECUTION,
quantity: 1,
unit: UsageUnit.INVOCATION,
creditsUsedMicro: 1,
});
await clickHouseClient.insert({
table: 'usageEvent',
values: [row],
format: 'JSONEachRow',
});
const queryResult = await clickHouseClient.query({
query: `
SELECT *
FROM usageEvent
WHERE workspaceId = '${workspaceId}'
`,
format: 'JSONEachRow',
});
const rows = await queryResult.json<Record<string, unknown>>();
expect(rows).toHaveLength(1);
expect(rows[0].userWorkspaceId).toBe('');
expect(rows[0].resourceId).toBe('');
expect(rows[0].resourceContext).toBe('');
});
});