This document describes the architecture (orgs, projects, keys, metering) and how it fits future dashboard auth. Numbers for tiers and caps are product assumptions, not fixed truth — iterate after you have real usage (sessions vs events cost, retention, which limits actually bind).
| Keep stable | Treat as temporary |
|---|---|
| Scoping telemetry to projects; ingest credentials per project | Tier names (FREE / PRO / …) |
| Organization as billing owner; soft-delete for audit/history | Unit caps, RPS, max projects, max keys |
| API keys as labeled, revocable objects with lifecycle fields | “Apps per project” as a limit dimension (may be wrong abstraction — see below) |
| Monthly usage meters (until you pivot to retention-weighted pricing) | Exact ingest unit definition (you might split events vs errors vs sessions) |
Do not let early billing sketches constrain the product: the schema is meant to stay useful if you later emphasize retention, error volume, or seat-based billing instead of raw units.
| Table | Purpose |
|---|---|
| Organization | Billing owner. plan_tier, optional Stripe ids, deleted_at (soft-delete). |
| Project | Telemetry namespace; deleted_at (soft-delete). slug unique per org. |
| ApiKey | Ingest credential and manageable object — see API key lifecycle below. |
| UsageMonthly | Per-project, UTC month: year_month, ingest_units. |
| Event, Session, ErrorGroup, ErrorOccurrence | All scoped with project_id. |
Ingest unit (current implementation): one per accepted POST /event, each POST /batch item, POST /session, POST /error. You may later split or weight types without changing the org/project/key shape.
Ingest units are a billing abstraction and may not directly reflect infrastructure cost, which can depend on payload size, retention, and query patterns.
“App” in payloads: today app is a logical label inside event/session/error payloads (e.g. web, server, iOS). It may evolve into a first-class entity or disappear as a billing dimension — the provisional “max apps per project” in plans.ts should not be treated as a permanent product constraint.
Keys are user-visible objects: product direction is to manage them in the UI (create, label, rotate, revoke) rather than treating them as opaque strings.
Each key is a row you can show in a UI, not only a secret string.
| Field | Role |
|---|---|
name |
Optional label (e.g. “Production”, “CI”). |
public_id + secret_hash |
Lookup and verification (tt_live_<publicId>_<secret>). |
created_at |
When the key was issued. |
last_used_at |
Updated on successful ingest auth (best-effort) so you can show “still in use”. |
revoked_at |
Explicit invalidation (rotation, security). Ingest rejects if set. |
expires_at |
Optional; if set and in the past, ingest rejects. Null means no expiry. |
deleted_at |
Soft-delete: hidden from normal UI, ingest rejects; keeps history. |
Derived states (for UI):
| State | Typical condition |
|---|---|
| Active | deleted_at and revoked_at null, and (expires_at null or future), project and org not soft-deleted |
| Revoked | revoked_at set |
| Expired | expires_at ≤ now |
| Deleted (archived) | deleted_at set |
Ingest validation order (conceptually): resolve row by public_id → reject if revoked, deleted, expired, or parent project/org soft-deleted → verify secret → accept.
Prefer deleted_at over hard DELETE for anything tied to telemetry history, audit, or billing.
- Organization: set
deleted_atwhen the org is closed; keep rows for reconciliation and support. - Project: set
deleted_atwhen a project is removed; events/errors remain attributable to historical IDs if needed. - ApiKey: use
revoked_atfor “this secret must stop working now”; usedeleted_atto archive the key record while keeping metadata.
Application code should treat deleted_at IS NULL as “active” for listings and for allowing ingest (together with key validity checks above).
Key shape: tt_live_<publicId>_<secret>
The tt_live_ prefix marks production credentials. Later you might add other prefixes (e.g. tt_test_ for sandbox-only keys) without changing the public_id / secret layout — not implemented yet, but the naming leaves room for it.
publicId: 32 lowercase hex characters.secret: hex string (no underscores); server stores onlyhash(publicId + ":" + secret)(SHA-256 hex).
| Header | Value |
|---|---|
Authorization |
Bearer tt_live_<publicId>_<secret> |
X-API-Key |
tt_live_<publicId>_<secret> |
Development: INGEST_ALLOW_UNAUTHENTICATED=true + TELEMETRY_PROJECT_ID — not for production.
The ingest model remains org → project → API key. Human login is separate: User + OrganizationMembership with an OrgRole.
| Concept | Role |
|---|---|
| User | Identity (email + password in the reference implementation) — not the same as an API key. |
| OrganizationMembership | (user_id, organization_id) with OWNER, EDITOR, or VIEWER. |
| Ingest | Still project API keys only. |
Onboarding: POST /api/auth/register without inviteToken creates a user with no OrganizationMembership. They create an organization via POST /api/meta/organizations (dashboard: Organization settings), then add projects and API keys. With inviteToken, registration adds a single membership on the invited organization at the invite role. Detailed permission matrix (who can resolve errors, create/revoke keys, etc.): see RBAC.md.
Retention is implemented in code: each tier has retentionDays in apps/api/src/config/plans.ts. A scheduled job deletes telemetry older than that window per project (see apps/api/src/jobs/run-retention.ts, pnpm --filter api retention). Operationally, you must run that job (e.g. nightly cron) in each environment.
Longer term, retention may still become a primary cost driver and you might tune days per tier or combine with ingest-unit caps as you learn query and storage patterns.
| Area | Status |
|---|---|
Orgs, projects, API keys, soft-delete, ingest auth, UsageMonthly metering |
Implemented — matches sections above. |
PLAN_LIMITS in apps/api/src/config/plans.ts |
Enforced — monthly ingest units (429 when over), max distinct app labels per project, max projects per org, max API keys per project. Per-tier ingest RPS in the table is not a separate token-bucket yet (global IP rate limits still apply on /ingest). |
| Retention by tier | Implemented — retentionDays per tier in plans.ts; run apps/api/src/jobs/run-retention.ts on a schedule (pnpm --filter api retention). |
Stripe → plan_tier + subscription state |
Webhooks (when STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET are set): checkout.session.completed (tier from session metadata; loads subscription for stripe_subscription_status + period end when possible), customer.subscription.updated (authoritative: status, period end, plan_tier from subscription or first price metadata), customer.subscription.deleted (FREE + clears Stripe fields). invoice.payment_failed is not used to write status (it can fire while the subscription is still active). Org rows store stripe_subscription_status and stripe_current_period_end. Effective limits: canceled, unpaid, incomplete, and incomplete_expired enforce FREE caps; past_due keeps paid limits (Stripe dunning) but the dashboard shows a billing banner. |
| Dashboard billing + usage | Banner when Stripe status is past_due, unpaid, canceled, incomplete, or incomplete_expired (billingHealth on /api/meta/session-context). Near-quota banner from usageQuota (≥90% monthly ingest). Sonner toast once per browser session with general Stripe disclaimer. |
For a broader “what’s still missing for a mature SaaS” checklist (Stripe events, ops, compliance, testing), see PRODUCTION-READINESS.md.
Source: PLAN_LIMITS in apps/api/src/config/plans.ts. Replace these after a few weeks of metrics — they are placeholders.
| Tier | Monthly ingest units | Max ingest RPS | Max apps / project | Max projects / org | Max API keys / project |
|---|---|---|---|---|---|
| FREE | 250,000 | 20 | 5 | 1 | 2 |
| PRO | 5,000,000 | 100 | 50 | 10 | 10 |
| BUSINESS | 50,000,000 | 500 | 500 | 50 | 50 |
There is no universal number; derive a bound from your infra and usage.
Estimate incremental monthly cost per unit (or per 1M units): DB writes/storage, egress, compute. Use bills ÷ units once you have traffic.
Fixed (F): base DB, minimum replicas, domains, tooling.
Let (S) = max monthly spend you accept on free users. With (N_f) free projects and (U_f) units per project per month:
[ \text{cost}_\text{free} \approx N_f \times U_f \times c_u + F_f ]
Keep (\text{cost}_\text{free} \leq S). Solve for caps conservatively; revisit when retention or event mix matters more than raw counts.
Log usage by project, margin, and which limits actually hit. Adjust provisional caps in plans.ts — not the org/project/key architecture.