Просмотр исходного кода

logger: implement Pino-based logging system with environment variable parsing and transport options

Yuki Takei 1 неделя назад
Родитель
Сommit
e5aa9dc939

+ 1 - 0
packages/logger/.gitignore

@@ -0,0 +1 @@
+/dist

+ 42 - 0
packages/logger/package.json

@@ -0,0 +1,42 @@
+{
+  "name": "@growi/logger",
+  "version": "1.0.0",
+  "description": "Pino-based logger factory for GROWI",
+  "license": "MIT",
+  "private": true,
+  "type": "module",
+  "main": "dist/index.cjs",
+  "module": "dist/index.js",
+  "types": "dist/index.d.ts",
+  "exports": {
+    ".": {
+      "import": "./dist/index.js",
+      "require": "./dist/index.cjs"
+    }
+  },
+  "scripts": {
+    "build": "vite build",
+    "clean": "shx rm -rf dist",
+    "dev": "vite build --mode dev",
+    "watch": "pnpm run dev -w --emptyOutDir=false",
+    "lint:biome": "biome check",
+    "lint:typecheck": "tsgo --noEmit",
+    "lint": "npm-run-all -p lint:*",
+    "test": "vitest run"
+  },
+  "dependencies": {
+    "minimatch": "^9.0.0",
+    "pino": "^9.0.0"
+  },
+  "peerDependencies": {
+    "pino-pretty": "^13.0.0"
+  },
+  "peerDependenciesMeta": {
+    "pino-pretty": {
+      "optional": true
+    }
+  },
+  "devDependencies": {
+    "pino-pretty": "^13.0.0"
+  }
+}

+ 92 - 0
packages/logger/src/env-var-parser.spec.ts

@@ -0,0 +1,92 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { parseEnvLevels } from './env-var-parser';
+
+describe('parseEnvLevels', () => {
+  const originalEnv = process.env;
+
+  beforeEach(() => {
+    // Reset env before each test
+    process.env = { ...originalEnv };
+    delete process.env.DEBUG;
+    delete process.env.TRACE;
+    delete process.env.INFO;
+    delete process.env.WARN;
+    delete process.env.ERROR;
+    delete process.env.FATAL;
+  });
+
+  afterEach(() => {
+    process.env = originalEnv;
+  });
+
+  it('returns empty object when no env vars are set', () => {
+    const result = parseEnvLevels();
+    expect(result).toEqual({});
+  });
+
+  it('parses a single namespace from DEBUG', () => {
+    process.env.DEBUG = 'growi:service:page';
+    const result = parseEnvLevels();
+    expect(result).toEqual({ 'growi:service:page': 'debug' });
+  });
+
+  it('parses multiple comma-separated namespaces from DEBUG', () => {
+    process.env.DEBUG = 'growi:routes:*,growi:service:page';
+    const result = parseEnvLevels();
+    expect(result).toEqual({
+      'growi:routes:*': 'debug',
+      'growi:service:page': 'debug',
+    });
+  });
+
+  it('parses all six level env vars', () => {
+    process.env.DEBUG = 'ns:debug';
+    process.env.TRACE = 'ns:trace';
+    process.env.INFO = 'ns:info';
+    process.env.WARN = 'ns:warn';
+    process.env.ERROR = 'ns:error';
+    process.env.FATAL = 'ns:fatal';
+    const result = parseEnvLevels();
+    expect(result).toEqual({
+      'ns:debug': 'debug',
+      'ns:trace': 'trace',
+      'ns:info': 'info',
+      'ns:warn': 'warn',
+      'ns:error': 'error',
+      'ns:fatal': 'fatal',
+    });
+  });
+
+  it('trims whitespace around namespace patterns', () => {
+    process.env.DEBUG = ' growi:service , growi:routes ';
+    const result = parseEnvLevels();
+    expect(result).toEqual({
+      'growi:service': 'debug',
+      'growi:routes': 'debug',
+    });
+  });
+
+  it('ignores empty entries from trailing/double commas', () => {
+    process.env.DEBUG = 'growi:service,,growi:routes,';
+    const result = parseEnvLevels();
+    expect(result).toEqual({
+      'growi:service': 'debug',
+      'growi:routes': 'debug',
+    });
+  });
+
+  it('uses the last value when the same namespace appears in multiple env vars', () => {
+    process.env.DEBUG = 'growi:service';
+    process.env.TRACE = 'growi:service';
+    const result = parseEnvLevels();
+    // TRACE is processed after DEBUG, so it wins
+    expect(result['growi:service']).toBe('trace');
+  });
+
+  it('supports glob wildcard patterns', () => {
+    process.env.DEBUG = 'growi:*';
+    const result = parseEnvLevels();
+    expect(result).toEqual({ 'growi:*': 'debug' });
+  });
+});

