Skip to content

Logging ​

Implements: ARCH-09, DOC-01 · requirement text and status in CHARTER.md

How ESI.ts emits log events, how it decides which logger receives them, and how to replace, route or silence that output. The library logs through one small interface, ILogger. The default implementation is pino at level warn, so an application that configures nothing sees only events that need attention.


The contract ​

ts
interface LogContext {
  [key: string]: unknown;
}

interface ILogger {
  fatal(message: string, context?: LogContext): void;
  error(message: string, context?: LogContext): void;
  warn(message: string, context?: LogContext): void;
  info(message: string, context?: LogContext): void;
  debug(message: string, context?: LogContext): void;
  trace(message: string, context?: LogContext): void;
}

Every call carries a human-readable message and, where the call site has something structured to say, a flat context object. The library drops empty context objects before they reach the logger, so context is either undefined or has at least one key.

An implementation must not throw. The pipeline does not guard log calls, so an exception from a logger surfaces as a failed request.

Public exports ​

All from the root entry point @lgriffin/esi.ts.

ExportKindPurpose
ILogger, LogContextinterfaceThe contract above
LogLeveltype'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'
createDefaultLogger(level)functionA new pino-backed ILogger. Level: argument, then ESI_LOG_LEVEL, then 'warn'
toPinoLogger(sink)functionAdapts any object with the six pino-style methods to ILogger
createNoopLogger()functionAn ILogger that discards everything
createConsoleLogger(level)functionA human-readable console ILogger. Level: argument, then ESI_LOG_LEVEL, then 'warn'
setLogger(logger)functionInstalls the global fallback logger
getLogger()functionReturns the global fallback logger (the pino default until setLogger is called)
logFatal … logTracefunctions(message, context?) helpers that write to the global logger. Six of them, one per level
EsiClientConfig.loggeroptionPer-client logger
EsiClientConfig.logLeveloptionPer-client pino logger at a LogLevel or 'silent', used only when logger is not given
ApiClient.setLogger(l)methodSets or clears (null) the per-client logger on an ApiClient you hold directly
EsiTokenManagerConfig.loggeroptionLogger for token refresh activity; see Token manager

The exported logFatal … logTrace helpers are the global-logger variants. The request pipeline uses internal per-client twins with a leading client argument; those are not exported.

How a logger is resolved ​

Every log call inside the pipeline names the ApiClient it belongs to. The logger is chosen at the moment of the call, in this order:

  1. Per-client. The logger stored on that ApiClient. configureApiClient sets it from EsiClientConfig.logger, or, failing that, builds a fresh pino logger from EsiClientConfig.logLevel. If neither option is given, the client has no logger of its own.
  2. Global. The logger installed with setLogger(), if one has been installed.
  3. Default. The module-level default logger, level from ESI_LOG_LEVEL or warn. Its pino instance is built on first use (see Import-time behaviour).

EsiClient, CustomEsiClient (via EsiClientBuilder) and every EsiApiFactory.create* method all route through configureApiClient, so the two config options behave the same on all three construction surfaces.

Resolution happens per call, so setLogger() affects clients that were constructed before it, provided they have no per-client logger.

Call sites ​

Every call site in the library logs through the per-client helpers in src/core/logger/clientLog.ts, which name the client and resolve the logger in the order above:

  • The request pipeline, retry strategy, pagination handlers, circuit breaker, deduplicator, createClient and the domain clients.
  • The rate limiter. configureApiClient and ApiClientBuilder attach the limiter they build to its client (RateLimiter.setClient), so every [ESI Rate Limit] event reaches that client's logger. A limiter you build yourself and pass to ApiClientBuilder.setRateLimiter is not attached, since one limiter may serve several clients; call setClient on it to choose.
  • The ETag cache, from its first line: configureApiClient passes the client to the constructor, so the "initialized" line reaches the client's logger too. The client's own logger is set before any middleware is built.
  • EsiClient.batch and EsiClient.batchPost, and the construction and shutdown lines of EsiClient and CustomEsiClient.

Two kinds of caller have no client to name and use the global logger by design: the standalone batchFetch and batchPost exports, and EsiTokenManager (see Token manager).

