6.6 KiB
Logging Conventions
Owner: Phase-02 Observability. Last update: 2026-05-17 (Task #34).
This document is the source of truth for how to log in this codebase.
Phase-02 Task #34 migrated every server-side console.* call to Pino
and locked the rule in via ESLint. Read this before adding new log
statements.
TL;DR
- Use the Pino logger appropriate to your runtime — never
console.*. - Pino argument order: object first, message string second.
- Never log raw PII (email, password, token, full prompt body, full webhook payload, Stripe card data).
- Errors go in the
errfield so Pino's built-in serializer kicks in.
Which logger?
| Runtime | Logger | Location |
|---|---|---|
React Router web (SSR / loaders / actions / .server.ts) |
webLogger (or createScopedLogger("scope")) |
apps/web/app/utils/logger.server.ts |
| Hono API service | apiLogger |
apps/api/src/logger.ts |
| BullMQ worker | workerLogger |
apps/worker/src/logger.ts |
Package code (packages/*/src/**) |
createChildLogger(defaultLogger, { component: "<pkg-name>", scope: "<feature>" }) from @toolctrl/logger |
packages/logger/src/index.ts |
For larger files / modules, scope your logger:
// apps/web/app/modules/users/services/AuthService.ts
import { createScopedLogger } from "~/utils/logger.server";
const authLogger = createScopedLogger("auth");
The scope name appears as a scope: "auth" field on every record, so
Loki/Grafana queries like {service="web"} | json | scope="auth" work
without having to grep through unrelated messages.
Pino argument order
// ❌ VORHER (console.* style — backwards)
console.log("User signed in", { userId, tenantId });
console.error("Failed to send email", error);
// ✅ NACHHER (Pino style — object first, msg second)
authLogger.info({ userId, tenantId, op: "user.signin" }, "User signed in");
authLogger.error({ err: error, userId, op: "user.signin" }, "Failed to send email");
The err key is special: Pino's built-in serializer flattens it to
{ type, message, stack } and auto-redacts error.config.headers.authorization.
If you log the error under any other key, the serializer does NOT run.
Log levels
| Level | Use for |
|---|---|
fatal |
Process is about to exit / data was lost. Never reached normally. |
error |
An operation failed and we cannot recover. Send to Sentry. |
warn |
Unexpected condition, but operation continued (fallback used, missing config). |
info |
Significant business event (subscription created, user signed in, seed run started). |
debug |
Verbose diagnostic for development. Hidden in prod by default. |
trace |
Per-iteration / hot-path debug. Almost never used. |
Production default level is info (env: LOG_LEVEL=info). Bump to
debug per service with LOG_LEVEL=debug if you need to capture a
specific flow.
Forbidden fields
Never log these — even if the user "doesn't think they matter":
- Raw
email— useemailFingerprint(email)from the seed/auth utils if you need correlation. - Raw
password,passwordHash,apiKey,secret,token,sessionToken,bearer. - AI prompt bodies — only log
{ promptHash, promptLength, model, tenantId }. Seeapps/web/app/modules/promptBuilder/services/.server/PromptBuilderService.tshashPrompt()for the SHA-256 helper. - Stripe webhook bodies — log only
{ eventType, eventId }. - ConvertKit / CRM API response bodies (contain subscriber email).
- Request headers containing
authorization,cookie,set-cookie.
packages/logger/src/index.ts defines a defensive redact list at
the Pino instance level — fields named password, token, secret,
apiKey, authorization, cookie (plus their *.<field> variants)
are replaced with [Redacted] even if a developer forgets. Do not
rely on this as your primary defence — redaction does NOT cascade
into nested objects with non-matching key names.
Pretty vs JSON
| Environment | Mode | Why |
|---|---|---|
Dev (NODE_ENV !== "production") |
Pretty-printed (auto-detect via pino-pretty if installed) |
Readable in your terminal. |
| Prod / Staging | JSON (default) | Coolify → Loki / Grafana / Sentry-Breadcrumb pipeline consumes JSON. Pretty-print would break ingestion. |
Override with LOG_PRETTY=1 if you need pretty in prod (only for a
short debug session — never permanent).
ESLint enforcement
no-console: 'error' is active for:
**/*.server.tsand**/*.server.tsxapps/api/src/**/*.ts,apps/worker/src/**/*.tspackages/*/src/**/*.tsapps/web/app/utils/**/*.tsapps/web/app/modules/**/services/**/*.tsapps/web/app/modules/**/db/**/*.ts
Override 'off' for: **/*.test.ts(x), **/*.spec.ts(x), scripts/**,
and client-bundle files (apps/web/app/components/**/*.tsx,
apps/web/app/routes/**/*.tsx without .server. suffix).
The lefthook pre-commit hook runs scripts/eslint-staged.mjs which
fails the commit on any new console.* in server code. Verified
2026-05-17 by injecting console.log into apps/api/src/test-tmp.ts
and observing the hook block the commit.
Migration inventory script
pnpm exec tsx scripts/observability/scan-console.ts
# generates docs/observability/console-migration-inventory.md
# exit 1 with --check if any live console.* remain in server paths
Wire this into CI eventually (already runs locally; CI run is tracked in Task #36 follow-ups).
Examples
Standard info event
authLogger.info(
{ userId, tenantId, op: "user.signin", method: "password" },
"User signed in",
);
Error with err serializer
try {
await sendEmail(...);
} catch (error) {
emailLogger.error(
{ err: error, recipientFingerprint: emailFingerprint(to), op: "email.send" },
"Failed to send email",
);
}
AI prompt (PII-safe)
promptLogger.info(
{
op: "executeTemplate.createChatCompletion",
templateId,
model,
promptHash: hashPrompt(prompt), // SHA-256, first 16 hex chars
promptLength: prompt.length,
tenantId,
},
"Creating AI chat completion",
);
Stripe / webhook (PII-safe)
pricingLogger.info(
{
op: "stripe.webhook.received",
eventType: event.type,
eventId: event.id,
livemode: event.livemode,
},
"Stripe webhook received",
);
See also
docs/improvement-plan/02-observability/logging-migration.md— full task spec (Task #34) including PII rationale.packages/logger/src/index.ts— Pino setup + redact paths.scripts/observability/scan-console.ts— inventory script.docs/observability/console-migration-inventory.md— auto-generated per-file inventory (0 hits after Phase-02 #34).