audit
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.
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, 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.
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));
}
{"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.
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");
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
cacheto record only reads that did real work. Listed beforecache, it records cache hits too, as operations withrequests: 0. - List it before
budgetto 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
clientsdoes, are entries of their own. - For the same events without the plugin, see hooks. For spans, see 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. |