@partner-api/logger
v3.0.0
Published
Logging module for partner API — runs on Node 20+, Deno, Bun and Workers
Downloads
620
Readme
Partner API Logger SDK (TypeScript)
Send structured logs and metrics to the Partner API ingest service.
Zero dependencies. Runs anywhere there is a global fetch and Web Crypto —
Node 20+, Deno, Bun, Cloudflare Workers, Vercel Edge, and Node-based serverless
runtimes such as Firebase Cloud Functions.
Installation
pnpm add @partner-api/logger # Node, Bun, bundlers
deno add npm:@partner-api/logger # DenoBoth a CommonJS and an ESM build ship in the package; require and import
each resolve to the right one.
Usage
import { Logger } from '@partner-api/logger';
const logger = new Logger({
tenantToken: process.env.PARTNER_API_TENANT_TOKEN!,
baseUrl: 'https://ingest.partnerapi.com', // optional
onError: (event) => console.error(event.message), // optional
});
// Buffered. Returns immediately — no network on your request path.
logger.info(apiKey, 'Order created', { orderId: order.id });Log calls never perform I/O and never throw or reject. They append to
an in-process buffer that drains in the background; the only way a delivery
failure reaches you is the onError hook. On a runtime that suspends your
instance after the response, await logger.flush() before returning — see
Delivery.
See packages/logger-spec/spec.md for the full cross-language behaviour contract.
Runtime support
| Runtime | Supported | Notes |
| -------------------------------- | --------- | ------------------------------------- |
| Node 20+ | yes | Both require and import |
| Firebase Cloud Functions (gen 2) | yes | Node 20/22 — flush before returning |
| Deno | yes | npm:@partner-api/logger |
| Supabase Edge Functions | yes | Deno runtime — see quickstart below |
| Bun | yes | |
| Cloudflare Workers / Vercel Edge | yes | |
| Node 18 | untested | Works, but EOL — see below |
| Node 16 and below | no | No global fetch |
engines declares >=20. That is a support policy, not a technical floor:
the code does run on Node 18, which has both fetch and
crypto.randomUUID — but 18 is end-of-life and is not covered by CI, so
nothing keeps it working. Install with --ignore-engines (or
engine-strict=false) if you need it anyway.
Egress proxies need a transport
If your logs leave through an HTTP proxy, read this before upgrading.
v1's axios read HTTP_PROXY / HTTPS_PROXY / NO_PROXY from the
environment automatically. The platform fetch does not, on any runtime, and
v2 has no dependency that could. Upgrading behind a proxy makes log delivery
fail silently — the env vars keep being set and stop being honoured.
Pass a transport that proxies:
import { fetch as undiciFetch, ProxyAgent } from 'undici';
const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!);
const logger = new Logger({
tenantToken,
fetch: (url, init) => undiciFetch(url, { ...init, dispatcher }),
});On Deno, DENO_CERT / --proxy handle this at the runtime level and no
override is needed.
Delivery: buffered and non-throwing
Telemetry must not be able to break, slow, or fail the request it describes. So (since v3) every log method buffers:
info/warn/error/debug/logRequest/logResponseappend to an in-memory queue and return an already-resolved promise. Awaiting them is harmless and instant; not awaiting them is also safe — there is no rejection to go unhandled.- The queue drains when it reaches
batchSizeentries, everyflushIntervalMs, and whenever you callflush(). Each drain sends one POST per batch instead of one POST per line, split so no request exceeds ingest's limit of 1000 entries per request. (Ingest also accepts a 5 MB JSON body and truncates any single entry over 250 KB; on top of that the SDK caps each request at ~1 MB of log lines, which keeps it well clear of the body limit and keeps one failed batch cheap to retry.) - Failed batches are retried with jittered exponential backoff. A batch that
still cannot be delivered is dropped and reported to
onError— never raised at whatever code happened to log at the time. - The buffer is fixed-size. When it is full the oldest entries are dropped first, counted, and reported.
metric() / metrics() are not buffered: a metric submission is an
explicit write you are entitled to a receipt for, so it awaits the POST and
throws on failure exactly as before.
Flush before the instance goes away
On any runtime that suspends or destroys the instance once the response is returned — Firebase Cloud Functions, Lambda, Cloud Run with CPU throttling, Vercel/Supabase Edge — drain the buffer before returning:
export const handler = onRequest(async (req, res) => {
const correlationId = await logger.logRequest(apiKey, {
method: req.method,
path: req.path,
});
const body = await doWork();
logger.logResponse(apiKey, {
statusCode: 200,
duration: Date.now() - start,
correlationId,
});
await logger.flush(); // one batched POST for the whole request
res.json(body);
});flush() resolves once everything buffered at call time has been delivered or
given up on. It never rejects. Where the platform offers a waitUntil, hand it
logger.flush() instead and keep even that off the response path.
Use shutdown() (alias close()) on a long-lived process's exit path: it
stops the periodic timer and flushes. The logger stays usable afterwards — a
later log call re-arms the timer.
Options
| Option | Default | What it does |
| ----------------- | ------- | --------------------------------------------------------------------- |
| onError | console.error of the message | Receives every drop: { reason, message, entryCount, status?, attempts?, retryable?, droppedTotal, cause? }. reason is flush-failed, buffer-overflow or invalid-entry. |
| batchSize | 100 | Buffered entries that trigger an automatic flush. Capped at 1000 (the ingest per-request maximum) and at maxBufferSize. |
| flushIntervalMs | 2000 | Milliseconds between automatic flushes. 0 disables the timer, leaving batchSize and flush() as the triggers. |
| maxBufferSize | 1000 | Entries held before the oldest are dropped. Bounds memory while ingest is unreachable. |
| maxRetries | 3 | Retries per batch after the first attempt. Network faults, 408, 429 and 5xx are retried; any other 4xx is a bad request and is dropped immediately. |
| retryBaseDelayMs| 200 | First backoff step. Equal jitter: half the window is the exponential step, half is random. |
| retryMaxDelayMs | 5000 | Backoff ceiling. |
| requestTimeoutMs| 5000 | Per-request deadline. Bounds how long an awaited flush() can hold a response — without it a blackholed ingest leaves the request pending forever. 0 disables it. Needs AbortSignal.timeout; where the runtime lacks it the SDK runs without a deadline. |
logger.stats() returns { buffered, delivered, dropped } if you want to
export the SDK's own health as a metric.
const logger = new Logger({
tenantToken,
batchSize: 50,
flushIntervalMs: 5000,
maxBufferSize: 5000,
onError: (event) => {
metrics.increment('partner_api.logs.dropped', event.entryCount);
console.error(event.message);
},
});Supabase Edge Functions quickstart
Supabase Edge Functions run on Deno, so there is no install step and no bundler
— import through the npm: specifier and the runtime fetches the package,
reads its exports map, and loads the ESM build.
// supabase/functions/orders/index.ts
import { Logger } from 'npm:@partner-api/logger@^3.0.0';
const logger = new Logger({
tenantToken: Deno.env.get('PARTNER_API_TENANT_TOKEN')!,
baseUrl: 'https://ingest.partnerapi.com',
// The isolate dies with the Response, so never wait on a timer for a drain.
flushIntervalMs: 0,
});
const apiKey = Deno.env.get('PARTNER_API_APP_KEY')!;
Deno.serve(async (req) => {
const start = Date.now();
const { pathname } = new URL(req.url);
const correlationId = await logger.logRequest(apiKey, {
method: req.method,
path: pathname,
});
const order = await createOrder(req);
logger.logResponse(apiKey, {
statusCode: 200,
duration: Date.now() - start,
correlationId,
});
await logger.flush(); // see "Flushing" below
return Response.json(order);
});Both credentials belong in Supabase secrets, not in the function source:
supabase secrets set PARTNER_API_TENANT_TOKEN=tenant_live_xxxxxxxxxxxx
supabase secrets set PARTNER_API_APP_KEY=your-app-api-keySecrets are readable from Deno.env.get immediately — no redeploy. Locally the
same two names come from supabase/functions/.env, which the CLI loads for you;
any other filename has to be passed as --env-file. Pick names outside
Supabase's reserved SUPABASE_* / SB_* set.
To drop the specifier prefix from function code, map it once in
supabase/functions/deno.json; Supabase rejects bare specifiers that aren't
mapped or prefixed with npm: / jsr:.
{
"imports": {
"@partner-api/logger": "npm:@partner-api/logger@^3.0.0"
}
}Flushing: the isolate dies with the Response
The Edge runtime tears the isolate down as soon as your handler's Response is
returned, so nothing left in the buffer at that moment is ever sent. Call
await logger.flush() before returning, as in the quickstart above — one
batched POST for the whole request, and it never rejects.
Where the runtime offers waitUntil, hand it the flush instead and keep even
that round-trip off the response path. EdgeRuntime is a runtime global rather
than something on the request context, and is absent under a bare deno run or
a unit test around the handler, so feature-detect it:
type EdgeRuntimeGlobal = {
EdgeRuntime?: { waitUntil(promise: Promise<unknown>): void };
};
/** Drains past the Response where the runtime allows it; awaitable where it doesn't. */
function drain(): Promise<void> {
const runtime = (globalThis as EdgeRuntimeGlobal).EdgeRuntime;
if (runtime?.waitUntil) {
runtime.waitUntil(logger.flush());
return Promise.resolve(); // the instance stays up until the flush resolves
}
return logger.flush();
}Locally, feature detection alone is not enough: the CLI runtime does define
EdgeRuntime, but defaults to policy = "oneshot", which terminates the
instance once the request completes and cuts the deferred flush off. Switch the
policy so background work survives the response:
# supabase/config.toml
[edge_runtime]
policy = "per_worker"Custom transport
fetch can be overridden — useful for proxying, retries, instrumentation, or
capturing requests in tests:
const logger = new Logger({
tenantToken,
fetch: (url, init) => myInstrumentedFetch(url, init),
});Upgrading from v2
v3 makes delivery buffered and non-throwing (PAPI-3647). Every signature is
unchanged and type-compatible — info still returns Promise<void>,
logRequest still returns Promise<string> — so existing code compiles and
runs untouched. What changes is what those promises mean.
Breaking changes:
- Log methods no longer reject.
info/warn/error/debug/logRequest/logResponsereturn an already-resolved promise; atry/catchor.catch()around them now never fires. Delivery failures arrive atonErrorinstead. If you were counting failed sends at the call site, move that counter into the hook. awaitno longer means "delivered". It means "buffered". Addawait logger.flush()at the point where you need the entries to have landed — before returning a response on a suspending runtime, or on a shutdown path. This is what the old "await every call" serverless caveat collapses into.- One POST now carries many entries. Batches are grouped by API key,
level,
partnerIdand upstream attribution, and capped at the ingest limit of 1000 entries. A test asserting one HTTP request per log line needs updating; the per-entry wire format is unchanged. - Failed batches are retried (network faults, 408, 429, 5xx; up to
maxRetries, default 3) and dropped after that. SetmaxRetries: 0for the old one-attempt behaviour. metric/metricsare unchanged — still awaited, still throwing.
Not breaking, worth knowing: flushIntervalMs defaults to 2000ms and the
timer is unref'd, so it never keeps a Node process alive. On a runtime that
freezes between requests, set flushIntervalMs: 0 and drive drains from
flush().
Upgrading from v1
v2 is a runtime-portability release. Logger, setContext,
info/warn/error/debug, logRequest, logResponse, metric,
metrics and redactPII all keep their v1 signatures, and the request
body is byte-identical to v1 — verified by diffing the raw bytes of both
versions across logs, metrics and full-context payloads.
Breaking changes:
- Proxy environment variables are no longer honoured. The big one — see
Egress proxies need a transport. If you
send logs through a proxy, v2 needs a
fetchoverride or it silently stops delivering. axiosis gone. It was the package's only dependency, and it is no longer installed transitively — anything that relied on that needs its ownaxiosdependency.- Deep imports are no longer resolvable. The package now declares an
exportsmap, so only@partner-api/loggeris importable; reaching into@partner-api/logger/dist/...breaks. Everything public is exported from the root. engines: node >=20is declared where v1 declared nothing. Underengine-strictthis blocks installs on Node 18, which does otherwise work.- Network-failure messages differ. Status-code failures are unchanged
(
Failed to send log: Request failed with status code 500). Connection failures now read as the underlying fault the same way axios reported it (Failed to send log: connect ECONNREFUSED 127.0.0.1:3003), unwrapped out offetch's opaqueTypeError: fetch failed, with the full chain kept onerror.cause. Exact strings come from the runtime, so don't match on them. - Request headers differ. No more
User-Agent: axios/1.x; the runtime sets its own defaults. Only relevant if a WAF or analytics keys off them.
Apart from the proxy case, no code change is needed for callers already on the documented API.
Outbound traffic & upstream attribution (PAPI-687)
By default every log entry is treated as inbound (partner → tenant). To
flag traffic the partner makes outbound to an upstream integration, set
the direction context plus the upstream-attribution fields:
logger.setContext({
direction: 'outbound',
upstreamIntegration: 'stripe', // name or ID, preferred for attribution
upstreamBaseUrl: 'https://api.stripe.com', // fallback when no name is known
});
await logger.info(apiKey, 'Calling Stripe charges API', {
method: 'POST',
path: '/v1/charges',
});directionis shipped as a Loki stream label so queries can isolate one direction:{tenantId="…", direction="outbound"}. Allowed values areinbound(default) andoutbound; ingest rejects anything else with a 400.upstreamIntegration/upstreamBaseUrlare folded into the log line as the structured fieldsupstream_integration/upstream_base_url(queryable via| json | upstream_integration="stripe"). They are not stream labels — base URLs are high-cardinality and would explode Loki's series count.- Backwards compatible: omitting
directionleaves entries on the existing inbound streams unchanged; no migration is needed for historical data.
PII Redaction Helper
For call sites that need to redact PII before passing user data into a downstream system whose logs you don't control, the package exposes a public redactPII() helper. The ruleset mirrors what the ingest service applies internally — emails, JWTs, Bearer tokens, named API-key prefixes, passwords, phone numbers, credit cards, IPv4/IPv6 addresses, and URL query strings.
import { redactPII } from '@partner-api/logger';
// Strings (regex pass)
redactPII('contact [email protected]');
// → 'contact [EMAIL_REDACTED]'
redactPII('Authorization: Bearer abc.def.ghi');
// → 'Authorization: Bearer [TOKEN_REDACTED]'
// Headers (sensitive keys replaced wholesale)
redactPII({
Authorization: 'Bearer secret',
'X-Api-Key': 'sk-livetestkey1234567890',
Cookie: 'session=abc',
});
// → {
// Authorization: 'Bearer [TOKEN_REDACTED]',
// 'X-Api-Key': '[KEY_REDACTED]',
// Cookie: '[COOKIE_REDACTED]',
// }
// JSON body / deeply nested objects
redactPII({
user: {
email: '[email protected]',
credentials: { password: 'hunter2', refresh_token: 'rt_xyz' },
},
});
// → {
// user: {
// email: '[EMAIL_REDACTED]',
// credentials: {
// password: '[PASSWORD_REDACTED]',
// refresh_token: '[TOKEN_REDACTED]',
// },
// },
// }
// Query strings (encoded as a Record)
redactPII({ user: 'alice', api_key: 'sk-livetestkey1234567890' });
// → { user: 'alice', api_key: '[KEY_REDACTED]' }Options (all default to true except redactUuids):
redactPII(input, {
preserveStructure: true, // keep sensitive keys with a redacted placeholder; false drops them
redactEmails: true,
redactApiKeys: true,
redactTokens: true,
redactPasswords: true,
redactPhoneNumbers: true,
redactCreditCards: true,
redactIpAddresses: true,
redactUrls: true,
redactUuids: false, // off by default — opt in for log-line redaction
});redactPII is pure — the input is never mutated. The return type matches the input type (string in → string out, object in → deep-cloned object out, null/undefined pass through).
