@kindgi/log
npm install @kindgi/log · source
Kindgi's structured logger. The runtime and @kindgi/api (createApp({ logger })) write the same records, so kindgi dev, Cloud Logging, Datadog or Loki read them the same way. Zero runtime dependencies.
- Levels per subsystem:
error,warn,info,debugandtrace, set per subsystem. A dotted name inherits its parent's level, sokernel=debugcoverskernel.sweeper. - Two formats:
- JSON: one record per line, for collectors.
- Pretty: for a person at a terminal.
- Redaction: applied to every record before it's written, at every level.
- W3C Trace Context helpers: they carry a caller's trace id through a request, a run, and the calls they make.
import { createLogger } from '@kindgi/log';
const log = createLogger({ level: 'info', format: 'json', write: (line) => process.stdout.write(`${line}\n`) });
const leases = log.child({ subsystem: 'leases' });leases.warn('could not expire the lease of run r-1', { runId: 'r-1', err });{"time":"2026-10-07T21:58:03.120Z","level":"warn","severity":"WARNING","subsystem":"leases","message":"could not expire the lease of run r-1","runId":"r-1","err":{"name":"Error","message":"…"}}- Every record has five fields:
time(RFC 3339),level,severity,subsystemandmessage.severityis Cloud Logging's name for the level (ERROR,WARNING,INFO,DEBUG), so Cloud Run and GKE show levels with no agent configuration.- Records without a subsystem are
root.
- Correlation ids come next, when known:
traceId,spanId,requestId,tenantId,projectId,runId,agentId,toolId, and others. Then the event's own fields. - Pass an error as
{ err: error }: it's written asname,messageandcode. Thestackis added aterrorlevel, or when the logger is atdebugor below. child(bindings)returns a logger whose records all carry the bindings. A child'ssubsystemreplaces its parent's, so name it in full (kernel.sweeper).isLevelEnabled(level)says whether a record would be written, so you can skip building costly fields.{ inMessage: [...] }, a third argument, names the fields the message already states:log.info('GET /v1/runs 200 12ms', { method, route, status, durationMs }, { inMessage: ['method', 'route', 'status', 'durationMs'] }). The pretty format leaves them out; JSON keeps every field.noopLoggerwrites nothing. It's the default where a logger isn't given.- Logging never throws. A sink that fails is ignored.
Configuration: loggerFromEnv
Section titled “Configuration: loggerFromEnv”import { loggerFromEnv } from '@kindgi/log';
const got = loggerFromEnv({ env: process.env, isTTY: process.stdout.isTTY === true, subsystems: ['http', 'kernel'] });if (got.kind === 'err') { console.error(got.message); process.exit(2);}for (const problem of got.problems) got.logger.warn(problem);| Variable | Values | Default |
|---|---|---|
KINDGI_LOG_LEVEL |
error warn info debug trace |
info |
KINDGI_LOG_LEVELS |
subsystem=level, comma-separated: kernel=debug,http=warn |
none |
KINDGI_LOG_FORMAT |
auto json pretty |
auto: pretty on a terminal or with KINDGI_DEV=true, JSON otherwise |
- What refuses to start: an unknown level or format, or a malformed
KINDGI_LOG_LEVELS. - What only warns: a subsystem no code logs under. It's returned in
problems, so a typo doesn't stop a server. - Colours: used in the pretty format on a terminal, unless
NO_COLORis set.
Redaction
Section titled “Redaction”-
Keys. A field whose key looks secret is written as
[redacted], at any depth. A key looks secret when, lowercased and without-or_, it ends withauthorization,cookie,token,password,secret(s),apikey,privatekey,passphraseorcredential(s);redactadds your own. SoaccessTokenandx-api-keyare redacted, andpromptTokensandsecretRefaren't. -
Values. Every string (the message, the fields, an error's message and stack) is scrubbed of known shapes only:
- a Kindgi token (
kgi_bt_…) keeps its prefix and last four characters; Bearer <anything>becomesBearer [redacted];- a URL's password (
postgres://user:pass@) becomesuser:***@.
Ids are never touched: the scrubber only matches exact patterns, and doesn't guess at entropy.
- a Kindgi token (
-
The full mask,
scrubText(text, { fullMask: true }), is for an error a run shows: on its record, in the console and in the model's view, which have more readers than a log. A log line is never written with it.- A Kindgi token becomes
[redacted]whole, with no prefix and no last four. - A URL's query or fragment value whose name looks secret becomes
[redacted], and its name stays. The name looks secret when it ends as a secret key does (rule 1), or withsignatureorsig, or is exactlykey. So?token=,#access_token=,X-Amz-Signature=, a SASsig=and?key=are masked, and?sort_key=and?page=aren't. Bearerand a URL's password read as in rule 2.
- A Kindgi token becomes
Pass a secret's name, never its value: redaction is the safety net, not the rule.
An error's causes: describeCauses
Section titled “An error's causes: describeCauses”describeCauses(err) is what an error's causes add to its message: '' when it has none, else ; caused by <Name> [<code>]: <message> for each. So a failed fetch says why:
import { describeCauses } from '@kindgi/log';
try { await fetch('https://shop.acme.example/orders');} catch (err) { throw new Error(`Listing orders failed: ${(err as Error).message}${describeCauses(err)}`); // Listing orders failed: fetch failed; caused by ConnectTimeoutError [UND_ERR_CONNECT_TIMEOUT]: Connect Timeout Error (attempted address: shop.acme.example:443, timeout: 10000ms)}- Three causes at most are looked at. One whose message the message above it already holds (its code too, when it has one) is passed over, so fewer may be said.
- An
AggregateErrorwith an empty message says its errors:AggregateError [ECONNREFUSED]: connect ECONNREFUSED ::1:4000, connect ECONNREFUSED 127.0.0.1:4000. - A plain object with a
messageor acodeis said like an error, but its owncauseisn't looked at, since a storage error's can carry a query's values. - Each cause is masked with the full mask, made one line, and cut at 500 characters. The cut never lands inside a word.
- A value that isn't an error has no causes. It never throws.
A tool's error already says its causes: the handler runtime and invokeTool use it.
Trace context
Section titled “Trace context”import { formatTraceparent, traceFromHeader } from '@kindgi/log';
const trace = traceFromHeader(request.headers.get('traceparent'));log.child({ traceId: trace.traceId, spanId: trace.spanId }).info('handling');response.headers.set('traceresponse', formatTraceparent(trace));traceFromHeadercontinues the caller's trace when itstraceparentis valid, with a new span whose parent is the caller's. A missing or malformed header starts a fresh trace.parseTraceparent,newTraceContext,childSpanandformatTraceparentare the pieces it's built from, as pure functions on Web Crypto.