versionless

Telemetry & the Dashboard

Opt-in via API key — no key, no network calls; with a key, adoption curves, sunset blockers, and per-endpoint drift light up.

Opt-in by design

The SDK is fully functional with zero telemetry. Nothing is collected, nothing is sent, there is no nag — without a key or a sink, versionless makes zero network calls. Two ways to turn insight on:

// 1. Local/dev: a console sink
import { consoleSink } from "@versionless/core";
v.telemetry.use(consoleSink());
// [versionless] GET GET /users/:* v=2025-06-01 key=key_abc transforms=2 status=200 3ms

// 2. The dashboard: pass your versionless API key
export const v = createVersionless({
  project: "billing-api",
  scheme: "date",
  current: "2026-07-21",
  resolve: [{ header: "x-api-version" }, { default: "current" }],
  apiKey: process.env.VERSIONLESS_API_KEY,   // vl_<keyId>_<secret>
  // otlpLogsUrl: "https://telemetry.internal/v1/logs", // self-host override
});

With an apiKey, project is required. Core maps request events to standard OpenTelemetry LogRecords (eventName = "versionless.request") and exports OTLP/HTTP JSON to /v1/logs. It sends the project as both service.name and the x-versionless-project transport header. The gateway validates the key, binds it to the first project it is used with, and forwards the OTLP body unchanged to the OpenTelemetry Collector.

The cloud receiver accepts standard OTLP logs and traces from any compatible exporter:

TransportEndpoint
OTLP/HTTP JSONPOST /v1/logs, POST /v1/traces
OTLP/HTTP protobufPOST /v1/logs, POST /v1/traces
OTLP/gRPCstandard LogsService and TraceService Export methods on port 4317

HTTP requests may use Content-Encoding: gzip. Authenticate every transport with Authorization: Bearer <versionless-api-key>. The HTTP receiver chooses its response encoding from the request Content-Type; gRPC uses the standard OTLP protobuf service definitions.

Three keys, three jobs

Easy to conflate, so precisely:

TermWhat it isWhere it lives
project (config)Stable display name for this API/service.createVersionless({ project })
apiKey (config)Your versionless cloud key, vl_<keyId>_<secret>. A secret, used only as the ingest bearer token.createVersionless({ apiKey })
owning accountThe team resolved from the API key. The secret never reaches storage.Ingest/store side
consumerKeyThe API consumer's key — read from x-api-key or the apiKey resolver's Authorization bearer — what the dashboard groups clients by. Store consumer key ids, never secret values.Per event

What gets recorded

One OpenTelemetry event per request. Internally, TelemetryEvent is mapped to an OTLP LogRecord; its fields become versionless.* and HTTP semantic attributes:

FieldTypeMeaning
tsnumberUnix millis when the request finished.
methodstringHTTP method; "TRPC" for procedure calls.
routestringCanonical pattern ("GET /users/:*", "trpc:demo.userList") — never a concrete path.
adapterstring"elysia" | "hono" | "express" | "trpc".
versionstringEffective (normalized) version served.
requestedVersion?stringRaw pin the client sent — only present when it differed from the effective version.
consumerKey?stringThe API consumer's key id.
latencyMsnumberRequest latency.
transformCountnumberHow many transforms this request ran (drift depth).
statusnumberResponse status code.

Bodies are never part of the event type — there is nothing to leak. Emission is fire-and-forget in a microtask, off the request path; a sink that throws is disabled from erroring again and reported once via onSinkError — a sink error can never fail a request.

Sampled traces

With an apiKey, the SDK also captures sampled request traces for the platform's Traces view — separate from request-log telemetry and sampled independently of the sample hook. Every sampled exchange ships its versionless span tree as a standard OTLP ExportTraceServiceRequest (exchange root, resolution, each transform, with timings and change labels) to /v1/traces:

createVersionless({
  apiKey: process.env.VERSIONLESS_API_KEY,
  project: "billing-api",
  // defaults: on, 10% head sampling
  traces: {
    sample: 0.05,                                  // SDK-set sampling rate
    filter: (attrs) =>                             // pre-sampling veto
      attrs["versionless.path"] !== "/health",
    // url: "https://telemetry.internal/v1/traces" // self-host override
  },
  // traces: false                                 // turn capture off
});

