Developer docs
01 // Overview
Overview
ve-track is cost attribution for AI shaped apps. One install, one wrapper line, and every provider fetch your app makes is priced and attributed to your app, your Clerk org, your end user, and the action they ran. It shows up on your dashboard within seconds.
The fastest path is the Quickstart. Already integrated and curious what changed? Pricing and version history live in the CHANGELOG.
02 // Quickstart
Quickstart
Four steps, about a minute. No infra to spin up.
1. Install
Add the package to any Cloudflare Worker, Node, Bun, or Deno project.
bun add github:Handbook-Enterprises/ve-track
# or: npm install github:Handbook-Enterprises/ve-track2. Issue a key
Open the Keys page, click New key, and copy the vt_live_… value.
3. Add two env vars
Put these in your worker's .dev.vars, and as secrets in production.
VE_TRACK_KEY=vt_live_xxxxxxxxxxxxxxxxxxxxxxxx
VE_TRACK_BASE_URL=https://track.viewengine.ai4. Wrap your handler
import { trackHandler } from "@viewengine/track";
import app from "./api";
export default trackHandler<Env>(
{ app: "my-app" },
{
fetch: (req, env, ctx) => app.fetch(req, env, ctx),
},
);That is it. Every external provider fetch is now intercepted, priced, attributed to the signed in Clerk user and org, and shipped to your dashboard. No Clerk app? Pass resolveUser: "none" to skip user attribution.
03 // Worker shapes
Worker shapes
trackHandler wraps any worker entry point. Pick the shape that matches yours.
Plain HTTP
export default trackHandler<Env>(
{ app: "my-app" },
{ fetch: (req, env, ctx) => app.fetch(req, env, ctx) },
);Queue
Wrap each message with trackMessage. The producer stamps auth and action on the message body, and ve-track reads them automatically.
export default trackHandler<Env>(
{ app: "my-app" },
{
queue: async (batch, env, ctx) => {
await Promise.all(
batch.messages.map((message) =>
trackMessage(message, async () => {
await processOne(message.body, env);
}),
),
);
},
},
);
// producer side:
await env.MY_QUEUE.send({
...payload,
auth: { userId, orgId },
action: "rank-refresh",
});Scheduled and email
Cron ticks default to action: "scheduled" and email triggers to action: "email". Wrap with trackAction to give them a real name.
scheduled: async (controller, env, ctx) => {
await trackAction("nightly-rebuild", async () => {
await rebuildEverything(env);
});
},04 // Tagging actions
Tagging actions
Tagging lets the dashboard tell you what each kind of run costs, for exampleai-search · $0.014 avg/run. That is the number you base credit prices on. Three ways, by precedence.
Per queue message
env.MY_QUEUE.send({ ...payload, action: "ai-search" });Per HTTP block
await trackAction("ai-search", async () => {
await openai.chat.completions.create(...);
});Worker shape default
Untagged work falls back to queue, scheduled, or email based on the entry point.
05 // Manual events
Manual events
Hit a provider the lib does not recognize, or already know the cost? Call trackUsage from inside any tracked scope. It inherits the scope's app, user, org, and action. Outside a scope it is a silent no op, so it is safe to leave in.
import { trackUsage } from "@viewengine/track";
const res = await fetch("https://api.some-provider.com/run", { ... });
const body = await res.json();
trackUsage({
provider: "some-provider",
costUsd: body.cost,
model: body.model,
promptTokens: body.usage?.input,
completionTokens: body.usage?.output,
statusCode: res.status,
});Credits
Billing your users in credits through Autumn or another credit system? Report every deduction with trackCredits and the dashboard breaks credit usage down by app, action, user, org, provider, and model, right next to cost. Call it after your billing call succeeds. Like trackUsage, it inherits the scope's app, user, org, and action, and it is a silent no op outside a scope.
import { trackCredits } from "@viewengine/track";
const result = await autumn.track({
customerId: user.id,
featureId: "ai_search",
value: 3,
});
trackCredits({
credits: 3,
action: "ai_search",
});To attach credits to the same row as the provider call that caused them, pass creditsCharged (and optionally creditPriceUsd) to trackUsage instead. That keeps the deduction on the usage event and powers the profitability view.
Charging the same credit price across all your apps? Set a default once in Settings → Credits and skip creditPriceUsd everywhere. Credit events that arrive without a price pick up the default at ingest. Pass creditPriceUsd only in an app with its own unique pricing, since an explicit value always wins over the default.
06 // Configuration
Configuration
trackHandler(config, handler) accepts:
| Field | Default | Notes |
|---|---|---|
app | required | Stable slug shown on the dashboard, like ve-rank. |
apiKey | env.VE_TRACK_KEY | Override if your secret is named differently. |
baseUrl | track.viewengine.ai | Point at staging or a self hosted instance. |
resolveUser | "clerk" | Reads the Clerk session. Pass a custom resolver, or "none" to disable. |
maxExtractBytes | unlimited | Skip usage extraction for responses whose Content Length exceeds this many bytes. The event still records latency and status. |
Resolution is lazy. The resolver runs at most once per request, and only when the request actually records a tracked event: a matched provider fetch, trackUsage, or trackCredits. Requests that never touch a provider pay zero resolution cost. If your app already verifies auth in middleware, pass a custom resolveUser that reads your existing auth context instead of verifying the token a second time.
Custom resolver for non Clerk auth:
trackHandler<Env>(
{
app: "my-app",
resolveUser: async (req, env) => ({
userId: parseSession(req)?.userId ?? null,
orgId: parseSession(req)?.tenantId ?? null,
}),
},
{ fetch: ... },
);07 // Providers
Providers
These domains are auto detected and priced for you, no config. Token based LLMs are priced server side from a live catalog; the rest use the cost the provider reports.
OpenAI
api.openai.com
Anthropic
api.anthropic.com
Gemini
generativelanguage.googleapis.com
OpenRouter
openrouter.ai/api
Perplexity
api.perplexity.ai
Cloro
api.cloro.dev
Fal
fal.run
Zyte
api.zyte.com
DataForSEO
api.dataforseo.com
Apify
api.apify.com
Firecrawl
api.firecrawl.dev
BrightData
brightdata.com
Add one with a single entry in src/providers.ts: match the URL, optionally enhance the request, and extract the cost and tokens from the response.
08 // Users and orgs
Users and orgs
With the default resolveUser: "clerk", ve-track attributes each event to the signed in Clerk user and org. If any step fails the request still runs, the event just is not user attributed. Which credentials it reads depends on the env vars you expose.
| Env vars present | Behavior |
|---|---|
CLERK_SECRET_KEY only | Verifies the Authorization: Bearer header. Cookie authenticated page requests are not attributed. |
+ CLERK_PUBLISHABLE_KEY | Full session resolution via Clerk's authenticateRequest, covering both bearer tokens and session cookies. Use this for SSR apps where page loads authenticate via cookies. |
+ CLERK_JWT_KEY | Verification becomes a local signature check against the PEM public key, no network round trip to Clerk even on cold isolates. Recommended for latency sensitive Workers. |
For queue messages, the producer stamps body.auth = { userId, orgId } on the message and trackMessage picks it up. The dashboard resolves IDs to names server side, so you see real people and orgs instead of raw IDs.