---
title: audit
description: Hand every operation's duration, request count and throttle time, and optionally every HTTP request, to a sink of your own, with no record data in the entries.
sidebar:
  label: audit
related:
  - /docs/api/hooks
  - /docs/plugins/tracing
  - /docs/rate-limits
---

`audit({ sink })` from `outbound-sdk/audit` calls your `sink` once for every operation the instance runs, with what was asked, how long it took, how many vendor requests it made and how long it waited for rate budget. With `requests: true` it also calls the sink once per HTTP request. A call entry holds no record data and no query values: only the operation's name, counts and timings. The plugin keeps nothing itself.

## Sample

This sample runs Instantly's adapter on the `fetch` from the [budget page](/docs/plugins/budget#sample), which serves the vendor's documented payloads from the repository. They are not live responses. A one-request `budget` inside the audit layer makes the second call fail on its second page.

```ts title="run.ts"
import { setImmediate } from "node:timers/promises";

import { createOutbound, OutboundError } from "outbound-sdk";
import { audit } from "outbound-sdk/audit";
import type { AuditEntry } from "outbound-sdk/audit";
import { budget } from "outbound-sdk/budget";
import { instantly } from "outbound-sdk/instantly";

import { documentedFetch } from "../plugins-budget/documented-fetch.ts";

const entries: AuditEntry[] = [];

// Instantly's adapter on a fetch that serves the vendor's documented
// payloads from disk, so there are real HTTP requests to record.
const outbound = createOutbound({
  adapter: instantly({ workspaceId: "acme" }),
  fetch: documentedFetch,
  plugins: [
    audit({
      requests: true,
      sink: (entry) => {
        entries.push(entry);
      },
    }),
    // Inside the audit layer, so the call it stops is recorded as a failure.
    budget({ maxRequests: 1 }),
  ],
});

await outbound.senders.list();

try {
  await outbound.senders.listAll().toArray();
} catch (error) {
  if (!OutboundError.is(error)) {
    throw error;
  }
}

// The sink is not awaited by the read. Give its entries a turn to arrive.
await setImmediate();

for (const entry of entries) {
  console.log(JSON.stringify(entry));
}
```

```text title="Output"
{"attempt":1,"durationMs":0,"lane":"default","method":"GET","provider":"instantly","status":200,"url":"https://api.instantly.ai/api/v2/accounts?limit=100","at":"2026-11-14T12:00:00.000Z","type":"request"}
{"at":"2026-11-14T12:00:00.000Z","durationMs":0,"error":null,"operation":"senders.list","provider":"instantly","records":2,"requests":1,"throttledMs":0,"type":"call","workspaceId":"acme"}
{"attempt":1,"durationMs":0,"lane":"default","method":"GET","provider":"instantly","status":200,"url":"https://api.instantly.ai/api/v2/accounts?limit=100","at":"2026-11-14T12:00:00.000Z","type":"request"}
{"at":"2026-11-14T12:00:00.000Z","durationMs":0,"error":null,"operation":"senders.list","provider":"instantly","records":2,"requests":1,"throttledMs":0,"type":"call","workspaceId":"acme"}
{"at":"2026-11-14T12:00:00.000Z","durationMs":0,"error":"RateLimited","operation":"senders.list","provider":"instantly","records":null,"requests":0,"throttledMs":0,"type":"call","workspaceId":"acme"}
```

Each page of a walk is its own operation and its own entry. The last entry is the page the budget refused: no records, no request, and the error code. Durations are `0` because the samples run with a fixed clock.

## Options

| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| `sink` | `(entry: AuditEntry) => void \| Promise<void>` | required | Receives every entry. It is not awaited, and a sink that throws or rejects is ignored. |
| `requests` | `boolean` | `false` | Also record each HTTP request. |
| `now` | `() => number` | `Date.now` | The clock, for tests. |

## Entries

A call entry (`type: "call"`), one per operation:

| Field | Type | Meaning |
| --- | --- | --- |
| `at` | `string` | When the operation started, ISO 8601 UTC. |
| `operation` | `string` | The operation kind, for example `senders.list`. |
| `provider`, `workspaceId` | `string` | The adapter's. |
| `durationMs` | `number` | From start to answer or error, including retries and throttle waits. |
| `requests` | `number` | Vendor requests this operation made. |
| `throttledMs` | `number` | Time this operation waited for rate budget. |
| `records` | `number \| null` | Records returned. `null` when it failed. |
| `error` | `string \| null` | The error code when it failed. An error that is not an `OutboundError` is recorded as `Provider`. |

A request entry (`type: "request"`), one per HTTP attempt, when `requests` is `true`:

| Field | Type | Meaning |
| --- | --- | --- |
| `at` | `string` | When the request settled, ISO 8601 UTC. |
| `method` | `string` | The HTTP method. |
| `url` | `string` | The request URL with secrets removed. The query string is kept. |
| `status` | `number \| null` | The HTTP status, or `null` when no response arrived. |
| `durationMs` | `number` | How long the attempt took. |
| `attempt` | `number` | Which attempt this was, starting at 1. |
| `lane` | `string` | The rate-limit lane the request was counted in. |
| `provider` | `string` | The adapter's. |

A request entry has no `workspaceId`. A request URL can carry a vendor id or a filter value in its path or query, so request entries are not free of data the way call entries are.

## On the in-memory adapter

The in-memory adapter makes no HTTP request, so `requests` is `0` and there are no request entries. Call entries still arrive.

```ts title="memory.ts"
import { createOutbound } from "outbound-sdk";
import { audit } from "outbound-sdk/audit";
import { memory } from "outbound-sdk/memory";

import { seed } from "../plugins-index/seed.ts";

const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "agency" }),
  plugins: [
    audit({
      // A sink that throws is ignored. The read below still succeeds.
      sink: (entry) => {
        if (entry.type === "call") {
          console.log(
            `${entry.at} ${entry.operation}: ${entry.records} records, ${entry.requests} requests, error ${entry.error}`
          );
        }

        throw new Error("the log disk is full");
      },
    }),
  ],
});

// One SDK call can be several operations: a campaign's stats and its leads.
await outbound.campaign("cmp_founders").stats();

const leads = await outbound.campaign("cmp_founders").leads.list();

console.log("the read returned", leads.items.length, "leads");
```

```text title="Output"
2026-11-14T12:00:00.000Z stats: 1 records, 0 requests, error null
2026-11-14T12:00:00.000Z leads.list: 2 records, 0 requests, error null
the read returned 2 leads
```

## What it changes

It changes no capability, no record and nothing in `meta`. It adds nothing to the instance.

## With other plugins

- List it inside `cache` to record only reads that did real work. Listed before `cache`, it records cache hits too, as operations with `requests: 0`.
- List it before `budget` to record the calls a budget refuses, as the sample does. An operation stopped by a layer outside the audit layer never reaches it and leaves no entry.
- A call refused by a capability check (`Unsupported`) or by input validation happens before any plugin runs and leaves no entry.
- Extra reads other plugins make through the chain, such as the campaign lookup `clients` does, are entries of their own.
- For the same events without the plugin, see [hooks](/docs/api/hooks). For spans, see [tracing](/docs/plugins/tracing).

## When it fails

| What happened | What you get | What to do |
| --- | --- | --- |
| The sink throws or rejects | Nothing. The read is not affected and the entry is lost. | Make the sink durable yourself if entries must not be lost. |
| The sink is slow | Nothing. The read does not wait for it. | Entries can arrive after the read returns, and are not guaranteed to have arrived when the process exits. |
| The operation fails | A call entry with `error` set and `records: null`, then the error reaches the caller unchanged | Nothing. |
