Skip to content

Commit 7367ac9

Browse files
committed
fix(admin): count former actors' runs once, not once per actor
Former actors' workflow runs were the sum of their per-user counts, so a run shared by two former actors counted twice. Group the same scan by a former-actor flag as well and read the distinct count from it. The flag tests membership against two uncorrelated lists, so each is hashed once. Also drop the last non-null assertion in the organizations list and shrink the explicit-any baseline.
1 parent d0e4ab7 commit 7367ac9

4 files changed

Lines changed: 88 additions & 95 deletions

File tree

‎apps/sim/lib/admin/dashboard-usage.integration.ts‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
readDashboardHistoricalActorSpend,
1414
readDashboardLedgerSpend,
1515
readDashboardLedgerSpendByUser,
16-
readDashboardWorkflowRuns,
16+
readDashboardOrganizationWorkflowRuns,
1717
} from '@/lib/admin/dashboard-usage'
1818
import type { ResolvedUsagePeriod } from '@/lib/billing/core/reporting-period'
1919

@@ -86,6 +86,9 @@ beforeAll(async () => {
8686
('u3', 'model', 'copilot', 0.1, 'organization', 'org-a', ${P}, NULL, '2026-08-07'),
8787
('u4', 'model', 'copilot', 0.2, 'organization', 'org-a', ${P}, NULL, '2026-08-07'),
8888
('u5', 'model', 'workflow', 0.3, 'organization', 'org-a', ${P}, 'e11', '2026-08-07'),
89+
-- one run shared by two former actors
90+
('u3', 'model', 'workflow', 0.1, 'organization', 'org-a', ${P}, 'e12', '2026-08-08'),
91+
('u5', 'model', 'workflow', 0.1, 'organization', 'org-a', ${P}, 'e12', '2026-08-08'),
8992
9093
-- org-b, reporting period: matched on created_at, start inclusive and end exclusive,
9194
-- whatever the rows are stamped with.
@@ -119,15 +122,15 @@ describe('dashboard ledger usage', () => {
119122
{ entity: { type: 'user', id: 'p1' }, period: stampedPeriod },
120123
])
121124

122-
expect(Object.fromEntries(spend)).toEqual({ 'org-a': 3.38, 'org-b': 1.75, p1: 0.6 })
125+
expect(Object.fromEntries(spend)).toEqual({ 'org-a': 3.58, 'org-b': 1.75, p1: 0.6 })
123126
})
124127

125128
it('reads an entity once however many times it is asked for', async () => {
126129
const spend = await readDashboardLedgerSpend([
127130
organizationWindow('org-a', stampedPeriod),
128131
organizationWindow('org-a', stampedPeriod),
129132
])
130-
expect(spend.get('org-a')).toBe(3.38)
133+
expect(spend.get('org-a')).toBe(3.58)
131134
})
132135