The same privacy line holds: only spans core itself creates are captured — never spans from your own code, and never bodies, headers, or concrete response data. Span attributes are the versioning metadata documented on the OpenTelemetry page. Sampling is head-based and per-exchange, so a captured trace is always complete. Traces buffer and ship with the same batching/breaker behavior as request logs. Collector-managed traces and logs are both retained for 180 days.

Trace capture works with or without an OTel setup: when you also pass tracing: otelTracing(), both backends observe the same spans — OTel gets every request into your observability stack, the platform gets the sampled subset.

How the HTTP sink behaves under stress

httpOtlpLogsSink (attached automatically by apiKey, or manually with custom options) is built to be forgettable:

  • Batching. Request logs buffer in memory and post every 5s (flushIntervalMs), up to 500 per batch (maxBatchSize). The flush timer is unrefed — it never keeps your process alive.
  • Ring buffer. The buffer caps at 10,000 records (maxBuffered); overflow drops the oldest records first. Failed batches are re-buffered under the same rule.
  • Circuit breaker. Three consecutive failed sends open the breaker for 30s — no hammering a down ingest endpoint — then delivery resumes.
  • Serverless. Under VERCEL the sink runs in immediate mode (no timer) and adapters call flush() post-response so request logs survive the function freezing.

Custom sinks

A sink is three methods, one required:

import type { TelemetryEvent, TelemetrySink } from "@versionless/core";

const mySink: TelemetrySink = {
  record(event: TelemetryEvent) {
    // Must be synchronous, non-blocking, and never throw.
    myBuffer.push(event);
  },
  async flush() { /* drain buffers; called on shutdown and per-response in serverless */ },
  async close() { /* release resources */ },
};

v.telemetry.use(mySink);

v.telemetry.emit(event) and v.telemetry.flush() are available for manual control. Multiple sinks fan out from one emission.

ClickHouse storage

The hosted and local stacks use the OpenTelemetry Collector's ClickHouse exporter directly. It owns protobuf/JSON decoding, gzip, OTLP/gRPC, queues, batching, retries, and the standard otel_logs and otel_traces schemas. Versionless tenant and project IDs are trusted OTel resource attributes; request dimensions remain ordinary LogAttributes, so unrelated application telemetry stays queryable without a separate record format.

The dashboard queries these native tables through the authenticated POST /v1/query boundary. Dashboard SQL is managed by TanStack React Query in the web app; the API owns only authentication, project/team authorization, resource limits, and the restricted ClickHouse connection. The same boundary is available through versionless query for ad hoc project analysis.

Sampling

createVersionless({
  // ...
  sample: (event) => event.route !== "GET /health",
});

The hook runs once, before fan-out to sinks; return false to drop. Use it for noise filtering or rate-based sampling — the demo server drops its own ingest endpoint's request logs (no self-ingestion loops) and applies a deterministic rate on event.ts.

The dashboard

The hosted dashboard turns the Versionless request-log stream into three answers: adoption (requests per version per day — who's still on what), sunset blockers (the exact consumer keys and routes with traffic on versions at or below a candidate — an empty list is your green light), and drift (average/p95 transform depth per endpoint — your force-migration shortlist).

Those analytics intentionally select records whose OTel eventName is versionless.request; unrelated application logs must not change adoption counts. The Telemetry page is the unfiltered receiver view: it shows every OTLP log and span for the selected project, including service and instrumentation scope, severity or duration, trace/span IDs, body, errors, and attributes.

Projects, sign-in, and API keys

Every insights view requires signing in; signed-out visitors are redirected to the dashboard's SDK-rendered sign-in handler. Telemetry is scoped to a named project under a team/account you're a member of. The project switcher groups projects by account, and each query verifies account membership before reading traffic.

The API keys page mints account keys (the description/expiry are yours to choose; the full key is shown exactly once, on creation). Pass one as apiKey alongside project in createVersionless. On first ingest the key is bound to that project, so a key cannot later write into a sibling project. Revoking it on the dashboard stops new gateway authorization within a minute. Creating and revoking keys requires the account's admin role; members can view insights but not manage keys.

Local development without an account still works: keys of the form vl_<id>_<secret> listed in VERSIONLESS_INGEST_KEYS are accepted by the authorization boundary (the demo server dogfoods this path), and the seed script can backfill demo traffic under your account and project with SEED_TEAM_ID=<team-id> SEED_PROJECT_NAME=my-api bun run seed.