Basilic
Architecture

Logging

Explicit logger imports for server (Pino) and client (console wrapper).

Complete guide for using the logging system across the monorepo.

Overview

The monorepo uses explicit subpath imports:

  • Server-side (@repo/utils/logger/server): Fastify API and Next.js server runtime — Pino for structured JSON logging
  • Client-side (@repo/utils/logger/client): Next.js client components — console wrapper with environment-controlled levels

Key principles:

  • Server: import { logger } from '@repo/utils/logger/server'
  • Client: import { logger } from '@repo/utils/logger/client'
  • Never use console.* directly in app code (enforced by linting)
  • Structured logging with support for bindings (requestId, userId, etc.)
  • Environment-based configuration (enabled/disabled, log levels)
  • Automatic secret redaction on server-side

Quick Start

// Server (Fastify, Next API routes, Node scripts)
import { logger } from '@repo/utils/logger/server'

// Client ('use client' components, browser)
import { logger } from '@repo/utils/logger/client'

logger.info({ userId: '123' }, 'User logged in')
logger.error({ error }, 'Request failed')

Usage

Server-side

Fastify routes: prefer request.log for automatic request context.

// apps/fastify/src/routes/users.ts
import type { FastifyInstance } from 'fastify'

export async function userRoutes(fastify: FastifyInstance) {
  fastify.get<{ Params: { id: string } }>('/users/:id', async (request, reply) => {
    const { id } = request.params
    request.log.info({ userId: id }, 'Fetching user')

    try {
      const user = await getUserById(id)
      request.log.info({ userId: id }, 'User fetched')
      return reply.send(user)
    } catch (error) {
      request.log.error({ error, userId: id }, 'Failed to fetch user')
      return reply.code(500).send({ error: 'Internal server error' })
    }
  })
}

Next.js route handlers & server actions: use logger (optionally child() for correlation).

// app/api/users/route.ts
import { logger } from '@repo/utils/logger/server'
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const requestLogger = logger.child({
    requestId: request.headers.get('x-request-id') || crypto.randomUUID(),
  })

  try {
    requestLogger.info({}, 'Processing request')
    const users = await getUsers()
    requestLogger.debug({ count: users.length }, 'Users fetched')
    return NextResponse.json(users)
  } catch (error) {
    requestLogger.error({ error }, 'Request failed')
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
  }
}

Client-side

Client logs are disabled by default in production.

// components/user-profile.tsx
'use client'

import { logger } from '@repo/utils/logger/client'
import { useEffect } from 'react'

export function UserProfile({ userId }: { userId: string }) {
  useEffect(() => {
    logger.info({ component: 'UserProfile', userId }, 'Component mounted')
    return () => logger.debug({ component: 'UserProfile', userId }, 'Component unmounted')
  }, [userId])

  const handleClick = () => {
    logger.info({ component: 'UserProfile', userId }, 'User profile clicked')
  }

  return <div onClick={handleClick}>...</div>
}

Environment Variables

Server-side (Fastify API, Next.js Server)

VariableTypeDefaultDescription
LOG_ENABLEDbooleantrueEnable/disable logging
LOG_LEVELdebug|info|warn|error|silentinfoMinimum log level
LOG_SERVICEstring"app"Service name for log entries

Example .env:

LOG_ENABLED=true
LOG_LEVEL=info
LOG_SERVICE=api

Client-side (Next.js Client Components)

VariableTypeDefaultDescription
NEXT_PUBLIC_LOG_ENABLEDbooleantrue (dev), false (prod)Enable/disable logging
NEXT_PUBLIC_LOG_LEVELdebug|info|warn|error|silentdebug (dev), info (prod)Minimum log level

Example .env:

NEXT_PUBLIC_LOG_ENABLED=true
NEXT_PUBLIC_LOG_LEVEL=info

Important: Client-side logs are disabled in production by default. This means log statements won't execute, improving performance and reducing bundle size.

CI and Test Environments

When running in CI (CI=true), or when NODE_ENV=test or VITEST is set (e.g. Vitest, Playwright E2E), the default log level is silent for both server and client. This reduces noise during tests. To enable logs for debugging, set LOG_LEVEL=debug (or info, warn, error) in .env.test or pass it when running tests.

Architecture

Explicit Subpath Imports

The logger uses explicit subpath imports — no conditional routing. Each entrypoint exports logger, Logger, and LogLevel:

  • @repo/utils/logger/server — Pino-based, for Node/Fastify/Next.js server
  • @repo/utils/logger/client — console-based, for browser/client components

Consumers import the appropriate subpath based on their runtime environment.

Why No Runtime Detection?

Runtime detection (typeof window, process checks) has several problems:

  1. Bundle size: Both implementations get bundled, increasing size
  2. Tree-shaking fails: Bundlers can't eliminate unused code
  3. Edge runtime issues: Edge runtimes may not have expected globals
  4. Type safety: TypeScript can't properly narrow types

Explicit imports avoid these issues: each file imports the logger that matches its runtime.

Log Levels

Use appropriate log levels:

  • debug: Detailed information for debugging (development only)
  • info: General informational messages
  • warn: Warning messages for potentially harmful situations
  • error: Error messages for failures
  • silent: Disable all logging
logger.debug({ data }, 'Detailed debug information')
logger.info({ userId }, 'User logged in')
logger.warn({ remaining: 10 }, 'Rate limit approaching')
logger.error({ error }, 'Database connection failed')

Best Practices

1. Use Structured Fields - Always use structured fields instead of string interpolation:

// ✅ Good
logger.info({ userId, action: 'login' }, 'User logged in')

// ❌ Bad
logger.info(`User ${userId} logged in`)

2. Request Correlation - Use child() to create request-scoped loggers:

const requestLogger = logger.child({ requestId, userId })
requestLogger.info('Processing request') // Includes requestId and userId

3. Secret Redaction - Server-side logger automatically redacts: password, token, secret, key, apiKey, accessToken, refreshToken, authorization, cookie, session

4. Error Logging - Always include error objects:

try {
  await processOrder(orderData)
} catch (error) {
  logger.error({ error, context: { userId, orderId } }, 'Failed to process order')
  throw error
}

5. Integration with @repo/sentry - When using captureError in Fastify, pass request.log to use Fastify's native logger. The catalog and response are built in the app (e.g. from apps/fastify/src/lib/catalogs/):

import { captureError } from '@repo/sentry/node'

fastify.setErrorHandler((error, request, reply) => {
  captureError({
    error,
    logger: request.log, // Uses Fastify's logger with request context
    label: `${request.method} ${request.url}`,
    tags: { app: 'api' },
  })
  // App builds safe response from its own catalog (getError, mapHttpStatusToErrorCode)
  const catalogError = getError(mapHttpStatusToErrorCode(error.statusCode)) ?? getError('UNEXPECTED_ERROR')
  reply.status(error.statusCode ?? 500).send(catalogError)
})

Troubleshooting

Logs not appearing:

  • Check LOG_ENABLED / NEXT_PUBLIC_LOG_ENABLED environment variables
  • Verify log level isn't higher than message level
  • Remember: client logs disabled in production by default

Console usage linting errors:

  • Ensure importing from @repo/utils/logger/server or @repo/utils/logger/client (not console.*)
  • Only packages/utils/logger/client.ts can use console.*

Pino in client bundles:

  • Use @repo/utils/logger/client in client code; never import @repo/utils/logger/server in browser bundles
  • Inspect bundle analysis with next build

On this page