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).
Overview
Section titled “Overview”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:
- Primary Production Sink: Structured JSON written to
stdout(fd1), captured natively by GCP Cloud Logging on Cloud Run and GCE VMs. - Optional OTLP Logs Export: When
OTEL_LOGS_EXPORTER=otlpandOTEL_EXPORTER_OTLP_ENDPOINTare set,@repo/observability/loggeremits structured log records via@opentelemetry/api-logs. - Optional Better Stack Logtail Transport: When
LOGTAIL_SOURCE_TOKENandLOGTAIL_INGEST_HOSTare set (and OTLP log export is not active), Pino uses a multistream worker transport (@logtail/pino+pino/fileto 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 stdoutBase 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): UsescreateConsoleLogger()withformatForConsole(). - Node (Astro build or runtime): Uses standard Pino writing JSON to
stdoutwithout any@opentelemetry/*or@logtail/pinodependencies.
Argument Normalization
Section titled “Argument Normalization”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 })→ mergesErrorinstances undererrand 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)extractsErrorarguments intoerror.messageanderror.stackattributes, merges plain-object keys intoattributes(JSON-stringifying nested objects so fields stay queryable), and joins string/Error messages intobody.toPinoArgs(args)wrapsbuildOtelLogRecord({}, 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' });Environment Variables
Section titled “Environment Variables”| 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). |
Configuration Examples
Section titled “Configuration Examples”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:
export LOG_LEVEL=infoexport OTEL_SERVICE_NAME=erp-unlocked-webappexport OTEL_EXPORTER_OTLP_ENDPOINT=https://in-otel.betterstack.comexport OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <betterstack-token>"
# Optional: emit logs over OTLPexport OTEL_LOGS_EXPORTER=otlp
# Optional alternative: stream Pino logs via @logtail/pino workerexport LOGTAIL_SOURCE_TOKEN=<logtail-source-token>export LOGTAIL_INGEST_HOST=in.logs.betterstack.comLocal Development
Section titled “Local Development”In local development without OTLP or Logtail variables set, Pino writes structured JSON directly to stdout:
export NODE_ENV=developmentexport LOG_LEVEL=debugTesting
Section titled “Testing”Unit tests for argument normalization and logger behavior live in packages/observability and packages/utils:
pnpm --filter @repo/observability testpnpm --filter @repo/utils test