microfleet/core

View on GitHub
packages/plugin-logger/src/logger.ts

Summary

Maintainability
B
4 hrs
Test Coverage
import { strict as assert } from 'node:assert'
import { resolve } from 'path'
import { PluginTypes } from '@microfleet/utils'
import { NotFoundError } from 'common-errors'
import { pino, symbols } from 'pino'
import { PrettyOptions } from 'pino-pretty'
import type { NodeOptions as SentryNodeOptions } from '@sentry/node'
import { defaultsDeep } from '@microfleet/utils'
import { Microfleet, PluginInterface } from '@microfleet/core-types'
import '@microfleet/plugin-validator'

export { SENTRY_FINGERPRINT_DEFAULT } from './constants'

const defaultConfig: LoggerConfig = {
  /**
   * anything thats not production will include extra logs
   */
  debug: process.env.NODE_ENV !== 'production',

  /**
   * Enables default logger to stdout
   */
  defaultLogger: true,

  // there are no USER env variable in docker image
  // so we can set default value based on its absence
  // NOTE: not intended for production usage
  prettifyDefaultLogger: !(process.env.NODE_ENV === 'production' || !process.env.USER),

  name: 'mservice',

  streams: {},

  options: {
    level: process.env.NODE_ENV !== 'production' ? 'debug' : 'info',
    redact: {
      paths: [
        'headers.cookie',
        'headers.authentication',
        'params.password',
        'query.token',
        'query.jwt',
        '*.awsElasticsearch.node',
        '*.awsElasticsearch.accessKeyId',
        '*.awsElasticsearch.secretAccessKey'
      ],
    },
  },
}

function streamsFactory(streamName: string, options: any): pino.TransportTargetOptions {
  if (streamName === 'sentry') {
    return {
      level: options.level || 'info',
      target: resolve(__dirname, '../lib/logger/streams/sentry-worker'),
      options,
    }
  }

  if (streamName === 'pretty') {
    return {
      level: options.level || 'debug',
      target: 'pino-pretty',
      options,
    }
  }

  return options
}

/**
 * Plugin Type
 */
export const type = PluginTypes.essential

/**
 * Relative priority inside the same plugin group type
 */
export const priority = 10

/**
 * Plugin Name
 */
export const name = 'logger'

export interface StreamConfiguration {
  sentry?: {
    minLevel?: number,
    level?: pino.Level,
    externalConfiguration?: string,
    sentry: SentryNodeOptions,
  };
  pretty?: PrettyOptions;
  [streamName: string]: any;
}

export type Logger = pino.Logger
// @TODO use pino.ThreadStream type in future https://github.com/pinojs/pino/blob/v7.6.3/pino.d.ts#L31
export type ThreadStream = any

export interface LoggerConfig {
  defaultLogger: Logger | boolean | pino.TransportBaseOptions;
  prettifyDefaultLogger: boolean;
  debug: boolean;
  name: string;
  options: pino.LoggerOptions;
  streams: StreamConfiguration;
  worker?: pino.TransportBaseOptions['worker'];
}

declare module '@microfleet/core-types' {
  export interface Microfleet {
    log: Logger & {
      flushSync(): void
    }
    logTransport?: any; // type ThreadStream = any https://github.com/pinojs/pino/blob/v7.6.3/pino.d.ts#L31
    logClose?: () => Promise<void>;
  }

  export interface ConfigurationOptional {
    logger: LoggerConfig;
  }
}

export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const

export const isCompatible = (obj: unknown): obj is Logger => {
  return typeof obj === 'object'
    && obj !== null
    && levels.every((level) => typeof (obj as Record<any, unknown>)[level] === 'function')
}

const noopInterface: PluginInterface = {
   async close() {
     return
   }
}

function flushSync(this: Logger): void {
  // @ts-expect-error has hidden prop
  this[symbols.streamSym].flushSync()
}

/**
 * Plugin init function.
 * @param  opts - Logger configuration.
 */
export async function attach(this: Microfleet, opts: Partial<LoggerConfig> = {}): Promise<PluginInterface> {
  const { version, config: { name: applicationName } } = this

  assert(this.hasPlugin('validator'), new NotFoundError('validator module must be included'))
  await this.validator.addLocation(resolve(__dirname, '../schemas'))
  const config = this.validator.ifError<LoggerConfig>('logger', defaultsDeep(opts, defaultConfig))

  const {
    debug,
    defaultLogger,
    prettifyDefaultLogger,
    options,
    worker,
    name: serviceName,
    streams: streamsConfig,
  } = config

  if (isCompatible(defaultLogger)) {
    // @ts-expect-error addign flushSync afterwards
    this.log = defaultLogger
    this.log.flushSync = flushSync
    return noopInterface
  }

  if (streamsConfig.sentry && !streamsConfig.sentry.sentry.release) {
    streamsConfig.sentry.sentry.release = version
  }

  const targets: pino.TransportTargetOptions[]  = []
  const pinoOptions: pino.LoggerOptions = {
    ...options,
    name: applicationName || serviceName,
  }

  if (defaultLogger) {
    const extra = typeof defaultLogger === 'boolean' ? {} : defaultLogger
    const level: pino.LevelWithSilentOrString = options.level || (debug ? 'debug' : 'info')
    targets.push({
      level,
      target: prettifyDefaultLogger
        ? 'pino-pretty'
        : 'pino/file',
      options: prettifyDefaultLogger
        ? { translateTime: true, ...extra.options }
        : { destination: process.stdout.fd, ...extra.options },
    })
  }

  for (const [streamName, streamConfig] of Object.entries(streamsConfig)) {
    targets.push(streamsFactory(streamName, streamConfig))
  }

  if (targets.length > 0) {
    assert(!pinoOptions.transport, 'transport must be undefined when using default streams')
    pinoOptions.transport = { targets, worker }
  }

  // @ts-expect-error adding flushSync afterwards
  this.log = pino(pinoOptions)
  this.log.flushSync = flushSync
  this.logClose = () => new Promise((resolve, reject) => {
    this.log.flush((err) => err ? reject(err) : resolve())
  })

  if (process.env.NODE_ENV === 'test') {
    this.log.debug({ config }, 'loaded logger configuration')
  }

  return {
    async close(this: Microfleet): Promise<void> {
      this.log.flush()
    }
  }
}