The mechanism is a lint rule, not a convention. npm run lint forbids any import of the global loggerUtil module inside src/core/requestPipeline/ and src/clients/, by any relative path (config/eslint/logger-imports.rules.cjs, a no-restricted-imports block). npm run lint:layers carries the same block and runs with --no-inline-config, so an eslint-disable comment cannot get round it. tests/tdd/layers/logger-imports-lint.test.ts proves the rule fires and that both configs load it.

ts
import { EsiClient, createDefaultLogger, setLogger } from '@lgriffin/esi.ts';

// Per-client: this client's pipeline events go to its own logger.
const trading = new EsiClient({
  clientId: 'trade-bot',
  logLevel: 'debug',
});

// Global fallback: every client without a per-client logger, plus the
// standalone batch helpers and the token manager, write here.
setLogger(createDefaultLogger('info'));

The default logger ​

createDefaultLogger(level?) returns toPinoLogger(pino({ level })). pino writes newline-delimited JSON to standard output, one object per event, with the context keys as top-level fields beside msg, level and time.

SourcePrecedenceNotes
level argument / logLevelhighestlogLevel takes a LogLevel or 'silent'
ESI_LOG_LEVEL env variablenextRead when the logger is built. Any pino level name, including silent
'warn'defaultLibrary events that need attention, nothing else

Valid levels are pino's: fatal, error, warn, info, debug, trace, plus silent to disable output. silent is accepted at runtime but is not a member of the LogLevel type, so use ESI_LOG_LEVEL=silent or createNoopLogger() rather than logLevel: 'silent'.

An unrecognised value throws from pino when the logger is built. For a client without logger or logLevel, and for EsiTokenManager and the standalone batch helpers, that is the first line logged through the default logger, not the import (see Import-time behaviour).

pino is a runtime dependency. The library does not configure transports, redaction or pretty-printing on the default instance. If you want any of those, build your own pino logger and pass it through toPinoLogger.

Import-time behaviour ​

Importing the package, from the root entry or any sub-path, constructs no logger, timer or network client, and package.json declares "sideEffects": false, so a bundler may drop every module a consumer does not use (CHARTER ARCH-06).

The default logger is an ILogger whose pino instance is built on its first use: the first call to a level method or to isLevelEnabled. ESI_LOG_LEVEL is read at that moment, so a value set after the import but before the first line is honoured. Once built, the default logger calls the pino-backed methods directly; the laziness costs nothing after the first line. Up to 10.2.3 the instance was built when the module loaded, so ESI_LOG_LEVEL was read, and an invalid value thrown, at import.

tests/tdd/core/importSideEffects.test.ts imports every entry point with pino replaced by a spy and fails if it was called, and bundles import { EsiError } from the root entry and fails if the bundle reaches pino.

Bringing your own logger ​

pino with your own options ​

toPinoLogger accepts anything with fatal, error, warn, info, debug and trace methods in pino's calling convention: (context, message) or (message). It calls through the sink object, so pino's this binding is preserved and child loggers work.

ts
import pino from 'pino';
import { EsiClient, toPinoLogger } from '@lgriffin/esi.ts';

const root = pino({
  level: 'info',
  transport: { target: 'pino-pretty' },
});

const esi = new EsiClient({
  clientId: 'my-app',
  logger: toPinoLogger(root.child({ component: 'esi' })),
});

Readable console output ​

createConsoleLogger(level?) is an ILogger for scripts and command-line tools, where pino's JSON lines get in the way. It resolves its level the way createDefaultLogger does (argument, ESI_LOG_LEVEL, 'warn'), accepts 'silent', and falls back to 'warn' on an unknown value instead of throwing.

info lines are written as they are; every other level is prefixed with its name (warn: ...). info and below go to console.log, warn to console.warn, error and fatal to console.error. Context becomes [key=value] tokens after the message: an Error shows its message, a string as is, anything else as JSON.

The programs in examples/ all use it the same way: one logger at info for the program's own output, and one at ESI_LOG_LEVEL passed to the client, so library diagnostics share the console format without crowding the output. The code blocks in the README and the guides follow the same pattern; fragments use the log built in the README Quick Start. None of them calls console directly; tests/tdd/scripts/examples-logging.test.ts holds the examples and the documentation blocks to that, and no-console is an error in src/, where ConsoleLogger.ts is the one exemption.

ts
import { EsiClient, createConsoleLogger } from '@lgriffin/esi.ts';

const log = createConsoleLogger('info');
const client = new EsiClient({ logger: createConsoleLogger() });

const status = await client.status.getStatus();
log.info(`Players online: ${status.players}`);

