# 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 `err` field 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: "", scope: "" })` from `@toolctrl/logger` | `packages/logger/src/index.ts` | For larger files / modules, scope your logger: ```ts // 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 ```ts // ❌ 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` — use `emailFingerprint(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 }`. See `apps/web/app/modules/promptBuilder/services/.server/PromptBuilderService.ts` `hashPrompt()` 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 `*.` 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.ts` and `**/*.server.tsx` - `apps/api/src/**/*.ts`, `apps/worker/src/**/*.ts` - `packages/*/src/**/*.ts` - `apps/web/app/utils/**/*.ts` - `apps/web/app/modules/**/services/**/*.ts` - `apps/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 ```bash 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 ```ts authLogger.info( { userId, tenantId, op: "user.signin", method: "password" }, "User signed in", ); ``` ### Error with err serializer ```ts try { await sendEmail(...); } catch (error) { emailLogger.error( { err: error, recipientFingerprint: emailFingerprint(to), op: "email.send" }, "Failed to send email", ); } ``` ### AI prompt (PII-safe) ```ts 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) ```ts 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).