+ 33 - 0
packages/logger/src/env-var-parser.ts

@@ -0,0 +1,33 @@
+import type { LoggerConfig } from './types';
+
+const LEVEL_ENV_VARS: ReadonlyArray<[string, string]> = [
+  ['DEBUG', 'debug'],
+  ['TRACE', 'trace'],
+  ['INFO', 'info'],
+  ['WARN', 'warn'],
+  ['ERROR', 'error'],
+  ['FATAL', 'fatal'],
+];
+
+/**
+ * Parse log-level environment variables into a namespace-to-level map.
+ * Reads: DEBUG, TRACE, INFO, WARN, ERROR, FATAL from process.env.
+ * Later entries in the list override earlier ones for the same namespace.
+ */
+export function parseEnvLevels(): Omit<LoggerConfig, 'default'> {
+  const result: Record<string, string> = {};
+
+  for (const [envVar, level] of LEVEL_ENV_VARS) {
+    const value = process.env[envVar];
+    if (!value) continue;
+
+    for (const pattern of value.split(',')) {
+      const trimmed = pattern.trim();
+      if (trimmed) {
+        result[trimmed] = level;
+      }
+    }
+  }
+
+  return result;
+}

+ 8 - 0
packages/logger/src/index.ts

@@ -0,0 +1,8 @@
+export { parseEnvLevels } from './env-var-parser';
+export { resolveLevel } from './level-resolver';
+export { initializeLoggerFactory, loggerFactory } from './logger-factory';
+export {
+  createBrowserOptions,
+  createNodeTransportOptions,
+} from './transport-factory';
+export type { Logger, LoggerConfig, LoggerFactoryOptions } from './types';

+ 103 - 0
packages/logger/src/level-resolver.spec.ts

@@ -0,0 +1,103 @@
+import { describe, expect, it } from 'vitest';
+
+import { resolveLevel } from './level-resolver';
+import type { LoggerConfig } from './types';
+
+describe('resolveLevel', () => {
+  const baseConfig: LoggerConfig = {
+    default: 'info',
+    'growi:service:page': 'debug',
+    'growi:routes:*': 'debug',
+    'growi:crowi': 'debug',
+  };
+
+  describe('config pattern matching', () => {
+    it('returns default level when no pattern matches', () => {
+      const result = resolveLevel('growi:unknown', baseConfig, {});
+      expect(result).toBe('info');
+    });
+
+    it('returns level for exact namespace match', () => {
+      const result = resolveLevel('growi:crowi', baseConfig, {});
+      expect(result).toBe('debug');
+    });
+
+    it('matches glob wildcard pattern', () => {
+      const result = resolveLevel('growi:routes:login', baseConfig, {});
+      expect(result).toBe('debug');
+    });
+
+    it('does not match partial namespace without wildcard', () => {
+      const config: LoggerConfig = {
+        default: 'warn',
+        'growi:service': 'debug',
+      };
+      // 'growi:service:page' should NOT match 'growi:service' (no wildcard)
+      const result = resolveLevel('growi:service:page', config, {});
+      expect(result).toBe('warn');
+    });
+
+    it('uses config default when provided', () => {
+      const config: LoggerConfig = { default: 'error' };
+      const result = resolveLevel('growi:anything', config, {});
+      expect(result).toBe('error');
+    });
+  });
+
+  describe('env override precedence', () => {
+    it('env override takes precedence over config pattern', () => {
+      const envOverrides = { 'growi:service:page': 'trace' };
+      const result = resolveLevel(
+        'growi:service:page',
+        baseConfig,
+        envOverrides,
+      );
+      expect(result).toBe('trace');
+    });
+
+    it('env override glob takes precedence over config exact match', () => {
+      const envOverrides = { 'growi:*': 'fatal' };
+      const result = resolveLevel('growi:crowi', baseConfig, envOverrides);
+      expect(result).toBe('fatal');
+    });
+
+    it('falls back to config when no env override matches', () => {
+      const envOverrides = { 'other:ns': 'trace' };
+      const result = resolveLevel('growi:crowi', baseConfig, envOverrides);
+      expect(result).toBe('debug');
+    });
+
+    it('falls back to config default when neither env nor config pattern matches', () => {
+      const envOverrides = { 'other:ns': 'trace' };
+      const result = resolveLevel('growi:unknown:ns', baseConfig, envOverrides);
+      expect(result).toBe('info');
+    });
+  });
+
+  describe('glob pattern matching', () => {
+    it('matches deep wildcard patterns', () => {
+      const config: LoggerConfig = {
+        default: 'info',
+        'growi:service:*': 'debug',
+      };
+      const result = resolveLevel('growi:service:auth', config, {});
+      expect(result).toBe('debug');
+    });
+
+    it('env override wildcard applies to multiple namespaces', () => {
+      const envOverrides = { 'growi:service:*': 'trace' };
+      const result1 = resolveLevel(
+        'growi:service:page',
+        baseConfig,
+        envOverrides,
+      );
+      const result2 = resolveLevel(
+        'growi:service:user',
+        baseConfig,
+        envOverrides,
+      );
+      expect(result1).toBe('trace');
+      expect(result2).toBe('trace');
+    });
+  });
+});

