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

195 lines
6.6 KiB
Markdown

# 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:
```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 `*.<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
```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).