Anything else ​

Implement the six methods. The library passes (message, context?), which already matches winston's (message, meta) form and console.

ts
import winston from 'winston';
import { EsiClient, type ILogger } from '@lgriffin/esi.ts';

const w = winston.createLogger({
  level: 'info',
  transports: [new winston.transports.Console()],
});

const logger: ILogger = {
  fatal: (message, context) => w.error(message, { ...context, fatal: true }),
  error: (message, context) => w.error(message, { ...context }),
  warn: (message, context) => w.warn(message, { ...context }),
  info: (message, context) => w.info(message, { ...context }),
  debug: (message, context) => w.debug(message, { ...context }),
  trace: (message, context) => w.silly(message, { ...context }),
};

const esi = new EsiClient({ clientId: 'my-app', logger });

A minimal console logger with a level floor:

ts
import type { ILogger, LogContext, LogLevel } from '@lgriffin/esi.ts';

const order: LogLevel[] = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];

export function consoleLogger(min: LogLevel = 'warn'): ILogger {
  const at =
    (level: LogLevel) =>
    (message: string, context?: LogContext): void => {
      if (order.indexOf(level) < order.indexOf(min)) return;
      const sink =
        order.indexOf(level) >= order.indexOf('error')
          ? console.error
          : console.log;
      sink(`[esi] ${level} ${message}`, context ?? '');
    };
  return {
    fatal: at('fatal'),
    error: at('error'),
    warn: at('warn'),
    info: at('info'),
    debug: at('debug'),
    trace: at('trace'),
  };
}

Token manager ​

EsiTokenManager is not an ApiClient and does not take part in per-client resolution. It uses EsiTokenManagerConfig.logger when one is given, and otherwise reads getLogger() at each log call, so a setLogger() made after the manager was built still reaches it. Its events carry the character ID in the message and no context object.

What the library logs ​

Messages are for people; context keys are for queries. Filter on the context, not the message text, which is not part of the public API and may change in a minor release.

url values are absolute request URLs. endpoint is the path relative to the base URL. templatePath is the unresolved ESI path, such as /markets/{region_id}/orders/.

Visible at the default level ​

ComponentLevelEventContext
Endpoint callwarnDeprecated endpoint called, on every call (message names replacement and sunset date)endpoint (method name)
Operation callwarnDeprecated generated operation called through createEsi(), on every call, worded as the endpoint warningoperationId
Domain clientswarnDeprecated client method called (AllianceClient contacts)allianceId
FetchwarnESI warning response header receivedurl, warningCode
RetrywarnRetrying after a retryable statusmethod, statusCode
Status handlingwarn5xx served from stale cachestatus
Status handlingwarnRate limited (420 or 429)status
PaginationwarnOffset pagination failed on a later pageurl, endpoint, error
PaginationwarnIncomplete walk truncated under the default incompletePages (empty page, more than 1000 pages, cursor page limit); the message names the optionpage, reason, endpoint, reportedPages, fetchedPages, itemCount
PaginationwarnCursor pagination stopped after consecutive failuresconsecutiveFailures
Circuit breakerwarnCircuit opened, or re-opened after a failed probekey, failures
Rate limiterwarnGroup blocked after 420/429; still blocked, request abortedgroup, waitMs (blocked only)
Rate limiterwarnToken bucket empty, waitinggroup
Rate limiterwarnLegacy error limit low or exhausted, waitingerrorLimitRemain / errorLimitReset
FetcherrorResponse body is not valid JSONurl
RetryerrorToken refresh failedendpoint
PaginationerrorOffset or cursor page fetch failedpage
Status handlingerrorUnexpected non-HTTP errornone
MetaClienterrorFetching the OpenAPI YAML failedurl

info ​

ComponentEventContext
ConstructionEsiClient or CustomEsiClient initialised, shut downbaseUrl, clientId / clients
ConstructionAccess token updated; token provider set or removednone
FetchEvery outgoing request (Hitting endpoint)method, endpoint
Status handling204 No Content; 304 served from the ETag cachestatus, cacheHitType: 'etag-304'
Retry401 received, refreshing; refreshed, retryingendpoint
PaginationPage count discovered; each page fetched; completionendpoint, totalPages, page, items, totalItems, pages, method
Circuit breakerHalf-open probe allowed; closed after probe; cleanupkey, cleaned
ETag cacheInitialised; cleared; configuration updatedmaxEntries
Batch helpers ¹Batch fetch or batch POST startedtotal, concurrency, chunks, chunkSize

