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 ids —
forceUpdate.getOrCreateVersionBuildcaches{ versionId, buildId }perproject:version:build:platformfor 15 minutes (key prefixingest: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:
| Metric | Type | Emitted on |
|---|---|---|
ingest.identify.duration | histogram | every identify |
ingest.identify.count | counter | every identify |
ingest.identify.session.created | counter | new session created |
ingest.identify.user.created | counter | new user created |
ingest.identify.device.created | counter | new device created |
ingest.events.duration | histogram | every events batch |
ingest.events.count | counter | processed event count |
ingest.events.batch.size | histogram | batch size |
ingest.events.high_volume | counter | batch 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/serveris 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 RedisCachePortso 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
createTeardownper tenant with tenant-specific storage and config. The latter is useful when each tenant has an isolated database. (For a single self-hosted app, passtenant: { 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
sessionSecreton every instance that serves the same clients, or sessions signed by one replica will fail verification on another. - Rotate by supplying a custom
TokenSignerthat can verify both the old and new keys during the rollover window. - Tune token lifetime with
config.sessionTokenExpiry(the JWTexp) and the row TTL withconfig.sessionTtlMs;config.sessionReuseExtendMscontrols 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_SECRETis 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
LoggerPortandMetricsPortare wired to your observability stack. -
td.router({ version, buildId, serviceId })is passed your build metadata soGET /andGET /healthreport it. - The React Native SDK's
ingestUrlpoints at your deployment, and the CORS allowlist (applied automatically by the adapters) covers your clients.