Trovella Wiki

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)
  • method and path from the HTTP request
  • userId and tenantId from the authenticated session
  • Cloud Trace correlation fields (when GOOGLE_CLOUD_PROJECT is 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 LevelNumericCloud Logging Severity
trace10DEBUG
debug20DEBUG
info30INFO
warn40WARNING
error50ERROR
fatal60CRITICAL

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:

  • email
  • password
  • token
  • authorization
  • cookie
  • req.headers.authorization
  • req.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

LevelWhen to UseExample
debugImplementation details useful during development. Off in production by default.logger.debug({ query }, "Cache miss, hitting database")
infoSignificant business events and operational milestones. Default production level.logger.info({ userId }, "User signed up")
warnRecoverable issues, degraded conditions, approaching limits.logger.warn({ retryCount: 3 }, "External API retry succeeded")
errorFailures that prevent completing a user request or background job.logger.error({ err, invoiceId }, "Payment processing failed")

Level Rules

  • Do not use error for expected failures. A 400 validation error or 404 not-found is normal operation -- log at info or warn.
  • Do not use info for per-request noise. The tRPC timing middleware already logs every request completion. Only log inside a procedure when something noteworthy happens.
  • Production runs at info level. Anything logged at debug does not appear in Cloud Logging unless LOG_LEVEL is 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

VariableEffect
NODE_ENV"production" = JSON output to stdout; anything else = pino-pretty
LOG_LEVELString: "debug", "info", "warn", "error". Default: info in production, debug in development
GOOGLE_CLOUD_PROJECTEnables Cloud Trace correlation from the X-Cloud-Trace-Context header

On this page