Skip to content

Commit f7693cd

Browse files
feat(usage): add enterprise organization usage monitoring (#7182)
* feat(usage): add enterprise organization usage monitoring Enterprise org admins had no way to see where their pooled credits go. The billing page hides the usage-limit field and the credit-usage drill-down for enterprise, and the credit-usage view is personal-scope only — so the one audience that negotiates a pooled commitment was the one audience that could not watch it burn. Everything needed already existed in `usage_log`, which is the single source of cost truth and is stamped `billing_entity_type='organization'` at charge time. This adds the read surface over it, plus one narrow write so BYOK usage is captured rather than discarded. Settings → Organization → Usage tracking, gated on hosted + enterprise with a `USAGE_MONITORING_ENABLED` self-hosted override, matching the other enterprise features. Five tabs, each answering one question: Overview (how much, and what kind of work), Members, Workspaces (drill into one for its Sources and Workflows), Models, BYOK. Only the visible tab's dimension is fetched — half the dimensions heap-scan the ledger, so a tab nobody opens never pays for one. Every ranked list closes with an explicit `Other (N more)` row so it reconciles to the headline figure. Track unbilled (BYOK) model usage --------------------------------- BYOK spans already reached `costSummary` with real token counts and were discarded at a single `if (modelData.total > 0)` gate. A new `model_unbilled` usage_log category records them at zero cost, written only at the terminal execution boundary and only when billing attribution is already resolved, so a BYOK-only run with no billable target still bails safely instead of hitting the attribution requirement. Every billing read over `usage_log` is `SUM(cost)`, so zero-cost rows change no total anywhere. Collapse organization settings onto the workspace plane ------------------------------------------------------ `/organization/[id]/settings/*` was a second plane with no UI entry point anywhere, whose nine sections all already render on the workspace plane. It is deleted, along with its renderer and unavailable page. `planes.organization` is replaced by a single `unified.organizationSection` marker that now derives both `ORGANIZATION_PLANE_UNIFIED_SECTIONS` and the section map in the workspace gate, removing a hand-maintained duplicate. Usage-threshold emails now link to the workspace-scoped billing page, and the two org-provisioning paths that could strand an admin with zero workspaces now backfill one. Shared chart module ------------------- `LineChart` moves to `components/charts/` with a matching `BarChart` sibling built from the same geometry, tooltip, and theme modules. The move also breaks the import edge from the chart to `logs/utils` → `@/blocks/registry`, which would otherwise have pulled the entire block registry into the settings chunk. Logs dashboard renders identically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(audits): record the usage-tracking module-graph baseline `check:tool-registry-boundary` ratchets each route's module count, and the settings section entry exceeded its allowance. Attribution, measured by stubbing the section out and by walking `origin/staging` in a scratch worktree: origin/staging 2112 (baseline 2083 — already +29, unrecorded) this branch, section stubbed 2115 this branch 2130 (+15 for the whole UsageMonitoring subtree) So the overrun is a shared budget: staging had spent 29 of the 42 allowed before this branch existed, and 15 more tipped it over. The 15 are all first-party — the panel, its contracts, hooks, and the shared chart module — with no accidental edge into a registry or a heavy barrel, so there is nothing to cut. Only the four entries this branch is responsible for are re-recorded. Running `--update-baseline` wholesale rewrote 411 lines, absorbing staging's drift across ~60 unrelated routes into this PR; the six routes that shrank are left informational for whoever earned them to claim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(usage): key the chart to the calendar the query grouped by Three review findings, all real. Densification walked UTC dates while `readUsageTimeSeries` groups by `date_trunc($bucket, created_at AT TIME ZONE $timezone)` — the viewer's calendar. For a non-UTC viewer the edge buckets never matched, so their cost stayed in the headline while their bar read zero. Week and month were worse: Postgres aligns those to Monday and the 1st, so a cursor stepping from an arbitrary period start shared no key with the query at all and the whole chart came back zeroed against a correct total. That is reachable today through an annual enterprise period, which resolves to `week`. `densifyUsageSeries` now takes the timezone, derives its first and last bucket through `Intl.DateTimeFormat('en-CA', { timeZone })`, and truncates both to the bucket boundary so the keys are the ones `date_trunc` emits. Stepping stays civil `YYYY-MM-DD` arithmetic — UTC as a proleptic calendar, never converted back to an instant, so no DST transition can shift a bucket. The custom-range picker passed `showTime`, so it serialized its end bound as an inclusive `…T23:59:59` local wall time; the resolver then added a further day. Every custom range covered 24 hours too many, a legal 92-day selection measured 93 and was rejected, and the wall-clock string parsed as local while the rest of the window logic is UTC. Dropped `showTime`: a time of day is precision a day-bucketed panel cannot render, and bare `YYYY-MM-DD` bounds parse as UTC midnight, which is what makes the half-open `+ DAY_MS` correct. Admin organization provisioning answered 500 for state it had already committed, and the existing-membership check then blocked the retry, leaving an organization no workspace could reach. Attachment is a follow-on effect, not part of creating the organization, and it is deliberately not folded into the creation transaction: it runs its own under a lock order that exists to avoid deadlocking against invitation acceptance, and re-deriving that in a route is how a deadlock ships. Its failure is now caught and logged, and the endpoint returns the organization it created. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(usage): correct reconciliation, gating, and error classification Second review round. The findings that were real, and what changed. Billing aggregates. `getBillingPeriodWorkflowRunCount` counts distinct workflow executions with no category predicate, and its own contract says executions with no billable usage are excluded. A BYOK-only run with a zero base charge writes nothing but a `model_unbilled` row, so it would newly appear in a figure that feeds the enterprise billing preview; the count now excludes unbilled categories. `recordUsage` also admits an unbilled entry only at exactly zero cost — the whole safety argument for the category is that every aggregate is `SUM(cost)`. Reconciliation. Breakdown rows and their remainder were each rounded to credits independently and compared against a separately rounded total, so with sub-credit fractions they could not add up — which is precisely what the `Other` row exists to prevent. They now go through one `apportionCredits` pass. The event list and the CSV also counted a row stamped exactly on the window end, which the summary excluded, because the ledger filter is `lte` while an analytics window is half-open; `endDateExclusive` makes the two agree. BYOK ranking. The tab is denominated in tokens and every row costs zero, so ranking by cost fell through to an alphabetical tiebreak — the "top providers" were whichever sorted first, and the hidden tail's tokens were dropped entirely. It ranks by tokens, and the remainder carries its own token total. Gating. The usage entry carried `hideWhenBillingDisabled` copied from Members, but the sidebar applies that filter before it consults `selfHostedOverride`, so it hid the section from exactly the deployment the override exists to serve. Members can carry the flag because it has no override to reach. Error classification. An over-long custom range threw past the orchestration policy and answered 500 on all four routes. A shared policy maps it to 400, the export route makes the same classification in its catch, and the picker now refuses the range up front rather than committing one the API will reject. Also: the segmented meter's overage tone was unreachable (both counts clamped to `segments`, so the comparison could never hold) and now scales both against `max(total, used)`; the allowance is only compared against the current period, since a rolling window can exceed a limit neither period did; `source` accepts a scalar and validates against the source enum instead of an unchecked cast; the bar chart's axis tick uses the same unit-aware formatter as its tooltip; and `assertValidTimezone` strips control characters before echoing a rejected value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(usage): resolve custom bounds in the viewer calendar, bound the open period Third review round. Custom ranges were anchored on UTC instants. The picker offers calendar days and sends `YYYY-MM-DD`, which arrives parsed as UTC midnight, so every non-UTC viewer's selection was shifted by their offset — a range labelled "Aug 1–31" covered half of Jul 31 and half of Aug 31 twelve hours east — and it contradicted the series, whose buckets are already the viewer's calendar days. The resolver now takes the timezone and reinterprets the same civil dates as midnight there, through `zonedWallClockToUtc`, and counts the span in civil days so a range containing a DST transition is not measured as 91.96. The timezone is threaded through the breakdown, events, and export inputs as well; the contract already carried it but only the summary consumed it, so the four surfaces would have resolved one range four ways. A deployment with no subscription resolves to `defaultBillingPeriod()`, the open pair 1970…9999. Rendered as a period that produced a thousand monthly buckets ending in 2053, stopped only by the densifier's loop guard — measured, not inferred. Self-hosted is exactly where it is reachable, since the usage flag opens the panel on deployments with no plan at all. An unbounded period now shows a rolling 30-day window, and its predecessor steps back by that window rather than by a span of eight millennia. Kept as one window rather than clamping only the chart, so the series still sums to the headline. The summary's delta used the `previous-period` preset, which must always return something and therefore approximates a stripe period's predecessor by stepping back the current period's length. Stripe periods are not equal-length, so the comparison could be measured against a window that is not the previous period — contradicting the comment directly above it. It now calls `resolvePreviousPeriod` and shows no delta when there is no exact predecessor. Reversed custom bounds measured a negative span, passed the cap, and returned an inverted range matching nothing — "no usage" rather than a bad request. They now throw, classified 400 alongside the too-large error. Admin organization provisioning reported an unqualified success when workspace attachment failed. It now returns `attachedWorkspaceIds` and records it on the audit event, so the incomplete state is visible to the caller and durable after the fact, rather than known only to the logs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(usage): repair the run-count predicate, align the ledger and analytics scopes Fourth review round. The first item is a break this branch introduced last round. `getBillingPeriodWorkflowRunCount` was rewritten to exclude unbilled categories via `<> ALL(${UNBILLED_USAGE_CATEGORIES})`. Interpolating a JavaScript array into a `sql` template emits parenthesized scalar binds, so the statement rendered as `ALL(($1))` and Postgres rejects it: "op ANY/ALL (array) requires array on right side" — verified against a real database. Its only caller builds the enterprise billing preview, so that preview would have thrown on every request. It now uses `notInArray`. Unit tests could not have caught it; `@sim/db` is mocked, so no statement is ever rendered. The ledger listing filtered on `created_at` while the analytics scope matches a stripe or default period on the stamps rows carry. The event list and the CSV therefore covered a different set than the totals above them — rows created inside the period but stamped to another, and the reverse. Both now derive their filter from one `usageWindowLedgerFilter`, which mirrors `buildUsageAnalyticsScope` case for case, with a test asserting the two branch on the same discriminant. An invalid timezone reached `assertValidTimezone` and surfaced as a 500 for what is an ordinary bad query param; it is now refused by the contract as a 400, with the SQL-boundary assertion left in place as the backstop it is. A bookmarked workspace id that no longer resolves opened a detail view with an untitled header and empty sections, against this repo's own deep-link rule. It now falls back to the list once the list has loaded. Also: `Cache-Control: no-store` on the CSV, which is every member's spend behind session auth and the one response a browser will cache; Export no longer gated on an unrelated summary query; the bar chart keeps its measurement ref on the empty branch; and the admin create-organization contract declares `attachedWorkspaceIds` plus `workspaceAttachmentFailed`, so a caller can tell an owner with no workspaces from provisioning that is genuinely incomplete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(usage): keep reporting-only rows and rounding out of the credit views Fifth review round, plus a migration renumber — staging took 0310, so the enum addition is now 0311. Unbilled rows leaked into every credit-denominated dimension. They carry a user, a workspace, a workflow and `source = 'workflow'` like any other row, so a BYOK-only member appeared in Members at zero credits and their runs inflated the event counts on Workspaces and Sources. Excluded with `HAVING SUM(cost) > 0` rather than a `category` predicate: `category` is not in `usage_log_billing_entity_created_at_cost_idx`, so filtering on it would force a heap fetch on `member` and `source`, the two dimensions that are index-only and the reason first paint is cheap. `cost` is in that index, and only an unbilled row can sum to zero. Ranking BYOK by tokens last round was half a change: `share` still divided cost by a total cost of zero, so every provider's bar rendered at the same minimum width and the ranking was invisible. Share is now measured in whatever the list is ranked by, derived from the same argument so the two cannot disagree. Series buckets were each rounded to credits independently, so any day under half a credit rendered as zero — an organization spending a fraction of a credit a day drew a flat chart beneath a positive headline. They now go through one `apportionCredits` pass, the same rule the breakdown rows use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(usage): drop inline workspace attachment, tighten window and chart edges Removes the workspace attachment from admin organization creation, on the maintainer's call. Three review rounds went into its failure semantics, and the conclusion each time was that it cannot be made honest inline: it runs its own transaction under a lock order that exists to avoid deadlocking against invitation acceptance, so it could only ever be best-effort after the organization committed. This codebase already solves the problem properly. `AdminMemberOperationView` tracks workspace moves with `pending | processing | dead_letter | applied` and per-workspace retry, and the enterprise-owner-claim path creates the workspace and the organization in one transaction and enqueues an outbox event for the rest. Provisioning belongs on one of those, not inline in a create call that also relocates the owner's billing payer as a side effect. The endpoint is back to creating an organization and its owner membership, and the contract says why. The rest are edges found in review: - A zero bucket rendered as a 3px colored bar. `chartPlotBand` clamps drawn geometry off the axis rule, but applied to zero it floored every densified empty day at the band — the opposite of what densifying zeros is for. - `preset=custom` without both dates fell back to the raw period, bypassing the unbounded-period bound added last round. It now recurses through `current-period` so there is one rule rather than two copies. - `Date.parse` accepts `2026-02-30` and rolls it forward, so a February request silently returned a window starting March 2. Dates now round-trip. - `?limit=` coerced to `0` and answered 400 instead of using the declared default. - The period picker bound to the raw URL preset, so a partial custom deep link read "Custom range" over current-period data — and suppressed the allowance that was exactly comparable to it. - The CSV rendered a sub-credit charge as the string "0 credits", losing it, and left the column unsummable. Export rows now carry unrounded credits and the CSV writes a bare number. Also regenerates the docs manifest, which CI flagged for the new docs page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(usage): surface export failures, make narrow charts scroll A network-level export failure produced an unhandled rejection and no toast. The action is fire-and-forget, so a dropped connection or a failed blob read was indistinguishable from a click that never registered. `resolveUsageBucket` ceiled its day count, so a 92-day range spanning the autumn fall-back measured 92 days and one hour, counted as 93, and silently rendered weekly bars for the longest range the picker allows. Rounded instead, with a test on both sides of the threshold. The client's custom-range guard only checked that both bounds were present, while the contract — tightened last round — rejects a date that does not exist. A deep link carrying `2026-02-30` therefore satisfied "resolved custom" and every query on the page answered 400, where before it merely returned a shifted window. Tightening one end without the other made a bad link worse; the client now applies the same calendar round-trip, so it falls back as the partial-link guard intends. The contract's own check also now covers the date portion of a datetime, not only the bare form. The export loop omitted `cursorCreatedAt`, whose documentation names this exact caller. Each page therefore resolved its cursor against the primary before reading the replica — up to 99 avoidable round-trips for a capped export. Both chart roots become `overflow-x-auto`. `CHART_MIN_WIDTH` says the chart "scrolls rather than compresses", and the code clipped: below the floor the rightmost bars and axis labels were cut off. That comment was added by this branch during the lift, so the contradiction is this PR's rather than inherited. At or above the floor there is no overflow and nothing renders differently. Applied to both charts because the floor lives in the shared hook. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(usage): drop the redundant organization query param, harden deep links `organizationId` was accepted and validated on every usage query and read by no handler — all four map `params.id`, which is also the value that gets authorized. It could never widen access, but it was an API that read as though the query mattered. Removed from the shared window schema and from the hooks and export URL that were sending it. The client's custom-range guard checked that both bounds were real dates but not their ordering or their span, while this PR added a 400 for each. A bookmarked link with reversed or over-long bounds was therefore marked resolved and failed all four queries, instead of degrading to the default window the guard exists to provide. It now checks all three conditions the resolver enforces. The charts' empty state pinned itself to the clamped minimum width, which forced horizontal overflow in a narrow container — fallout from adding scrolling to the chart root, which that branch returns before reaching. It takes the container width now; the floor protects axis labels, and this branch draws none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(usage): apply the deep-link guard that was written but never wired The previous round added `isUsableCustomRange` and left `isResolvedCustom` calling `isCalendarDate` — the edit replacing the guard did not apply, so the function shipped unused and the fix it described never took effect. It is wired now, and a new `organization-usage.test.ts` covers the contract's side of these rules so a dropped edit here fails a test rather than a review. That contract check also had a hole of its own: `if (!datePart) return true` treated a value with no `YYYY-MM-DD` prefix as nothing to verify, so anything `Date.parse` accepted passed. `2026-08` was read as August 1 — a window the caller never asked for, returned as though it had. The prefix is required now, anchored so a trailing suffix cannot slip past, and the client mirrors it. Both charts drop `useRef(generateShortId(7))` for `useId`. The reported hydration mismatch is not real — both return early while `containerWidth === null`, which holds on the server and on the first client render, so the gradient never exists in hydrated markup. The waste is real: a ref initializer runs every render and all but the first result is discarded, which is the repo's own lazy-init rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(usage): accept only a bare calendar date for custom bounds Rewriting the date refinement last round dropped the `Date.parse` guard that had been there, so `2026-08-01Tgarbage` was accepted, the route built an `Invalid Date`, and `civilBoundKey`'s `toISOString` threw — a 500 for a malformed query string, which is the class of failure this PR has been converting into 400s. Verified directly rather than inferred: the schema returned `accepted: true` for a value whose `Date` was invalid. A datetime carrying an offset was wrong in a quieter way. The check validated its date part while the resolver read the UTC day off the whole value, so `2026-08-01T22:00:00-05:00` displayed as August 1 and queried August 2. Both contract and client now accept a bare `YYYY-MM-DD` and nothing else, which is the only form the picker produces — `showTime` was removed early in this PR — so the looser rules bought nothing and cost two defects. `isUsableCustomRange` also drops its defensive `slice(0, 10)`: tolerating a shape we have decided not to accept is how the loose rule crept in to begin with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(usage): reject an empty custom date instead of reading it as absent `if (!value) return true` let `?start-date=` through, the route's ternary turned the empty string into `undefined`, and the partial-selection fallback answered about the current period rather than the range named in the request. Absent stays valid — the picker clears the param rather than blanking it, and a missing bound is a real state the resolver handles. Explicitly blank is only reachable from a hand-built request, where a 400 beats a window nobody asked for. Deliberately different from `usageLimitSchema`, which does coerce `''` to its default: that field declares one, so omission has a documented meaning. These bounds declare none, so treating blank as absent substitutes a different answer rather than a default one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 292e59f commit f7693cd

99 files changed

Lines changed: 26110 additions & 722 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/en/platform/enterprise/index.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ Track configuration and security-relevant actions across your organization for c
5353

5454
---
5555

56+
## Usage Tracking
57+
58+
See where your organization's credits go — by member, workspace, model, and platform feature. See the [usage tracking guide](/platform/enterprise/usage-tracking).
59+
60+
---
61+
5662
## Data Retention
5763

5864
Configure how long execution logs, soft-deleted resources, and Chat data are kept before permanent deletion. See the [data retention guide](/platform/enterprise/data-retention).

apps/docs/content/docs/en/platform/enterprise/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"custom-blocks",
1111
"whitelabeling",
1212
"audit-logs",
13+
"usage-tracking",
1314
"data-retention",
1415
"data-drains",
1516
"forks"
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
---
2+
title: Usage Tracking
3+
description: See where your organization's credits go, by member, workspace, and model
4+
---
5+
6+
import { Callout } from 'fumadocs-ui/components/callout'
7+
import { FAQ } from '@/components/ui/faq'
8+
import { Image } from '@/components/ui/image'
9+
10+
Usage tracking shows how your organization consumes credits across every part of the platform — which members, which workspaces, which models, and which product features. Use it to monitor spend against your commitment, find what is driving it, and export the underlying events for chargeback.
11+
12+
All figures are in **credits** (1 credit = $0.005). See [cost calculation](/platform/costs) for how credits are derived.
13+
14+
---
15+
16+
## Viewing usage
17+
18+
Go to **Settings → Organization → Usage tracking** in your workspace.
19+
20+
<Image src="/static/enterprise/usage-tracking-overview.png" alt="Usage tracking Overview tab showing the period selector, credits used against the organization limit, a daily usage chart, and a Sources breakdown listing Sim Chat, Workflow, and Agent block" width={900} height={578} />
21+
22+
The period selector applies to every tab:
23+
24+
| Period | What it covers |
25+
|--------|----------------|
26+
| **Current period** | Your organization's current billing period |
27+
| **Previous period** | The period immediately before it |
28+
| **Last 7 days** / **Last 30 days** | A rolling window ending now |
29+
| **Custom range** | Any range up to 92 days |
30+
31+
<Callout type="info">
32+
Daily bars are drawn in your browser's timezone, while a billing period begins at a fixed UTC instant. The first and last bar of a period can therefore be partial, and two admins in different timezones will see the same total split across slightly different days.
33+
</Callout>
34+
35+
### Tabs
36+
37+
| Tab | Answers |
38+
|-----|---------|
39+
| **Overview** | How much have we used, against what limit, and what kind of work was it |
40+
| **Members** | Which people are driving usage |
41+
| **Workspaces** | Which workspaces are driving usage — select one to drill in |
42+
| **Models** | Which models we are paying for |
43+
| **BYOK** | What ran on our own provider keys |
44+
45+
Selecting a workspace opens its detail view, which splits that workspace's usage into **Sources** (what kind of work) and **Workflows** (the individual workflow runs). **Open logs** jumps to that workspace's execution logs.
46+
47+
<Image src="/static/enterprise/usage-tracking-workspace-detail.png" alt="A workspace's detail view with a Sources section listing Sim Chat and Workflow, and a Workflows section ranking individual workflows by credits" width={900} height={598} />
48+
49+
The two sections answer different questions, and the difference is the point: **Sources** adds up to the workspace's total, while **Workflows** covers only the workflow-run part of it. In the example above, Sources totals 4,435 credits but the workflows list only accounts for the 161 credits under Workflow — the other 4,274 came from Chat, which no workflow produced.
50+
51+
---
52+
53+
## What each source means
54+
55+
A **source** is the part of the platform that consumed the credits. Every charge belongs to exactly one source, so the Sources breakdown always adds up to your total.
56+
57+
| Source | Platform features that bill to it |
58+
|--------|-----------------------------------|
59+
| **Workflow** | Every workflow run — model calls made on Sim's hosted keys, hosted-key tool calls (web search, scraping, and similar), and the per-run base charge |
60+
| **Sim Chat** | The Chat panel, agent calls made through the API, and the email Inbox |
61+
| **Agent block** | The Agent block running inside a workflow |
62+
| **Knowledge Base** | Embedding documents on upload or connector sync, and semantic search queries |
63+
| **Wand** | Inline AI generation in editors |
64+
| **Enrichment** | Table column enrichment |
65+
| **Voice input** | Voice sessions |
66+
| **Voice output** | Spoken responses in deployed chats |
67+
| **Sim Chat (MCP)** | Retired. Appears only for historical periods |
68+
69+
### Nuances worth knowing
70+
71+
These follow from how charges are recorded, and they explain most questions about why a number looks the way it does.
72+
73+
**Workflow covers the whole run, not just the base charge.** A workflow row includes three things: the models it called on Sim's hosted keys, any hosted-key tool calls, and the per-run base charge. Model usage typically dominates, but the per-run charge is not negligible — across a high-volume organization it commonly accounts for around a fifth of workflow spend, because it applies to every run whether or not the workflow calls a model.
74+
75+
**Agent block is billed separately from the workflow that contains it.** An Agent block runs inside a workflow but is metered on its own, so its credits appear under **Agent block** rather than **Workflow**. Nothing is counted twice — but it also means Workflow alone understates what your workflows cost. Read the two lines together.
76+
77+
**Sim Chat covers three surfaces.** The Chat panel, headless agent calls through the API, and the Inbox all record as **Sim Chat** and cannot be separated from one another. If you need per-surface attribution today, separate them by workspace instead.
78+
79+
**Only workflow runs carry workflow attribution.** Chat, Agent block, Wand, Knowledge Base, and voice usage are not produced by a workflow and have no workflow attached. That is the gap between a workspace's **Sources** and **Workflows** sections shown above.
80+
81+
**Some usage has no workspace.** Agent calls made through the API may not name a workspace, particularly from headless or self-hosted callers. That usage is grouped under **No workspace** on the Workspaces tab. It is still counted in your total.
82+
83+
**Models excludes non-model charges.** The Models tab covers model usage only, so it will read lower than your period total — the difference is per-run base charges, hosted-key tool charges, and fixed charges such as voice sessions.
84+
85+
<Callout type="warning">
86+
Each tab is a different slice of the same charges, not an additional set of them. Members, Workspaces, and Sources each add up to your period total on their own; adding two tabs together double-counts.
87+
</Callout>
88+
89+
---
90+
91+
## Bring your own keys (BYOK)
92+
93+
When a workspace or organization supplies its own provider key, Sim does not charge for that model usage. Those calls are still recorded so you can see the volume.
94+
95+
The **BYOK** tab groups this usage by provider and reports **tokens** rather than credits, because the credit cost is zero by definition. Tokens on your own keys are not included in the credit totals anywhere else in the panel.
96+
97+
<Callout type="info">
98+
Tool calls and Chat usage made on your own keys are not yet recorded. BYOK currently covers model usage in workflow runs.
99+
</Callout>
100+
101+
---
102+
103+
## Exporting
104+
105+
**Export** downloads the events behind the current period and filters as a CSV with columns `Date, Source, Description, Workflow, Credits`. Credits are exported as plain numbers so the column can be summed, and carry decimals — an individual event often costs a fraction of a credit.
106+
107+
**All events** opens the full ledger — every credit-consuming event, newest first, with its own filters and export.
108+
109+
Very large exports are capped. When that happens the download still succeeds and Sim tells you it was truncated; narrow the date range to capture everything.
110+
111+
---
112+
113+
<FAQ items={[
114+
{
115+
question: "Who can view usage tracking?",
116+
answer: "Organization owners and admins. On Sim Cloud you must be on the Enterprise plan. Regular members cannot see organization usage, including their own."
117+
},
118+
{
119+
question: "Why doesn't the Models tab add up to my total?",
120+
answer: "The Models tab covers model usage only. Your total also includes per-run base charges, hosted-key tool charges, and fixed charges such as voice sessions. Use the Sources breakdown on the Overview tab for a figure that reconciles to the total."
121+
},
122+
{
123+
question: "Why is Sim Chat so much larger than Workflow?",
124+
answer: "Sim Chat covers the Chat panel, API agent calls, and the Inbox together, and conversational usage is typically token-heavy. It is common for Chat to be the largest line in an organization that uses it."
125+
},
126+
{
127+
question: "Can I see how much the Inbox or the API specifically costs?",
128+
answer: "Not directly — both record as Sim Chat. Where those surfaces run in dedicated workspaces, the Workspaces tab gives you the equivalent breakdown."
129+
},
130+
{
131+
question: "What is “No workspace”?",
132+
answer: "Usage that was not attributed to a workspace, most often agent calls made through the API without a workspace context. It is included in your organization total."
133+
},
134+
{
135+
question: "Is there an API for usage data?",
136+
answer: "Not currently. Usage tracking is a settings surface; use the CSV export to move the data into your own systems."
137+
},
138+
{
139+
question: "Why does a period show partial days at each end?",
140+
answer: "Daily buckets follow your browser's timezone while billing periods begin at a fixed UTC instant, so the first and last bucket of a period can cover part of a day."
141+
},
142+
{
143+
question: "Does this match my invoice?",
144+
answer: "The Overview total for the current period is the same figure your billing page reports. Rolling windows such as Last 7 days do not align with a billing period and will not match an invoice."
145+
}
146+
]} />
147+
148+
---
149+
150+
## Self-hosted setup
151+
152+
Self-hosted deployments use environment variables instead of the billing/plan check.
153+
154+
### Environment variables
155+
156+
```bash
157+
USAGE_MONITORING_ENABLED=true
158+
NEXT_PUBLIC_USAGE_MONITORING_ENABLED=true
159+
```
160+
161+
Once enabled, usage tracking is viewable in **Settings → Organization → Usage tracking** for organization owners and admins.
123 KB
Loading
122 KB
Loading

apps/sim/app/api/emails/preview/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ const emailTemplates = {
160160
scope: 'organization',
161161
currentUsage: 500,
162162
limit: 500,
163-
ctaLink: 'https://sim.ai/organization/org_123/settings/billing',
163+
ctaLink: 'https://sim.ai/workspace/ws_123/settings/billing',
164164
}),
165165

