feat(self-hosting): report on-prem usage to a Sim instance for valuation - #8349
myxamediyar wants to merge 1 commit into
Conversation
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>
|
@myxamediyar is attempting to deploy a commit to the Sim Team on Vercel. A member of the Team first needs to authorize it. |
|
| id: generateId(), | ||
| deploymentId: id, | ||
| usdPerCredit: usdPerCredit.toString(), | ||
| effectiveFrom: now, |
There was a problem hiding this comment.
First day goes unvalued When a deployment is registered with an initial rate after UTC midnight, that rate takes effect at the current instant, but usage is valued at the start of each day. The first day's usage therefore has no applicable rate and is reported as unvalued, even though the deployment shows a current rate. Make the initial rate cover that day, or require the operator to choose when valuation begins.
| await db | ||
| .insert(onpremUsageReport) | ||
| .values(rows) | ||
| .onConflictDoUpdate({ | ||
| target: [onpremUsageReport.deploymentId, onpremUsageReport.periodStart], | ||
| set: { | ||
| periodEnd: sql`excluded.period_end`, | ||
| workflowExecutions: sql`excluded.workflow_executions`, | ||
| workflowExecutionsFailed: sql`excluded.workflow_executions_failed`, | ||
| workflowDurationMs: sql`excluded.workflow_duration_ms`, | ||
| credits: sql`excluded.credits`, | ||
| inputTokens: sql`excluded.input_tokens`, | ||
| outputTokens: sql`excluded.output_tokens`, | ||
| breakdown: sql`excluded.breakdown`, | ||
| schemaVersion: sql`excluded.schema_version`, | ||
| reportedAt: sql`excluded.reported_at`, | ||
| receivedAt: sql`now()`, | ||
| }, | ||
| }) |
There was a problem hiding this comment.
Older reports overwrite newer totals If overlapping reports arrive out of order, this upsert replaces every figure without checking when the report was made. An older partial snapshot can overwrite a newer day's totals; after that day leaves the lookback window, the understated figures persist. Reject stale updates or enforce report ordering.
| const [row] = await db | ||
| .insert(onpremDeployment) | ||
| .values({ id, name, apiKeyHash: sha256Hex(apiKey), createdAt: now, updatedAt: now }) | ||
| .returning() | ||
| if (usdPerCredit !== undefined) { | ||
| await db.insert(onpremDeploymentRate).values({ | ||
| id: generateId(), | ||
| deploymentId: id, | ||
| usdPerCredit: usdPerCredit.toString(), | ||
| effectiveFrom: now, | ||
| createdAt: now, | ||
| }) |
There was a problem hiding this comment.
Failed registration strands deployment When an initial rate is requested, the deployment is inserted before the rate. If the rate insert fails, registration returns an error but leaves the deployment stored without ever returning its plaintext API key. Retrying the requested ID then conflicts. Create both records atomically.
| }) | ||
| expect(fetchMock).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Tests assert mock calls This test checks whether
fetchMock was called, rather than checking an observable outcome. The repository's testing directive says never to write tests that assert mock calls; the same pattern appears in the receiver and reporter tests. Replace these assertions with boundary-level checks before merging to satisfy that requirement.
Context Used: CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| const [reports, rates, [presented]] = await Promise.all([ | ||
| db | ||
| .select() | ||
| .from(onpremUsageReport) | ||
| .where( | ||
| and( | ||
| eq(onpremUsageReport.deploymentId, id), | ||
| gte(onpremUsageReport.periodStart, from), | ||
| lt(onpremUsageReport.periodStart, to) | ||
| ) | ||
| ) | ||
| .orderBy(asc(onpremUsageReport.periodStart)), | ||
| loadRates([id]).then((byId) => byId.get(id) ?? []), | ||
| presentDeployments([deployment], now), |
There was a problem hiding this comment.
Summary
Self-hosted deployments run without a meter, so Sim has no view of what they consume. This adds an opt-in reporting path, off by default:
Both halves live in this codebase: a self-hosted deployment is the sender, whichever Sim instance owns the admin API is the receiver.
Design notes
The collector reads; it does not instrument.
recordUsagealready writesusage_logon every deployment regardless ofBILLING_ENABLED— its own comment calls the ledger the universal source of truth for cost including self-hosted. Workflow counts, status and duration are likewise already inworkflow_execution_logs. Nothing about how a workflow runs changed.Aggregated in SQL. Both queries
GROUP BYday and return counts and sums, so per-execution rows, identifiers, inputs, outputs and tool names never reach the reporting code — the privacy property holds by construction, not by a filter someone must maintain.descriptionis kept only where it names a model.Separate from
lib/core/telemetry.tson purpose. That pipeline is fire-and-forget by design (trackPlatformEventswallows errors;/api/telemetryforwards with a 5s abort and no retry). Right for product analytics, wrong for figures with a dollar value. This re-sends a trailing window every run and the receiver upserts, so a day is delivered at least once and converges without an outbox or local state.Off the execution path, structurally. The cron endpoint is the reporter's only caller. Disabled means
getOnPremTelemetryConfig()returns before any query orfetch— the first statement of the run, covered by a test assertingfetchis never called. A failing receiver yields afailedresult and a log line.Credits are the fact; dollars are derived. The receiver stores credits per day plus an append-only, effective-dated rate table.
valueUsagejoins each day to the rate whoseeffectiveFrommost recently precedesperiodStart. A backdated rate re-values the days it now covers on the next read; no stored credit ever changes; days before the first rate reportusd: nulland sum intounvaluedCreditsrather than being priced silently. Rates live on the receiver, so they are commercial terms the customer cannot edit and changing one needs no redeploy.Minimal surface. Three tables, one schema-versioned wire contract shared by sender and receiver, one cron route, one ingest route, three admin routes. No new pool profile, no Redis lock (upserts make overlapping runs safe), no outbox (re-sending the window is the retry).
Full rationale and known limitations:
apps/sim/lib/onprem-telemetry/README.md. Operator documentation:apps/docs/content/docs/platform/self-hosting/usage-telemetry.mdx.Known limitations
POST /api/billing/update-costskips the cost update whenBILLING_ENABLEDis unset, so the four Sim-Chat-family sources read as zero. Recording those callbacks unconditionally is the natural follow-up.usage_loghas no index led bycreated_at; the window scan is sequential. The job runs every six hours to keep that cheap. A concurrent index onusage_log (created_at)is the fix if needed — not added here to avoid triggering an index build on Sim Cloud's ledger as a side effect.reportedAtrecords when it was last sent.Type of Change
Testing
bunx vitest run lib/onprem-telemetry app/api/onprem-telemetry app/api/cron/onprem-usage-report— 5 files, 26 tests passing.Unit coverage: day bucketing and the aggregate shape (
collect.test.ts), the disabled-path assertion thatfetchis never called plus failure handling (report.test.ts), rate selection including backdating and the pre-first-ratenullcase (rates.test.ts), and both routes.onprem-telemetry.integration.tsexercises sender → receiver → admin read end to end against a database.Reviewers may want to focus on
rates.ts(the effective-dating rule) and on the claim that the two queries incollect.tscannot leak per-row data.Checklist
🤖 Generated with Claude Code