+ 38 - 0
packages/logger/src/level-resolver.ts

@@ -0,0 +1,38 @@
+import { minimatch } from 'minimatch';
+
+import type { LoggerConfig } from './types';
+
+/**
+ * Resolve the log level for a namespace.
+ * Priority: env var match > config pattern match > config default.
+ */
+export function resolveLevel(
+  namespace: string,
+  config: LoggerConfig,
+  envOverrides: Omit<LoggerConfig, 'default'>,
+): string {
+  // 1. Check env overrides first (highest priority)
+  for (const [pattern, level] of Object.entries(envOverrides)) {
+    if (matchesPattern(namespace, pattern)) {
+      return level;
+    }
+  }
+
+  // 2. Check config patterns (excluding the 'default' key)
+  for (const [pattern, level] of Object.entries(config)) {
+    if (pattern === 'default') continue;
+    if (matchesPattern(namespace, pattern)) {
+      return level;
+    }
+  }
+
+  // 3. Fall back to config default
+  return config.default;
+}
+
+function matchesPattern(namespace: string, pattern: string): boolean {
+  // Exact match
+  if (namespace === pattern) return true;
+  // Glob match using minimatch
+  return minimatch(namespace, pattern);
+}

+ 70 - 0
packages/logger/src/logger-factory.spec.ts

@@ -0,0 +1,70 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { initializeLoggerFactory, loggerFactory } from './logger-factory';
+import type { LoggerConfig } from './types';
+
+// Reset the module-level cache/state between tests
+beforeEach(() => {
+  vi.resetModules();
+});
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+describe('initializeLoggerFactory + loggerFactory', () => {
+  const config: LoggerConfig = {
+    default: 'info',
+    'growi:debug:*': 'debug',
+  };
+
+  it('returns a logger with info() method', () => {
+    initializeLoggerFactory({ config });
+    const logger = loggerFactory('growi:test');
+    expect(typeof logger.info).toBe('function');
+    expect(typeof logger.debug).toBe('function');
+    expect(typeof logger.warn).toBe('function');
+    expect(typeof logger.error).toBe('function');
+    expect(typeof logger.trace).toBe('function');
+    expect(typeof logger.fatal).toBe('function');
+  });
+
+  it('returns the same logger instance for the same namespace (cache hit)', () => {
+    initializeLoggerFactory({ config });
+    const logger1 = loggerFactory('growi:service:page');
+    const logger2 = loggerFactory('growi:service:page');
+    expect(logger1).toBe(logger2);
+  });
+
+  it('returns different logger instances for different namespaces', () => {
+    initializeLoggerFactory({ config });
+    const logger1 = loggerFactory('growi:service:page');
+    const logger2 = loggerFactory('growi:service:user');
+    expect(logger1).not.toBe(logger2);
+  });
+
+  it('resolves log level from config for matched pattern', () => {
+    initializeLoggerFactory({ config });
+    const logger = loggerFactory('growi:debug:something');
+    expect(logger.level).toBe('debug');
+  });
+
+  it('uses default level when no pattern matches', () => {
+    initializeLoggerFactory({ config });
+    const logger = loggerFactory('growi:unmatched:ns');
+    expect(logger.level).toBe('info');
+  });
+
+  it('re-initializing clears the cache', () => {
+    initializeLoggerFactory({ config });
+    const logger1 = loggerFactory('growi:service:page');
+
+    // Re-initialize with different config
+    initializeLoggerFactory({ config: { default: 'warn' } });
+    const logger2 = loggerFactory('growi:service:page');
+
+    // After re-init, cache is cleared — new instance
+    expect(logger1).not.toBe(logger2);
+    expect(logger2.level).toBe('warn');
+  });
+});

+ 61 - 0
packages/logger/src/logger-factory.ts

