Release candidate — 1.0.0-rc.4
← Back to blog

Trace Every API Call Without Leaking Secrets

Oleksandr Zhuravlov

A charge fails in production, you add a console.log around the call to your payment provider, ship it, and three days later a security scan flags your log index: it holds live Authorization: Bearer sk_live_... headers, an access token sitting in a query string, and a full customer record per request. You wanted to see what the call did. You spilled the secret it carried.

That trade — visibility for exposure — is the default of every hand-rolled logger. StitchAPI inverts it. Tracing is off until you ask for it, and when you ask for it, redaction is already on.

The honest "before"

Here is the logging you actually write when a call misbehaves:

async function chargeCard(token, amount) {
    const headers = {
        authorization: `Bearer ${process.env.PAYMENTS_TOKEN}`,
        'idempotency-key': token,
    };
    const url = `https://api.payments.com/charges?access_token=${accessToken}`;

    console.log('-> POST', url, headers, { amount, card }); // here it is

    const res = await fetch(url, {
        method: 'POST',
        headers,
        body: JSON.stringify({ amount, card }),
    });
    console.log('<- ', res.status, await res.clone().json());
    return res.json();
}

Every sharp edge is real:

  • The headers object you printed contains the bearer token verbatim. So does any logger you hand it to.
  • The access_token query parameter is a secret, now in plaintext in the URL you logged.
  • { amount, card } is the request body — the card object is PII, logged in full.
  • This line shows the first attempt only. If fetch is wrapped in a retry loop, the 429, the wait, and the second attempt never appear.
  • Redaction is a step you have to remember, every time, on every new field. The day you forget is the day it lands in the index.

You can write a redact() helper. You then have to remember to call it, keep its denylist current, and trust that no teammate logs the raw object next to it.

The stitch version

A stitch is the call as a declaration. Tracing is one field, and the redaction is structural — it runs inside the engine before a byte reaches the sink.

import { bearer, env, stitch } from 'stitchapi';

const chargeCard = stitch({
    method: 'POST',
    url: 'https://api.payments.com/charges',
    auth: bearer(env('PAYMENTS_TOKEN')),
    retry: { attempts: 3, on: [429, 503], respectRetryAfter: true },
    trace: 'console',
});

trace: 'console' is the quickest on-switch. Make the call exactly as before:

await chargeCard({
    query: { access_token: accessToken },
    body: { amount, card },
});

The trace that lands shows the request, every retry, and the result — with the secrets already gone:

  • The authorization header reads [REDACTED]. So does cookie. Both are on the built-in denylist; you opted out of nothing.
  • The secret value of the access_token query parameter is scrubbed out of the logged URL string, and the same value is scrubbed from the structured input.query it also appears in. Scrub the URL, miss the copy: not here.
  • The request body and the response are truncated to 2048 bytes, so a megabyte of PII never becomes a megabyte of log.

You did not write a redact(). You wrote trace: 'console'. The redaction is the default, not the step you can forget.

Off by default is the security posture

Omit trace and there is no tracing — no sink, no serialization, no hot-path cost. A stitch with no trace field does the call and nothing else. This is the safe state, and it is the state you are in until you opt out of it deliberately.

When you do turn it on, 'console' is the shorthand. For anything past a terminal, reach for a sink:

import {
    consoleSink,
    createTrace,
    fileSink,
    multiplex,
    stitch,
} from 'stitchapi';

// Pretty console output (same as the 'console' shorthand, but explicit):
const a = stitch({ url, trace: consoleSink() });

// One JSON object per line, appended to disk:
const b = stitch({ url, trace: fileSink('./traces.jsonl') });

// Configure redaction and capture:
const trace = createTrace({
    console: true,
    file: './traces.jsonl',
    redactHeaders: ['x-internal-token', 'x-customer-id'],
    maxBodyBytes: 4096,
});
const c = stitch({ url, trace });

// Fan one trace to several sinks at once:
const d = stitch({
    url,
    trace: multiplex(consoleSink(), fileSink('./traces.jsonl')),
});

createTrace is where you tune the two things you might want to widen. redactHeaders adds your own header names to the built-in denylist — it does not replace authorization and cookie, it joins them. maxBodyBytes defaults to 2048; pass maxBodyBytes: false to capture full bodies when you are debugging in a place you trust and have decided the size is fine.

What gets redacted, before anything is written

The redaction is not a post-processing pass you hope ran. It happens between the event and the sink, on every sink, every time:

AxisA hand-rolled loggerA stitch trace
Secret headersYou print headers; the token is in itauthorization, cookie, and your redactHeaders names become [REDACTED]
URL credentialshttps://user:pass@host logged wholescrubUrl strips inline credentials from the URL string
Secret query valuesThe token in ?token=... is plaintextScrubbed from the URL string and the structured input.query
Large bodiesFull PII body, however bigTruncated to maxBodyBytes (2048 by default)
New secret fieldYou must remember to redact itOn the denylist, or one redactHeaders entry away

The contrast is the whole point: with a logger, safety is a habit you maintain. With a stitch, safety is the default and capturing more is the explicit choice.

The trace rides the event stream

A stitch emits a stream of events as it runs — start, progress, drift, result, done — and the trace is driven off that stream. That is why a trace shows what a single log line cannot: every retry attempt, every wait before a retry, a drift event when a validated response shape changed, and the terminal result. You see the 429, the backoff, the second attempt, and the eventual 200 as separate entries — not one line written after everything already happened.

This is the same event stream that powers everything else observable about a call, so the trace and your other instrumentation see the identical sequence of facts.

OpenTelemetry, when you have a collector

If your logs go to an OpenTelemetry pipeline, there is an OTLP exporter that turns the same event stream into spans for your collector — the OTLP guide covers wiring it up. The redaction story is the same: secrets are scrubbed before the span is built, so an OTLP backend never receives what a console.log would have handed it.

Start narrow, widen on purpose

The smallest safe trace is one field: trace: 'console', and the secrets are already handled. That is the call you have right now, made visible without making it dangerous. When you need durable traces, swap 'console' for fileSink(...); when you need to fan out, wrap them in multiplex(...); when you need a span pipeline, point the OTLP exporter at your collector. Each step is one edit, and at every step redaction stays on by default — widening capture (maxBodyBytes: false, extra redactHeaders) is a thing you choose, never a thing you forget. The field that costs nothing when absent is the field that protects you the moment it is present.

Try it

npm install stitchapi@rc

Add trace: 'console' to one stitch and watch a redacted call come through. Then read trace sinks for fileSink/createTrace/multiplex, the helpers reference for every option, and the event stream for what each trace entry maps to. For the secrets a trace must never print in the first place, see auth as a capability, not a credential; for catching shape changes a trace will surface as drift, see schema drift is a production bug; to decide which transport carries these calls, see choosing an HTTP adapter.