Add billing usage analytics dashboard with ClickHouse integration (#18592)
## Summary This PR adds a comprehensive billing usage analytics feature that provides detailed breakdowns of credit consumption across execution types, users, resources, and time periods. The implementation includes a new ClickHouse-backed analytics service, GraphQL API endpoint, and a frontend dashboard component. ## Key Changes ### Backend - **New BillingAnalyticsService**: Queries ClickHouse for usage breakdowns by user, resource, execution type, and time series data - **BillingEventWriterService**: Writes billing events to ClickHouse for analytics while maintaining best-effort semantics (never blocks Stripe billing) - **ClickHouse Schema**: Added `billingEvent` table with 3-year TTL for storing detailed billing event data - **GraphQL Resolver**: New `getBillingAnalytics` query that aggregates usage data for the current billing period, protected by feature flag and billing permissions - **Enhanced BillingUsageEvent**: Added `userWorkspaceId` field to track per-user credit consumption - **AI Billing Integration**: Updated AI billing service to pass `userWorkspaceId` when recording usage events ### Frontend - **SettingsBillingAnalyticsSection**: New component displaying: - Usage breakdown by execution type with progress bars - Daily usage time series chart (28-day view) - Per-user credit consumption breakdown - Per-resource (agent/workflow) credit consumption breakdown - **SettingsUsage Page**: Dedicated page for viewing usage analytics - **GraphQL Query**: `GetBillingAnalytics` query with generated hooks - **Navigation**: Added Usage menu item in settings (feature-flagged) - **Mock Data**: Included screenshot mock data for preview/testing ### Feature Flag - Added `IS_USAGE_ANALYTICS_ENABLED` feature flag to control visibility and access to analytics features ## Implementation Details - Analytics data is queried in parallel for performance - ClickHouse writes are non-blocking to ensure billing operations never fail - Progress bars use dynamic coloring from a predefined palette - Time series visualization normalizes bar heights relative to max value - Empty state handling when no analytics data is available - Responsive UI with proper text truncation for long names https://claude.ai/code/session_01Y1EqrX6PFq3EJxJq89h7DF --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ import { BillingProductEntity } from 'src/engine/core-modules/billing/entities/b
|
||||
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/filters/billing-api-exception.filter';
|
||||
import { BillingFeatureUsedListener } from 'src/engine/core-modules/billing/listeners/billing-feature-used.listener';
|
||||
import { BillingUsageEventListener } from 'src/engine/core-modules/billing/listeners/billing-usage-event.listener';
|
||||
import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener';
|
||||
import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service';
|
||||
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||
@@ -79,7 +79,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
BillingResolver,
|
||||
BillingPlanService,
|
||||
BillingWorkspaceMemberListener,
|
||||
BillingFeatureUsedListener,
|
||||
BillingUsageEventListener,
|
||||
BillingService,
|
||||
BillingRestApiExceptionFilter,
|
||||
BillingSyncCustomerDataCommand,
|
||||
|
||||
@@ -26,7 +26,7 @@ import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-module
|
||||
import {
|
||||
INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
|
||||
toDisplayCredits,
|
||||
} from 'src/engine/core-modules/billing/utils/to-display-credits.util';
|
||||
} from 'src/engine/core-modules/usage/utils/to-display-credits.util';
|
||||
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 { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
@@ -374,7 +374,5 @@ export class BillingResolver {
|
||||
PermissionsExceptionCode.PERMISSION_DENIED,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export const BILLING_FEATURE_USED = 'BILLING_FEATURE_USED';
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export enum BillingMeterEventName {
|
||||
WORKFLOW_NODE_RUN = 'WORKFLOW_NODE_RUN',
|
||||
}
|
||||
+7
-7
@@ -5,22 +5,22 @@ import { Injectable } from '@nestjs/common';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { OnCustomBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-custom-batch-event.decorator';
|
||||
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
|
||||
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
|
||||
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { CustomWorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/custom-workspace-batch-event.type';
|
||||
|
||||
@Injectable()
|
||||
export class BillingFeatureUsedListener {
|
||||
export class BillingUsageEventListener {
|
||||
constructor(
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
@OnCustomBatchEvent(BILLING_FEATURE_USED)
|
||||
async handleBillingFeatureUsedEvent(
|
||||
payload: CustomWorkspaceEventBatch<BillingUsageEvent>,
|
||||
@OnCustomBatchEvent(USAGE_RECORDED)
|
||||
async handleUsageRecordedEvent(
|
||||
payload: CustomWorkspaceEventBatch<UsageEvent>,
|
||||
) {
|
||||
if (!isDefined(payload.workspaceId)) {
|
||||
return;
|
||||
@@ -40,7 +40,7 @@ export class BillingFeatureUsedListener {
|
||||
|
||||
await this.billingUsageService.billUsage({
|
||||
workspaceId: payload.workspaceId,
|
||||
billingEvents: payload.events,
|
||||
usageEvents: payload.events,
|
||||
});
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -18,7 +18,7 @@ import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
|
||||
import { StripeCreditGrantService } from 'src/engine/core-modules/billing/stripe/services/stripe-credit-grant.service';
|
||||
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
|
||||
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@@ -50,10 +50,10 @@ export class BillingUsageService {
|
||||
|
||||
async billUsage({
|
||||
workspaceId,
|
||||
billingEvents,
|
||||
usageEvents,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
billingEvents: BillingUsageEvent[];
|
||||
usageEvents: UsageEvent[];
|
||||
}) {
|
||||
const workspaceStripeCustomer =
|
||||
await this.billingCustomerRepository.findOne({
|
||||
@@ -71,12 +71,10 @@ export class BillingUsageService {
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
billingEvents.map((event) =>
|
||||
usageEvents.map((usageEvent) =>
|
||||
this.stripeBillingMeterEventService.sendBillingMeterEvent({
|
||||
eventName: event.eventName,
|
||||
value: event.value,
|
||||
usageEvent,
|
||||
stripeCustomerId: workspaceStripeCustomer.stripeCustomerId,
|
||||
dimensions: event.dimensions,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const STRIPE_BILLING_METER_EVENT_NAME = 'WORKFLOW_NODE_RUN';
|
||||
+5
-5
@@ -4,11 +4,11 @@ import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
|
||||
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
|
||||
import { STRIPE_BILLING_METER_EVENT_NAME } from 'src/engine/core-modules/billing/stripe/constants/stripe-billing-meter-event-name.constant';
|
||||
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
|
||||
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
|
||||
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class StripeBillingAlertService {
|
||||
@@ -36,7 +36,7 @@ export class StripeBillingAlertService {
|
||||
): Promise<void> {
|
||||
const meter = (await this.stripeBillingMeterService.getAllMeters()).find(
|
||||
(meterItem) => {
|
||||
return meterItem.event_name === BillingMeterEventName.WORKFLOW_NODE_RUN;
|
||||
return meterItem.event_name === STRIPE_BILLING_METER_EVENT_NAME;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+12
-19
@@ -4,9 +4,9 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { type BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { STRIPE_BILLING_METER_EVENT_NAME } from 'src/engine/core-modules/billing/stripe/constants/stripe-billing-meter-event-name.constant';
|
||||
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
|
||||
import { type BillingDimensions } from 'src/engine/core-modules/billing/types/billing-dimensions.type';
|
||||
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -27,35 +27,28 @@ export class StripeBillingMeterEventService {
|
||||
}
|
||||
|
||||
async sendBillingMeterEvent({
|
||||
eventName,
|
||||
value,
|
||||
usageEvent,
|
||||
stripeCustomerId,
|
||||
dimensions,
|
||||
}: {
|
||||
eventName: BillingMeterEventName;
|
||||
value: number;
|
||||
usageEvent: UsageEvent;
|
||||
stripeCustomerId: string;
|
||||
dimensions?: BillingDimensions;
|
||||
}) {
|
||||
const payload: Record<string, string> = {
|
||||
value: value.toString(),
|
||||
value: usageEvent.creditsUsedMicro.toString(),
|
||||
stripe_customer_id: stripeCustomerId,
|
||||
execution_type: usageEvent.operationType.toLowerCase(),
|
||||
};
|
||||
|
||||
if (dimensions) {
|
||||
payload.execution_type = dimensions.execution_type;
|
||||
if (usageEvent.resourceId) {
|
||||
payload.resource_id = usageEvent.resourceId;
|
||||
}
|
||||
|
||||
if (dimensions.resource_id !== undefined) {
|
||||
payload.resource_id = dimensions.resource_id || 'none';
|
||||
}
|
||||
|
||||
if (dimensions.execution_context_1 !== undefined) {
|
||||
payload.execution_context_1 = dimensions.execution_context_1 || 'none';
|
||||
}
|
||||
if (usageEvent.resourceContext) {
|
||||
payload.execution_context_1 = usageEvent.resourceContext;
|
||||
}
|
||||
|
||||
await this.stripe.billing.meterEvents.create({
|
||||
event_name: eventName,
|
||||
event_name: STRIPE_BILLING_METER_EVENT_NAME,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export type BillingExecutionType =
|
||||
| 'workflow_execution'
|
||||
| 'code_execution'
|
||||
| 'ai_token';
|
||||
|
||||
export type BillingDimensions = {
|
||||
execution_type: BillingExecutionType;
|
||||
resource_id?: string | null;
|
||||
execution_context_1?: string | null;
|
||||
};
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { type NonNegative } from 'type-fest';
|
||||
|
||||
import { type BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { type BillingDimensions } from 'src/engine/core-modules/billing/types/billing-dimensions.type';
|
||||
|
||||
export type BillingUsageEvent = {
|
||||
eventName: BillingMeterEventName;
|
||||
value: NonNegative<number>;
|
||||
dimensions?: BillingDimensions;
|
||||
};
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entitie
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
import { type BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-display-credits.util';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
|
||||
|
||||
export const formatBillingDatabaseProductToGraphqlDTO = (
|
||||
plan: BillingGetPlanResult,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-acc
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { BillingWebhookModule } from 'src/engine/core-modules/billing-webhook/billing-webhook.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { UsageModule } from 'src/engine/core-modules/usage/usage.module';
|
||||
import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.module';
|
||||
import { TimelineCalendarEventModule } from 'src/engine/core-modules/calendar/timeline-calendar-event.module';
|
||||
import { CaptchaModule } from 'src/engine/core-modules/captcha/captcha.module';
|
||||
@@ -82,6 +83,7 @@ import { FileModule } from './file/file.module';
|
||||
AuthModule,
|
||||
BillingModule,
|
||||
BillingWebhookModule,
|
||||
UsageModule,
|
||||
ClientConfigModule,
|
||||
FeatureFlagModule,
|
||||
FileModule,
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
|
||||
[EventLogTable.WORKSPACE_EVENT]: 'workspaceEvent',
|
||||
[EventLogTable.PAGEVIEW]: 'pageview',
|
||||
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
|
||||
[EventLogTable.USAGE_EVENT]: 'usageEvent',
|
||||
};
|
||||
|
||||
export type EventLogCleanupParams = {
|
||||
|
||||
@@ -36,6 +36,19 @@ type ClickHouseEventRecord = {
|
||||
isCustom?: boolean;
|
||||
};
|
||||
|
||||
type ClickHouseUsageEventRecord = {
|
||||
timestamp: string;
|
||||
userWorkspaceId?: string;
|
||||
resourceType?: string;
|
||||
operationType?: string;
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
creditsUsedMicro?: number;
|
||||
resourceId?: string;
|
||||
resourceContext?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const ALLOWED_TABLES = Object.values(EventLogTable);
|
||||
const MAX_LIMIT = 10000;
|
||||
|
||||
@@ -43,6 +56,7 @@ const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
|
||||
[EventLogTable.WORKSPACE_EVENT]: 'workspaceEvent',
|
||||
[EventLogTable.PAGEVIEW]: 'pageview',
|
||||
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
|
||||
[EventLogTable.USAGE_EVENT]: 'usageEvent',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -67,7 +81,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.USAGE_EVENT
|
||||
? 'resourceType'
|
||||
: input.table === EventLogTable.PAGEVIEW
|
||||
? 'name'
|
||||
: 'event';
|
||||
|
||||
const whereClauses: string[] = ['"workspaceId" = {workspaceId:String}'];
|
||||
const params: Record<string, unknown> = { workspaceId };
|
||||
@@ -178,15 +196,24 @@ export class EventLogsService {
|
||||
params.eventTypePattern = `%${filters.eventType.toLowerCase()}%`;
|
||||
}
|
||||
|
||||
// TODO: Legacy event tables (workspaceEvent, pageview, objectEvent) use
|
||||
// userId because some actions are logged out. Usage events use
|
||||
// userWorkspaceId directly which is more relevant in a workspace context.
|
||||
// Consider migrating all event tables to userWorkspaceId for consistency.
|
||||
if (isDefined(filters.userWorkspaceId)) {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: filters.userWorkspaceId },
|
||||
select: ['userId'],
|
||||
});
|
||||
if (table === EventLogTable.USAGE_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 +249,27 @@ export class EventLogsService {
|
||||
}
|
||||
|
||||
private normalizeRecords(
|
||||
records: ClickHouseEventRecord[],
|
||||
records: ClickHouseEventRecord[] | ClickHouseUsageEventRecord[],
|
||||
table: EventLogTable,
|
||||
): EventLogRecord[] {
|
||||
return records.map((record) => {
|
||||
if (table === EventLogTable.USAGE_EVENT) {
|
||||
return (records as ClickHouseUsageEventRecord[]).map((record) => ({
|
||||
event: record.resourceType ?? '',
|
||||
timestamp: new Date(record.timestamp),
|
||||
userId: record.userWorkspaceId,
|
||||
properties: {
|
||||
operationType: record.operationType,
|
||||
quantity: record.quantity,
|
||||
unit: record.unit,
|
||||
creditsUsedMicro: record.creditsUsedMicro,
|
||||
resourceId: record.resourceId,
|
||||
resourceContext: record.resourceContext,
|
||||
...(record.metadata ?? {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
return (records as ClickHouseEventRecord[]).map((record) => {
|
||||
const eventName =
|
||||
table === EventLogTable.PAGEVIEW
|
||||
? (record.name ?? '')
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export const USAGE_RECORDED = 'USAGE_RECORDED';
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
@InputType()
|
||||
export class UsageAnalyticsInput {
|
||||
@Field(() => Date, { nullable: true })
|
||||
periodStart?: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
periodEnd?: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
userWorkspaceId?: string;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UsageBreakdownItemDTO } from 'src/engine/core-modules/usage/dtos/usage-breakdown-item.dto';
|
||||
import { UsageTimeSeriesDTO } from 'src/engine/core-modules/usage/dtos/usage-time-series.dto';
|
||||
import { UsageUserDailyDTO } from 'src/engine/core-modules/usage/dtos/usage-user-daily.dto';
|
||||
|
||||
@ObjectType('UsageAnalytics')
|
||||
export class UsageAnalyticsDTO {
|
||||
@Field(() => [UsageBreakdownItemDTO])
|
||||
usageByUser: UsageBreakdownItemDTO[];
|
||||
|
||||
@Field(() => [UsageBreakdownItemDTO])
|
||||
usageByOperationType: UsageBreakdownItemDTO[];
|
||||
|
||||
@Field(() => [UsageTimeSeriesDTO])
|
||||
timeSeries: UsageTimeSeriesDTO[];
|
||||
|
||||
@Field(() => Date)
|
||||
periodStart: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
periodEnd: Date;
|
||||
|
||||
@Field(() => UsageUserDailyDTO, { nullable: true })
|
||||
userDailyUsage?: UsageUserDailyDTO;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, Float, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('UsageBreakdownItem')
|
||||
export class UsageBreakdownItemDTO {
|
||||
@Field(() => String)
|
||||
key: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
label?: string;
|
||||
|
||||
@Field(() => Float)
|
||||
creditsUsed: number;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, Float, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('UsageTimeSeries')
|
||||
export class UsageTimeSeriesDTO {
|
||||
@Field(() => String)
|
||||
date: string;
|
||||
|
||||
@Field(() => Float)
|
||||
creditsUsed: number;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UsageTimeSeriesDTO } from 'src/engine/core-modules/usage/dtos/usage-time-series.dto';
|
||||
|
||||
@ObjectType('UsageUserDaily')
|
||||
export class UsageUserDailyDTO {
|
||||
@Field(() => String)
|
||||
userWorkspaceId: string;
|
||||
|
||||
@Field(() => [UsageTimeSeriesDTO])
|
||||
dailyUsage: UsageTimeSeriesDTO[];
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum UsageOperationType {
|
||||
AI_TOKEN = 'AI_TOKEN',
|
||||
WORKFLOW_EXECUTION = 'WORKFLOW_EXECUTION',
|
||||
CODE_EXECUTION = 'CODE_EXECUTION',
|
||||
}
|
||||
|
||||
registerEnumType(UsageOperationType, {
|
||||
name: 'UsageOperationType',
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export enum UsageResourceType {
|
||||
AI = 'AI',
|
||||
WORKFLOW = 'WORKFLOW',
|
||||
APP = 'APP',
|
||||
STORAGE = 'STORAGE',
|
||||
API = 'API',
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export enum UsageUnit {
|
||||
CREDIT = 'CREDIT',
|
||||
TOKEN = 'TOKEN',
|
||||
INVOCATION = 'INVOCATION',
|
||||
MINUTE = 'MINUTE',
|
||||
BYTE = 'BYTE',
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { OnCustomBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-custom-batch-event.decorator';
|
||||
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
|
||||
import { UsageEventWriterService } from 'src/engine/core-modules/usage/services/usage-event-writer.service';
|
||||
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
|
||||
import { CustomWorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/custom-workspace-batch-event.type';
|
||||
|
||||
@Injectable()
|
||||
export class UsageEventListener {
|
||||
constructor(
|
||||
private readonly usageEventWriterService: UsageEventWriterService,
|
||||
) {}
|
||||
|
||||
@OnCustomBatchEvent(USAGE_RECORDED)
|
||||
handleUsageRecordedEvent(payload: CustomWorkspaceEventBatch<UsageEvent>) {
|
||||
if (!isDefined(payload.workspaceId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.usageEventWriterService.writeToClickHouse(
|
||||
payload.workspaceId,
|
||||
payload.events,
|
||||
);
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
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';
|
||||
|
||||
export type UsageBreakdownItem = {
|
||||
key: string;
|
||||
label?: string;
|
||||
creditsUsed: number;
|
||||
};
|
||||
|
||||
export type UsageTimeSeriesPoint = {
|
||||
date: string;
|
||||
creditsUsed: number;
|
||||
};
|
||||
|
||||
type BreakdownRowMicro = {
|
||||
key: string;
|
||||
creditsUsedMicro: number;
|
||||
};
|
||||
|
||||
type TimeSeriesRowMicro = {
|
||||
date: string;
|
||||
creditsUsedMicro: number;
|
||||
};
|
||||
|
||||
type PeriodParams = {
|
||||
workspaceId: string;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
};
|
||||
|
||||
const ALLOWED_GROUP_BY_FIELDS = [
|
||||
'userWorkspaceId',
|
||||
'resourceId',
|
||||
'operationType',
|
||||
'resourceType',
|
||||
] as const;
|
||||
|
||||
type GroupByField = (typeof ALLOWED_GROUP_BY_FIELDS)[number];
|
||||
|
||||
const BREAKDOWN_QUERY_LIMIT = 50;
|
||||
|
||||
@Injectable()
|
||||
export class UsageAnalyticsService {
|
||||
constructor(private readonly clickHouseService: ClickHouseService) {}
|
||||
|
||||
async getUsageByUser(params: PeriodParams): Promise<UsageBreakdownItem[]> {
|
||||
return this.queryBreakdown({
|
||||
...params,
|
||||
groupByField: 'userWorkspaceId',
|
||||
extraWhere: "AND userWorkspaceId != ''",
|
||||
});
|
||||
}
|
||||
|
||||
async getUsageByOperationType(
|
||||
params: PeriodParams & { userWorkspaceId?: string },
|
||||
): Promise<UsageBreakdownItem[]> {
|
||||
return this.queryBreakdown({
|
||||
...params,
|
||||
groupByField: 'operationType',
|
||||
...(params.userWorkspaceId && {
|
||||
extraWhere: 'AND userWorkspaceId = {userWorkspaceId:String}',
|
||||
extraParams: { userWorkspaceId: params.userWorkspaceId },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async getUsageByUserTimeSeries(
|
||||
params: PeriodParams & { userWorkspaceId: string },
|
||||
): Promise<UsageTimeSeriesPoint[]> {
|
||||
return this.queryTimeSeries({
|
||||
...params,
|
||||
extraWhere: 'AND userWorkspaceId = {userWorkspaceId:String}',
|
||||
extraParams: { userWorkspaceId: params.userWorkspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async getUsageTimeSeries(
|
||||
params: PeriodParams,
|
||||
): Promise<UsageTimeSeriesPoint[]> {
|
||||
return this.queryTimeSeries(params);
|
||||
}
|
||||
|
||||
private async queryBreakdown({
|
||||
workspaceId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
groupByField,
|
||||
extraWhere = '',
|
||||
extraParams,
|
||||
}: PeriodParams & {
|
||||
groupByField: GroupByField;
|
||||
extraWhere?: string;
|
||||
extraParams?: Record<string, unknown>;
|
||||
}): Promise<UsageBreakdownItem[]> {
|
||||
const query = `
|
||||
SELECT
|
||||
${groupByField} AS key,
|
||||
sum(creditsUsedMicro) AS creditsUsedMicro
|
||||
FROM usageEvent
|
||||
WHERE workspaceId = {workspaceId:String}
|
||||
AND timestamp >= {periodStart:String}
|
||||
AND timestamp < {periodEnd:String}
|
||||
${extraWhere}
|
||||
GROUP BY ${groupByField}
|
||||
ORDER BY creditsUsedMicro DESC
|
||||
LIMIT ${BREAKDOWN_QUERY_LIMIT}
|
||||
`;
|
||||
|
||||
const rows = await this.clickHouseService.select<BreakdownRowMicro>(query, {
|
||||
workspaceId,
|
||||
periodStart: formatDateForClickHouse(periodStart),
|
||||
periodEnd: formatDateForClickHouse(periodEnd),
|
||||
...(extraParams ?? {}),
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
key: row.key,
|
||||
creditsUsed: toDisplayCredits(row.creditsUsedMicro),
|
||||
}));
|
||||
}
|
||||
|
||||
private async queryTimeSeries({
|
||||
workspaceId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
extraWhere = '',
|
||||
extraParams,
|
||||
}: PeriodParams & {
|
||||
extraWhere?: string;
|
||||
extraParams?: Record<string, unknown>;
|
||||
}): Promise<UsageTimeSeriesPoint[]> {
|
||||
const query = `
|
||||
SELECT
|
||||
formatDateTime(timestamp, '%Y-%m-%d') AS date,
|
||||
sum(creditsUsedMicro) AS creditsUsedMicro
|
||||
FROM usageEvent
|
||||
WHERE workspaceId = {workspaceId:String}
|
||||
AND timestamp >= {periodStart:String}
|
||||
AND timestamp < {periodEnd:String}
|
||||
${extraWhere}
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
|
||||
const rows = await this.clickHouseService.select<TimeSeriesRowMicro>(
|
||||
query,
|
||||
{
|
||||
workspaceId,
|
||||
periodStart: formatDateForClickHouse(periodStart),
|
||||
periodEnd: formatDateForClickHouse(periodEnd),
|
||||
...(extraParams ?? {}),
|
||||
},
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
date: row.date,
|
||||
creditsUsed: toDisplayCredits(row.creditsUsedMicro),
|
||||
}));
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class UsageEventWriterService {
|
||||
private readonly logger = new Logger(UsageEventWriterService.name);
|
||||
|
||||
constructor(
|
||||
private readonly clickHouseService: ClickHouseService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
writeToClickHouse(workspaceId: string, usageEvents: UsageEvent[]): void {
|
||||
if (!this.twentyConfigService.get('CLICKHOUSE_URL')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = formatDateForClickHouse(new Date());
|
||||
|
||||
const rows = usageEvents.map((usageEvent) => ({
|
||||
timestamp: now,
|
||||
workspaceId,
|
||||
userWorkspaceId: usageEvent.userWorkspaceId ?? '',
|
||||
resourceType: usageEvent.resourceType,
|
||||
operationType: usageEvent.operationType,
|
||||
quantity: usageEvent.quantity,
|
||||
unit: usageEvent.unit,
|
||||
creditsUsedMicro: usageEvent.creditsUsedMicro,
|
||||
resourceId: usageEvent.resourceId ?? '',
|
||||
resourceContext: usageEvent.resourceContext ?? '',
|
||||
metadata: '{}',
|
||||
}));
|
||||
|
||||
this.clickHouseService.insert('usageEvent', rows).catch((error) => {
|
||||
this.logger.error('Failed to write usage events to ClickHouse', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { type UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum';
|
||||
import { type UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
|
||||
import { type UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
|
||||
|
||||
export type UsageEvent = {
|
||||
resourceType: UsageResourceType;
|
||||
operationType: UsageOperationType;
|
||||
creditsUsedMicro: number;
|
||||
quantity: number;
|
||||
unit: UsageUnit;
|
||||
resourceId?: string | null;
|
||||
resourceContext?: string | null;
|
||||
userWorkspaceId?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UsageResolver } from 'src/engine/core-modules/usage/usage.resolver';
|
||||
import { UsageAnalyticsService } from 'src/engine/core-modules/usage/services/usage-analytics.service';
|
||||
import { UsageEventWriterService } from 'src/engine/core-modules/usage/services/usage-event-writer.service';
|
||||
import { UsageEventListener } from 'src/engine/core-modules/usage/listeners/usage-event.listener';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClickHouseModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
TypeOrmModule.forFeature([UserWorkspaceEntity]),
|
||||
],
|
||||
providers: [
|
||||
UsageResolver,
|
||||
UsageAnalyticsService,
|
||||
UsageEventWriterService,
|
||||
UsageEventListener,
|
||||
],
|
||||
exports: [UsageEventWriterService, UsageAnalyticsService],
|
||||
})
|
||||
export class UsageModule {}
|
||||
@@ -0,0 +1,160 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Query } from '@nestjs/graphql';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { In, type Repository } from 'typeorm';
|
||||
|
||||
import { UsageAnalyticsDTO } from 'src/engine/core-modules/usage/dtos/usage-analytics.dto';
|
||||
import { UsageAnalyticsInput } from 'src/engine/core-modules/usage/dtos/inputs/usage-analytics.input';
|
||||
import {
|
||||
type UsageBreakdownItem,
|
||||
UsageAnalyticsService,
|
||||
} 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 { 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';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
|
||||
@MetadataResolver()
|
||||
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
export class UsageResolver {
|
||||
constructor(
|
||||
private readonly usageAnalyticsService: UsageAnalyticsService,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {}
|
||||
|
||||
@Query(() => UsageAnalyticsDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED)
|
||||
async getUsageAnalytics(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@Args('input', { nullable: true }) input?: UsageAnalyticsInput,
|
||||
): Promise<UsageAnalyticsDTO> {
|
||||
const defaultPeriodEnd = new Date();
|
||||
const defaultPeriodStart = new Date();
|
||||
|
||||
defaultPeriodStart.setDate(defaultPeriodStart.getDate() - 30);
|
||||
|
||||
const periodStart = input?.periodStart ?? defaultPeriodStart;
|
||||
const periodEnd = input?.periodEnd ?? defaultPeriodEnd;
|
||||
|
||||
const periodParams = {
|
||||
workspaceId: workspace.id,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
};
|
||||
|
||||
const [usageByUser, usageByOperationType, timeSeries] = await Promise.all([
|
||||
this.usageAnalyticsService.getUsageByUser(periodParams),
|
||||
this.usageAnalyticsService.getUsageByOperationType({
|
||||
...periodParams,
|
||||
userWorkspaceId: input?.userWorkspaceId ?? undefined,
|
||||
}),
|
||||
this.usageAnalyticsService.getUsageTimeSeries(periodParams),
|
||||
]);
|
||||
|
||||
const resolvedUsageByUser = await this.resolveBreakdownKeys(
|
||||
usageByUser,
|
||||
(ids) => this.resolveUserNames(ids, workspace.id),
|
||||
);
|
||||
|
||||
const result: UsageAnalyticsDTO = {
|
||||
usageByUser: resolvedUsageByUser,
|
||||
usageByOperationType,
|
||||
timeSeries,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
};
|
||||
|
||||
if (input?.userWorkspaceId) {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: input.userWorkspaceId, workspaceId: workspace.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (isDefined(userWorkspace)) {
|
||||
const dailyUsage =
|
||||
await this.usageAnalyticsService.getUsageByUserTimeSeries({
|
||||
...periodParams,
|
||||
userWorkspaceId: input.userWorkspaceId,
|
||||
});
|
||||
|
||||
result.userDailyUsage = {
|
||||
userWorkspaceId: input.userWorkspaceId,
|
||||
dailyUsage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async resolveBreakdownKeys(
|
||||
items: UsageBreakdownItem[],
|
||||
resolveNames: (ids: string[]) => Promise<Map<string, string>>,
|
||||
): Promise<UsageBreakdownItem[]> {
|
||||
if (items.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const ids = items.map((item) => item.key);
|
||||
const nameMap = await resolveNames(ids);
|
||||
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
label: nameMap.get(item.key),
|
||||
}));
|
||||
}
|
||||
|
||||
private async resolveUserNames(
|
||||
userWorkspaceIds: string[],
|
||||
workspaceId: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const nameMap = new Map<string, string>();
|
||||
|
||||
if (userWorkspaceIds.length === 0) {
|
||||
return nameMap;
|
||||
}
|
||||
|
||||
const userWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: { id: In(userWorkspaceIds), workspaceId },
|
||||
relations: ['user'],
|
||||
select: {
|
||||
id: true,
|
||||
user: { firstName: true, lastName: true, email: true },
|
||||
},
|
||||
});
|
||||
|
||||
for (const userWorkspace of userWorkspaces) {
|
||||
if (!isDefined(userWorkspace.user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { firstName, lastName, email } = userWorkspace.user;
|
||||
const fullName = `${firstName} ${lastName}`.trim();
|
||||
|
||||
nameMap.set(userWorkspace.id, fullName || email);
|
||||
}
|
||||
|
||||
return nameMap;
|
||||
}
|
||||
}
|
||||
+13
-10
@@ -1,7 +1,9 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
|
||||
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
|
||||
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';
|
||||
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
@@ -334,16 +336,17 @@ describe('AiBillingService', () => {
|
||||
expect(
|
||||
mockWorkspaceEventEmitter.emitCustomBatchEvent,
|
||||
).toHaveBeenCalledWith(
|
||||
BILLING_FEATURE_USED,
|
||||
USAGE_RECORDED,
|
||||
[
|
||||
{
|
||||
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
|
||||
value: 7500,
|
||||
dimensions: {
|
||||
execution_type: 'ai_token',
|
||||
resource_id: 'agent-id-123',
|
||||
execution_context_1: 'gpt-4o',
|
||||
},
|
||||
resourceType: UsageResourceType.AI,
|
||||
operationType: UsageOperationType.AI_TOKEN,
|
||||
creditsUsedMicro: 7500,
|
||||
quantity: 1500,
|
||||
unit: UsageUnit.TOKEN,
|
||||
resourceId: 'agent-id-123',
|
||||
resourceContext: 'gpt-4o',
|
||||
userWorkspaceId: null,
|
||||
},
|
||||
],
|
||||
'workspace-1',
|
||||
|
||||
+34
-16
@@ -2,9 +2,11 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type LanguageModelUsage } from 'ai';
|
||||
|
||||
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
|
||||
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
|
||||
import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant';
|
||||
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';
|
||||
import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type';
|
||||
import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/utils/compute-cost-breakdown.util';
|
||||
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
|
||||
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
|
||||
@@ -54,32 +56,48 @@ export class AiBillingService {
|
||||
billingInput: BillingUsageInput,
|
||||
workspaceId: string,
|
||||
agentId?: string | null,
|
||||
userWorkspaceId?: string | null,
|
||||
): void {
|
||||
const costInDollars = this.calculateCost(modelId, billingInput);
|
||||
const creditsUsed = Math.round(
|
||||
const creditsUsedMicro = Math.round(
|
||||
convertDollarsToBillingCredits(costInDollars),
|
||||
);
|
||||
|
||||
this.sendAiTokenUsageEvent(workspaceId, creditsUsed, modelId, agentId);
|
||||
const totalTokens =
|
||||
(billingInput.usage.inputTokens ?? 0) +
|
||||
(billingInput.usage.outputTokens ?? 0) +
|
||||
(billingInput.cacheCreationTokens ?? 0);
|
||||
|
||||
this.emitAiTokenUsageEvent(
|
||||
workspaceId,
|
||||
creditsUsedMicro,
|
||||
totalTokens,
|
||||
modelId,
|
||||
agentId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
private sendAiTokenUsageEvent(
|
||||
private emitAiTokenUsageEvent(
|
||||
workspaceId: string,
|
||||
creditsUsed: number,
|
||||
creditsUsedMicro: number,
|
||||
totalTokens: number,
|
||||
modelId: ModelId,
|
||||
agentId?: string | null,
|
||||
userWorkspaceId?: string | null,
|
||||
): void {
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<BillingUsageEvent>(
|
||||
BILLING_FEATURE_USED,
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
|
||||
USAGE_RECORDED,
|
||||
[
|
||||
{
|
||||
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
|
||||
value: creditsUsed,
|
||||
dimensions: {
|
||||
execution_type: 'ai_token',
|
||||
resource_id: agentId || null,
|
||||
execution_context_1: modelId,
|
||||
},
|
||||
resourceType: UsageResourceType.AI,
|
||||
operationType: UsageOperationType.AI_TOKEN,
|
||||
creditsUsedMicro,
|
||||
quantity: totalTokens,
|
||||
unit: UsageUnit.TOKEN,
|
||||
resourceId: agentId || null,
|
||||
resourceContext: modelId,
|
||||
userWorkspaceId: userWorkspaceId || null,
|
||||
},
|
||||
],
|
||||
workspaceId,
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-display-credits.util';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agen
|
||||
import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/utils/compute-cost-breakdown.util';
|
||||
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
|
||||
import { extractCacheCreationTokens } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-display-credits.util';
|
||||
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
|
||||
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
|
||||
|
||||
+1
@@ -247,6 +247,7 @@ export class ChatExecutionService {
|
||||
{ usage, cacheCreationTokens },
|
||||
workspace.id,
|
||||
null,
|
||||
userWorkspaceId,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
+1
@@ -245,6 +245,7 @@ describe('WorkspaceEntityManager', () => {
|
||||
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED: false,
|
||||
IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED: false,
|
||||
IS_DRAFT_EMAIL_ENABLED: false,
|
||||
IS_USAGE_ANALYTICS_ENABLED: false,
|
||||
IS_RICH_TEXT_V1_MIGRATED: false,
|
||||
IS_DIRECT_GRAPHQL_EXECUTION_ENABLED: false,
|
||||
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: false,
|
||||
|
||||
+5
@@ -105,6 +105,11 @@ export const seedFeatureFlags = async ({
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED,
|
||||
workspaceId: workspaceId,
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
|
||||
workspaceId: workspaceId,
|
||||
|
||||
Reference in New Issue
Block a user