d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
756 lines
25 KiB
TypeScript
756 lines
25 KiB
TypeScript
import { createHash } from 'node:crypto'
|
|
import { db, dbReplica } from '@sim/db'
|
|
import { usageLog, workflow, workspace } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { and, desc, eq, gte, inArray, lt, lte, or, sql } from 'drizzle-orm'
|
|
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
|
|
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
|
|
import { apportionCredits } from '@/lib/billing/credits/conversion'
|
|
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
|
|
import type { DbClient, DbOrTx } from '@/lib/db/types'
|
|
|
|
const logger = createLogger('UsageLog')
|
|
|
|
/**
|
|
* Usage log category types
|
|
*/
|
|
export type UsageLogCategory = 'model' | 'fixed' | 'tool'
|
|
|
|
/**
|
|
* Usage log source types
|
|
*/
|
|
export type UsageLogSource =
|
|
| 'workflow'
|
|
| 'wand'
|
|
| 'copilot'
|
|
| 'workspace-chat'
|
|
| 'mcp_copilot'
|
|
| 'mothership_block'
|
|
| 'knowledge-base'
|
|
| 'voice-input'
|
|
| 'enrichment'
|
|
|
|
/**
|
|
* usage_log sources that make up the "copilot" cost breakdown shown in billing
|
|
* summaries: the copilot agent, mothership/workspace chat, MCP copilot, and
|
|
* mothership blocks. Mirrors the source set billed via /api/billing/update-cost.
|
|
*/
|
|
export const COPILOT_USAGE_SOURCES: UsageLogSource[] = [
|
|
'copilot',
|
|
'workspace-chat',
|
|
'mcp_copilot',
|
|
'mothership_block',
|
|
]
|
|
|
|
/**
|
|
* Metadata for 'model' category charges
|
|
*/
|
|
export interface ModelUsageMetadata {
|
|
inputTokens: number
|
|
outputTokens: number
|
|
toolCost?: number
|
|
}
|
|
|
|
/**
|
|
* Union type for all usage log metadata types
|
|
*/
|
|
export type UsageLogMetadata = ModelUsageMetadata | Record<string, unknown> | null
|
|
|
|
export type BillingEntityType = 'user' | 'organization'
|
|
|
|
export interface BillingEntity {
|
|
type: BillingEntityType
|
|
id: string
|
|
}
|
|
|
|
/**
|
|
* A single usage entry to be recorded in the usage_log table.
|
|
*/
|
|
interface UsageEntry {
|
|
category: UsageLogCategory
|
|
source: UsageLogSource
|
|
description: string
|
|
cost: number
|
|
eventKey?: string
|
|
sourceReference?: string
|
|
metadata?: UsageLogMetadata
|
|
}
|
|
|
|
interface RecordUsageBaseParams {
|
|
/** The user being charged */
|
|
userId: string
|
|
/** One or more usage_log entries to record. Total cost is derived from these. */
|
|
entries: UsageEntry[]
|
|
/** Workspace context */
|
|
workspaceId?: string
|
|
/** Workflow context */
|
|
workflowId?: string
|
|
/** Execution context */
|
|
executionId?: string
|
|
}
|
|
|
|
/**
|
|
* Parameters for the central recordUsage function.
|
|
* This is the single entry point for all billing mutations.
|
|
*
|
|
* Callers that pass `tx` (e.g. the per-execution advisory-lock reconciliation
|
|
* in the workflow completion path) must pre-resolve the billing context before
|
|
* opening the transaction: resolving it inside would run the subscription
|
|
* lookups on the global pool while the tx already holds a pooled connection,
|
|
* starving the pool under load (see recordCumulativeUsage for the history).
|
|
*/
|
|
export type RecordUsageParams = RecordUsageBaseParams &
|
|
(
|
|
| {
|
|
/** Transaction the ledger INSERT participates in. */
|
|
tx: DbOrTx
|
|
/** Billing entity scope, resolved before the transaction opened. */
|
|
billingEntity: BillingEntity
|
|
/** Billing period bounds, resolved before the transaction opened. */
|
|
billingPeriod: { start: Date; end: Date }
|
|
}
|
|
| {
|
|
tx?: undefined
|
|
/** Billing entity scope, resolved by caller when already known. */
|
|
billingEntity?: BillingEntity
|
|
/** Billing period bounds, resolved by caller when already known. */
|
|
billingPeriod?: { start: Date; end: Date }
|
|
}
|
|
)
|
|
|
|
export function stableEventKey(parts: Record<string, unknown>): string {
|
|
const payload = Object.keys(parts)
|
|
.sort()
|
|
.map((key) => `${key}:${String(parts[key] ?? '')}`)
|
|
.join('|')
|
|
return createHash('sha256').update(payload).digest('hex')
|
|
}
|
|
|
|
type ResolvedSubscription = Awaited<ReturnType<typeof getHighestPrioritySubscription>>
|
|
|
|
export interface BillingContext {
|
|
billingEntity: BillingEntity
|
|
billingPeriod: { start: Date; end: Date }
|
|
}
|
|
|
|
/**
|
|
* Derive the billing entity + period from an ALREADY-resolved subscription.
|
|
* Callers that already hold the subscription (e.g. the workflow completion path,
|
|
* which fetches it for usage-threshold emails) can derive the context once and
|
|
* pass it into recordUsage so resolveBillingContext skips a redundant lookup.
|
|
* This is the single source of the entity/period derivation — keep it the only
|
|
* place that maps a subscription to a billing context.
|
|
*/
|
|
export function deriveBillingContext(
|
|
userId: string,
|
|
subscription: ResolvedSubscription
|
|
): BillingContext {
|
|
const billingEntity: BillingEntity =
|
|
subscription && isOrgScopedSubscription(subscription, userId)
|
|
? { type: 'organization', id: subscription.referenceId }
|
|
: { type: 'user', id: userId }
|
|
|
|
const billingPeriod =
|
|
subscription?.periodStart && subscription.periodEnd
|
|
? { start: subscription.periodStart, end: subscription.periodEnd }
|
|
: defaultBillingPeriod()
|
|
|
|
return { billingEntity, billingPeriod }
|
|
}
|
|
|
|
async function resolveBillingContext(
|
|
userId: string,
|
|
billingEntity?: BillingEntity,
|
|
billingPeriod?: { start: Date; end: Date }
|
|
): Promise<BillingContext> {
|
|
if (billingEntity && billingPeriod) {
|
|
return { billingEntity, billingPeriod }
|
|
}
|
|
|
|
const subscription = await getHighestPrioritySubscription(userId)
|
|
const derived = deriveBillingContext(userId, subscription)
|
|
return {
|
|
billingEntity: billingEntity ?? derived.billingEntity,
|
|
billingPeriod: billingPeriod ?? derived.billingPeriod,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns post-cutover usage for an attributed billing entity/period.
|
|
* Legacy pre-cutover usage remains in userStats as a baseline until reset.
|
|
*/
|
|
export async function getBillingPeriodUsageCost(
|
|
billingEntity: BillingEntity,
|
|
billingPeriod: { start: Date; end: Date },
|
|
source?: UsageLogSource | UsageLogSource[],
|
|
executor: DbClient = db
|
|
): Promise<number> {
|
|
const conditions = [
|
|
eq(usageLog.billingEntityType, billingEntity.type),
|
|
eq(usageLog.billingEntityId, billingEntity.id),
|
|
eq(usageLog.billingPeriodStart, billingPeriod.start),
|
|
eq(usageLog.billingPeriodEnd, billingPeriod.end),
|
|
]
|
|
if (source) {
|
|
conditions.push(
|
|
Array.isArray(source) ? inArray(usageLog.source, source) : eq(usageLog.source, source)
|
|
)
|
|
}
|
|
|
|
const [row] = await executor
|
|
.select({
|
|
cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
|
|
})
|
|
.from(usageLog)
|
|
.where(and(...conditions))
|
|
|
|
return Number.parseFloat(row?.cost ?? '0')
|
|
}
|
|
|
|
export async function getBillingPeriodUsageCostByUser(
|
|
billingEntity: BillingEntity,
|
|
billingPeriod: { start: Date; end: Date },
|
|
source?: UsageLogSource | UsageLogSource[],
|
|
executor: DbClient = db
|
|
): Promise<Map<string, number>> {
|
|
const conditions = [
|
|
eq(usageLog.billingEntityType, billingEntity.type),
|
|
eq(usageLog.billingEntityId, billingEntity.id),
|
|
eq(usageLog.billingPeriodStart, billingPeriod.start),
|
|
eq(usageLog.billingPeriodEnd, billingPeriod.end),
|
|
]
|
|
if (source) {
|
|
conditions.push(
|
|
Array.isArray(source) ? inArray(usageLog.source, source) : eq(usageLog.source, source)
|
|
)
|
|
}
|
|
|
|
const rows = await executor
|
|
.select({
|
|
userId: usageLog.userId,
|
|
cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)`,
|
|
})
|
|
.from(usageLog)
|
|
.where(and(...conditions))
|
|
.groupBy(usageLog.userId)
|
|
|
|
return new Map(rows.map((row) => [row.userId, Number.parseFloat(row.cost ?? '0')]))
|
|
}
|
|
|
|
/**
|
|
* A single user's usage_log cost inside an organization's own workspaces within
|
|
* a wall-clock window (by `created_at`).
|
|
*
|
|
* Unlike {@link getBillingPeriodUsageCostByUser} (which filters by the attributed
|
|
* billing entity and the stored billing-period columns), this joins `workspace`
|
|
* on `organization_id` and filters by `user_id` + a `created_at` range. That
|
|
* captures the member's consumption inside org-owned workspaces regardless of
|
|
* which billing entity the row was attributed to — required for per-member
|
|
* org-workspace usage, including external members whose runs bill to their own
|
|
* personal entity and mothership/copilot cost attributed to the using user.
|
|
* Scoped to one user so it uses the `(user_id, created_at)` index rather than
|
|
* scanning the whole org's period on the execution hot path.
|
|
*/
|
|
export async function getOrgWorkspaceUsageCostForUser(
|
|
organizationId: string,
|
|
userId: string,
|
|
window: { start: Date; end: Date }
|
|
): Promise<number> {
|
|
const [row] = await db
|
|
.select({ cost: sql<string>`COALESCE(SUM(${usageLog.cost}), 0)` })
|
|
.from(usageLog)
|
|
.innerJoin(workspace, eq(workspace.id, usageLog.workspaceId))
|
|
.where(
|
|
and(
|
|
eq(usageLog.userId, userId),
|
|
eq(workspace.organizationId, organizationId),
|
|
gte(usageLog.createdAt, window.start),
|
|
lt(usageLog.createdAt, window.end)
|
|
)
|
|
)
|
|
|
|
return Number.parseFloat(row?.cost ?? '0')
|
|
}
|
|
|
|
/**
|
|
* Records usage as append-only billing events.
|
|
*
|
|
* This intentionally avoids per-event userStats updates: userStats is retained
|
|
* as the pre-cutover period baseline and for low-frequency billing trackers,
|
|
* but usage writes no longer contend on the user_stats row.
|
|
*/
|
|
export async function recordUsage(params: RecordUsageParams): Promise<void> {
|
|
// The usage ledger is written regardless of BILLING_ENABLED so it is the
|
|
// single, universal source of truth for cost (including self-hosted, where
|
|
// it powers the logs-page cost display). Billing *enforcement* (Stripe /
|
|
// overage) is gated separately by callers, not here.
|
|
const {
|
|
userId,
|
|
entries,
|
|
workspaceId,
|
|
workflowId,
|
|
executionId,
|
|
billingEntity,
|
|
billingPeriod,
|
|
tx,
|
|
} = params
|
|
|
|
const validEntries = entries.filter((e) => e.cost > 0)
|
|
|
|
if (validEntries.length === 0) {
|
|
return
|
|
}
|
|
|
|
const context = await resolveBillingContext(userId, billingEntity, billingPeriod)
|
|
|
|
const insertedRows = await (tx ?? db)
|
|
.insert(usageLog)
|
|
.values(
|
|
validEntries.map((entry, index) => {
|
|
const sourceReference =
|
|
entry.sourceReference ??
|
|
[executionId, workflowId, workspaceId, entry.source, entry.description, index]
|
|
.filter((part) => part !== undefined && part !== null && part !== '')
|
|
.join(':')
|
|
const eventKey =
|
|
entry.eventKey ??
|
|
stableEventKey({
|
|
userId,
|
|
source: entry.source,
|
|
category: entry.category,
|
|
description: entry.description,
|
|
sourceReference,
|
|
executionId,
|
|
workflowId,
|
|
workspaceId,
|
|
index,
|
|
})
|
|
|
|
return {
|
|
id: generateId(),
|
|
userId,
|
|
category: entry.category,
|
|
source: entry.source,
|
|
description: entry.description,
|
|
metadata: entry.metadata ?? null,
|
|
cost: entry.cost.toString(),
|
|
eventKey,
|
|
billingEntityType: context.billingEntity.type,
|
|
billingEntityId: context.billingEntity.id,
|
|
billingPeriodStart: context.billingPeriod.start,
|
|
billingPeriodEnd: context.billingPeriod.end,
|
|
workspaceId: workspaceId ?? null,
|
|
workflowId: workflowId ?? null,
|
|
executionId: executionId ?? null,
|
|
}
|
|
})
|
|
)
|
|
.onConflictDoNothing({
|
|
target: usageLog.eventKey,
|
|
where: sql`${usageLog.eventKey} IS NOT NULL`,
|
|
})
|
|
.returning({ cost: usageLog.cost })
|
|
|
|
const insertedCost = insertedRows.reduce((sum, row) => sum + Number.parseFloat(row.cost), 0)
|
|
|
|
if (insertedRows.length < validEntries.length) {
|
|
logger.debug('Skipped duplicate usage events', {
|
|
userId,
|
|
attemptedEntries: validEntries.length,
|
|
insertedEntries: insertedRows.length,
|
|
})
|
|
}
|
|
|
|
logger.debug('Recorded usage', {
|
|
userId,
|
|
totalCost: insertedCost,
|
|
entryCount: validEntries.length,
|
|
sources: [...new Set(validEntries.map((e) => e.source))],
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Floating-point tolerance for cumulative cost comparison. Costs are dollars;
|
|
* a sub-microcent difference is treated as "no change" so a DB round-trip
|
|
* (decimal string -> float) can't manufacture a spurious top-up.
|
|
*/
|
|
export const CUMULATIVE_COST_EPSILON = 1e-9
|
|
|
|
/**
|
|
* Decide whether an incoming CUMULATIVE cost for a request should bill, given
|
|
* what has already been recorded for it.
|
|
*
|
|
* Billing is a monotonic top-up: only a strictly-higher cumulative bills, and
|
|
* it bills just the delta above what's recorded; a same-or-lower cumulative is
|
|
* a no-op. This is the core invariant that makes repeated flushes of a single
|
|
* request converge to the true total exactly once — a partial mid-loop flush
|
|
* (e.g. after a provider error), the recovered terminal flush, and abort-race
|
|
* duplicates all reconcile to the maximum cumulative with no under- or
|
|
* over-billing, independent of arrival order.
|
|
*/
|
|
export function resolveCumulativeTopUp(
|
|
recordedCost: number,
|
|
incomingCost: number
|
|
): { shouldBill: boolean; delta: number; newTotal: number } {
|
|
if (incomingCost <= recordedCost + CUMULATIVE_COST_EPSILON) {
|
|
return { shouldBill: false, delta: 0, newTotal: recordedCost }
|
|
}
|
|
return { shouldBill: true, delta: incomingCost - recordedCost, newTotal: incomingCost }
|
|
}
|
|
|
|
export interface RecordCumulativeUsageParams {
|
|
userId: string
|
|
workspaceId?: string
|
|
source: UsageLogSource
|
|
/** Model name, stored as the row description. */
|
|
model: string
|
|
/** The request's CUMULATIVE cost so far (not a per-leg delta). */
|
|
cost: number
|
|
/** Stable per-request key; the single ledger row is keyed on this. */
|
|
eventKey: string
|
|
metadata?: UsageLogMetadata
|
|
}
|
|
|
|
export interface RecordCumulativeUsageResult {
|
|
/** True when a new (delta) charge was recorded for this flush. */
|
|
billed: boolean
|
|
/** Amount newly charged by this flush (0 on a duplicate/lower flush). */
|
|
delta: number
|
|
/** The request's recorded cumulative cost after this flush. */
|
|
total: number
|
|
}
|
|
|
|
/**
|
|
* Bounds the wait for the per-event-key advisory lock (and any row/index lock
|
|
* waits inside the critical section). The Go mothership gives each UpdateCost
|
|
* POST a 5s deadline, retries 3x with backoff, then dead-letters the charge
|
|
* keyed on the same idempotency key — so a stuck lock holder must surface as
|
|
* a fast, retryable failure (SQLSTATE 55P03) within that budget rather than
|
|
* an unbounded wait that pins pooled connections.
|
|
*/
|
|
const CUMULATIVE_FLUSH_LOCK_TIMEOUT_MS = 3_000
|
|
|
|
/**
|
|
* Record a request's CUMULATIVE cost idempotently with monotonic top-up.
|
|
*
|
|
* Keeps exactly ONE usage_log row per `eventKey` holding the MAX cumulative
|
|
* cost ever submitted for the request, billing only the incremental delta on
|
|
* each flush. A per-key transactional advisory lock serializes concurrent
|
|
* flushes so the read-then-write — including the first insert — is race-free
|
|
* (no two flushes can both believe they are first and clobber each other).
|
|
* The billing context is resolved BEFORE the transaction and the lock wait is
|
|
* bounded by `lock_timeout`, keeping the critical section to one SELECT plus
|
|
* one INSERT/UPDATE on a single pooled connection.
|
|
*
|
|
* Because every leg flushes its cumulative and this converges to the max,
|
|
* there is no under-billing if the request recovers after a partial flush, no
|
|
* over-billing from duplicate/abort-race flushes, and no lost billing if the
|
|
* process dies between legs — each leg's cost is durably recorded as it lands.
|
|
*/
|
|
export async function recordCumulativeUsage(
|
|
params: RecordCumulativeUsageParams
|
|
): Promise<RecordCumulativeUsageResult> {
|
|
const { userId, workspaceId, source, model, cost, eventKey, metadata } = params
|
|
|
|
// Resolved before the locked transaction on purpose: resolving inside it
|
|
// ran the subscription lookups on the global pool while this tx already
|
|
// held a pooled connection plus the advisory lock, so under load N
|
|
// first-flush transactions each pinned a connection while waiting for one
|
|
// more — starving the pool and queueing every same-key flush (and the Go
|
|
// side's retries) behind the stall.
|
|
const billingContext = await resolveBillingContext(userId)
|
|
|
|
return db.transaction(async (tx) => {
|
|
// Serialize all flushes for this request (lock auto-releases at tx end),
|
|
// with a bounded wait so a pathological holder fails this flush fast and
|
|
// lets the caller retry instead of hanging the connection.
|
|
await tx.execute(
|
|
sql`select set_config('lock_timeout', ${`${CUMULATIVE_FLUSH_LOCK_TIMEOUT_MS}ms`}, true)`
|
|
)
|
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${eventKey}, 0))`)
|
|
|
|
const [existing] = await tx
|
|
.select({ id: usageLog.id, cost: usageLog.cost })
|
|
.from(usageLog)
|
|
.where(eq(usageLog.eventKey, eventKey))
|
|
.limit(1)
|
|
|
|
const recorded = existing ? Number.parseFloat(existing.cost) : 0
|
|
const { shouldBill, delta, newTotal } = resolveCumulativeTopUp(recorded, cost)
|
|
|
|
if (!shouldBill) {
|
|
return { billed: false, delta: 0, total: recorded }
|
|
}
|
|
|
|
if (existing) {
|
|
// Top up the single row to the new (higher) cumulative; the
|
|
// period total is SUM(usage_log.cost), so this lifts it by the delta.
|
|
await tx
|
|
.update(usageLog)
|
|
.set({ cost: newTotal.toString(), metadata: metadata ?? null })
|
|
.where(eq(usageLog.id, existing.id))
|
|
} else {
|
|
// First flush for this request: insert the canonical row with the
|
|
// pre-resolved billing context. Runs in the same tx + advisory lock.
|
|
await recordUsage({
|
|
userId,
|
|
workspaceId,
|
|
tx,
|
|
billingEntity: billingContext.billingEntity,
|
|
billingPeriod: billingContext.billingPeriod,
|
|
entries: [
|
|
{
|
|
category: 'model',
|
|
source,
|
|
description: model,
|
|
cost: newTotal,
|
|
eventKey,
|
|
sourceReference: eventKey,
|
|
...(metadata ? { metadata } : {}),
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
return { billed: true, delta, total: newTotal }
|
|
})
|
|
}
|
|
|
|
interface UsageLogFilter {
|
|
source?: UsageLogSource
|
|
workspaceId?: string
|
|
startDate?: Date
|
|
endDate?: Date
|
|
}
|
|
|
|
function buildUsageLogConditions(userId: string, filter: UsageLogFilter) {
|
|
const conditions = [eq(usageLog.userId, userId)]
|
|
if (filter.source) conditions.push(eq(usageLog.source, filter.source))
|
|
if (filter.workspaceId) conditions.push(eq(usageLog.workspaceId, filter.workspaceId))
|
|
if (filter.startDate) conditions.push(gte(usageLog.createdAt, filter.startDate))
|
|
if (filter.endDate) conditions.push(lte(usageLog.createdAt, filter.endDate))
|
|
return conditions
|
|
}
|
|
|
|
/**
|
|
* Apportions credits across every log matching the filter (not just one
|
|
* page), so a row's `creditCost` is identical everywhere it's shown — the
|
|
* paginated list and the CSV export both call this rather than each
|
|
* apportioning their own subset, which would let the same row disagree
|
|
* between the two (or between pages of the same list) since apportionment
|
|
* depends on the complete set's total.
|
|
*/
|
|
export async function getUsageCreditsByLogId(
|
|
userId: string,
|
|
filter: UsageLogFilter
|
|
): Promise<Record<string, number>> {
|
|
const rows = await dbReplica
|
|
.select({ id: usageLog.id, cost: usageLog.cost })
|
|
.from(usageLog)
|
|
.where(and(...buildUsageLogConditions(userId, filter)))
|
|
.orderBy(desc(usageLog.createdAt), desc(usageLog.id))
|
|
|
|
return apportionCredits(
|
|
rows.map((row) => ({ key: row.id, dollars: Number.parseFloat(row.cost) }))
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Options for querying usage logs
|
|
*/
|
|
export interface GetUsageLogsOptions {
|
|
/** Filter by source */
|
|
source?: UsageLogSource
|
|
/** Filter by workspace */
|
|
workspaceId?: string
|
|
/** Start date (inclusive) */
|
|
startDate?: Date
|
|
/** End date (inclusive) */
|
|
endDate?: Date
|
|
/** Maximum number of results */
|
|
limit?: number
|
|
/** Cursor for pagination (log ID) */
|
|
cursor?: string
|
|
/**
|
|
* The cursor row's `createdAt`, when the caller already has it (e.g. a
|
|
* multi-page export loop holding the previous page's rows in memory).
|
|
* Skips the row lookup that would otherwise resolve it from `cursor`.
|
|
*/
|
|
cursorCreatedAt?: Date
|
|
/**
|
|
* Whether to compute the full-filter `summary` aggregate (default `true`).
|
|
* A cursor-paginated caller collecting every page (e.g. a CSV export) only
|
|
* needs `logs` from each page and would otherwise pay for the same
|
|
* cursor-independent `SUM`/`GROUP BY` scan once per page for a result it
|
|
* never reads — set `false` to skip it.
|
|
*/
|
|
includeSummary?: boolean
|
|
}
|
|
|
|
/**
|
|
* Usage log entry returned from queries
|
|
*/
|
|
interface UsageLogEntry {
|
|
id: string
|
|
createdAt: string
|
|
category: UsageLogCategory
|
|
source: UsageLogSource
|
|
description: string
|
|
metadata?: UsageLogMetadata
|
|
cost: number
|
|
workspaceId?: string
|
|
workflowId?: string
|
|
/** Name of the referenced workflow, when `workflowId` resolves to one. */
|
|
workflowName?: string
|
|
executionId?: string
|
|
}
|
|
|
|
/**
|
|
* Result from getUserUsageLogs
|
|
*/
|
|
export interface UsageLogsResult {
|
|
logs: UsageLogEntry[]
|
|
/** `{ totalCost: 0, bySource: {} }` when `includeSummary` is `false`. */
|
|
summary: {
|
|
totalCost: number
|
|
bySource: Record<string, number>
|
|
}
|
|
pagination: {
|
|
nextCursor?: string
|
|
hasMore: boolean
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get usage logs for a user with optional filtering and pagination
|
|
*/
|
|
export async function getUserUsageLogs(
|
|
userId: string,
|
|
options: GetUsageLogsOptions = {}
|
|
): Promise<UsageLogsResult> {
|
|
const {
|
|
source,
|
|
workspaceId,
|
|
startDate,
|
|
endDate,
|
|
limit = 50,
|
|
cursor,
|
|
cursorCreatedAt,
|
|
includeSummary = true,
|
|
} = options
|
|
|
|
try {
|
|
const conditions = buildUsageLogConditions(userId, { source, workspaceId, startDate, endDate })
|
|
|
|
if (cursor) {
|
|
let resolvedCursorCreatedAt = cursorCreatedAt
|
|
|
|
if (!resolvedCursorCreatedAt) {
|
|
// Cursor resolution stays on the primary: the page itself reads a
|
|
// load-balanced replica, and a laggier sibling replica missing the
|
|
// cursor row would silently restart pagination from page 1.
|
|
const cursorLog = await db
|
|
.select({ createdAt: usageLog.createdAt })
|
|
.from(usageLog)
|
|
.where(eq(usageLog.id, cursor))
|
|
.limit(1)
|
|
resolvedCursorCreatedAt = cursorLog[0]?.createdAt
|
|
}
|
|
|
|
if (resolvedCursorCreatedAt) {
|
|
const cursorCondition = or(
|
|
lt(usageLog.createdAt, resolvedCursorCreatedAt),
|
|
and(eq(usageLog.createdAt, resolvedCursorCreatedAt), lt(usageLog.id, cursor))
|
|
)
|
|
if (cursorCondition) conditions.push(cursorCondition)
|
|
}
|
|
}
|
|
|
|
const logs = await dbReplica
|
|
.select({
|
|
id: usageLog.id,
|
|
createdAt: usageLog.createdAt,
|
|
category: usageLog.category,
|
|
source: usageLog.source,
|
|
description: usageLog.description,
|
|
metadata: usageLog.metadata,
|
|
cost: usageLog.cost,
|
|
workspaceId: usageLog.workspaceId,
|
|
workflowId: usageLog.workflowId,
|
|
workflowName: workflow.name,
|
|
executionId: usageLog.executionId,
|
|
})
|
|
.from(usageLog)
|
|
.leftJoin(workflow, eq(usageLog.workflowId, workflow.id))
|
|
.where(and(...conditions))
|
|
.orderBy(desc(usageLog.createdAt), desc(usageLog.id))
|
|
.limit(limit + 1)
|
|
|
|
const hasMore = logs.length > limit
|
|
const resultLogs = hasMore ? logs.slice(0, limit) : logs
|
|
|
|
const transformedLogs: UsageLogEntry[] = resultLogs.map((log) => ({
|
|
id: log.id,
|
|
createdAt: log.createdAt.toISOString(),
|
|
category: log.category as UsageLogCategory,
|
|
source: log.source as UsageLogSource,
|
|
description: log.description,
|
|
...(log.metadata ? { metadata: log.metadata as UsageLogMetadata } : {}),
|
|
cost: Number.parseFloat(log.cost),
|
|
...(log.workspaceId ? { workspaceId: log.workspaceId } : {}),
|
|
...(log.workflowId ? { workflowId: log.workflowId } : {}),
|
|
...(log.workflowName ? { workflowName: log.workflowName } : {}),
|
|
...(log.executionId ? { executionId: log.executionId } : {}),
|
|
}))
|
|
|
|
const bySource: Record<string, number> = {}
|
|
let totalCost = 0
|
|
|
|
if (includeSummary) {
|
|
const summaryConditions = buildUsageLogConditions(userId, {
|
|
source,
|
|
workspaceId,
|
|
startDate,
|
|
endDate,
|
|
})
|
|
|
|
const summaryResult = await dbReplica
|
|
.select({
|
|
source: usageLog.source,
|
|
totalCost: sql<string>`SUM(${usageLog.cost})`,
|
|
})
|
|
.from(usageLog)
|
|
.where(and(...summaryConditions))
|
|
.groupBy(usageLog.source)
|
|
|
|
for (const row of summaryResult) {
|
|
const sourceCost = Number.parseFloat(row.totalCost || '0')
|
|
bySource[row.source] = sourceCost
|
|
totalCost += sourceCost
|
|
}
|
|
}
|
|
|
|
return {
|
|
logs: transformedLogs,
|
|
summary: {
|
|
totalCost,
|
|
bySource,
|
|
},
|
|
pagination: {
|
|
nextCursor:
|
|
hasMore && resultLogs.length > 0 ? resultLogs[resultLogs.length - 1].id : undefined,
|
|
hasMore,
|
|
},
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to get usage logs', {
|
|
error: toError(error).message,
|
|
userId,
|
|
options,
|
|
})
|
|
throw error
|
|
}
|
|
}
|