npm package: @replayio-app-building/netlify-recorder
Description: Capture and replay Netlify function executions as Replay recordings
Latest version: 0.3.0

# @replayio-app-building/netlify-recorder

Capture and replay Netlify function executions as [Replay](https://replay.io) recordings. This package intercepts outbound network calls and environment variable reads during handler execution, stores the captured data as a blob, and can later reproduce the exact execution as a Replay recording for debugging and analysis.

## Installation

```bash
npm install @replayio-app-building/netlify-recorder
```

## Integration Options

There are two ways to use this package:

- **Option A: Use the Netlify Recorder service (recommended)** — The service handles blob storage, request tracking, and recording creation. Your app just wraps its handlers and calls `remoteCallbacks()`. No database or container infrastructure needed.

- **Option B: Self-hosted** — You manage your own blob storage, database tables, and recording containers. Full control, but requires more setup.

---

## Option A: Using the Netlify Recorder Service

The Netlify Recorder app (`https://netlify-recorder-bm4wmw.netlify.app`) provides a hosted service that stores captured request data, manages a pool of recording containers, and creates Replay recordings on demand. Your app needs zero database or infrastructure setup.

### 1. Wrap your Netlify function

Use `startRequest` / `finishRequest` with `remoteCallbacks()` to capture request data and send it to the service:

```typescript
import {
  startRequest,
  finishRequest,
  remoteCallbacks,
} from "@replayio-app-building/netlify-recorder";
import type { Handler } from "@netlify/functions";

const RECORDER_URL = "https://netlify-recorder-bm4wmw.netlify.app";

const handler: Handler = async (event) => {
  const reqContext = startRequest({
    method: event.httpMethod,
    url: event.path,
    headers: event.headers as Record<string, string>,
    body: event.body ?? undefined,
  });

  try {
    const result = await myBusinessLogic();

    return await finishRequest(
      reqContext,
      remoteCallbacks(RECORDER_URL),
      {
        statusCode: 200,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(result),
      },
      { handlerPath: "netlify/functions/my-handler" }
    );
  } catch (err) {
    reqContext.cleanup();
    throw err;
  }
};

export { handler };
```

That's it for capturing. The `remoteCallbacks(url)` helper sends the captured blob data to the Netlify Recorder service's `/api/store-request` endpoint, which uploads it to blob storage and stores the request metadata. The response includes an `X-Replay-Request-Id` header with the request ID managed by the service.

### 2. Create recordings

When you want to turn a captured request into a Replay recording, POST to the service's `create-recording` endpoint with the request ID:

```typescript
const response = await fetch(
  `${RECORDER_URL}/api/create-recording`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      requestId,
      webhookUrl: "https://your-app.netlify.app/api/on-recording-complete", // optional
    }),
  }
);
```

The service looks up the stored blob data, dispatches the work to a pool container, and creates the recording.

If `webhookUrl` is provided, the service will POST the result to that URL when the recording completes (or fails):

On success:
```json
{ "status": "recorded", "recordingId": "a1b2c3d4-..." }
```

On failure:
```json
{ "status": "failed", "error": "Error message" }
```

### 3. Check recording status

You can poll the recording status at any time:

```typescript
const res = await fetch(
  `${RECORDER_URL}/api/get-request?requestId=${requestId}`
);
const { status, recordingId } = await res.json();
// status: "captured" | "processing" | "recorded" | "failed"
// recordingId: string | null
```

---

## Option B: Self-Hosted

If you need full control, you can manage your own blob storage, database, and recording containers. This requires more setup but gives you complete ownership of the data pipeline.

### 1. Wrap your Netlify function

Use `startRequest` / `finishRequest` with custom callbacks that write to your own storage and database:

```typescript
import { startRequest, finishRequest } from "@replayio-app-building/netlify-recorder";
import type { Handler } from "@netlify/functions";

const handler: Handler = async (event) => {
  const reqContext = startRequest({
    method: event.httpMethod,
    url: event.path,
    headers: event.headers as Record<string, string>,
    body: event.body ?? undefined,
  });

  try {
    const result = await myBusinessLogic();

    return await finishRequest(
      reqContext,
      {
        uploadBlob: async (data) => {
          // Upload the JSON string to your blob storage (S3, R2, etc.)
          const res = await originalFetch("https://storage.example.com/upload", {
            method: "PUT",
            body: data,
          });
          const { url } = await res.json();
          return url;
        },
        storeRequestData: async ({ blobUrl, commitSha, branchName, handlerPath }) => {
          const [row] = await sql`
            INSERT INTO requests (blob_url, commit_sha, branch_name, handler_path, status)
            VALUES (${blobUrl}, ${commitSha}, ${branchName}, ${handlerPath}, 'captured')
            RETURNING id
          `;
          return row.id;
        },
      },
      {
        statusCode: 200,
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(result),
      }
    );
  } catch (err) {
    reqContext.cleanup();
    throw err;
  }
};

export { handler };
```

### 2. Create a requests database table

```sql
CREATE TABLE IF NOT EXISTS requests (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  blob_url TEXT,
  commit_sha TEXT,
  handler_path TEXT,
  recording_id TEXT,
  status TEXT NOT NULL DEFAULT 'captured'
    CHECK (status IN ('captured', 'processing', 'recorded', 'failed')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

### 3. Create a background function to produce recordings

```typescript
import { ensureRequestRecording } from "@replayio-app-building/netlify-recorder";
import type { Handler } from "@netlify/functions";

const handler: Handler = async (event) => {
  const { requestId } = JSON.parse(event.body ?? "{}");

  const recordingId = await ensureRequestRecording(requestId, {
    repositoryUrl: process.env.APP_REPOSITORY_URL!,
    replayApiKey: process.env.RECORD_REPLAY_API_KEY!,
    lookupRequest: async (id) => {
      const [row] = await sql`
        SELECT blob_url, commit_sha, branch_name, handler_path
        FROM requests WHERE id = ${id}
      `;
      return {
        blobUrl: row.blob_url,
        commitSha: row.commit_sha,
        branchName: row.branch_name ?? "main",
        handlerPath: row.handler_path,
      };
    },
    updateStatus: async (id, status, recordingId) => {
      await sql`
        UPDATE requests
        SET status = ${status},
            recording_id = ${recordingId ?? null},
            updated_at = NOW()
        WHERE id = ${id}
      `;
    },
  });

  return {
    statusCode: 200,
    body: JSON.stringify({ recordingId }),
  };
};

export { handler };
```

### 4. Create a container script

This script runs inside the recording container under `replay-node`:

```typescript
// scripts/create-request-recording.ts
import { createRequestRecording } from "@replayio-app-building/netlify-recorder";

const args = process.argv.slice(2);
const blobUrl = args[args.indexOf("--blob-url") + 1]!;
const handlerPath = args[args.indexOf("--handler-path") + 1]!;

await createRequestRecording(blobUrl, handlerPath, {
  method: "POST",
  url: handlerPath,
  headers: {},
});
```

### Required infrastructure

Self-hosted recording requires these environment variables:

| Variable | Description |
|---|---|
| `INFISICAL_CLIENT_ID` | Infisical service account client ID |
| `INFISICAL_CLIENT_SECRET` | Infisical service account client secret |
| `INFISICAL_PROJECT_ID` | Infisical project ID |
| `INFISICAL_ENVIRONMENT` | Infisical environment (e.g. `production`) |
| `FLY_API_TOKEN` | Fly.io API token for container management |
| `FLY_APP_NAME` | Fly.io app name for container deployment |
| `APP_REPOSITORY_URL` | Git repository URL for container cloning |
| `RECORD_REPLAY_API_KEY` | Replay API key for recording upload |

---

## API Reference

### `startRequest(requestInfo): RequestContext`

Begins capturing a Netlify handler execution. Patches `globalThis.fetch` and `process.env` to record all outbound network calls and environment variable reads.

**Parameters:**
- `requestInfo.method` — HTTP method of the incoming request
- `requestInfo.url` — Request URL/path
- `requestInfo.headers` — Request headers
- `requestInfo.body` — Optional request body

**Returns:** A `RequestContext` object to pass to `finishRequest`.

### `finishRequest(requestContext, callbacks, response, options?): Promise<HandlerResponse>`

Finalizes the request capture. Restores original `fetch` and `process.env`, serializes the captured data, uploads it via the callbacks, and returns the response with `X-Replay-Request-Id` header set.

**Parameters:**
- `requestContext` — The context returned by `startRequest`
- `callbacks` — Either `remoteCallbacks(serviceUrl)` or custom `{ uploadBlob, storeRequestData }` callbacks
- `response` — The handler's response object (`{ statusCode, headers?, body? }`)
- `options.handlerPath` — Path to the handler file (used for recording metadata)

### `remoteCallbacks(serviceUrl): FinishRequestCallbacks`

Creates callbacks for `finishRequest` that send captured data to the Netlify Recorder service. The service handles blob storage and request tracking — no local database needed.

**Parameters:**
- `serviceUrl` — Base URL of the Netlify Recorder service (e.g. `"https://netlify-recorder-bm4wmw.netlify.app"`)

### `ensureRequestRecording(requestId, options): Promise<string>`

Spawns a container via `@replayio/app-building` to create a Replay recording from captured request data. Returns the recording ID. Only needed for self-hosted setups (Option B).

**Parameters:**
- `requestId` — The request to create a recording for
- `options.repositoryUrl` — Git repository URL for the container to clone
- `options.replayApiKey` — Replay API key for recording upload
- `options.lookupRequest(id)` — Fetches `{ blobUrl, commitSha, branchName, handlerPath }` from the database
- `options.updateStatus(id, status, recordingId?)` — Updates the request status in the database

### `createRequestRecording(blobUrl, handlerPath, requestInfo): Promise<void>`

Called inside a container running under `replay-node`. Downloads the captured data blob, installs replay-mode interceptors (which return pre-recorded responses instead of making real calls), and executes the original handler so replay-node can record the execution. Only needed for self-hosted setups (Option B).

**Parameters:**
- `blobUrl` — URL to the captured data blob
- `handlerPath` — Path to the handler module to execute
- `requestInfo` — The original request info to replay

## Environment Variables

| Variable | Required | Description |
|---|---|---|
| `COMMIT_SHA` | No | Git commit hash (defaults to `"HEAD"`) |
| `BRANCH_NAME` | No | Git branch name for container cloning (defaults to `"main"`) |
| `RECORD_REPLAY_API_KEY` | For self-hosted recording | Replay API key for uploading recordings |
| `APP_REPOSITORY_URL` | For self-hosted recording | Git repository URL for container cloning |

## How It Works

1. **Capture phase** (`startRequest` / `finishRequest`): When a Netlify function handles a request, `startRequest` patches `globalThis.fetch` and `process.env` with Proxies that record every outbound network call and environment variable read. When the handler completes, `finishRequest` restores the originals, serializes the captured data to JSON, and sends it to either the remote service (via `remoteCallbacks`) or your own storage (via custom callbacks).

2. **Recording phase**: The captured blob is sent to a recording container (either via the Netlify Recorder service or self-hosted). Inside the container, `createRequestRecording` downloads the blob, installs replay-mode interceptors that return the pre-recorded responses, and re-executes the handler under `replay-node`. Since replay-node records all execution, this produces a Replay recording of the exact same handler execution.