Files
HiveGrid-Wireframes/hivegrid-cloud/docs/observability/logging.md
T

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 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: "<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 — 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 *.<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.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

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).