Teardown

Advanced

Redis caching, logging and OpenTelemetry metrics, atomicity, multi-tenant and multi-instance deployment.

This page covers the production concerns: caching, observability, atomicity, and running the runtime at scale. It mirrors how Teardown's own ingest and dashboard backends are wired.

Shipping to production?

Jump to the Deployment checklist for the must-haves (schema applied, SESSION_SECRET set and identical across replicas, optional shared cache). The sections above it explain each piece. Everything except the database and session secret is optional with a safe no-op default.

Redis caching

createTeardown accepts a CachePort. By default it is a no-op (NullCache) — the runtime is correct without a cache, just slower (every version/build resolve and session lookup hits the database). Pass a cache to accelerate the hot paths.

The port matches @teardown/redis's CacheInterface exactly, so a Redis cache is structurally assignable:

import { createTeardown } from "@teardown/server";
import type { CachePort } from "@teardown/server/ports";
import { cache } from "./lib/cache"; // your @teardown/redis instance

const td = createTeardown({
  storage,
  cache: cache satisfies CachePort,
  config: { sessionSecret: process.env.SESSION_SECRET! },
});

What gets cached:

  • Resolved version/build idsforceUpdate.getOrCreateVersionBuild caches { versionId, buildId } per project:version:build:platform for 15 minutes (key prefix ingest:version_build:).
  • Sessions — the session service caches by device id (ingest:session:) and by token (ingest:session:token:), with a TTL tracking the token's expiry, re-caching on reuse-extend and invalidating on rotation.

Any cache implementing the three methods (get / set / del) works — it does not have to be Redis.

Logging

Pass a LoggerPort to capture the runtime's structured logs (identify/events flow, push-token decisions, cascade outcomes, hook failures). Each method takes a message and an optional context object.

import type { LoggerPort } from "@teardown/server/ports";

const logger: LoggerPort = {
  debug: (m, ctx) => console.debug(m, ctx),
  info: (m, ctx) => console.info(m, ctx),
  warn: (m, ctx) => console.warn(m, ctx),
  error: (m, ctx) => console.error(m, ctx),
};

const td = createTeardown({ storage, logger, config: { sessionSecret } });

An OpenTelemetry logger whose methods have the (message, context?) signature satisfies LoggerPort directly.

Metrics (OpenTelemetry)

The runtime emits counters and histograms through a MetricsPort — by default a no-op. Provide an implementation to route them onto OpenTelemetry instruments (or any metrics backend). Attributes carry { orgId?, projectId?, environment?, status? }.

import type { MetricsPort, MetricAttributes } from "@teardown/server/ports";

const metrics: MetricsPort = {
  counter: (name, value, attrs?: MetricAttributes) => otelCounter(name).add(value, attrs),
  histogram: (name, value, attrs?: MetricAttributes) => otelHistogram(name).record(value, attrs),
};

const td = createTeardown({ storage, metrics, config: { sessionSecret } });

The metric names match the hosted ingest:

MetricTypeEmitted on
ingest.identify.durationhistogramevery identify
ingest.identify.countcounterevery identify
ingest.identify.session.createdcounternew session created
ingest.identify.user.createdcounternew user created
ingest.identify.device.createdcounternew device created
ingest.events.durationhistogramevery events batch
ingest.events.countcounterprocessed event count
ingest.events.batch.sizehistogrambatch size
ingest.events.high_volumecounterbatch over the high-volume threshold

Atomicity and the transaction hook

The identify flow is idempotent and individually-atomic — it relies on the race-safe upsert* repository methods, not on a wrapping transaction. So TeardownStorage.transaction is optional and the runtime never requires it (the in-memory adapter omits it entirely).

If your store supports transactions and you want a unit of work to be all-or-nothing, implement transaction to wrap the work and re-expose the transaction handle as a fresh TeardownStorage:

transaction: (work) => db.transaction((tx) => work(wrapAsTeardownStorage(tx))),

The most important atomicity guarantees — the two natural-key upserts — are enforced at the repository level regardless of transaction, so concurrent identify calls for the same app version never create duplicate version/build rows. See Bring your own database for how to implement them.

Multi-tenant and multi-instance

Because there are no singletons, the runtime scales in both directions:

  • Multiple instances@teardown/server is stateless across requests; all state lives in your database and (optionally) your shared cache. Run as many replicas behind a load balancer as you like. Use a shared Redis CachePort so cache hits and session caching are consistent across replicas. The race-safe upserts make concurrent identify calls across replicas safe.
  • Multiple tenants — you can run one runtime for all tenants (tenancy is already enforced per request via the API key → project/org resolution and the environment scoping in storage), or instantiate a separate createTeardown per tenant with tenant-specific storage and config. The latter is useful when each tenant has an isolated database. (For a single self-hosted app, pass tenant: { orgId, projectId } instead — see Getting Started.)
// One runtime per tenant database
const runtimes = new Map<string, Teardown>();
function runtimeFor(tenant: Tenant): Teardown {
  let td = runtimes.get(tenant.id);
  if (!td) {
    td = createTeardown({
      storage: createMyStorage(tenant.databaseUrl), // your TeardownStorage implementation
      cache,
      config: { sessionSecret: tenant.sessionSecret },
    });
    runtimes.set(tenant.id, td);
  }
  return td;
}

Session secret and token signing

The default signer is HS256 via jose, keyed on config.sessionSecret — the HMAC key the server signs and verifies client session tokens with. Generate one with openssl rand -base64 32 and store it in your backend's secret manager (never commit it, never ship it to clients). For correctness across a fleet:

  • Use the same sessionSecret on every instance that serves the same clients, or sessions signed by one replica will fail verification on another.
  • Rotate by supplying a custom TokenSigner that can verify both the old and new keys during the rollover window.
  • Tune token lifetime with config.sessionTokenExpiry (the JWT exp) and the row TTL with config.sessionTtlMs; config.sessionReuseExtendMs controls how much a reused session's expiry is pushed out on each identify.

Deployment checklist

  • Your storage's connection points at a database with your schema applied (you own the migrations).
  • SESSION_SECRET is set and identical across replicas (or a custom multi-key signer is wired).
  • A shared CachePort (e.g. Redis) is configured if you run more than one replica and want session/version caching.
  • A LoggerPort and MetricsPort are wired to your observability stack.
  • td.router({ version, buildId, serviceId }) is passed your build metadata so GET / and GET /health report it.
  • The React Native SDK's ingestUrl points at your deployment, and the CORS allowlist (applied automatically by the adapters) covers your clients.