feat(event-logs): add billing events table to audit logs viewer

- Add BILLING_EVENT to EventLogTable enum (shared, generated types)
- Handle billingEvent ClickHouse table in EventLogsService with proper
  column mapping (eventType→event, userWorkspaceId→userId, metadata→properties)
- Add "Billing Events" option to EventLogTableSelector
- Add BILLING_EVENT_COLUMNS config for results table
- Fix CSS alphabetical ordering lint errors in SettingsBillingAnalyticsSection
- Replace navigate() with Link for usage page navigation (lint fix)

https://claude.ai/code/session_01Y1EqrX6PFq3EJxJq89h7DF
This commit is contained in:
Claude
2026-03-12 15:42:59 +00:00
parent c07c62e292
commit 2b61fa5717
8 changed files with 110 additions and 46 deletions
@@ -1698,6 +1698,7 @@ export type EventLogRecord = {
};
export enum EventLogTable {
BILLING_EVENT = 'BILLING_EVENT',
OBJECT_EVENT = 'OBJECT_EVENT',
PAGEVIEW = 'PAGEVIEW',
WORKSPACE_EVENT = 'WORKSPACE_EVENT'
@@ -25,10 +25,10 @@ const StyledBarRow = styled.div`
`;
const StyledClickableBarRow = styled(StyledBarRow)`
cursor: pointer;
border-radius: ${themeCssVariables.border.radius.sm};
padding: ${themeCssVariables.spacing[1]};
cursor: pointer;
margin: -${themeCssVariables.spacing[1]};
padding: ${themeCssVariables.spacing[1]};
&:hover {
background-color: ${themeCssVariables.background.transparent.light};
@@ -36,19 +36,19 @@ const StyledClickableBarRow = styled(StyledBarRow)`
`;
const StyledBarLabel = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
align-items: center;
`;
const StyledLabelText = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60%;
`;
const StyledValueText = styled.span`
@@ -58,8 +58,8 @@ const StyledValueText = styled.span`
`;
const StyledTimeSeriesContainer = styled.div`
display: flex;
align-items: flex-end;
display: flex;
gap: 2px;
height: 80px;
width: 100%;
@@ -132,7 +132,14 @@ const StyledBackButton = styled.button`
`;
const getBarColors = (theme: {
color: { blue: string; purple: string; green: string; orange: string; turquoise: string; pink: string };
color: {
blue: string;
purple: string;
green: string;
orange: string;
turquoise: string;
pink: string;
};
}): string[] => [
theme.color.blue,
theme.color.purple,
@@ -219,9 +226,7 @@ export const SettingsBillingAnalyticsSection = () => {
);
const displayTimeSeries =
selectedUserId && userDailyUsage
? userDailyUsage.dailyUsage
: timeSeries;
selectedUserId && userDailyUsage ? userDailyUsage.dailyUsage : timeSeries;
const maxTimeSeriesValue = Math.max(
...displayTimeSeries.map((point) => point.creditsUsed),
@@ -278,15 +283,10 @@ export const SettingsBillingAnalyticsSection = () => {
label={t`Usage by Type`}
value={`${formatNumber(totalCredits)} total`}
/>
<HorizontalSeparator
noMargin
color={theme.background.tertiary}
/>
<HorizontalSeparator noMargin color={theme.background.tertiary} />
{usageByExecutionType.map((item, index) => {
const percentage =
totalCredits > 0
? (item.creditsUsed / totalCredits) * 100
: 0;
totalCredits > 0 ? (item.creditsUsed / totalCredits) * 100 : 0;
return (
<StyledBarRow key={item.key}>
@@ -295,8 +295,8 @@ export const SettingsBillingAnalyticsSection = () => {
{EXECUTION_TYPE_LABELS[item.key] ?? item.key}
</StyledLabelText>
<StyledValueText>
{formatNumber(item.creditsUsed)} (
{Math.round(percentage)}%)
{formatNumber(item.creditsUsed)} ({Math.round(percentage)}
%)
</StyledValueText>
</StyledBarLabel>
<ProgressBar
@@ -369,9 +369,7 @@ export const SettingsBillingAnalyticsSection = () => {
<SubscriptionInfoContainer>
{usageByUser.map((item, index) => {
const percentage =
totalCredits > 0
? (item.creditsUsed / totalCredits) * 100
: 0;
totalCredits > 0 ? (item.creditsUsed / totalCredits) * 100 : 0;
return (
<StyledClickableBarRow
@@ -406,9 +404,7 @@ export const SettingsBillingAnalyticsSection = () => {
<SubscriptionInfoContainer>
{usageByResource.map((item, index) => {
const percentage =
totalCredits > 0
? (item.creditsUsed / totalCredits) * 100
: 0;
totalCredits > 0 ? (item.creditsUsed / totalCredits) * 100 : 0;
return (
<StyledBarRow key={item.key}>
@@ -1,5 +1,5 @@
import { useLingui } from '@lingui/react/macro';
import { useNavigate } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SettingsBillingAnalyticsSection } from '@/billing/components/SettingsBillingAnalyticsSection';
@@ -10,7 +10,12 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { isDefined, getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { H2Title, IconChartBar, IconCircleX, IconCreditCard } from 'twenty-ui/display';
import {
H2Title,
IconChartBar,
IconCircleX,
IconCreditCard,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import {
@@ -24,7 +29,6 @@ export const SettingsBillingContent = () => {
const { t } = useLingui();
const { redirect } = useRedirect();
const navigate = useNavigate();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
@@ -85,12 +89,13 @@ export const SettingsBillingContent = () => {
title={t`Usage`}
description={t`View detailed usage analytics for your workspace`}
/>
<Button
Icon={IconChartBar}
title={t`View usage`}
variant="secondary"
onClick={() => navigate(getSettingsPath(SettingsPath.Usage))}
/>
<Link to={getSettingsPath(SettingsPath.Usage)}>
<Button
Icon={IconChartBar}
title={t`View usage`}
variant="secondary"
/>
</Link>
</Section>
<Section>
<H2Title
@@ -79,6 +79,23 @@ const OBJECT_EVENT_COLUMNS: ColumnConfig[] = [
},
];
const BILLING_EVENT_COLUMNS: ColumnConfig[] = [
{ id: 'event', label: msg`Event Type`, minWidth: 100, defaultWidth: 160 },
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 140,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 130 },
{
id: 'properties',
label: msg`Details`,
minWidth: 200,
defaultWidth: 400,
},
];
const StyledScrollWrapperContainer = styled.div`
height: 100%;
overflow: hidden;
@@ -159,9 +176,12 @@ export const EventLogResultsTable = ({
const { t } = useLingui();
const showObjectEventColumns = selectedTable === EventLogTable.OBJECT_EVENT;
const baseColumns = showObjectEventColumns
? OBJECT_EVENT_COLUMNS
: DEFAULT_COLUMNS;
const baseColumns =
selectedTable === EventLogTable.OBJECT_EVENT
? OBJECT_EVENT_COLUMNS
: selectedTable === EventLogTable.BILLING_EVENT
? BILLING_EVENT_COLUMNS
: DEFAULT_COLUMNS;
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() =>
Object.fromEntries(baseColumns.map((col) => [col.id, col.defaultWidth])),
@@ -27,6 +27,10 @@ export const EventLogTableSelector = ({
value: EventLogTable.OBJECT_EVENT,
label: t`Object Events`,
},
{
value: EventLogTable.BILLING_EVENT,
label: t`Billing Events`,
},
];
return (
@@ -11,6 +11,7 @@ const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
[EventLogTable.WORKSPACE_EVENT]: 'workspaceEvent',
[EventLogTable.PAGEVIEW]: 'pageview',
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
[EventLogTable.BILLING_EVENT]: 'billingEvent',
};
export type EventLogCleanupParams = {
@@ -36,6 +36,17 @@ type ClickHouseEventRecord = {
isCustom?: boolean;
};
type ClickHouseBillingEventRecord = {
timestamp: string;
userWorkspaceId?: string;
eventType?: string;
executionType?: string;
creditsUsed?: number;
resourceId?: string;
resourceContext?: string;
metadata?: Record<string, unknown>;
};
const ALLOWED_TABLES = Object.values(EventLogTable);
const MAX_LIMIT = 10000;
@@ -43,6 +54,7 @@ const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
[EventLogTable.WORKSPACE_EVENT]: 'workspaceEvent',
[EventLogTable.PAGEVIEW]: 'pageview',
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
[EventLogTable.BILLING_EVENT]: 'billingEvent',
};
@Injectable()
@@ -67,7 +79,11 @@ export class EventLogsService {
const limit = Math.min(input.first ?? 100, MAX_LIMIT);
const tableName = CLICKHOUSE_TABLE_NAMES[input.table];
const eventFieldName =
input.table === EventLogTable.PAGEVIEW ? 'name' : 'event';
input.table === EventLogTable.BILLING_EVENT
? 'eventType'
: input.table === EventLogTable.PAGEVIEW
? 'name'
: 'event';
const whereClauses: string[] = ['"workspaceId" = {workspaceId:String}'];
const params: Record<string, unknown> = { workspaceId };
@@ -179,14 +195,19 @@ export class EventLogsService {
}
if (isDefined(filters.userWorkspaceId)) {
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { id: filters.userWorkspaceId },
select: ['userId'],
});
if (table === EventLogTable.BILLING_EVENT) {
whereClauses.push('"userWorkspaceId" = {userWorkspaceId:String}');
params.userWorkspaceId = filters.userWorkspaceId;
} else {
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { id: filters.userWorkspaceId },
select: ['userId'],
});
if (isDefined(userWorkspace)) {
whereClauses.push('"userId" = {userId:String}');
params.userId = userWorkspace.userId;
if (isDefined(userWorkspace)) {
whereClauses.push('"userId" = {userId:String}');
params.userId = userWorkspace.userId;
}
}
}
@@ -222,10 +243,25 @@ export class EventLogsService {
}
private normalizeRecords(
records: ClickHouseEventRecord[],
records: ClickHouseEventRecord[] | ClickHouseBillingEventRecord[],
table: EventLogTable,
): EventLogRecord[] {
return records.map((record) => {
if (table === EventLogTable.BILLING_EVENT) {
return (records as ClickHouseBillingEventRecord[]).map((record) => ({
event: record.eventType ?? '',
timestamp: new Date(record.timestamp),
userId: record.userWorkspaceId,
properties: {
executionType: record.executionType,
creditsUsed: record.creditsUsed,
resourceId: record.resourceId,
resourceContext: record.resourceContext,
...(record.metadata ?? {}),
},
}));
}
return (records as ClickHouseEventRecord[]).map((record) => {
const eventName =
table === EventLogTable.PAGEVIEW
? (record.name ?? '')
@@ -2,4 +2,5 @@ export enum EventLogTable {
WORKSPACE_EVENT = 'WORKSPACE_EVENT',
PAGEVIEW = 'PAGEVIEW',
OBJECT_EVENT = 'OBJECT_EVENT',
BILLING_EVENT = 'BILLING_EVENT',
}