166166
// Operational notification emails
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { getOrganizationUsageBreakdownContract } from '@/lib/api/contracts/organization-usage'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { getOrganizationUsageBreakdown } from '@/lib/billing/application/organization-usage/get-organization-usage-breakdown'
8+
import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations'
9+
import { organizationUsageErrorPolicy } from '@/app/api/organizations/[id]/usage/error-policy'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
/**
14+
* One route for all five dimensions: they share a scope, a window, a row shape, and
15+
* authorization, so five routes would be five copies of the same mapping. Separate
16+
* from the summary because three of the five heap-scan the ledger.
17+
*/
18+
export const GET = defineInternalJsonRoute({
19+
contract: getOrganizationUsageBreakdownContract,
20+
auth: internalSessionAuth,
21+
operation: organizationUsageOperations.readBreakdown,
22+
rateLimit: internalRateLimits.none({
23+
reason:
24+
'Authenticated org-admin settings read, gated on enterprise entitlement and billing authority',
25+
}),
26+
errorPolicy: organizationUsageErrorPolicy,
27+
mapInput: ({ params, query }) => ({
28+
organizationId: params.id,
29+
dimension: query.dimension,
30+
workspaceId: query.workspaceId,
31+
preset: query.preset,
32+
startDate: query.startDate ? new Date(query.startDate) : undefined,
33+
endDate: query.endDate ? new Date(query.endDate) : undefined,
34+
timezone: query.timezone,
35+
limit: query.limit,
36+
}),
37+
useCase: getOrganizationUsageBreakdown,
38+
present: (result) => result,
39+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {
2+
extendInternalErrorPolicy,
3+
internalErrorResponse,
4+
internalOrchestrationErrorPolicy,
5+
} from '@/lib/api/server/routes'
6+
import {
7+
UsageWindowRangeInvertedError,
8+
UsageWindowRangeTooLargeError,
9+
} from '@/lib/billing/core/usage-analytics'
10+
11+
/**
12+
* The window resolver throws when a custom range exceeds its cap or ends before it
13+
begins, both of which are
14+
* caller-fixable input error rather than a fault. Without this it fell through to
15+
* the orchestration policy's `unhandled` branch and every over-long range answered
16+
* `500 Internal server error`, so the client could neither surface the real reason
17+
* nor tell the two apart.
18+
*
19+
* Shared by all four usage routes so they cannot classify the same throw differently.
20+
*/
21+
export const organizationUsageErrorPolicy = extendInternalErrorPolicy(
22+
internalOrchestrationErrorPolicy,
23+
(error) =>
24+
error instanceof UsageWindowRangeTooLargeError || error instanceof UsageWindowRangeInvertedError
25+
? internalErrorResponse(400, { error: error.message })
26+
: null
27+
)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { listOrganizationUsageEventsContract } from '@/lib/api/contracts/organization-usage'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { listOrganizationUsageEvents } from '@/lib/billing/application/organization-usage/list-organization-usage-events'
8+
import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations'
9+
import { organizationUsageErrorPolicy } from '@/app/api/organizations/[id]/usage/error-policy'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
/**
14+
* The raw ledger, paged. Separate from the summary because it owns a cursor
15+
* lifecycle and its own staleness — folding it in would re-run the headline
16+
* aggregate on every scroll.
17+
*/
18+
export const GET = defineInternalJsonRoute({
19+
contract: listOrganizationUsageEventsContract,
20+
auth: internalSessionAuth,
21+
operation: organizationUsageOperations.listEvents,
22+
rateLimit: internalRateLimits.none({
23+
reason:
24+
'Authenticated org-admin settings read, gated on enterprise entitlement and billing authority',
25+
}),
26+
errorPolicy: organizationUsageErrorPolicy,
27+
mapInput: ({ params, query }) => ({
28+
organizationId: params.id,
29+
preset: query.preset,
30+
startDate: query.startDate ? new Date(query.startDate) : undefined,
31+
endDate: query.endDate ? new Date(query.endDate) : undefined,
32+
timezone: query.timezone,
33+
source: query.source,
34+
limit: query.limit,
35+
cursor: query.cursor,
36+
}),
37+
useCase: listOrganizationUsageEvents,
38+
present: (result) => result,
39+
})

0 commit comments

Comments
 (0)