AI agents: read this page as markdown at /docs/additional-guides/backend-recorder.md, or start from the full AI-readable index at /llms.txt.

Install the backend recorder

The backend recorder captures the server side of your users' sessions. It intercepts HTTP requests and responses in your Node.js app using OpenTelemetry approach and exports them to Meticulous, where they are used to stub out backend calls during replays. This means Meticulous can replay sessions accurately even when they depend on data returned by your own API.

The backend recorder is installed via the @alwaysmeticulous/backend-recorder-launcher package from the Meticulous SDK. It is complementary to the frontend recorder — install the frontend recorder to capture user activity in the browser, and the backend recorder to capture the matching server-side requests.

When do I need the backend recorder?

The backend recorder is only required when your app uses server-side rendering (SSR). In SSR apps, data is fetched on the server before the page reaches the browser, so the frontend recorder never sees those requests — the backend recorder captures them instead so Meticulous can stub them during replay. If your app renders entirely on the client (e.g. a standard SPA), the frontend recorder already captures every request and the backend recorder is not needed. If you have any issues setting up the backend recorder then click here to book a call with us.

1. Install the package

npm install @alwaysmeticulous/backend-recorder-launcher

The recorder must be loaded before your application code so it can patch Node.js' HTTP modules before any requests are made. Pick the option below that matches your setup.

2. Initialize the recorder

3. Configuration options

initBackendRecorder accepts an optional config object:

OptionTypeDescription
enabledbooleanEnable or disable the recorder. Defaults to true.
meticulousProjectNamestringThe name of your Meticulous project.
recordingTokenstringToken used to authenticate span uploads. This is the same recording token used by the frontend recorder snippet.
exportMode"local" | "s3"Where to export recorded spans. Defaults to "s3", which uploads to Meticulous. Use "local" to write sessions to disk for debugging.
localOutputDirstringDirectory for local exports. Only used when exportMode is "local".
flushIntervalMsnumberHow often to flush spans, in milliseconds.
spanRedactionHooks((value: string, jsonPath: readonly string[]) => string)[]Ordered record-time hooks that transform redactable span strings before they are uploaded. Node.js only.

A common pattern is to record only in the environments you care about:

await initBackendRecorder({
  enabled: process.env.NODE_ENV !== "production",
  meticulousProjectName: "<your-project-name>",
  recordingToken: "<RECORDING_TOKEN>",
});

Redacting recorded backend data

Use spanRedactionHooks to replace sensitive values before completed spans are saved or uploaded. Each hook receives every redactable string plus the jsonPath locating it within the span, and must return a string. Hooks run in the order they are provided:

await initBackendRecorder({
  meticulousProjectName: "<your-project-name>",
  recordingToken: "<RECORDING_TOKEN>",
  spanRedactionHooks: [
    (value, jsonPath) =>
      jsonPath.at(-1) === "meticulous.prisma.args"
        ? redactJsonLeaves(value)
        : value.replace(
            /api_key=[A-Za-z0-9_-]+/g,
            "api_key=[REDACTED_API_KEY]",
          ),
  ],
});

// Only a string leaf can hold a secret: a where: { apiKey: { ... } } relation
// filter shares the name but is structure, so the type check leaves it intact.
function redactJsonLeaves(json: string): string {
  return JSON.stringify(JSON.parse(json), (key, value) =>
    key === "apiKey" && typeof value === "string" ? "[REDACTED]" : value,
  );
}

The hooks cover span names, error/status messages, and all span attribute values, including strings inside arrays or objects and JSON captured inside strings. They do not transform attribute names, trace/span/parent IDs, timestamps, span kind, client-technology routing, or frontend session IDs.

Hooks run only while recording. If a hook throws or returns a non-string, Meticulous abandons the recording rather than saving the span without redaction. Replacing backend-generated request data can also change a replay match key; use stable replacements and verify that the corresponding input will have the same value during replay. This programmatic option applies to the Node.js recorder and is not available to the Cloudflare Workers sidecar.

