DEV Community

A Tiny LLM Request Recorder I Use to Reproduce Production Failures

A Tiny LLM Request Recorder I Use to Reproduce Production Failures

Most LLM failures are easy to describe and surprisingly hard to reproduce. A user reports that the model returned an empty answer. A tool call disappeared halfway through a stream. One provider rejected a request that worked everywhere else.

Then I open the logs and find something like this: LLM request failed: 400 Bad Request. Technically true. Operationally useless.

The missing piece is usually the exact request shape: model, parameters, message roles, tool definitions, timeout behavior, and the raw provider response. I wanted something smaller than a full observability platform, so I built a request recorder around fetch. It stores enough information to inspect or replay a failed call without logging the API key.

What the recorder captures

For each request, I want:

  • a unique request ID
  • timestamp and duration
  • URL and model
  • sanitized request body
  • HTTP status
  • raw response body
  • network or timeout errors

I deliberately do not record the Authorization header. Prompt content is also redacted by default. Full payload capture must be enabled explicitly because storing production prompts can create a much worse problem than the bug being investigated.

The recorder

This example runs on Node.js 18 or newer and has no external dependencies. Create recorded-fetch.mjs:

import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";

function sanitize(value, captureContent) {
  if (Array.isArray(value)) {
    return value.map((item) => sanitize(item, captureContent));
  }
  if (!value || typeof value !== "object") {
    return value;
  }
  const result = {};
  for (const [key, child] of Object.entries(value)) {
    const normalizedKey = key.toLowerCase();
    if (
      normalizedKey.includes("api_key") ||
      normalizedKey.includes("apikey") ||
      normalizedKey.includes("authorization")
    ) {
      result[key] = "[REDACTED]";
      continue;
    }
    if (
      !captureContent &&
      (normalizedKey === "content" ||
        normalizedKey === "prompt" ||
        normalizedKey === "input")
    ) {
      result[key] = "[REDACTED]";
      continue;
    }
    result[key] = sanitize(child, captureContent);
  }
  return result;
}

async function saveRecording(directory, recording) {
  await mkdir(directory, { recursive: true });
  const filename = `${recording.startedAt.replaceAll(":", "-")}-${recording.id}.json`;
  const destination = path.join(directory, filename);
  await writeFile(destination, JSON.stringify(recording, null, 2), "utf8");
  return destination;
}

export function createRecordedFetch({
  directory = ".llm-recordings",
  captureContent = false,
} = {}) {
  return async function recordedFetch(url, options = {}) {
    const id = randomUUID();
    const startedAt = new Date().toISOString();
    const start = performance.now();
    let requestBody;
    try {
      requestBody =
        typeof options.body === "string"
          ? JSON.parse(options.body)
          : options.body;
    } catch {
      requestBody = "[UNPARSEABLE BODY]";
    }
    const recording = {
      id,
      startedAt,
      request: {
        url: String(url),
        method: options.method ?? "GET",
        body: sanitize(requestBody, captureContent),
      },
    };
    try {
      const response = await fetch(url, options);
      const rawResponse = await response.text();
      recording.durationMs = Math.round(performance.now() - start);
      recording.response = {
        status: response.status,
        statusText: response.statusText,
        headers: Object.fromEntries(response.headers.entries()),
        body: rawResponse,
      };
      const savedTo = await saveRecording(directory, recording);
      if (!response.ok) {
        throw new Error(
          `LLM request ${id} failed with ${response.status}. Recording: ${savedTo}`
        );
      }
      return { requestId: id, response, rawResponse, savedTo };
    } catch (error) {
      if (!recording.response) {
        recording.durationMs = Math.round(performance.now() - start);
        recording.error = {
          name: error.name,
          message: error.message,
        };
        await saveRecording(directory, recording);
      }
      throw error;
    }
  };
}

This wrapper reads the response body once and returns it as rawResponse. That is important because a fetch response body cannot normally be consumed twice.

Using it for an LLM request

Create example.mjs:

import { createRecordedFetch } from "./recorded-fetch.mjs";

const recordedFetch = createRecordedFetch({
  directory: ".llm-recordings",
  captureContent: false,
});

const baseUrl = process.env.LLM_BASE_URL ?? "https://api.openai.com/v1";
const apiKey = process.env.LLM_API_KEY;

if (!apiKey) {
  throw new Error("LLM_API_KEY is required");
}

