Files
flyer-crawler.projectium.com/docs/adr/ADR-028-client-side-structured-logging.md
Torben Sorensen 4d323a51ca
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 49m39s
fix tour / whats new collision
2026-02-12 04:29:43 -08:00

8.0 KiB

ADR-028: Standardized Client-Side Structured Logging

Date: 2026-02-10

Status: Accepted

Source: Imported from flyer-crawler project (ADR-026)

Related: ADR-027, ADR-029

Context

Following the standardization of backend logging in ADR-027, it is clear that our frontend components also require a consistent logging strategy. Currently, components either use console.log directly or a simple wrapper, but without a formal standard, this can lead to inconsistent log formats and difficulty in debugging user-facing issues.

While the frontend does not have the concept of a "request-scoped" logger, the principles of structured, context-rich logging are equally important for:

  1. Effective Debugging: Understanding the state of a component or the sequence of user interactions that led to an error
  2. Integration with Monitoring Tools: Sending structured logs to services like Sentry/Bugsink or LogRocket allows for powerful analysis and error tracking in production
  3. Clean Test Outputs: Uncontrolled logging can pollute test runner output, making it difficult to spot actual test failures

An existing client-side logger at src/services/logger.client.ts provides a simple, structured logging interface. This ADR formalizes its use as the application standard.

Decision

We will adopt a standardized, application-wide structured logging policy for all client-side (React) code.

1. Mandatory Use of the Global Client Logger

All frontend components, hooks, and services MUST use the global logger singleton exported from src/services/logger.client.ts. Direct use of console.log, console.error, etc., is discouraged.

2. Pino-like API for Structured Logging

The client logger mimics the pino API, which is the standard on the backend. It supports two primary call signatures:

  • logger.info('A simple message');
  • logger.info({ key: 'value' }, 'A message with a structured data payload');

The second signature, which includes a data object as the first argument, is strongly preferred, especially for logging errors or complex state.

3. Mocking in Tests

All Jest/Vitest tests for components or hooks that use the logger MUST mock the src/services/logger.client.ts module. This prevents logs from appearing in test output and allows for assertions that the logger was called correctly.

Implementation

Client Logger Service

Located in src/services/logger.client.ts:

type LogLevel = 'debug' | 'info' | 'warn' | 'error';

interface LoggerOptions {
  level?: LogLevel;
  enabled?: boolean;
}

const LOG_LEVELS: Record<LogLevel, number> = {
  debug: 0,
  info: 1,
  warn: 2,
  error: 3,
};

class ClientLogger {
  private level: LogLevel;
  private enabled: boolean;

  constructor(options: LoggerOptions = {}) {
    this.level = options.level ?? 'info';
    this.enabled = options.enabled ?? import.meta.env.DEV;
  }

  private shouldLog(level: LogLevel): boolean {
    return this.enabled && LOG_LEVELS[level] >= LOG_LEVELS[this.level];
  }

  private formatMessage(data: object | string, message?: string): string {
    if (typeof data === 'string') {
      return data;
    }
    const payload = JSON.stringify(data, null, 2);
    return message ? `${message}\n${payload}` : payload;
  }

  debug(data: object | string, message?: string): void {
    if (this.shouldLog('debug')) {
      console.debug(`[DEBUG] ${this.formatMessage(data, message)}`);
    }
  }

  info(data: object | string, message?: string): void {
    if (this.shouldLog('info')) {
      console.info(`[INFO] ${this.formatMessage(data, message)}`);
    }
  }

  warn(data: object | string, message?: string): void {
    if (this.shouldLog('warn')) {
      console.warn(`[WARN] ${this.formatMessage(data, message)}`);
    }
  }

  error(data: object | string, message?: string): void {
    if (this.shouldLog('error')) {
      console.error(`[ERROR] ${this.formatMessage(data, message)}`);
    }
  }
}

export const logger = new ClientLogger({
  level: import.meta.env.DEV ? 'debug' : 'warn',
  enabled: true,
});

Example Usage

Logging an Error in a Component:

// In a React component or hook
import { logger } from '../services/logger.client';
import { notifyError } from '../services/notificationService';

const fetchData = async () => {
  try {
    const data = await apiClient.getData();
    return data;
  } catch (err) {
    // Log the full error object for context, along with a descriptive message.
    logger.error({ err }, 'Failed to fetch component data');
    notifyError('Something went wrong. Please try again.');
  }
};

Logging State Changes:

// In a Zustand store or state hook
import { logger } from '../services/logger.client';

const useAuthStore = create((set) => ({
  login: async (credentials) => {
    logger.info({ email: credentials.email }, 'User login attempt');
    try {
      const user = await authService.login(credentials);
      logger.info({ userId: user.id }, 'User logged in successfully');
      set({ user, isAuthenticated: true });
    } catch (error) {
      logger.error({ error }, 'Login failed');
      throw error;
    }
  },
}));

Mocking the Logger in Tests

// In a *.test.tsx file
import { vi } from 'vitest';

// Mock the logger at the top of the test file
vi.mock('../services/logger.client', () => ({
  logger: {
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
    debug: vi.fn(),
  },
}));

describe('MyComponent', () => {
  beforeEach(() => {
    vi.clearAllMocks(); // Clear mocks between tests
  });

  it('should log an error when fetching fails', async () => {
    // ... test setup to make fetch fail ...

    // Assert that the logger was called with the expected structure
    expect(logger.error).toHaveBeenCalledWith(
      expect.objectContaining({ err: expect.any(Error) }),
      'Failed to fetch component data',
    );
  });
});

Integration with Error Tracking

When using Sentry/Bugsink for error tracking (see ADR-029), the client logger can be extended to send logs as breadcrumbs:

import * as Sentry from '@sentry/react';

class ClientLogger {
  // ... existing implementation

  error(data: object | string, message?: string): void {
    if (this.shouldLog('error')) {
      console.error(`[ERROR] ${this.formatMessage(data, message)}`);
    }

    // Add to Sentry breadcrumbs for error context
    Sentry.addBreadcrumb({
      category: 'log',
      level: 'error',
      message: typeof data === 'string' ? data : message,
      data: typeof data === 'object' ? data : undefined,
    });
  }
}

Consequences

Positive

  • Consistency: All client-side logs will have a predictable structure, making them easier to read and parse
  • Debuggability: Errors logged with a full object ({ err }) capture the stack trace and other properties, which is invaluable for debugging
  • Testability: Components that log are easier to test without polluting CI/CD output. We can also assert that logging occurs when expected
  • Future-Proof: If we later decide to send client-side logs to a remote service, we only need to modify the central logger.client.ts file instead of every component
  • Error Tracking Integration: Logs can be used as breadcrumbs in Sentry/Bugsink for better error context

Negative

  • Minor Boilerplate: Requires importing the logger in every file that needs it and mocking it in every corresponding test file. However, this is a small and consistent effort
  • Production Noise: Care must be taken to configure appropriate log levels in production to avoid performance impact

Key Files

  • src/services/logger.client.ts - Client-side logger implementation
  • src/services/logger.server.ts - Backend logger (for reference)

References