Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.voight.xyz/llms.txt

Use this file to discover all available pages before exploring further.

If you sell to other developers shipping an AI product (Auth0 of LLMs, multi-tenant SaaS, B2B copilots), the question your customers ask first is:
“What’s the cost per end-user this month?”
Per-user spend is the answer. One line of code at your request boundary tags every LLM call with the user it belongs to. The dashboard’s Users sub-tab then aggregates spend, traces, tokens, and tool use per user — without you handing Voight any PII you don’t already own.

The 1-line pattern

Wrap your provider client once (the OpenAI or Anthropic SDK), then at your request boundary call withTrace and pass a tags.userId:
import { withTrace } from '@voightxyz/openai'   // or '@voightxyz/anthropic'

app.post('/api/chat', async (req, res) => {
  await withTrace(
    async () => {
      // your existing handler — every LLM call inside this block
      // is automatically tagged with userId, plan, org, etc.
      const reply = await openai.chat.completions.create({ ... })
      res.json({ reply })
    },
    {
      routeTag: 'POST /api/chat',
      tags: {
        userId: req.user.id,              // ← the one line
        plan: req.user.plan,
        org: req.user.organizationId,
      },
    },
  )
})
That’s it. The wrapper stamps metadata.tags = { userId, plan, org } on every event inside the withTrace block via AsyncLocalStorage — no manual passing through call sites, no middleware to wire.

Why this design

We considered three alternatives and rejected them:
PatternWhy we didn’tWhy explicit tagging won
Auto-detect user from request headersFragile (every auth system different), and means Voight reads your auth contextYou stay in control of what’s tagged
Require a “Voight user object” via dashboardForces a sync flow, breaks for SSO/SCIM appsZero coupling to user-management
Capture client IP / fingerprintGDPR landmine, useless for B2B SaaSTags are your IDs, not ours
You decide what userId is — a database ID, an Auth0 sub, a Clerk user.id, a hashed email, anything. Voight stores it as a tag string. We never see the underlying user record.

Examples for common auth systems

import { withTrace } from '@voightxyz/openai'
import { auth } from 'express-oauth2-jwt-bearer'

app.use(auth({ audience: 'api', issuerBaseURL: '...' }))

app.post('/api/chat', async (req, res) => {
  await withTrace(
    async () => { /* your handler */ },
    {
      routeTag: 'POST /api/chat',
      tags: {
        userId: req.auth.payload.sub,                  // "auth0|abc123"
        org: req.auth.payload['https://app/org_id'],
      },
    },
  )
})
import { withTrace } from '@voightxyz/openai'
import { auth } from '@clerk/nextjs/server'

export async function POST(req: Request) {
  const { userId, orgId } = await auth()
  return withTrace(
    async () => { /* your handler */ },
    {
      routeTag: 'POST /api/chat',
      tags: { userId, org: orgId, plan: req.headers.get('x-plan') },
    },
  )
}
import { withTrace } from '@voightxyz/openai'
import { auth } from '@/auth'

export async function POST(req: Request) {
  const session = await auth()
  return withTrace(
    async () => { /* your handler */ },
    {
      routeTag: 'POST /api/chat',
      tags: {
        userId: session?.user?.id ?? 'anonymous',
        plan: session?.user?.plan ?? 'free',
      },
    },
  )
}
import { withTrace } from '@voightxyz/openai'
import jwt from 'jsonwebtoken'

app.post('/api/chat', async (req, res) => {
  const token = req.headers.authorization?.replace('Bearer ', '')
  const claims = jwt.verify(token, process.env.JWT_SECRET) as any

  await withTrace(
    async () => { /* your handler */ },
    {
      routeTag: 'POST /api/chat',
      tags: { userId: claims.sub, plan: claims.plan },
    },
  )
})
// Public marketing assistant — no user, but you still want
// per-session attribution and abuse signals.
await withTrace(
  async () => { /* handler */ },
  {
    routeTag: 'POST /api/public-chat',
    tags: {
      userId: `anon_${req.cookies.session_id}`,    // stable per browser
      source: 'marketing-site',
    },
  },
)
The Users sub-tab will list anonymous users alongside authenticated ones, prefixed by your chosen tag value. You decide the convention.

What you see in the dashboard

Open the Users sub-tab under AI Apps. You get one row per unique userId value, sorted by spend (descending by default):
ColumnAggregated from
Usermetadata.tags.userId — your tag value, shown verbatim
Spend (USD)Sum of cost per event for that user in the time window
TracesNumber of withTrace blocks that user triggered
TokensInput + output + cache reads + cache creations
Last seenMAX(timestamp) for any event tagged with that user
Top modelThe model that user spent the most on
Click any row → drilldown to that user’s traces (every withTrace block they triggered), then into individual events (every LLM call, with prompt, response, tokens, latency). The User filter pill in the top bar lets you narrow the entire AI Apps section (Overview, Traces, Models, Tools) to one user — same way the Provider and Agent pills work.