const payload = {
  model: process.env.LLM_MODEL ?? "gpt-4.1-mini",
  messages: [
    {
      role: "user",
      content: "Explain idempotency in one paragraph.",
    },
  ],
  temperature: 0.2,
};

const result = await recordedFetch(`${baseUrl}/chat/completions`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(payload),
  signal: AbortSignal.timeout(30_000),
});

const data = JSON.parse(result.rawResponse);
console.log({
  requestId: result.requestId,
  recording: result.savedTo,
  answer: data.choices?.[0]?.message?.content,
});

Run it with:

LLM_API_KEY="your-api-key" node example.mjs

Every call now produces a JSON file under .llm-recordings. A failed request might look like this:

{
  "id": "9e50d5a9-4a0d-42a2-94c9-711d36b2057d",
  "startedAt": "2026-07-17T08:14:32.442Z",
  "request": {
    "url": "https://api.example.com/v1/chat/completions",
    "method": "POST",
    "body": {
      "model": "example-model",
      "messages": [
        {
          "role": "user",
          "content": "[REDACTED]"
        }
      ],
      "temperature": 0.2
    }
  },
  "durationMs": 418,
  "response": {
    "status": 400,
    "statusText": "Bad Request",
    "headers": {
      "content-type": "application/json"
    },
    "body": "{ \"error\":{ \"message\": \"Unsupported parameter: temperature\" }}"
  }
}

That is already more useful than a generic 400. It tells me the failure was probably caused by a provider compatibility difference, not the prompt, network, or model output.

Replaying a request

For local or staging environments, I can temporarily set:

const recordedFetch = createRecordedFetch({
  captureContent: true,
});

The resulting request body can then be replayed with a small script. Create replay.mjs:

import { readFile } from "node:fs/promises";

const recordingPath = process.argv[2];
if (!recordingPath) {
  throw new Error("Usage: node replay.mjs <recording.json>");
}

const recording = JSON.parse(await readFile(recordingPath, "utf8"));
const serializedBody = JSON.stringify(recording.request.body);

if (serializedBody.includes("[REDACTED]")) {
  throw new Error(
    "This recording contains redacted values and cannot be replayed."
  );
}

const apiKey = process.env.LLM_API_KEY;
if (!apiKey) {
  throw new Error("LLM_API_KEY is required");
}

const response = await fetch(recording.request.url, {
  method: recording.request.method,
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: serializedBody,
  signal: AbortSignal.timeout(30_000),
});

console.log("Status:", response.status);
console.log(await response.text());

Then run:

LLM_API_KEY="your-api-key" \
  node replay.mjs .llm-recordings/example.json

I only enable full-content recording with synthetic or approved test data. I would not turn it on globally in production.

What this has helped me distinguish

The recorder is most useful when several failures look identical from the outside. It helps separate:

  • Request-shape failures: Unsupported parameters, invalid tool schemas, wrong message roles, or model-specific limits.
  • Transport failures: Timeouts, connection resets, interrupted streams, and responses that never reach the application.
  • Provider failures: A structured error response, unexpected content type, or different interpretation of an OpenAI-compatible field.
  • Application failures: The provider returned a valid response, but my parser, state machine, or tool executor mishandled it.

Those categories lead to very different fixes. Retrying all four of them is not a debugging strategy.

Production considerations

This recorder is intentionally small, so it leaves several decisions to the application using it. Before running something like this in production, I would add:

  • a retention period for recordings
  • sampling instead of recording every successful request
  • encryption at rest
  • size limits for request and response bodies
  • stricter redaction for user and tool data
  • access controls around the recording directory
  • correlation with the application trace or operation ID

I would also record failed calls more aggressively than successful ones. Keeping every response forever is an expensive way to create a privacy problem.

Why I keep this wrapper small

There are good observability products that can collect richer traces, token usage, latency distributions, and model-level metrics. I still like having a tiny recorder close to the HTTP boundary. It gives me a provider-neutral artifact I can inspect before data passes through SDK abstractions, response parsers, retry policies, or agent frameworks.

When I test multiple OpenAI-compatible endpoints-including my work on TokenBay-that raw boundary is often where compatibility problems become easiest to see.

The goal is not to log everything. It is to make the next production failure reproducible enough that I do not have to debug it from a single error message.

Comments

No comments yet. Start the discussion.