Structured Logging
Pino logger configuration, factory functions, Cloud Logging integration, PII redaction, and logging conventions.
All server-side logging in Trovella uses Pino through the @repo/logger package. console.log is banned via ESLint (no-console: error). This page covers the logger factories, production output format, PII redaction, and conventions for what to log.
Logger Factories
The package exports three factory functions. Each creates a Pino logger with different context bindings.
getLogger(name) -- General Purpose
Use for operations not tied to a specific HTTP request: startup, configuration, seeds, migrations, standalone scripts.
import { getLogger } from "@repo/logger";
const logger = getLogger("seed");
logger.info("Seeding reference data");
logger.info({ count: 42 }, "Reference data seeded");
The name parameter becomes the service field in structured output, making it filterable in Cloud Logging.
ctx.logger -- tRPC Request Logger
Every tRPC procedure receives a pre-built logger on ctx.logger. It is created in packages/api/src/context.ts using createRequestLogger and enriched with:
requestId(random UUID)methodandpathfrom the HTTP requestuserIdandtenantIdfrom the authenticated session- Cloud Trace correlation fields (when
GOOGLE_CLOUD_PROJECTis set)
export const widgetRouter = router({
create: authorizedProcedure.input(createWidgetSchema).mutation(async ({ ctx, input }) => {
ctx.logger.info({ widgetName: input.name }, "Creating widget");
// ...
ctx.logger.info({ widgetId: created.id }, "Widget created");
return created;
}),
});
Do not create a new logger inside tRPC routers -- ctx.logger already carries request context. This is ESLint-enforced in @repo/api.
createRequestLogger(req, opts) -- Non-tRPC Routes
Use in standalone Next.js route handlers (health checks, webhooks) where there is no tRPC context.
import { createRequestLogger } from "@repo/logger";
export async function POST(req: Request) {
const logger = createRequestLogger(req);
logger.info("Processing webhook");
// ...
}
The function extracts Cloud Trace headers, generates a requestId, and captures the HTTP method and path.
createJobLogger(jobName, opts) -- Background Jobs
Use inside Inngest functions or any background task. Enriched with jobName, runId, userId, and tenantId.
import { createJobLogger } from "@repo/logger";
const logger = createJobLogger("process-invoice", {
runId: event.id,
userId: event.data["userId"],
tenantId: event.data["tenantId"],
});
logger.info("Starting invoice processing");
Create the logger once per job invocation and reuse it -- do not create loggers inside loops.
tRPC Request Timing Middleware
The publicProcedure in packages/api/src/trpc.ts includes a middleware that automatically logs every tRPC request with its duration:
export const publicProcedure = t.procedure.use(async ({ ctx, path, type, next }) => {
const start = performance.now();
const result = await next();
const durationMs = Math.round(performance.now() - start);
if (result.ok) {
ctx.logger.info({ path, type, durationMs }, "tRPC request completed");
} else {
ctx.logger.error(
{ path, type, durationMs, error: result.error.message },
"tRPC request failed",
);
}
return result;
});
Because every procedure inherits from publicProcedure, all requests are logged with path, type, duration, and outcome. This means you do not need to log "request started" or "request completed" inside individual procedures -- the middleware handles it.
Production Output Format
In production (NODE_ENV=production), Pino writes JSON to stdout. Cloud Logging on the Compute Engine VM ingests this automatically. The output includes a severity field mapped from Pino's numeric levels:
| Pino Level | Numeric | Cloud Logging Severity |
|---|---|---|
| trace | 10 | DEBUG |
| debug | 20 | DEBUG |
| info | 30 | INFO |
| warn | 40 | WARNING |
| error | 50 | ERROR |
| fatal | 60 | CRITICAL |
The messageKey is set to message (instead of Pino's default msg) to match Cloud Logging's expected field name. Timestamps use ISO 8601 format.
Cloud Trace Correlation
When GOOGLE_CLOUD_PROJECT is set, the request logger extracts the X-Cloud-Trace-Context header and formats it into Cloud Trace fields:
{
"logging.googleapis.com/trace": "projects/trovella-prod/traces/abc123...",
"logging.googleapis.com/spanId": "def456..."
}
This lets you click from a Cloud Logging entry directly into the corresponding Cloud Trace span, correlating logs with request latency data.
PII Redaction
Pino's built-in redaction automatically replaces sensitive fields with [Redacted] before output. The following paths are redacted:
emailpasswordtokenauthorizationcookiereq.headers.authorizationreq.headers.cookie
This is a safety net, not the primary defense. The real rule is: don't log PII in the first place. Log userId instead of email. Log tenantId instead of organization billing details. Log entity IDs, not entity contents.
Log Levels
| Level | When to Use | Example |
|---|---|---|
debug | Implementation details useful during development. Off in production by default. | logger.debug({ query }, "Cache miss, hitting database") |
info | Significant business events and operational milestones. Default production level. | logger.info({ userId }, "User signed up") |
warn | Recoverable issues, degraded conditions, approaching limits. | logger.warn({ retryCount: 3 }, "External API retry succeeded") |
error | Failures that prevent completing a user request or background job. | logger.error({ err, invoiceId }, "Payment processing failed") |
Level Rules
- Do not use
errorfor expected failures. A 400 validation error or 404 not-found is normal operation -- log atinfoorwarn. - Do not use
infofor per-request noise. The tRPC timing middleware already logs every request completion. Only log inside a procedure when something noteworthy happens. - Production runs at
infolevel. Anything logged atdebugdoes not appear in Cloud Logging unlessLOG_LEVELis overridden.
What to Log
Always Log
- State transitions -- user signed up, organization created, role changed, invitation accepted
- External system interactions -- API calls to third parties (start, success, failure, duration)
- Background job lifecycle -- job started, key step completed, job finished or failed
- Authorization decisions -- access denied (with context for debugging, not the sensitive data)
- Retry and fallback events -- when a retry succeeds or a fallback path is taken
- Slow operations -- anything exceeding expected duration thresholds
Never Log
- Passwords, tokens, API keys, secrets -- Pino redaction covers common paths but do not rely on it as the sole defense
- Full request/response bodies -- log relevant fields, not entire payloads
- PII beyond what is needed -- log
userId, not email or name - Routine happy-path noise -- the tRPC middleware tracks request timing; do not add redundant logging
Structured Context
Always pass context as the first argument using Pino's object-first pattern, not interpolated into the message string:
// Correct -- structured, searchable, machine-parseable
logger.info({ orderId, amount, currency }, "Payment processed");
// Incorrect -- context buried in string, cannot filter or aggregate
logger.info(`Payment processed for order ${orderId}: ${amount} ${currency}`);
Include the minimum fields needed to locate the log entry later:
- Entity IDs --
userId,tenantId,orderId,widgetId - Operation outcome --
{ success: true },{ retryCount: 2 } - Duration --
{ durationMs: 340 }for timed operations - Error details --
{ err }(Pino serializes Error objects automatically)
Error Logging
Pass the Error object as err in the context -- Pino's default serializer extracts the message, stack trace, and type:
try {
await externalApi.call();
} catch (err) {
logger.error({ err, endpoint: "/api/external" }, "External API call failed");
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
Do not log and throw the same error at multiple layers. If a tRPC procedure catches an error from a utility function, only the procedure should log it. The utility should throw, not log. Otherwise the same error appears multiple times.
Environment Variables
| Variable | Effect |
|---|---|
NODE_ENV | "production" = JSON output to stdout; anything else = pino-pretty |
LOG_LEVEL | String: "debug", "info", "warn", "error". Default: info in production, debug in development |
GOOGLE_CLOUD_PROJECT | Enables Cloud Trace correlation from the X-Cloud-Trace-Context header |