tracing
Emit one OpenTelemetry span per SDK operation and one per HTTP request, with the span names and attributes the plugin sets.
tracing({ tracer }) from outbound-sdk/tracing emits OpenTelemetry spans: one for every operation the instance runs and one for every HTTP request. Pass a tracer from @opentelemetry/api. The plugin is written against the small part of the tracer and span interfaces it uses (TracerLike, SpanLike), so the package does not depend on OpenTelemetry and anything with a matching startSpan works.
Sample
The tracer here is a stand-in that prints each span when it ends. The first instance runs Instantly’s adapter on the fetch from the budget page, which serves the vendor’s documented payloads from the repository, not live responses. The second is the in-memory adapter.
import { createOutbound, OutboundError } from "outbound-sdk";
import { instantly } from "outbound-sdk/instantly";
import { memory } from "outbound-sdk/memory";
import { tracing } from "outbound-sdk/tracing";
import type { SpanLike, TracerLike } from "outbound-sdk/tracing";
import { documentedFetch } from "../plugins-budget/documented-fetch.ts";
// In production pass `trace.getTracer("outbound")` from @opentelemetry/api.
// This stand-in prints each span when it ends, to show what the plugin emits.
const tracer: TracerLike = {
startSpan: (name, options): SpanLike => {
const attributes = new Map(Object.entries(options?.attributes ?? {}));
let status = "unset";
return {
end: () => {
console.log(`${name} [${status}]`, Object.fromEntries(attributes));
},
setAttribute: (key, value) => {
attributes.set(key, value);
},
setStatus: ({ code }) => {
status = code === 1 ? "ok" : "error";
},
};
},
};
// Instantly's adapter on a fetch that serves the vendor's documented
// payloads from disk: one operation span and one HTTP span.
const vendor = createOutbound({
adapter: instantly({ workspaceId: "acme" }),
fetch: documentedFetch,
plugins: [tracing({ tracer })],
});
await vendor.senders.list();
// A failed operation ends its span with the error code. The in-memory
// adapter makes no HTTP request, so there is no HTTP span here.
const local = createOutbound({
adapter: memory({ workspaceId: "agency" }),
plugins: [tracing({ tracer })],
});
try {
await local.campaigns.get("cmp_missing");
} catch (error) {
if (!OutboundError.is(error)) {
throw error;
}
}
outbound.http GET [ok] {
'http.request.method': 'GET',
'outbound.attempt': 1,
'outbound.lane': 'default',
'outbound.provider': 'instantly',
'url.full': 'https://api.instantly.ai/api/v2/accounts?limit=100',
'http.response.status_code': 200
}
outbound.senders.list [ok] {
'outbound.operation': 'senders.list',
'outbound.provider': 'instantly',
'outbound.workspace': 'acme',
'outbound.records': 2,
'outbound.evidence': 'exact',
'outbound.requests': 1
}
outbound.campaigns.get [error] {
'outbound.operation': 'campaigns.get',
'outbound.provider': 'memory',
'outbound.workspace': 'agency',
'outbound.error': 'NotFound',
'outbound.requests': 0
}
Options
| Option | Type | Default | Meaning |
|---|---|---|---|
tracer |
TracerLike |
required | An object with startSpan(name, options?). trace.getTracer(name) from @opentelemetry/api returns one. |
now |
() => number |
Date.now |
The clock used to back-date HTTP spans, for tests. |
Spans
An operation span is named outbound.<operation kind>, for example outbound.campaigns.list.
| Attribute | Set | Value |
|---|---|---|
outbound.operation |
At start | The operation kind. |
outbound.provider |
At start | The adapter’s name. |
outbound.workspace |
At start | The adapter’s workspace label. |
outbound.records |
On success | Records returned. |
outbound.evidence |
On success | exact, sampled or derived. |
outbound.error |
On failure | The error code. An error that is not an OutboundError is Provider. |
outbound.requests |
Always, at the end | Vendor requests the operation made. |
Its status is OK (code 1) on success. On failure it is ERROR (code 2) with the error’s message.
An HTTP span is named outbound.http <METHOD>, one per attempt, so a retried request is several spans.
| Attribute | Value |
|---|---|
http.request.method |
The method. |
http.response.status_code |
The status. Absent when no response arrived. |
url.full |
The request URL with secrets removed. |
outbound.attempt |
Which attempt this was, starting at 1. |
outbound.lane |
The rate-limit lane. |
outbound.provider |
The adapter’s name. |
Its status is OK for a status below 400, and ERROR otherwise, including when no response arrived.
Two things to know when reading a trace:
- HTTP spans are created after the request finishes. The plugin learns of a request from the
onRequesthook, which fires once it has settled. It starts the span withstartTimeset to now minus the request’s duration and ends it at once. In the sample the HTTP span therefore prints before its operation span. - The plugin passes no parent context. It calls
startSpan(name, { attributes, startTime })and nothing else. How a span is parented is up to your tracer and whatever context is active when it is called.
What it changes
It changes no capability, no record and nothing in meta. It adds nothing to the instance.
With other plugins
The position rules are the same as for audit: inside cache to trace only real work, and outside any layer whose refusals you want to see as failed spans. HTTP spans do not depend on position. They come from a hook, so every request the instance makes is a span.
When it fails
| What happened | What you get | What to do |
|---|---|---|
| The operation fails | An ERROR span with outbound.error, ended, then the error reaches the caller unchanged |
Nothing. |
| A request gets no response | An ERROR HTTP span with no http.response.status_code |
Nothing. |
startSpan or a span method throws inside the operation layer |
The read fails with that error | Pass a tracer that does not throw. OpenTelemetry’s API tracers do not. |
startSpan throws while recording an HTTP span |
Nothing. Hooks cannot fail a read. | Nothing. |