Skip to content

Commit 5e2f9cc

Browse files
myxamediyarclaude
andcommitted
feat(self-hosting): report on-prem usage to a Sim instance for valuation
Self-hosted deployments run without a meter, so Sim has no view of what they consume. This adds an opt-in reporting path: a cron job aggregates the existing usage ledger by UTC day and POSTs counts and sums to a receiving Sim instance, which stores the credits and values them at a per-deployment, effective-dated rate read through the admin API. The collector is a reader, not new instrumentation — `usage_log` and `workflow_execution_logs` are already written on every deployment. Both queries aggregate in SQL, so per-execution rows, identifiers, inputs and outputs never reach the reporting code. The only caller is the cron route, and a disabled or failing run returns before touching anything a workflow depends on. Credits are stored as the fact; dollars are derived at read time by joining each day to the rate whose effectiveFrom most recently precedes it, so a backdated rate re-values its days without mutating a stored credit, and days before the first rate report usd: null rather than being priced silently. Design notes and known limitations: apps/sim/lib/onprem-telemetry/README.md Operator docs: apps/docs/content/docs/platform/self-hosting/usage-telemetry.mdx Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e105e07 commit 5e2f9cc

33 files changed

Lines changed: 32240 additions & 0 deletions

File tree

‎apps/docs/content/docs/platform/self-hosting/environment-variables.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,18 @@ Your reverse proxy's body-size limit must be at least as large as the app limits
188188

189189
See [Observability](/platform/self-hosting/observability).
190190

191+
## Usage telemetry
192+
193+
Off unless all four required variables are set. See [Usage Telemetry](/platform/self-hosting/usage-telemetry).
194+
195+
| Variable | Description |
196+
|----------|-------------|
197+
| `ONPREM_TELEMETRY_ENABLED` | `true` to report daily usage to a Sim instance. Leave unset on airgapped deployments |
198+
| `ONPREM_TELEMETRY_ENDPOINT` | Base URL of the receiving instance |
199+
| `ONPREM_TELEMETRY_DEPLOYMENT_ID` | Deployment id issued by the receiving instance's admin API |
200+
| `ONPREM_TELEMETRY_API_KEY` | Deployment API key issued alongside the id |
201+
| `ONPREM_TELEMETRY_LOOKBACK_DAYS` | Trailing days each run re-sends. Defaults to `7`, max `90` |
202+
191203
## Knowledge Bases
192204

193205
| Variable | Description |

