Release candidate — 1.0.0-rc.9
StitchAPI
GuidesObservability

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

A 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:

ValueEffect
(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 TraceSinkAny custom sink — e.g. from createTrace / multiplex / otlp.sink (stitchapi/otlp).
falseForces 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:

EventLevel
resultinfo
errorerror
driftthe finding's own level (error / warn / info)
start / progress / info / donedebug
deltanever 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' } }),
});

info events default to debug, which an info-level production logger never prints. One of them is worth promoting: a throttle.per-process notice means a rate or concurrency limit is not fleet-wide on your store (see the capability table). It fires once per throttle, so raise just that topic with levelOf:

import { ,  } from 'stitchapi';

const  = ({
    : 'https://api.example.com',
    : '/search',
    : (, {
        : () =>
            . === 'info' && . === 'throttle.per-process'
                ? 'warn'
                : ,
    }),
});

@stitchapi/pino and @stitchapi/nest leave info announcements out of their logs; to see this one there, read it off the event stream (.stream()) or add trace: 'console' alongside.

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):

  • authorization
  • proxy-authorization
  • cookie
  • set-cookie
  • x-api-key

Those five are the fixed list. The same redaction also covers a header whose name ends in -key (api-key, Ocp-Apim-Subscription-Key, X-RapidAPI-Key, x-goog-api-key — the dashes defeat the api_key stem), one that names a session (x-session-id), and one that carries a secret stem (x-auth-token, x-csrf-token, x-client-secret, x-amz-signature) or a name you secrets.register. A few -key headers are not credentials and stay readable: Idempotency-Key (a dedupe token worth having when you debug a retry), Sec-WebSocket-Key, Surrogate-Key and X-Cache-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 JSONL sink applies the same [REDACTED] to any secret-named key at any depth of the record — the shared secrets denylist (password, client_secret, access_token, api_key, signature, … and any name you secrets.register) — and that covers the payloads: the request body, GraphQL variables, the response data and every streamed chunk. An OAuth password-grant body or a token response therefore reaches disk as {"grant_type":"password","password":"[REDACTED]"} and {"access_token":"[REDACTED]","expires_in":3600}.

A payload is not a headers map, so two rules differ from the header and URL ones:

  • A secret name redacts every string beneath it, and only strings. A key such as api_keys, tokens or credentials taints its whole subtree, so api_keys: ["A1"], credentials: { pass: "P1", key: "K1" } and apiKeys: [{ key: "sk-…" }] reach disk as [REDACTED] strings — at any depth, whatever the inner keys are called. A token count (usage.output_tokens, max_tokens), a flag (signature_valid: true), a null or an empty string sits under a secret-looking name but is not a secret, and stays readable.
  • code, key and auth are ordinary fields in a payload (a status code, a cache key) and stay readable there. In a URL query they are credentials, so ?code=… and ?key=… are redacted in the url and in the structured input.query.

The match is by key name only — a secret under an innocuous name, or inside a free-text string, is not caught. A string under a name that merely contains a stem (next_page_token) is still redacted: over-matching is the safe direction.

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. The scrub is textual, so it reaches a relative or protocol-relative URL, a URL nested in another param's value, ;-separated pairs and a JSON-escaped URL too — not only a well-formed absolute one.

The URL also shows up in failure text: a transport writes the URL it called into its own error message, and with apiKey({ in: 'query' }) that URL carries the key. The engine scrubs it before the error event is built, so a .message that quotes a URL — absolute, schemeless or relative — (and the retry progress detail that quotes the same text) reads ?api_key=REDACTED for every sink, a custom one included. The start event's raw url is a different field: a custom sink still has to scrub that one itself.

Need to redact a header whose name none of those rules catches (say a partner's x-acme-ref)? 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-acme-ref'],
    }),
});

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, "chars": 51234, "preview": "{\"items\":[{\"id\":1,…" }

preview is the first body.chars characters of the (already 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. Header, URL and secret-named body fields are redacted (above) before the cap applies, so neither the stored body nor its preview holds one, at any cap — full capture included. Customer data in a response body is the case this leaves open: the customer data you didn't mean to log counts which destinations carry a whole body and which carry none, and why there is nothing in between.

Opt into full capture — the whole body, untruncated — when you need it, with body: { chars: 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.js

Migration: 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, { body: { chars: false } })). A custom TraceSink is unaffected: it receives the live events and decides for itself.

See also

On this page