debug ​

ComponentEventContext
Cache policySpec-aware cache hit (no network call)method, templatePath
Cache policyResponse cached with ETagmethod, etag
ETag cacheEntry hit, expired or stored; path prefix invalidatedurl; count
HeadersIf-None-Match attachedetag
DeduplicatorIdentical in-flight GET coalescednone (key in the message)
Batch helpers ¹Batch completesucceeded, failed
Token managerRefreshed, stale refresh discarded, JWT not decodednone

¹ To the client's logger through EsiClient.batch and batchPost; to the global or default logger through the standalone batchFetch and batchPost exports.

Nothing in the library logs at fatal or trace today. The levels exist so an ILogger is a complete adapter for pino.

Silencing and capturing in tests ​

Three ways to silence the library, from narrowest to widest:

ts
import { EsiClient, createNoopLogger, setLogger } from '@lgriffin/esi.ts';

// One client.
const client = new EsiClient({ clientId: 'test', logger: createNoopLogger() });

// Everything without a per-client logger, including the rate limiter.
setLogger(createNoopLogger());
bash
# The default pino instance, process-wide.
ESI_LOG_LEVEL=silent npm test

A per-client no-op logger does not silence rate-limiter or batch-helper events. Use setLogger or ESI_LOG_LEVEL when those appear in test output.

To assert on log output, pass a recording logger. setLogger is global state, so restore it in afterEach.

ts
import {
  EsiClient,
  getLogger,
  setLogger,
  type ILogger,
} from '@lgriffin/esi.ts';

const levels = ['fatal', 'error', 'warn', 'info', 'debug', 'trace'] as const;
const recorder = Object.fromEntries(
  levels.map((l) => [l, jest.fn()]),
) as unknown as jest.Mocked<ILogger>;

const original = getLogger();
afterEach(() => setLogger(original));

it('warns when a request is retried', async () => {
  const client = new EsiClient({ clientId: 'test', logger: recorder });
  // … drive a retryable failure through jest-fetch-mock …
  expect(recorder.warn).toHaveBeenCalledWith(
    expect.stringContaining('retrying'),
    expect.objectContaining({ statusCode: 503 }),
  );
});

See TESTING.md for the transport-seam mocking these tests should use.

Secrets in log output ​

The bearer token travels only in the Authorization header, and no log call includes request headers, so an access token never reaches a log line. The same holds for refresh tokens in EsiTokenManager.

URLs are redacted at the logger boundary. Before a line reaches any logger, per-client or global, every query parameter in its message and in each top-level string context value (url, endpoint, and the rest) whose name is one sanitizeUrl redacts (token, access_token, refresh_token, code, client_secret and the others SECURITY.md lists, percent-encoded or not) has its value replaced with %5BREDACTED%5D, the URL-encoded [REDACTED] that errors carry. The rest of the line is logged exactly as written, so absolute, relative and protocol-relative URLs, several URLs separated by punctuation, and URLs new URL rejects are all covered. A line with nothing to redact reaches the logger unchanged. A logger that implements isLevelEnabled is asked first, so a disabled level costs nothing. The exported logFatal … logTrace helpers and EsiTokenManager redact too.

What is not redacted: nested objects inside the context, parameter names outside the list (matching is case-sensitive), and a value containing a comma or semicolon, which is redacted only up to that character. Keep credentials in headers. The full defence chain is in SECURITY.md.

tests/tdd/core/redactLog.test.ts and clientLog.test.ts cover the boundary, and tests/bdd/features/core/0058-logging.feature drives a request whose URL carries a token through the real pipeline and reads the redacted form at the logger.

Known gaps ​

Stated against the charter, measured against the code at the time of writing.

ARCH-06 · Enforced: nothing built at import. Closed by #268: the default logger builds its pino instance on first use (the deprecated src/core/logger/logger.ts module, reachable from no entry point, was removed when knip began blocking the release gate), and package.json declares "sideEffects": false (see Import-time behaviour).

ARCH-09 · Enforced: per-client logging. Closed by #296 and #265: every call site uses the per-client logger (see Call sites), and npm run lint holds src/core/requestPipeline/ and src/clients/ off the global loggerUtil.

Released under the GPL-3.0-or-later license.