Empty state — why don’t I see any users?

If you’ve wired the wrapper but the Users tab shows zero users, it’s almost always one of these:
  1. You’re not calling withTrace — the wrapper captures everything, but tags only flow into events that ran inside a withTrace block. Calls made outside it have no user attribution. Wrap your request boundary.
  2. You’re not passing tags.userIdwithTrace accepts an optional tags map. Without userId, the event has no user to attribute to.
  3. Time window — the Users tab respects the dashboard’s window selector (default 28 days). Switch to 7d or 1d if you only deployed today.
  4. Privacy: minimal — Minimal mode strips prompts and responses but keeps tokens, costs, and tags. Users tab works in Minimal. If you set up something stricter (custom redaction layer), confirm your tags aren’t being filtered out.
A typical fix is one line of code (the tags.userId field on withTrace). After your next request the user appears in the dashboard within ~2 seconds.

Multiple tags, multiple dimensions

tags is a flat string-to-string map. You can attribute by any dimension you care about:
tags: {
  userId: req.user.id,
  plan: req.user.plan,            // 'free' / 'pro' / 'enterprise'
  org: req.user.organizationId,   // multi-tenant
  feature: 'summarize',           // which feature triggered this LLM call
  env: process.env.NODE_ENV,      // 'production' vs 'staging'
  region: process.env.FLY_REGION,
}
The dashboard surfaces userId as the primary user dimension (drives the Users sub-tab). The other tags are queryable via the API — GET /v1/me/ai-apps/overview?tag.plan=pro returns metrics scoped to Pro users only. Future dashboard releases will surface tag-based segmentation natively (cost-per-plan, cost-per-feature).

FAQ

Tag values are stored verbatim as strings. Choose what to send accordingly.
  • ✅ Database IDs, Auth0 sub, Clerk IDs — these are pseudonymous identifiers, not personal data on their own
  • ✅ Hashed emails (SHA-256), opaque session IDs
  • ⚠️ Raw email addresses, names, IPs — personal data; only send if your DPA covers it
  • ⚠️ Anonymous user fingerprints — may be personal data depending on jurisdiction
A user-deletion request maps to deleting all events tagged with that user. The Data handling page covers retention and deletion in detail.
Yes — that’s the primary use case. Tag every request with userId (the end-user inside your customer’s tenant) and org (the customer tenant). Then:
  • Filter the dashboard by org=acme-corp to see one tenant’s traffic
  • Drill into individual users inside that tenant
  • Export per-tenant cost reports via GET /v1/me/ai-apps/users?tag.org=acme-corp
Each tenant gets a Voight account if they want their own dashboard, but you can also operate a single Voight account for all customers and surface per-tenant views internally.
If your app is public (a marketing site assistant, a free demo), still call withTrace — just use a stable browser-scoped identifier for userId:
tags: { userId: `anon_${cookies.sessionId}` }
This gives you abuse signals (one anon hammering 1000 calls/hour) and per-session cost visibility without auth.
No. withTrace uses Node’s AsyncLocalStorage to maintain the tag scope — no synchronous overhead, no extra network calls. Tags are appended to the event payload that the wrapper was going to send anyway. The captured numbers are identical to what you’d see without tags.
No — withTrace runs server-side (where the wrapped OpenAI / Anthropic client lives). The tag value is set once at the request boundary in your backend and propagates to every LLM call that handler triggers.If you have a serverless setup (Next.js Route Handlers, Vercel Functions, AWS Lambda), withTrace still works — AsyncLocalStorage is part of Node’s runtime, not tied to long-lived processes.
Two paths:
  1. You operate one Voight account, tag by org, and expose internal per-tenant views to your customers via your own admin UI (queryable via /v1/me/ai-apps/*).
  2. Each customer has their own Voight account with their own API key. You run two wrappers in parallel (yours for ops; theirs for their customer-facing dashboard).
Most customers start at (1) and graduate to (2) when a single enterprise tenant needs SOC 2 access logs of their own.

Wiring it end-to-end

If you haven’t installed the wrapper yet, the order is:
  1. Install the wrapper for your provider — OpenAI or Anthropic
  2. Wrap your client at module load time (one place in your codebase)
  3. Call withTrace at your request boundary with tags.userId
  4. Open the dashboard → AI Apps → Users sub-tab
  5. Profit (literally — you now know which users to upsell, throttle, or upgrade)
The AI Apps overview walks through the full dashboard surface — Overview / Traces / Models / Tools / Users — and how the tags you set here drive every panel.

Next