Trace sinks
Tracing is off by default — opt in with trace: console, a fileSink, env vars, or your own TraceSink.
A trace sink is any consumer of a stitch's event stream —
reach for one when you want every start → progress → drift → result → done
teed somewhere durable. Tracing is off by default: a stitch makes its call
and writes nothing, prints nothing
(no side effects by default). You turn it on per
stitch with the trace field, globally with an env var, or handle events
yourself by iterating .stream().
Example
Opt in per stitch with the trace field — 'console' for the colored stderr
stream, or fileSink(path) for JSONL on disk:
import { , } from 'stitchapi';
// Colored one-line-per-event stream to stderr.
const = ({
: 'https://api.example.com',
: '/search',
: 'console',
});
// JSONL appended to a file you name.
const = ({
: 'https://api.example.com',
: '/report',
: ('./runs/today.jsonl'),
});Or wire it globally by environment variable — handy for turning tracing on without touching code:
# Colored stream to stderr.
STITCH_TRACE_CONSOLE=1 node run.js
# Append JSONL to a path (off unless set).
STITCH_TRACE_FILE=./runs/today.jsonl node run.js
# ALSO fan the same events to an OTLP collector.
STITCH_EXPORT=otlp node run.js
# Turn file tracing OFF: 0, false, or an empty value disable it (no file is written).
STITCH_TRACE_FILE=0 node run.js
# Capture full request/response bodies (the JSONL truncates them to 2048 chars by default).
STITCH_TRACE_MAX_BODY=full node run.jsA stitch-local trace wins over the environment: trace: false forces tracing
off even when the env vars are set.
To consume events as your own sink, iterate the stream — that's the public seam for custom handling:
import { } from 'stitchapi';
const = ({ : 'https://api.example.com', : '/search' });
for await (const of ({ : { : 'mango' } }).()) {
if (. === 'drift')
.(.., ..);
if (. === 'result') .(.);
}Options
The trace field accepts:
| Value | Effect |
|---|---|
| (unset) | Falls back to the env vars below — off unless one is set. |
'console' | The colored one-line-per-event stream to stderr. |
fileSink(p) | JSONL appended to p (defaults to ~/.stitch/runs/proto.jsonl). |
a TraceSink | Any custom sink — e.g. from createTrace / multiplex / otlpSink. |
false | Forces tracing off, even when STITCH_TRACE_* is set. |
When trace is unset, four env vars control the built-in sink:
STITCH_TRACE_CONSOLE=1 turns on the colored stderr stream;
STITCH_TRACE_FILE=<path> appends JSONL to that path (off when unset, 0, or
false); STITCH_EXPORT=otlp adds OTLP as one more sink fed from the same
events — see OTLP export; and
STITCH_TRACE_MAX_BODY tunes body truncation
(full to capture whole bodies, or a character cap).
To build sinks in code, consoleSink() and fileSink(path) return the
single-purpose sinks, createTrace({ console, file }) returns one that does
both, and multiplex(a, b, ...) fans one event stream out to several sinks at
once. All are exported from stitchapi.
Logging to a host logger
Already running pino, winston, or just console? loggerSink(logger) bridges the
event stream straight onto it — no JSONL file, no OTLP collector. It is
logger-agnostic (anything with error / warn / info / debug methods
satisfies the LoggerLike shape) and payload-free: it logs only metadata —
name, method, scrubbed URL, status, attempt counts, drift path/level, timing —
never event.input, event.data, or a streamed delta chunk.
import { , } from 'stitchapi';
// `console` already satisfies LoggerLike; swap in `pino()` / a winston logger / a
// Nest `Logger` and nothing else changes.
const = ({
: 'https://api.example.com',
: '/search',
: (),
});Each StitchEvent maps to a level:
| Event | Level |
|---|---|
result | info |
error | error |
drift | the finding's own level (error / warn / info) |
start / progress / info / done | debug |
delta | never logged — a streamed chunk is raw response data. |
Override any per-type level with levels — for example, demote the happy-path
result to debug so production logs stay quiet until something drifts or errors:
import { , } from 'stitchapi';
const = ({
: 'https://api.example.com',
: '/search',
: (, { : { : 'debug' } }),
});This is the generic core counterpart to @stitchapi/nest's NestJS-specific
nestLoggerSink, which forwards the same payload-free metadata to a Nest
Logger — see NestJS integration.
Turning tracing off
There are two off switches, both of which disable the built-in sinks:
// Per-stitch: `trace: false` disables ALL built-in sinks for this stitch and wins
// over every STITCH_TRACE_* env var. (A `TraceSink` value replaces them instead.)
const = ({
: 'https://api.example.com',
: '/health',
: false,
});Globally, set STITCH_TRACE_FILE to 0, false, or an empty string to turn file
tracing off — these are treated as the off switch, not as a path, so no file named
0 or false is ever written to the working directory.
Default secret redaction
Before any event is written to the built-in JSONL (or console) sink, header values
for a fixed denylist are replaced with the literal [REDACTED], matched
case-insensitively wherever headers appear in the event payload (the start
event's input headers, response headers, and so on):
authorizationproxy-authorizationcookieset-cookiex-api-key
So a call carrying authorization: 'Bearer …' traces as
"authorization":"[REDACTED]" — the raw secret never reaches disk. Redaction
happens at the sink boundary, so the live request still sends the real header; only
the trace copy is scrubbed.
The same boundary scrubs URLs: a start event's resolved URL has any userinfo
(https://user:pass@host) removed and the values of secret-bearing query params
(api_key, access_token, signature, …) replaced with REDACTED — in both the
JSONL url field and the OTLP url.full
attribute. The matching keys in the start event's structured input.query are
redacted the same way, so the two views can't disagree; benign params (page,
sort) survive for observability.
Need to redact a non-standard auth header (say x-auth-token)? Widen the
denylist — you can extend it but never shrink it — with redactHeaders:
import { , } from 'stitchapi';
const = ({
: 'https://api.example.com',
: '/search',
: ('./runs/today.jsonl', {
: ['x-auth-token', 'x-session-id'],
}),
});A TraceSink is just { handle(event, ctx), flush?() } — implement those two
and you have a custom consumer you can pass to trace or multiplex. The same
shape backs the console, JSONL, and OTLP sinks, which is why all three come from
one source without the call site knowing. See
Reference → Helpers for the exported building blocks
and Reference → Event types for the StitchEvent
union a sink receives.
Anti-pattern: don't assume a custom TraceSink inherits the built-in
[REDACTED] scrubbing and write the raw event payload straight to your
destination — its headers still carry the live secret (authorization,
cookie, x-api-key), so a handle that logs them verbatim leaks a
credential the call resolves but the caller never sees; scrub the same
header denylist inside your handle before you persist, or wrap the
built-in sink via multiplex so redaction runs first. See Capability, not
credential.
The CLI honors the same default: stitch run traces nothing until you pass
--trace, which records the JSONL file (at ~/.stitch/runs/proto.jsonl, or
wherever STITCH_TRACE_FILE points) so stitch trace
has a log to summarize. Use --trace=console to stream to stderr instead.
Body truncation and full capture
To keep one large response from bloating the log — and to limit how much payload
is persisted by default — the built-in JSONL sink truncates the request body
(the start event) and the response value (the result event) once their JSON
encoding passes a character cap. The oversized value is replaced with a compact
marker:
{ "truncated": true, "bytes": 51234, "preview": "{\"items\":[{\"id\":1,…" }preview is the first maxBodyBytes characters of the (already header-redacted)
JSON, so you keep a readable head of the payload without storing all of it. The
default cap is 2048 characters; 0 keeps only the marker, no preview.
Truncation bounds size — it is not field-level redaction, and a preview can still contain a body-embedded secret. Header and URL secrets are redacted (above) regardless of the cap.
Opt into full capture — the whole body, untruncated — when you need it, with
maxBodyBytes: false on the file sink:
import { , } from 'stitchapi';
const = ({
: 'https://api.example.com',
: '/report',
// false = capture the whole body; a number sets a custom character cap.
: ('./runs/today.jsonl', { : false }),
});Or globally, without touching code, via the env knob the built-in sink reads:
# Whole bodies, no truncation.
STITCH_TRACE_MAX_BODY=full node run.js
# A custom character cap (here 8 KB).
STITCH_TRACE_MAX_BODY=8192 node run.jsMigration: earlier releases stored every request and response body in the
JSONL trace in full. The built-in file sink now truncates them to 2048
characters by default, and resolved URLs have their userinfo and secret query
values scrubbed. No API changed shape and nothing was removed — if a tool or
stitch trace workflow relied on whole bodies on disk, restore the old
behaviour with STITCH_TRACE_MAX_BODY=full (or
fileSink(path, { maxBodyBytes: false })). A custom TraceSink is unaffected:
it receives the live events and decides for itself.