133136
it('splits spend by acting user', async () => {
@@ -139,9 +142,9 @@ describe('dashboard ledger usage', () => {
139142
expect(Object.fromEntries(spend.get('org-a') ?? [])).toEqual({
140143
u1: 1.51,
141144
u2: 0.37,
142-
u3: 1,
145+
u3: 1.1,
143146
u4: 0.2,
144-
u5: 0.3,
147+
u5: 0.4,
145148
})
146149
expect(Object.fromEntries(spend.get('org-b') ?? [])).toEqual({ u6: 1.75 })
147150
})
@@ -154,18 +157,21 @@ describe('dashboard ledger usage', () => {
154157
expect((await readDashboardLedgerSpendByUser([window], [])).size).toBe(0)
155158
})
156159

157-
it('counts distinct workflow runs in total and for each actor of a shared run', async () => {
158-
const orgA = await readDashboardWorkflowRuns(organizationWindow('org-a', stampedPeriod))
159-
expect(orgA?.total).toBe(5)
160-
expect(Object.fromEntries(orgA?.byUser ?? [])).toEqual({ u1: 2, u2: 2, u3: 1, u5: 1 })
160+
it('counts distinct workflow runs in total, for each actor of a shared run, and once across former actors', async () => {
161+
const orgA = await readDashboardOrganizationWorkflowRuns('org-a', stampedPeriod)
162+
expect(orgA?.total).toBe(6)
163+
expect(Object.fromEntries(orgA?.byUser ?? [])).toEqual({ u1: 2, u2: 2, u3: 2, u5: 2 })
164+
expect(orgA?.formerActors).toBe(3)
161165

162-
const orgB = await readDashboardWorkflowRuns(organizationWindow('org-b', reportingPeriod))
166+
const orgB = await readDashboardOrganizationWorkflowRuns('org-b', reportingPeriod)
163167
expect(orgB?.total).toBe(3)
164168
expect(Object.fromEntries(orgB?.byUser ?? [])).toEqual({ u6: 3 })
169+
expect(orgB?.formerActors).toBe(3)
165170

166-
const empty = await readDashboardWorkflowRuns(organizationWindow('org-c', stampedPeriod))
171+
const empty = await readDashboardOrganizationWorkflowRuns('org-c', stampedPeriod)
167172
expect(empty?.total).toBe(0)
168173
expect(empty?.byUser.size).toBe(0)
174+
expect(empty?.formerActors).toBe(0)
169175
})
170176

171177
it('reports runs as unknown when the count outlasts its budget', async () => {
@@ -190,7 +196,7 @@ describe('dashboard ledger usage', () => {
190196
})
191197
try {
192198
await locked
193-
expect(await readDashboardWorkflowRuns(organizationWindow('org-a', stampedPeriod))).toBeNull()
199+
expect(await readDashboardOrganizationWorkflowRuns('org-a', stampedPeriod)).toBeNull()
194200
} finally {
195201
release()
196202
await holder
@@ -200,6 +206,6 @@ describe('dashboard ledger usage', () => {
200206

201207
it('spends only for actors who are neither members nor collaborators of the organization', async () => {
202208
const spend = await readDashboardHistoricalActorSpend('org-a', stampedPeriod)
203-
expect(Object.fromEntries(spend)).toEqual({ u3: 1, u4: 0.2, u5: 0.3 })
209+
expect(Object.fromEntries(spend)).toEqual({ u3: 1.1, u4: 0.2, u5: 0.4 })
204210
})
205211
})

‎apps/sim/lib/admin/dashboard-usage.ts‎

Lines changed: 55 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { db } from '@sim/db'
22
import { member, permissions, usageLog, workspace } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { getPostgresCancellationReason } from '@sim/utils/errors'
5-
import { and, countDistinct, eq, inArray, notExists, type SQL, sql } from 'drizzle-orm'
6-
import { alias, unionAll } from 'drizzle-orm/pg-core'
5+
import { and, countDistinct, eq, inArray, notInArray, type SQL, sql } from 'drizzle-orm'
6+
import { unionAll } from 'drizzle-orm/pg-core'
77
import { readLedgerBounded } from '@/lib/billing/core/ledger-read'
88
import type { ResolvedUsagePeriod } from '@/lib/billing/core/reporting-period'
99
import { buildUsageAnalyticsScope } from '@/lib/billing/core/usage-analytics'
@@ -50,6 +50,8 @@ interface DashboardWorkflowRuns {
5050
total: number
5151
/** Runs per acting user; a run with rows from two actors counts for each. */
5252
byUser: Map<string, number>
53+
/** Distinct runs across the organization's former actors together. */
54+
formerActors: number
5355
}
5456

5557
/**
@@ -139,25 +141,58 @@ export async function readDashboardLedgerSpendByUser(
139141
}
140142

141143
/**
142-
* One entity's workflow runs over its period, in total and per acting user, from one
143-
* scan. `null` when the count does not finish within {@link WORKFLOW_RUNS_BUDGET_MS}.
144+
* Who no longer represents an organization's usage: an actor who is neither a member nor a
145+
* collaborator on one of its workspaces any more. Uncorrelated, so each list is hashed once
146+
* rather than probed for every ledger row.
144147
*/
145-
export async function readDashboardWorkflowRuns(
146-
window: DashboardLedgerWindow
148+
function isFormerActor(organizationId: string): SQL {
149+
const members = db
150+
.select({ userId: member.userId })
151+
.from(member)
152+
.where(eq(member.organizationId, organizationId))
153+
const collaborators = db
154+
.select({ userId: permissions.userId })
155+
.from(permissions)
156+
.innerJoin(
157+
workspace,
158+
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspace.id))
159+
)
160+
.where(eq(workspace.organizationId, organizationId))
161+
return sql`(${notInArray(usageLog.userId, members)} and ${notInArray(usageLog.userId, collaborators)})`
162+
}
163+
164+
/**
165+
* An organization's workflow runs over its period, in total, per acting user and across its
166+
* former actors, from one scan. `null` when the count does not finish within
167+
* {@link WORKFLOW_RUNS_BUDGET_MS}.
168+
*/
169+
export async function readDashboardOrganizationWorkflowRuns(
170+
organizationId: string,
171+
period: ResolvedUsagePeriod
147172
): Promise<DashboardWorkflowRuns | null> {
148-
let rows: { userId: string | null; workflowRuns: number }[]
173+
let rows: { userId: string | null; formerActor: boolean | null; workflowRuns: number }[]
149174
try {
150175
rows = await readLedgerBounded(
151176
db,
152-
(tx) =>
153-
tx
177+
(tx) => {
178+
const workflowRows = tx
154179
.select({
155-
userId: sql<string | null>`${usageLog.userId}`,
156-
workflowRuns: countDistinct(usageLog.executionId),
180+
userId: usageLog.userId,
181+
executionId: usageLog.executionId,
182+
formerActor: sql<boolean>`${isFormerActor(organizationId)}`.as('former_actor'),
157183
})
158184
.from(usageLog)
159-
.where(and(...windowScope(window), WORKFLOW_SOURCE))
160-
.groupBy(sql`grouping sets ((${usageLog.userId}), ())`),
185+
.where(and(...windowScope(organizationWindow(organizationId, period)), WORKFLOW_SOURCE))
186+
.as('workflow_rows')
187+
return tx
188+
.select({
189+
userId: sql<string | null>`${workflowRows.userId}`,
190+
formerActor: sql<boolean | null>`${workflowRows.formerActor}`,
191+
workflowRuns: countDistinct(workflowRows.executionId),
192+
})
193+
.from(workflowRows)
194+
.groupBy(sql`grouping sets ((${workflowRows.userId}), (${workflowRows.formerActor}), ())`)
195+
},
161196
WORKFLOW_RUNS_BUDGET_MS
162197
)
163198
} catch (error) {
@@ -166,71 +201,34 @@ export async function readDashboardWorkflowRuns(
166201
getPostgresCancellationReason(error) === 'statement_timeout'
167202
if (!overBudget) throw error
168203
logger.warn('Workflow run count exceeded its budget', {
169-
entityType: window.entity.type,
170-
entityId: window.entity.id,
204+
organizationId,
171205
budgetMs: WORKFLOW_RUNS_BUDGET_MS,
172206
})
173207
return null
174208
}
175209

176-
const runs: DashboardWorkflowRuns = { total: 0, byUser: new Map() }
210+
const runs: DashboardWorkflowRuns = { total: 0, byUser: new Map(), formerActors: 0 }
177211
for (const row of rows) {
178-
if (row.userId === null) runs.total = row.workflowRuns
179-
else runs.byUser.set(row.userId, row.workflowRuns)
212+
if (row.userId !== null) runs.byUser.set(row.userId, row.workflowRuns)
213+
else if (row.formerActor === null) runs.total = row.workflowRuns
214+
else if (row.formerActor) runs.formerActors = row.workflowRuns
180215
}
181216
return runs
182217
}
183218

184-
const historicalUsageMember = alias(member, 'historical_usage_member')
185-
const historicalUsagePermission = alias(permissions, 'historical_usage_permission')
186-
const historicalUsageWorkspace = alias(workspace, 'historical_usage_workspace')
187-
188-
/**
189-
* An organization's period spend per actor who is neither a member nor a collaborator on
190-
* one of its workspaces any more.
191-
*/
219+
/** An organization's period spend per former actor (see {@link isFormerActor}). */
192220
export async function readDashboardHistoricalActorSpend(
193221
organizationId: string,
194222
period: ResolvedUsagePeriod
195223
): Promise<Map<string, number>> {
196-
const currentMember = db
197-
.select({ value: sql`1` })
198-
.from(historicalUsageMember)
199-
.where(
200-
and(
201-
eq(historicalUsageMember.organizationId, organizationId),
202-
eq(historicalUsageMember.userId, usageLog.userId)
203-
)
204-
)
205-
const currentCollaborator = db
206-
.select({ value: sql`1` })
207-
.from(historicalUsagePermission)
208-
.innerJoin(
209-
historicalUsageWorkspace,
210-
and(
211-
eq(historicalUsagePermission.entityType, 'workspace'),
212-
eq(historicalUsagePermission.entityId, historicalUsageWorkspace.id)
213-
)
214-
)
215-
.where(
216-
and(
217-
eq(historicalUsageWorkspace.organizationId, organizationId),
218-
eq(historicalUsagePermission.userId, usageLog.userId)
219-
)
220-
)
221-
222224
const rows = await db
223225
.select({
224226
userId: usageLog.userId,
225227
cost: sql<string>`coalesce(sum(${usageLog.cost}), 0)`,
226228
})
227229
.from(usageLog)
228230
.where(
229-
and(
230-
...windowScope(organizationWindow(organizationId, period)),
231-
notExists(currentMember),
232-
notExists(currentCollaborator)
233-
)
231+
and(...windowScope(organizationWindow(organizationId, period)), isFormerActor(organizationId))
234232
)
235233
.groupBy(usageLog.userId)
236234
return new Map(rows.map((row) => [row.userId, Number(row.cost)]))

‎apps/sim/lib/admin/dashboard.ts‎

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
readDashboardHistoricalActorSpend,
2020
readDashboardLedgerSpend,
2121
readDashboardLedgerSpendByUser,
22-
readDashboardWorkflowRuns,
22+
readDashboardOrganizationWorkflowRuns,
2323
} from '@/lib/admin/dashboard-usage'
2424
import {
2525
getOrganizationUsageLimitFallbackDollars,
@@ -564,21 +564,14 @@ export async function listDashboardOrganizations({ search, limit, offset }: Pagi
564564
)
565565
)
566566
const subscriptionByOrganization = new Map(subscriptionRows.map((row) => [row.referenceId, row]))
567-
const usagePeriodsByOrganization = new Map(
568-
organizationIds.map(
569-
(organizationId) =>
570-
[
571-
organizationId,
572-
resolveDashboardUsagePeriod(subscriptionByOrganization.get(organizationId) ?? null),
573-
] as const
574-
)
575-
)
567+
const organizations = orgRows.map((org) => {
568+
const latestSubscription = subscriptionByOrganization.get(org.id) ?? null
569+
return { org, latestSubscription, usagePeriod: resolveDashboardUsagePeriod(latestSubscription) }
570+
})
576571
const spendByOrganization = await readDashboardLedgerSpend(
577-
[...usagePeriodsByOrganization].map(([organizationId, period]) =>
578-
organizationWindow(organizationId, period)
579-
)
572+
organizations.map(({ org, usagePeriod }) => organizationWindow(org.id, usagePeriod))
580573
)
581-
const data = orgRows.map((org) => {
574+
const data = organizations.map(({ org, latestSubscription, usagePeriod }) => {
582575
const membership = membershipsByOrganization.get(org.id)
583576
const owner =
584577
membership?.ownerId && membership.ownerName && membership.ownerEmail
@@ -592,12 +585,12 @@ export async function listDashboardOrganizations({ search, limit, offset }: Pagi
592585
org,
593586
memberCount: membership?.memberCount ?? 0,
594587
externalCollaboratorCount: externalCountByOrganization.get(org.id) ?? 0,
595-
latestSubscription: subscriptionByOrganization.get(org.id) ?? null,
588+
latestSubscription,
596589
provisioning: provisionings.get(org.id) ?? null,
597590
owner,
598591
usageDollars: spendByOrganization.get(org.id) ?? 0,
599592
workflowRuns: null,
600-
usagePeriod: usagePeriodsByOrganization.get(org.id)!,
593+
usagePeriod,
601594
})
602595
return summary
603596
})
@@ -726,18 +719,15 @@ export async function getDashboardOrganization(
726719
historicalActorSpend,
727720
}
728721
}
729-
const [workflowRuns, page] = await Promise.all([readDashboardWorkflowRuns(window), readPage()])
722+
const [workflowRuns, page] = await Promise.all([
723+
readDashboardOrganizationWorkflowRuns(organizationId, usagePeriod),
724+
readPage(),
725+
])
730726
const limits = new Map(page.limitRows.map((row) => [row.userId, Number(row.limit)]))
731727
const usageByUser = page.spendByOrganization.get(organizationId) ?? new Map<string, number>()
732728
const runsOf = (userId: string) => (workflowRuns ? (workflowRuns.byUser.get(userId) ?? 0) : null)
733729
let historicalUsedDollars = 0
734730
for (const cost of page.historicalActorSpend.values()) historicalUsedDollars += cost
735-
let historicalWorkflowRuns: number | null = null
736-
if (workflowRuns) {
737-
historicalWorkflowRuns = 0
738-
for (const userId of page.historicalActorSpend.keys())
739-
historicalWorkflowRuns += runsOf(userId) ?? 0
740-
}
741731
const workspaceTotal = page.workspaceCountRows[0]?.value ?? 0
742732
return {
743733
...base,
@@ -749,7 +739,7 @@ export async function getDashboardOrganization(
749739
historicalActorUsage: {
750740
usedDollars: historicalUsedDollars,
751741
usedCredits: dollarsToCredits(historicalUsedDollars),
752-
workflowRuns: historicalWorkflowRuns,
742+
workflowRuns: workflowRuns?.formerActors ?? null,
753743
actorCount: page.historicalActorSpend.size,
754744
},
755745
members: page.memberRows.map((row) => ({

‎scripts/check-explicit-any.baseline.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1353,7 +1353,6 @@
13531353
"apps/sim/hooks/use-permission-config.test.tsx": 10,
13541354
"apps/sim/hooks/use-permission-config.ts": 1,
13551355
"apps/sim/hooks/use-voice-input.test.tsx": 1,
1356-
"apps/sim/lib/admin/dashboard.ts": 2,
13571356
"apps/sim/lib/api/contracts/mothership-assistant-tools.test.ts": 2,
13581357
"apps/sim/lib/api/contracts/mothership-management-tools.test.ts": 1,
13591358
"apps/sim/lib/api/contracts/v2/tables.test.ts": 1,

0 commit comments

Comments
 (0)