Skip to content
Preview: outbound-sdk is not on npm yet and its repository is private. Instantly and HeyReach are beta, Smartlead is unverified.What is verified
outbound-sdk
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 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. 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.

Was this page helpful?