@@ -0,0 +1,61 @@
+import type { Logger } from 'pino';
+import pino from 'pino';
+
+import { parseEnvLevels } from './env-var-parser';
+import { resolveLevel } from './level-resolver';
+import {
+  createBrowserOptions,
+  createNodeTransportOptions,
+} from './transport-factory';
+import type { LoggerConfig, LoggerFactoryOptions } from './types';
+
+const isBrowser =
+  typeof window !== 'undefined' && typeof window.document !== 'undefined';
+
+let moduleConfig: LoggerConfig = { default: 'info' };
+let envOverrides: Omit<LoggerConfig, 'default'> = {};
+const loggerCache = new Map<string, Logger>();
+
+/**
+ * Initialize the logger factory with configuration.
+ * Must be called once at application startup before any loggerFactory() calls.
+ * Subsequent calls clear the cache and apply the new config.
+ */
+export function initializeLoggerFactory(options: LoggerFactoryOptions): void {
+  moduleConfig = options.config;
+  envOverrides = parseEnvLevels();
+  loggerCache.clear();
+}
+
+/**
+ * Create or retrieve a cached pino logger for the given namespace.
+ */
+export function loggerFactory(name: string): Logger {
+  const cached = loggerCache.get(name);
+  if (cached != null) {
+    return cached;
+  }
+
+  const isProduction = process.env.NODE_ENV === 'production';
+  const level = resolveLevel(name, moduleConfig, envOverrides);
+
+  let logger: Logger;
+
+  if (isBrowser) {
+    const browserOpts = createBrowserOptions(isProduction);
+    logger = pino({
+      name,
+      level,
+      ...browserOpts,
+    });
+  } else {
+    const { transport } = createNodeTransportOptions(isProduction);
+    logger =
+      transport != null
+        ? pino({ name, level }, pino.transport(transport))
+        : pino({ name, level });
+  }
+
+  loggerCache.set(name, logger);
+  return logger;
+}

+ 69 - 0
packages/logger/src/transport-factory.spec.ts

@@ -0,0 +1,69 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { createNodeTransportOptions } from './transport-factory';
+
+describe('createNodeTransportOptions', () => {
+  const originalEnv = process.env;
+
+  beforeEach(() => {
+    process.env = { ...originalEnv };
+    delete process.env.FORMAT_NODE_LOG;
+  });
+
+  afterEach(() => {
+    process.env = originalEnv;
+  });
+
+  describe('development mode', () => {
+    it('returns pino-pretty transport config', () => {
+      const opts = createNodeTransportOptions(false);
+      expect(opts.transport).toBeDefined();
+      expect(opts.transport?.target).toBe('pino-pretty');
+    });
+
+    it('includes translateTime, ignore, and singleLine options', () => {
+      const opts = createNodeTransportOptions(false);
+      const popts = opts.transport?.options as Record<string, unknown>;
+      expect(popts?.translateTime).toBeTruthy();
+      expect(popts?.ignore).toContain('pid');
+      expect(popts?.ignore).toContain('hostname');
+    });
+  });
+
+  describe('production mode — raw JSON', () => {
+    it('returns no transport when FORMAT_NODE_LOG is "false"', () => {
+      process.env.FORMAT_NODE_LOG = 'false';
+      const opts = createNodeTransportOptions(true);
+      expect(opts.transport).toBeUndefined();
+    });
+
+    it('returns no transport when FORMAT_NODE_LOG is "0"', () => {
+      process.env.FORMAT_NODE_LOG = '0';
+      const opts = createNodeTransportOptions(true);
+      expect(opts.transport).toBeUndefined();
+    });
+  });
+
+  describe('production mode — formatted (pino-pretty)', () => {
+    it('returns pino-pretty transport when FORMAT_NODE_LOG is unset', () => {
+      delete process.env.FORMAT_NODE_LOG;
+      const opts = createNodeTransportOptions(true);
+      expect(opts.transport).toBeDefined();
+      expect(opts.transport?.target).toBe('pino-pretty');
+    });
+
+    it('returns pino-pretty transport when FORMAT_NODE_LOG is "true"', () => {
+      process.env.FORMAT_NODE_LOG = 'true';
+      const opts = createNodeTransportOptions(true);
+      expect(opts.transport).toBeDefined();
+      expect(opts.transport?.target).toBe('pino-pretty');
+    });
+
+    it('returns pino-pretty transport when FORMAT_NODE_LOG is "1"', () => {
+      process.env.FORMAT_NODE_LOG = '1';
+      const opts = createNodeTransportOptions(true);
+      expect(opts.transport).toBeDefined();
+      expect(opts.transport?.target).toBe('pino-pretty');
+    });
+  });
+});

