Skip to content

Logging

Holocron has two parallel output channels:

  • print — user-facing UX output: formatted results, success lines, the text a user is meant to read.
  • logger — operational output: internal state, debug traces, errors, and the structured context that routes to Axiom.

They are not a hierarchy. logger does not wrap or replace print. This page covers logger, implemented by the @theholocron/logger package.

Every command and plugin imports Logger — never Pino directly. Pino is an implementation detail hidden behind the interface, so the logging library can be swapped without touching a call site.

interface Logger {
debug(obj: Record<string, unknown>, msg?: string): void;
debug(msg: string): void;
info(obj: Record<string, unknown>, msg?: string): void;
info(msg: string): void;
warn(obj: Record<string, unknown>, msg?: string): void;
warn(msg: string): void;
error(obj: Record<string, unknown>, msg?: string): void;
error(msg: string): void;
child(bindings: Record<string, unknown>): Logger;
}
import { createLogger } from "@theholocron/logger";
const { logger, runId } = createLogger({ level: "info" });
const log = logger.child({ command: "sync-github", repo: "theholocron/configs" });
log.info({ branch: "main" }, "opening PR");

createLogger returns { logger, runId }. The runId is a UUID bound to every line the logger and its children emit — one query pulls a whole run back out of Axiom. It is printed via print when --debug or --verbose is passed.

Child loggers inherit every binding from their parent and add their own, so passing logger.child({ module }) into a module means every line from that module carries command, repo, runId, env, and module with no extra wiring.

Field Set by Value
runId root logger (startup) UUID, unique per command invocation
env root logger (startup) "ci" when CI is detected, else "local"
command command child logger active holocron command name
module module child logger e.g. "sync-github", "setup-workflows"
repo command child logger "owner/name" when available
level Pino level name string
time Pino ISO 8601 timestamp

setup, doctor, sync, secrets sync, deploy, and cleanup-preview emit a structured line per unit of work — a <command>: start, one line per step / row / file (with capability, step, status, detail), and a <command>: done carrying the ok / fail / skip counts. Pull a whole run back out of Axiom by its runId:

['holocron-ci'] | where runId == "<uuid>" | sort by _time asc

Resolved in priority order:

  1. --verbose → debug, --quiet → error (resolved by the command layer)
  2. HOLOCRON_LOG_LEVEL env var (debug | info | warn | error)
  3. log.level in holocron.config
  4. "info" (default)

The resolved level applies to every transport — there is no separate Axiom gate.

--debug is distinct from --verbose: it does not change the level. It prints one line — Run ID: <uuid> — at the end of the command so you have a reference for querying Axiom, without the full debug trace. (--verbose prints it too.)

Environment Output
Local, on a TTY pino-pretty — colourised, human-readable
CI (CI truthy) newline-delimited JSON to stdout
Axiom configured Axiom, in a non-blocking Pino worker thread

The Axiom transport is added when Axiom credentials are present and HOLOCRON_TELEMETRY is not "false".

Env var Fallback Purpose
HOLOCRON_AXIOM_TOKEN AXIOM_TOKEN Axiom API token
HOLOCRON_AXIOM_DATASET AXIOM_DATASET target dataset name

Two datasets, selected by the single HOLOCRON_AXIOM_DATASET value:

Value When
holocron-ci CI
holocron-local local — opt-in

The transport activates only when both a token and a dataset resolve. With no dataset, runs write only to their console transport and never attempt an Axiom connection.

The token resolves from HOLOCRON_AXIOM_TOKEN → AXIOM_TOKEN → the OS keyring (axiom.<org>, then bare axiom). The dataset resolves from HOLOCRON_AXIOM_DATASET → AXIOM_DATASET → holocron.config log.axiom.dataset. Env vars always win.

The sync and sync-github reusable workflows read the token from the HOLOCRON_AXIOM_TOKEN secret and the dataset from the HOLOCRON_AXIOM_DATASET variable — set both at the org level once (HOLOCRON_AXIOM_DATASET = holocron-ci). Thin callers forward them via secrets: inherit; the variable is visible to the reusable workflow automatically. HOLOCRON_TELEMETRY=false in a workflow disables shipping without removing the secret.

Store the token in the OS keyring once:

Terminal window
holocron auth set axiom.theholocron <TOKEN>

That token backs both holocron setup / holocron doctor (the logs capability, which provisions and checks the datasets) and the logger’s Axiom transport. Then opt local runs into shipping by naming the dataset in holocron.config — log: { axiom: { dataset: "holocron-local" } }.

Leave log.axiom.dataset unset (and HOLOCRON_AXIOM_DATASET unexported) to keep local runs on pino-pretty only.

HOLOCRON_TELEMETRY=false disables the Axiom transport (the logger keeps working — it just writes locally). The same switch also turns off Sentry and PostHog — see Telemetry for all three sinks.

For a committed, repo-wide choice rather than an environment variable, the telemetry block in holocron.config ({ enabled: false }, or { analytics: "none" } to keep error reporting) is the version-controlled peer of HOLOCRON_TELEMETRY=false. The env var still wins — see Telemetry → Opting out.

Sensitive field paths are stripped in Pino’s serialisation layer, before any transport sees a line. Redacted values become [Redacted].

Redacted: token, secret, password, apiKey, secrets[*].value, headers.authorization, headers["x-api-key"], and the same keys one level down (*.token, *.secret, …). The full list is exported as REDACTED_PATHS.

import { defineConfig } from "@theholocron/holocron-config";
export default defineConfig({
log: {
level: "info", // "debug" | "info" | "warn" | "error"
axiom: { dataset: "holocron-local" }, // opt local runs into Axiom shipping
},
});

Only the level and the non-secret Axiom dataset name belong in config. The Axiom token never does — it comes from an env var or the OS keyring.