Skip to content

Logging Configuration

This document describes the logging configuration implemented in packages/observability/src/logger.ts (@repo/observability/logger) and packages/utils/src/logger.ts (@repo/utils/logger).

ERP-Unlocked uses a lazy-loaded, delegating logger architecture that works across browser bundles, Astro SSR/build prerendering, Cloudflare Workers, and Node.js servers on GCP Cloud Run and GCE VMs:

  1. Primary Production Sink: Structured JSON written to stdout (fd 1), captured natively by GCP Cloud Logging on Cloud Run and GCE VMs.
  2. Optional OTLP Logs Export: When OTEL_LOGS_EXPORTER=otlp and OTEL_EXPORTER_OTLP_ENDPOINT are set, @repo/observability/logger emits structured log records via @opentelemetry/api-logs.
  3. Optional Better Stack Logtail Transport: When LOGTAIL_SOURCE_TOKEN and LOGTAIL_INGEST_HOST are set (and OTLP log export is not active), Pino uses a multistream worker transport (@logtail/pino + pino/file to stdout).

4-Tier Logger Resolution (packages/observability/src/logger.ts)

Section titled “4-Tier Logger Resolution (packages/observability/src/logger.ts)”

When createLogger() is called, it immediately returns a delegating proxy backed by a console logger, and asynchronously resolves the appropriate backend in loadPinoLogger() using a 4-tier hierarchy:

1. Browser / Non-Node (!isNode)
└── Console logger (console.debug / log / warn / error)
2. Astro Prerendering / Build Context (isPrerendering)
└── Simple Pino logger to stdout (no worker transports, avoids build hangs)
3. OpenTelemetry Logs API (OTEL_LOGS_EXPORTER === 'otlp' && OTEL_EXPORTER_OTLP_ENDPOINT)
└── @opentelemetry/api-logs logger (emits SeverityNumber + body + structured attributes)
4. Server Pino Logger (Default Node Runtime)
├── If LOGTAIL_SOURCE_TOKEN && LOGTAIL_INGEST_HOST:
│ pino.transport with two targets:
│ • @logtail/pino (ships to https://${LOGTAIL_INGEST_HOST})
│ • pino/file (destination: 1 / stdout for GCP Cloud Logging & docker logs)
└── Otherwise:
Standard Pino writing structured JSON to stdout

Base Logger (packages/utils/src/logger.ts)

Section titled “Base Logger (packages/utils/src/logger.ts)”

Shared packages (packages/*) and lightweight apps without OpenTelemetry (internal, Cloudflare Workers) import @repo/utils/logger. It uses the same lazy delegating pattern:

  • Browser (!isNode): Uses createConsoleLogger() with formatForConsole().
  • Node (Astro build or runtime): Uses standard Pino writing JSON to stdout without any @opentelemetry/* or @logtail/pino dependencies.

Standard Pino requires object-first calls (pino.info(mergingObject, 'message')) and silently drops trailing objects passed in message-first calls (pino.info('message', { context })). Because call sites across the monorepo use both conventions, both loggers normalize arguments automatically:

normalizeLogArgs (packages/utils/src/logger.ts)

Section titled “normalizeLogArgs (packages/utils/src/logger.ts)”

Translates call-site arguments into Pino’s (mergeObj, msg) signature:

  • Pino-style (object-first): logger.info({ orderId, connectionId }, 'Order synced') → passed through unchanged.
  • Message-first: logger.error('Sync failed', err, { connectionId }) → merges Error instances under err and spreads context objects into the first argument ({ err, connectionId }, 'Sync failed').

buildOtelLogRecord & toPinoArgs (packages/observability/src/logger.ts)

Section titled “buildOtelLogRecord & toPinoArgs (packages/observability/src/logger.ts)”

Ensures identical structured fields whether logs flow through @opentelemetry/api-logs or Pino:

  • buildOtelLogRecord(parentAttributes, args) extracts Error arguments into error.message and error.stack attributes, merges plain-object keys into attributes (JSON-stringifying nested objects so fields stay queryable), and joins string/Error messages into body.
  • toPinoArgs(args) wraps buildOtelLogRecord({}, args) and returns [attributes, body] (or [body] when there are no attributes) for Pino.
import { logger } from '@repo/observability/logger';
// Both call styles preserve all structured fields:
logger.info('Customer matched', { customerId: 'cust_123', score: 0.98 });
logger.info({ customerId: 'cust_123', score: 0.98 }, 'Customer matched');
// Errors can be passed in any position after the message:
logger.error('ERP submission failed', error, { orderId: 'ord_456' });
Variable Used By Description
LOG_LEVEL @repo/utils, @repo/observability Minimum log level (debug, info, warn, error). Defaults to info.
OTEL_SERVICE_NAME @repo/observability Service identifier attached to base log fields (service) and OTel resource attributes.
OTEL_LOGS_EXPORTER @repo/observability Set to otlp (alongside OTEL_EXPORTER_OTLP_ENDPOINT) to enable Tier 3 OTLP log emission.
OTEL_EXPORTER_OTLP_ENDPOINT @repo/observability OTLP collector endpoint (e.g., https://in-otel.betterstack.com).
OTEL_EXPORTER_OTLP_HEADERS @repo/observability Auth header for OTLP export (e.g., Authorization=Bearer <token>).
LOGTAIL_SOURCE_TOKEN @repo/observability Better Stack Logtail source token for Tier 4 @logtail/pino transport (optional).
LOGTAIL_INGEST_HOST @repo/observability Better Stack Logtail ingest host (e.g., in.logs.betterstack.com).

Production / Staging (Cloud Run & GCE VMs)

Section titled “Production / Staging (Cloud Run & GCE VMs)”

On GCP Cloud Run and GCE VMs, stdout JSON is automatically collected by GCP Cloud Logging. To also export traces (and optionally logs) via OTLP:

Terminal window
export LOG_LEVEL=info
export OTEL_SERVICE_NAME=erp-unlocked-webapp
export OTEL_EXPORTER_OTLP_ENDPOINT=https://in-otel.betterstack.com
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <betterstack-token>"
# Optional: emit logs over OTLP
export OTEL_LOGS_EXPORTER=otlp
# Optional alternative: stream Pino logs via @logtail/pino worker
export LOGTAIL_SOURCE_TOKEN=<logtail-source-token>
export LOGTAIL_INGEST_HOST=in.logs.betterstack.com

In local development without OTLP or Logtail variables set, Pino writes structured JSON directly to stdout:

Terminal window
export NODE_ENV=development
export LOG_LEVEL=debug

Unit tests for argument normalization and logger behavior live in packages/observability and packages/utils:

Terminal window
pnpm --filter @repo/observability test
pnpm --filter @repo/utils test