+ 76 - 0
packages/logger/src/transport-factory.ts

@@ -0,0 +1,76 @@
+import type { LoggerOptions, TransportSingleOptions } from 'pino';
+
+interface NodeTransportOptions {
+  transport?: TransportSingleOptions;
+}
+
+/**
+ * Returns whether FORMAT_NODE_LOG env var indicates formatted output.
+ * Formatted is the default (returns true when unset or truthy).
+ * Returns false only when explicitly set to 'false' or '0'.
+ */
+function isFormattedOutputEnabled(): boolean {
+  const val = process.env.FORMAT_NODE_LOG;
+  if (val === undefined || val === null) return true;
+  return val !== 'false' && val !== '0';
+}
+
+/**
+ * Create pino transport/options for Node.js environment.
+ * Development: pino-pretty with human-readable output.
+ * Production: raw JSON by default; pino-pretty when FORMAT_NODE_LOG is truthy.
+ */
+export function createNodeTransportOptions(
+  isProduction: boolean,
+): NodeTransportOptions {
+  if (!isProduction) {
+    // Development: always use pino-pretty
+    return {
+      transport: {
+        target: 'pino-pretty',
+        options: {
+          translateTime: 'SYS:standard',
+          ignore: 'pid,hostname',
+          singleLine: false,
+        },
+      },
+    };
+  }
+
+  // Production: raw JSON unless FORMAT_NODE_LOG enables formatting
+  if (!isFormattedOutputEnabled()) {
+    return {};
+  }
+
+  return {
+    transport: {
+      target: 'pino-pretty',
+      options: {
+        translateTime: 'SYS:standard',
+        ignore: 'pid,hostname',
+        singleLine: false,
+      },
+    },
+  };
+}
+
+/**
+ * Create pino browser options.
+ * Development: uses the resolved namespace level.
+ * Production: defaults to 'error' level to minimize console noise.
+ */
+export function createBrowserOptions(
+  isProduction: boolean,
+): Partial<LoggerOptions> {
+  const browserOptions: Partial<LoggerOptions> = {
+    browser: {
+      asObject: false,
+    },
+  };
+
+  if (isProduction) {
+    return { ...browserOptions, level: 'error' };
+  }
+
+  return browserOptions;
+}

+ 21 - 0
packages/logger/src/types.ts

@@ -0,0 +1,21 @@
+import type { Logger } from 'pino';
+
+/**
+ * Maps namespace patterns (with glob support) to log level strings.
+ * Must include a 'default' key as the fallback level.
+ * Example: { 'growi:service:*': 'debug', 'default': 'info' }
+ */
+export type LoggerConfig = {
+  default: string;
+  [namespacePattern: string]: string;
+};
+
+/**
+ * Options passed to initializeLoggerFactory().
+ */
+export interface LoggerFactoryOptions {
+  config: LoggerConfig;
+}
+
+// Re-export pino Logger type so consumers can type-annotate without importing pino directly
+export type { Logger };

+ 11 - 0
packages/logger/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "$schema": "http://json.schemastore.org/tsconfig",
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "paths": {
+      "~/*": ["./src/*"]
+    },
+    "types": ["vitest/globals"]
+  },
+  "include": ["src"]
+}

+ 37 - 0
packages/logger/vite.config.ts

@@ -0,0 +1,37 @@
+import path from 'node:path';
+import glob from 'glob';
+import { nodeExternals } from 'rollup-plugin-node-externals';
+import { defineConfig } from 'vite';
+import dts from 'vite-plugin-dts';
+
+export default defineConfig({
+  plugins: [
+    dts({
+      copyDtsFiles: true,
+    }),
+    {
+      ...nodeExternals({
+        devDeps: true,
+        builtinsPrefix: 'ignore',
+      }),
+      enforce: 'pre',
+    },
+  ],
+  build: {
+    outDir: 'dist',
+    sourcemap: true,
+    lib: {
+      entry: glob.sync(path.resolve(__dirname, 'src/**/*.ts'), {
+        ignore: '**/*.spec.ts',
+      }),
+      name: 'logger',
+      formats: ['es', 'cjs'],
+    },
+    rollupOptions: {
+      output: {
+        preserveModules: true,
+        preserveModulesRoot: 'src',
+      },
+    },
+  },
+});

+ 11 - 0
packages/logger/vitest.config.ts

@@ -0,0 +1,11 @@
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  plugins: [tsconfigPaths()],
+  test: {
+    environment: 'node',
+    clearMocks: true,
+    globals: true,
+  },
+});