Many redactable strings are serialized JSON — database query arguments and results, and request and response bodies. Rewriting those with text substitutions risks emitting something that no longer parses: a pattern matching "apiKey": followed by an unquoted run will consume the { of an object value, and replacing a number or boolean with an unquoted placeholder is invalid JSON too. Meticulous matches database mocks on the serialized arguments, so a recording that no longer parses stops matching exactly and falls back to looser tiers, which can serve another query's result. Prefer parsing the JSON, redacting the leaf values, and re-serializing — use jsonPath to recognise those attributes — and return the input unchanged when a hook redacts nothing, so unaffected bodies keep their exact bytes.

4. Flush spans on shutdown

initBackendRecorder returns a handle with a stopRecording() method. Call it before your process exits so any pending spans are flushed and uploaded:

const handle = await initBackendRecorder({
  /* ...config... */
});

process.on("SIGTERM", async () => {
  await handle?.stopRecording();
  process.exit(0);
});

5. Recording anything else

The recorder instruments the common clients automatically — fetch, http, Postgres, Prisma, Redis. For anything else, wrap the call yourself:

const user = await handle.withMeticulousOperation(
  { name: "crm.getUser", key: { id } },
  () => crm.getUser(id),
);

While recording, Meticulous runs your function and captures what it returned. During a replay it does not run it — it returns the recorded result (or throws the recorded error) in its place. That is why the wrapper has to make the call rather than be told about it afterwards.

There are two good reasons to reach for this.

The first is a client we don't instrument — a gRPC stub, a vendor SDK with its own transport.

The second is more interesting, and applies even to calls we do instrument: an operation that sits above the network. Take a function that checks an in-process cache and only calls an API on a miss. If the cache was warm while recording there was no request to record, so nothing is captured and the replay has nothing to serve. Wrap the function instead and the recording holds the operation itself — so it replays whether or not the cache happened to be warm, and cache hits stop making replays inconsistent.

const getUser = (id: string) =>
  handle.withMeticulousOperation({ name: "users.get", key: { id } }, async () => {
    const cached = cache.get(id);
    if (cached) return cached;
    const user = await api.fetchUser(id);
    cache.set(id, user);
    return user;
  });

A few things to know:

  • name identifies the operation, so renaming it invalidates existing recordings. A test run compares against a base recorded days or weeks earlier, so after a rename every call to that operation has nothing to match and the request fails. Rename deliberately.
  • key is what distinguishes one call from another — usually the arguments. Leave out values that change on every call but don't affect the result, such as a request id or a nonce; including them means no call ever matches its recording. Timestamps and UUIDs are handled for you, and if a key still doesn't match, Meticulous falls back to a recording of the same operation.
  • Arguments and results are stored as JSON, so a Date comes back as a string and a Map as {}. Meticulous logs a warning naming the exact field when it sees one while recording. Thrown errors are captured and re-thrown with their name, message and custom properties intact, though instanceof checks against your own error class won't match.
  • Synchronous functions stay synchronous on both paths.
  • A call with no recording fails the request rather than quietly running for real — a replay that reaches live services isn't reproducible.

To record app state that no call produces — resolved feature flags, a chosen experiment arm — use:

handle.recordMeticulousObservation("featureFlags.resolved", flags);

This only records: it never stubs anything, never throws, and is ignored during replay.

If you'd rather not hand us the call

Some teams don't want their own code running inside our callback. The same capture is available as two calls you make yourself, with the branch in your code:

function getUser(id: string) {
  if (handle.isMeticulousReplaying()) {
    return handle.stubWithMeticulous<User>(`user_${id}`);
  }

  const user = crm.getUser(id);
  handle.recordWithMeticulous(`user_${id}`, user);

  return user;
}

recordWithMeticulous takes the value the operation produced. A promise is fine, and is the usual case: its resolved value is recorded, the promise you return is untouched, and stubWithMeticulous then returns a promise to match. These are the same recordings withMeticulousOperation produces, so you can move between the two forms without invalidating anything. Here the name is the whole identity — there is no separate key, so put whatever distinguishes one call from another into the name.

Use isMeticulousReplaying() for the branch rather than checking an env var yourself. The mode a process was started in, and an image built for Meticulous, are both the same in either mode, so neither tells you whether a recorded outcome can actually be served.

Two things you give up by splitting it, which is why wrapping is still the better default where it's acceptable:

  • A thrown error isn't captured. recordWithMeticulous is handed a value, so a call that threw never reaches it and the replay has nothing to serve. A rejected promise is captured. If failure is part of the flow, wrap instead.
  • The branch is yours to get right, and only the replay side of it is exercised by a replay — so a mistake in the other side won't show up until it reaches production.

That's it — once your app is running with the backend recorder enabled, server-side requests will be captured alongside the frontend sessions and used to stub backend calls during replay.