Available in the TypeScript, Python, and Ruby SDKs (the ones with replay). The Go SDK has no replay, so database branching does not apply.
How it works
Capture is automatic in current SDKs: eligible root traces record the wall-clock instant they ran and a snapshot reference, with no SDK configuration. So there’s nothing to turn on for new traces; setup is just two steps:- Connect your database once in the Bitfab dashboard. Your source database can be any Postgres; Bitfab provisions a managed, branchable copy from it.
- Wire replay by passing
dbBranchto the replay call and reading the per-trace branch URL inside the replayed function.
1. Connect your database
In the Bitfab dashboard, open Integrations → Database and paste your Postgres connection string. Bitfab provisions a branchable managed copy from it; discovery and engine setup take a few minutes, after which the Database section shows Connected. Your source database can be any Postgres. You do not set anyNEON_* environment variables (those are Bitfab-side server configuration). You only provide your source connection string in the dashboard. The connection string is encrypted at rest. When a replay item has a captured snapshot and uses its provisioned branch, writes stay on that isolated, ephemeral copy and never touch your source database. Items without a snapshot use the normal database path, so keep unsafe writes behind replay-mocked spans.
2. Wire replay to the branch
PassdbBranch to the replay call and connect through the resolved branch’s URL inside the replayed function. true requests a branch per replay item with the mirror’s own sizing; pass an object instead to tune it, and see Sizing and warming the branch for the fields. The accessor that reads the branch differs slightly per SDK.
Sizing and warming the branch
A replay branch is created fresh, so it starts with an empty cache and whatever compute size the mirror was provisioned with. If your replay’s query latency looks nothing like the original trace’s, that gap is usually the branch, not your code. Two optional settings close it, both passed indbBranch:
minCu/maxCuset the branch compute’s autoscaling floor and ceiling, in Neon Compute Units. Setting them equal pins the size, which is what you want for an experiment: with a range, an item that runs later can hit an endpoint that has already scaled up and post a better number for identical code. Equal values are a fixed-size compute, allowed up to 56 CU. A range is autoscaling, which Neon caps at an 8 CU span and a 16 CU ceiling, sominCu: 2, maxCu: 16is rejected. Sizes are a discrete set, not a continuum:0.25,0.5, every integer to16, then even numbers to56. Anything invalid fails the replay immediately rather than silently falling back to your live database. Note that a pinned size above 16 CU stays always-active, since Neon does not scale those to zero, so it keeps billing until the branch is released.warmupSqlruns as part of the branch’s readiness check, before your function ever sees the lease. Warm-up time is therefore not charged to the replayed call. Passing it raises the readiness check’s budget from 10 seconds to 240 seconds, since warming a real working set is not aSELECT 1. Invalid SQL, or a warm-up that outruns that budget, fails the lease rather than quietly handing back a cold branch.
dbBranch: true instead and the branch keeps the mirror’s own defaults, while branching stays on.
Handling branch failures
Bitfab never falls back to the live database after a requested branch fails. The replay function is not invoked, and that attempt still returns an item with no replay trace ID and a structured database branch replay error inreplayError (TypeScript) or replay_error (Python and Ruby).
The error is DbBranchReplayError (Bitfab::DbBranchReplayError in Ruby). Its code and message are the values produced by the server resolver, and its original trace ID identifies the affected attempt. Codes such as branch_create_failed, branch_warmup_invalid, branch_warmup_failed, and snapshot_from_replaced_origin can be handled without parsing the compatible item error string. A malformed captured ref reports invalid_snapshot_ref; an unexpected server resolver failure reports internal_error; and an HTTP, timeout, or network failure while asking for the branch reports lease_request_failed with the original client exception retained as cause. If the entire replay later raises, the same typed errors remain on the items attached to the top-level ReplayError.
Only a trace with no captured snapshot reference may use the normal live-database fallback. Once a snapshot reference is present, malformed or unreadable snapshot data fails that item rather than running it against current data.
Reading the branch
The accessor returns a branch only inside a replay item that has a resolved one, and a nullish result is the normal fallback path:- On the live request path it is null: your function keeps using its normal
DATABASE_URL. The same code works in production and in replay. - For traces captured before the SDK version that added always-on snapshot capture, it is null (no snapshot ref), so the item replays against your normal database.
Every field the service puts on the lease is exposed, so a field added server-side reaches your code without an SDK upgrade. Only the connection string is special: reading it is what marks the branch as used.
Resolve the connection per call, not at import
The most common reason a wired replay still hits production is a database client created once at module import: a module-level pool/engine bound toDATABASE_URL captures that value before any replay context exists. Refactor so the connection string is resolved per call (or per replay item) and your replayed function can build, or be handed, a client from the branch URL. Each replay item receives its own branch URL at runtime, so an import-time pool cannot be made safe by changing the process-wide DATABASE_URL.
Verifying it works
Capture is automatic, but a trace only carries a snapshot ref if it was recorded by an SDK version with always-on capture, so smoke-test with a recently captured trace:- Run the instrumented function once so a new trace lands.
- Replay that trace and confirm, inside the function, that the accessor returned a branch and that
snapshotTimestampmatches the moment the source trace ran. A differing URL host only proves some branch was handed over; the pinned instant proves it is the right point in history. - Open the test run URL from the replay result to inspect the experiment.
Bitfab tracks whether the branch was actually used
Each replayed trace records whether your code actually obtained the branch URL during that item (readingdatabaseUrl / database_url counts; calling the accessor or reading any other field does not). The experiment data for the trace then shows one of three states:
- Used: a branch was provisioned and your function took its URL.
- Provisioned but never read: a branch was ready, but the function never asked for the URL. This usually means the replay silently hit your live database; the most common cause is a connection pool created at module import (see “Resolve the connection per call, not at import” above).
- No branch: the item had no snapshot to branch from, so the function ran against its normal database. A provisioning failure does not produce a replay trace because the function is not invoked; it returns an item with a structured replay error instead.
Where the provisioning time went
Every replay item reports how long its branch took to provision, broken down by phase, so a slow replay can be attributed rather than guessed at:item["db_branch_timings"] and Ruby as
item[:db_branch_timings]. It is also recorded on the replayed trace itself, so
the breakdown outlives the run that produced it.
Three things worth knowing when you read these numbers:
- They are measured server-side, from Bitfab to your database provider. Your
own runner will observe the total plus its round trip to the branch’s region,
which the lease reports as
region. - They are per item, not per replay. Each item gets its own branch and its own compute, so a cold one and a warm one are not comparable.
- A failed resolve still reports them, carrying the phases it reached and a
totalMsthat is time-to-failure. Phases after the failure are absent rather than zero, so “it spent four minutes and then failed” is answerable.
startedAt plus the running sum of the
phases before it. Reporting a wall-clock stamp per phase would make clock skew
between our servers and your runner read as latency.
Optional: pin the provider (TypeScript)
Capture works with no configuration. In TypeScript you may pass a provider to pin it at capture time; it is not required, and the provider is otherwise resolved at replay time:Limitations
- TypeScript, Python, and Ruby only (Go has no replay).
- Postgres only.
- Branch leases are short-lived (a few minutes) and created fresh per replay item. Replay completes well within that window; the lease is released and the branch deleted afterward.
- The branch reflects the source database’s state at the captured instant, bounded by replication lag (typically sub-second to a few seconds).
- When a branch is provisioned and used, replay writes land only on that ephemeral branch and are discarded; they never propagate to your source database. A trace with no snapshot uses the normal database path, so unsafe writes on that path must still be mocked.
- Only traces captured by an SDK version with always-on snapshot capture can be branched. Older traces replay against your normal database.