‎apps/docs/content/docs/platform/self-hosting/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"desktop",
2323
"---Operate---",
2424
"observability",
25+
"usage-telemetry",
2526
"scaling",
2627
"upgrades",
2728
"troubleshooting"
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
---
2+
title: Usage Telemetry
3+
description: Report daily workflow and credit usage from a self-hosted deployment to a Sim instance
4+
---
5+
6+
import { Callout } from 'fumadocs-ui/components/callout'
7+
8+
A self-hosted deployment can report a daily summary of its usage — workflow runs, credits, and model token volume — to a Sim instance, so that instance can see how the deployment is used over time and value that usage at a rate agreed for the deployment.
9+
10+
The feature is **off by default**. Nothing is collected or sent until the four variables below are set, and the workflow execution path never touches it: reporting runs from a background job that reads the deployment's own database after the fact.
11+
12+
## What is sent
13+
14+
One record per UTC calendar day, re-sent for a trailing window on every run so a day converges on its final figures once it has elapsed. Each record carries:
15+
16+
| Field | Source |
17+
|-------|--------|
18+
| Workflow executions, failures, total duration | Execution logs |
19+
| Credits | The usage ledger's dollar sum for the day × 200 |
20+
| Input and output tokens, per model | The usage ledger's token metadata |
21+
| Events and credits per usage source | The usage ledger (`workflow`, `knowledge-base`, `wand`, …) |
22+
23+
Nothing else crosses the wire: no workflow inputs or outputs, no credentials, no workflow or user identifiers, and no tool or block names. Aggregation happens inside the database query, so per-execution rows never reach the reporting code.
24+
25+
<Callout type="info">
26+
On a self-hosted deployment, models usually run on your own API keys, so their ledger rows carry **tokens with zero credits**. Credits on such a deployment mostly reflect the per-run execution charge (1 credit per workflow run); token volume is reported alongside so the receiving instance can see model usage that Sim never billed.
27+
</Callout>
28+
29+
## Enable reporting
30+
31+
Register the deployment on the receiving instance (see [Receiving reports](#receiving-reports) below), then set on the self-hosted deployment:
32+
33+
```bash
34+
ONPREM_TELEMETRY_ENABLED=true
35+
ONPREM_TELEMETRY_ENDPOINT=https://sim.example.com # the receiving instance
36+
ONPREM_TELEMETRY_DEPLOYMENT_ID=acme-prod # issued at registration
37+
ONPREM_TELEMETRY_API_KEY=simot_… # issued at registration, shown once
38+
```
39+
40+
Optional:
41+
42+
| Variable | Default | Description |
43+
|----------|---------|-------------|
44+
| `ONPREM_TELEMETRY_LOOKBACK_DAYS` | `7` | How many trailing days each run re-sends (max 90). Bounds how long the receiving instance can be unreachable before a day is missed. |
45+
46+
The report runs from the `onprem-usage-report` background job every six hours (Docker Compose: `docker/crontab`; Kubernetes: `cronjobs.jobs.onpremUsageReport`). To send immediately:
47+
48+
```bash
49+
curl -H "Authorization: Bearer $CRON_SECRET" https://your-deployment/api/cron/onprem-usage-report
50+
```
51+
52+
The response reports `delivered`, `failed` (with the reason), or `disabled`.
53+
54+
## Disable reporting
55+
56+
Unset `ONPREM_TELEMETRY_ENABLED` (or set it to `false`). The next job run exits before running any query or opening any connection, and stays that way until re-enabled. No restart is needed. This is the right setting for airgapped deployments; the job itself is harmless to leave scheduled.
57+
58+
A deployment that is enabled but cannot reach the endpoint keeps running workflows normally. The job logs the failure and re-sends the same window next time.
59+
60+
## Receiving reports
61+
62+
The receiving instance is any Sim deployment with the [admin API](/platform/self-hosting/environment-variables) enabled. Register a deployment and (optionally) its rate in one call:
63+
64+
```bash
65+
curl -X POST https://sim.example.com/api/v1/admin/onprem-telemetry/deployments \
66+
-H "x-admin-key: $ADMIN_API_KEY" -H "Content-Type: application/json" \
67+
-d '{"id": "acme-prod", "name": "Acme production", "usdPerCredit": 0.005}'
68+
```
69+
70+
The response includes `apiKey` exactly once; only its hash is stored.
71+
72+
### Conversion rates
73+
74+
Each deployment has its own append-only history of credit → dollar rates:
75+
76+
```bash
77+
# Set a new rate, effective now
78+
curl -X POST https://sim.example.com/api/v1/admin/onprem-telemetry/deployments/acme-prod/rates \
79+
-H "x-admin-key: $ADMIN_API_KEY" -H "Content-Type: application/json" \
80+
-d '{"usdPerCredit": 0.004}'
81+
82+
# Backdate a correction
83+
curl -X POST … -d '{"usdPerCredit": 0.0045, "effectiveFrom": "2026-09-01T00:00:00Z"}'
84+
```
85+
86+
Rates are never edited or deleted. Credits are the stored fact; dollars are computed when usage is read, from the rate whose `effectiveFrom` most recently precedes each day's start. That defines what a rate change does to history:
87+
88+
- A rate effective **now** changes the value of days from now on. Earlier days keep the rate that covered them.
89+
- A rate effective at an **earlier instant** re-values the days from that instant forward the next time they are read. No credit figure changes, and the rate table shows what applied when.
90+
- Days before a deployment's first rate have no dollar value; the usage response counts their credits as `unvaluedCredits` rather than pricing them silently.
91+
92+
### Reading usage
93+
94+
```bash
95+
curl "https://sim.example.com/api/v1/admin/onprem-telemetry/deployments/acme-prod/usage?from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z" \
96+
-H "x-admin-key: $ADMIN_API_KEY"
97+
```
98+
99+
Every row states its `credits`, the `rate` it was valued with (`usdPerCredit` and `effectiveFrom`), and the resulting `usd`, plus the per-source and per-model breakdown the deployment sent. Totals cover the range. `GET …/deployments` lists deployments with their current rate and last report time; `GET …/deployments/{id}/rates` shows the rate history.
100+
101+
## Limitations
102+
103+
- **Cooperative, not enforced.** The deployment controls its own database and can disable reporting; nothing here proves completeness. It is a usage picture, not a metering system.
104+
- **Chat usage is not included.** With billing disabled, cost callbacks from the Sim agent service are acknowledged without being recorded, so Chat model usage never reaches the ledger on a self-hosted deployment.
105+
- **The current day is partial** until the first run after UTC midnight; `reportedAt` on each row says when it was last sent.
106+
- **Ledger scan.** Each run aggregates the trailing window from `usage_log` by `created_at`. On a very large deployment consider adding an index on `usage_log (created_at)`.

‎apps/sim/.env.example‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,15 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
192192

193193
# Admin API (Optional - for self-hosted GitOps)
194194
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.
195+
196+
# On-prem usage telemetry (Optional). Reports daily workflow counts, credits and
197+
# token volume for this deployment to a Sim instance. Off unless all four are set;
198+
# leave unset on airgapped deployments. See docs: Self-Hosting → Usage telemetry.
199+
# ONPREM_TELEMETRY_ENABLED=true
200+
# ONPREM_TELEMETRY_ENDPOINT=https://sim.ai # Base URL of the receiving instance
201+
# ONPREM_TELEMETRY_DEPLOYMENT_ID= # Issued by the receiving instance's admin API
202+
# ONPREM_TELEMETRY_API_KEY= # Issued alongside the id; shown once
203+
# ONPREM_TELEMETRY_LOOKBACK_DAYS=7 # Trailing days re-sent each run (max 90)
195204
# Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces
196205

197206
# Enterprise Features (Optional - self-hosted). One switch enables organizations, SSO,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { createMockRequest } from '@sim/testing'
2+
import { authInternalMock, authInternalMockFns } from '@sim/testing/mocks/auth-internal.mock'
3+
import { createMockFetch } from '@sim/testing/mocks/fetch.mock'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { env } from '@/lib/core/config/env'
6+
7+
vi.mock('@/lib/auth/internal', () => authInternalMock)
8+
9+
import { GET } from '@/app/api/cron/onprem-usage-report/route'
10+
11+
describe('GET /api/cron/onprem-usage-report', () => {
12+
let fetchMock: ReturnType<typeof createMockFetch>
13+
14+
beforeEach(() => {
15+
env.ONPREM_TELEMETRY_ENABLED = undefined
16+
fetchMock = createMockFetch({ json: { accepted: 0 } })
17+
vi.stubGlobal('fetch', fetchMock)
18+
authInternalMockFns.mockVerifyCronAuth.mockReturnValue(null)
19+
})
20+
21+
afterEach(() => vi.unstubAllGlobals())
22+
23+
it('requires cron authentication', async () => {
24+
authInternalMockFns.mockVerifyCronAuth.mockReturnValueOnce(
25+
new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 })
26+
)
27+
const response = await GET(createMockRequest('GET'))
28+
expect(response.status).toBe(401)
29+
})
30+
31+
it('answers disabled without any outbound request when telemetry is off', async () => {
32+
const response = await GET(createMockRequest('GET'))
33+
expect(response.status).toBe(200)
34+
await expect(response.json()).resolves.toEqual({
35+
success: true,
36+
status: 'disabled',
37+
reason: 'ONPREM_TELEMETRY_ENABLED is not set',
38+
})
39+
expect(fetchMock).not.toHaveBeenCalled()
40+
})
41+
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { verifyCronAuth } from '@/lib/auth/internal'
4+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5+
import { runOnPremUsageReport } from '@/lib/onprem-telemetry/report'
6+
7+
const logger = createLogger('OnPremUsageReportCron')
8+
9+
export const dynamic = 'force-dynamic'
10+
11+
/**
12+
* Cron endpoint that reports this deployment's usage to the configured Sim
13+
* instance. Scheduled in helm/sim/values.yaml (`cronjobs.jobs.onpremUsageReport`)
14+
* and docker/crontab.
15+
*
16+
* The whole feature lives behind this endpoint: it is the only caller of the
17+
* reporter, and nothing on the workflow execution path imports it. When
18+
* `ONPREM_TELEMETRY_ENABLED` is unset the reporter returns before touching the
19+
* database or the network. Re-reports are idempotent on the receiver, so no
20+
* lock is needed to keep overlapping runs or multiple replicas correct.
21+
*/
22+
export const GET = withRouteHandler(async (request: NextRequest) => {
23+
const authError = verifyCronAuth(request, 'On-prem usage report')
24+
if (authError) return authError
25+
26+
const result = await runOnPremUsageReport()
27+
28+
if (result.status === 'failed') {
29+
logger.warn('On-prem usage report run failed', result)
30+
return NextResponse.json({ success: false, ...result }, { status: 502 })
31+
}
32+
33+
return NextResponse.json({ success: true, ...result })
34+
})
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { sha256Hex } from '@sim/security/hash'
2+
import {
3+
createMockRequest,
4+
dbChainMockFns,
5+
queueTableRows,
6+
resetDbChainMock,
7+
schemaMock,
8+
} from '@sim/testing'
9+
import { beforeEach, describe, expect, it } from 'vitest'
10+
import { POST } from '@/app/api/onprem-telemetry/report/route'
11+
12+
const API_KEY = 'simot_test_key'
13+
const URL = 'http://localhost:3000/api/onprem-telemetry/report'
14+
15+
const bucket = {
16+
periodStart: '2026-09-26T00:00:00.000Z',
17+
periodEnd: '2026-09-27T00:00:00.000Z',
18+
workflowExecutions: 4,
19+
workflowExecutionsFailed: 1,
20+
workflowDurationMs: 1000,
21+
credits: 4,
22+
inputTokens: 10,
23+
outputTokens: 5,
24+
sources: [{ source: 'workflow', category: 'fixed', events: 4, credits: 4 }],
25+
models: [],
26+
}
27+
28+
function report(body: unknown, headers: Record<string, string> = {}) {
29+
return createMockRequest('POST', body, { Authorization: `Bearer ${API_KEY}`, ...headers }, URL)
30+
}
31+
32+
function validBody(overrides: Record<string, unknown> = {}) {
33+
return {
34+
schemaVersion: 1,
35+
deploymentId: 'acme-prod',
36+
reportedAt: '2026-09-27T06:15:00.000Z',
37+
buckets: [bucket],
38+
...overrides,
39+
}
40+
}
41+
42+
describe('POST /api/onprem-telemetry/report', () => {
43+
beforeEach(() => resetDbChainMock())
44+
45+
it('rejects a request without a bearer token', async () => {
46+
const response = await POST(createMockRequest('POST', validBody(), {}, URL))
47+
expect(response.status).toBe(401)
48+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
49+
})
50+
51+
it('rejects an unknown key', async () => {
52+
const response = await POST(report(validBody()))
53+
expect(response.status).toBe(401)
54+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
55+
})
56+
57+
it('looks the deployment up by the hash of the key, never the key', async () => {
58+
queueTableRows(schemaMock.onpremDeployment, [{ id: 'acme-prod' }])
59+
await POST(report(validBody()))
60+
const boundValues = dbChainMockFns.where.mock.calls.flat().map((c) => JSON.stringify(c))
61+
expect(boundValues.join()).toContain(sha256Hex(API_KEY))
62+
expect(boundValues.join()).not.toContain(API_KEY)
63+
})
64+
65+
it('refuses a body claiming a different deployment', async () => {
66+
queueTableRows(schemaMock.onpremDeployment, [{ id: 'acme-prod' }])
67+
const response = await POST(report(validBody({ deploymentId: 'someone-else' })))
68+
expect(response.status).toBe(403)
69+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
70+
})
71+
72+
it('rejects a payload that does not match the contract', async () => {
73+
queueTableRows(schemaMock.onpremDeployment, [{ id: 'acme-prod' }])
74+
const response = await POST(report(validBody({ schemaVersion: 2 })))
75+
expect(response.status).toBe(400)
76+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
77+
})
78+
79+
it('upserts one row per day and reports how many were accepted', async () => {
80+
queueTableRows(schemaMock.onpremDeployment, [{ id: 'acme-prod' }])
81+
const duplicateDay = { ...bucket, workflowExecutions: 9 }
82+
const response = await POST(report(validBody({ buckets: [bucket, duplicateDay] })))
83+
84+
expect(response.status).toBe(200)
85+
await expect(response.json()).resolves.toEqual({ accepted: 1 })
86+
const [rows] = dbChainMockFns.values.mock.calls[0] as [Array<Record<string, unknown>>]
87+
expect(rows).toHaveLength(1)
88+
expect(rows[0]).toMatchObject({
89+
deploymentId: 'acme-prod',
90+
periodStart: new Date(bucket.periodStart),
91+
workflowExecutions: 9,
92+
credits: '4',
93+
breakdown: { sources: bucket.sources, models: [] },
94+
schemaVersion: 1,
95+
})
96+
})
97+
})

0 commit comments

Comments
 (0)