@ngworker/lumberjack

Lumberjack for Angular - Chop and cut Angular logs like a professional Lumberjack.

Usage no npm install needed!

<script type="module">
  import ngworkerLumberjack from 'https://cdn.skypack.dev/@ngworker/lumberjack';
</script>

README

Chop and cut Angular logs like a professional lumberjack.

Logo by Felipe Zambrano


MIT commitizen PRs styled with prettier All Contributors ngworker Wallaby.js

Lines of Code Coverage Duplicated Lines (%)
Quality Gate Status Security Rating Reliability Rating Maintainability Rating

Lumberjack is a versatile Angular logging library, specifically designed to be extended and customized. It provides a few simple log drivers (logging mechanisms, transports, log drivers) out-of-the-box. It's easy to enable the built-in log drivers or create and use custom log drivers.

For support, please refer to the #lumberjack channel in the Angular Discord server.

Features

  • ✅ Configurable multilevel logging
  • ✅ Plugin-based log driver architecture
  • ✅ Robust error handling
  • ✅ Console driver
  • ✅ HTTP driver
  • ✅ Logger base class
  • ✅ Lumberjack service
  • ✅ Best practices guide

Table of Contents

Installation

Lumberjack is published as the @ngworker/lumberjack package.

Toolchain Command
Angular CLI ng add @ngworker/lumberjack
NPM CLI npm install @ngworker/lumberjack
PNPM CLI pnpm add @ngworker/lumberjack
Nx CLI Install using your package manager, then nx generate @ngworker/lumberjack:ng-add
Yarn CLI yarn add @ngworker/lumberjack

Compatibility

Lumberjack version 2.x has verified compatibility with the following Angular versions.

Angular version Lumberjack 2.x support
12.0.x
11.2.x
11.1.x
11.0.x
10.2.x
10.1.x
10.0.x
9.1.x
9.0.x

If the version you are using is not listed, please raise an issue in our GitHub repository.


Usage

For a complete walkthrough video please visit @ngworker/lumberjack v2 - Show & Tell BLS024

To register Lumberjack, add LumberjackModule.forRoot() to your root or core Angular module or use the schematics.

ng add @ngworker/lumberjack

The ng add command will add the following code to your module.

// (...)
import { LumberjackModule } from '@ngworker/lumberjack';
import { LumberjackConsoleDriverModule } from '@ngworker/lumberjack/console-driver';

@NgModule({
  imports: [
      // (...)
    LumberjackModule.forRoot(),
    LumberjackConsoleDriverModule.forRoot(),
      // (...)
  ],
  // (...)
})

You must also register the log driver modules for the log drivers that you want to enable.

If you want to add the LumberjackHttpDriver, use the following command

ng add @ngworker/lumberjack --http-driver

This command will add the below code to your app module.

// (...)
import { LumberjackModule } from '@ngworker/lumberjack';
import { LumberjackHttpDriverModule } from '@ngworker/lumberjack/http-driver';
import { LumberjackConsoleDriverModule } from '@ngworker/lumberjack/console-driver';

@NgModule({
  imports: [
    // (...)
    LumberjackModule.forRoot(),
    LumberjackConsoleDriverModule.forRoot(),
    LumberjackHttpDriverModule.withOptions({
      origin: '<app-name>',
      storeUrl: '/api/logs',
      retryOptions: { maxRetries: 5, delayMs: 250 },
    }),
    // (...)
  ],
  // (...)
})
export class AppModule {}

ng add schematic options

Option Type Optional? Description
console-driver Boolean (defaults to true) Yes Whether to register LumberjackConsoleDriver.
http-driver Boolean (defaults to false) Yes Whether to register LumberjackHttpDriver.

Using the LumberjackService

For quick or simple use cases, you can use the LumberjackService directly by passing logs to its log method. However, we recommend implementing application-specific logger services instead. See the Best practices section.

First, inject the LumberjackService where you want to use it.

import { Component } from '@angular/core';
import { LumberjackService } from '@ngworker/lumberjack';

@Component({
  // (...)
})
export class MyComponent implements OnInit {
  constructor(private lumberjack: LumberjackService) {}
  // (...)
}

Then we can start logging. However, you'll also want to inject LumberjackTimeService to maintain a high level of testability.

// (...)
import { LumberjackService, LumberjackTimeService } from '@ngworker/lumberjack';

// (...)
export class MyComponent implements OnInit {
  constructor(private lumberjack: LumberjackService, private time: LumberjackTimeService) {}

  // (...)
  ngOnInit(): void {
    this.lumberjack.log({
      level: LumberjackLevel.Info,
      message: 'Hello, World!',
      scope: 'MyComponent',
      createdAt: this.time.getUnixEpochTicks(),
    });
  }
}

LumberjackModule

Optionally, we can pass one or more options to LumberjackModule.forRoot.

Option Type Optional? Description
format (log: LumberjackLog) => string Yes Pass a custom formatter to transform a log into a log message.
levels LumberjackConfigLevels Yes The root log levels defining the default log levels for log drivers.

Default options

Lumberjack's configuration is flexible. We can provide a full configuration object, a partial option set, or no options at all.

Lumberjack replaces omitted options with defaults.

When the format option is not configured, Lumberjack will use the following default formatter.

function lumberjackFormatLog({ scope, createdAt: timestamp, level, message }: LumberjackLog) {
  return `${level} ${utcTimestampFor(timestamp)}${scope ? ` [${scope}]` : ''} ${message}`;
}

Where utcTimestampFor is a function that converts Unix Epoch ticks to UTC 0 hours offset with milliseconds resolution.

Default log levels

When the levels setting is not configured, log levels are configured depending on whether our application runs in development mode or production mode.

By default, in development mode, all log levels are enabled.

By default, in production mode, the following log levels are enabled:

  • Critical
  • Error
  • Info
  • Warning

Log drivers

Earlier, we briefly introduced the term log driver. This section explains in depth how to use and configure them and how to create custom log drivers.

A log driver is the conduit used by the Lumberjack to output or persist application logs.

Lumberjack offers basic log drivers out-of-the-box, namely the LumberjackConsoleDriver and the LumberjackHttpDriver.

Every log driver implements the LumberjackLogDriver interface.

export interface LumberjackLogDriver<TPayload extends LumberjackLogPayload | void = void> {
  readonly config: LumberjackLogDriverConfig;
  logCritical(driverLog: LumberjackLogDriverLog<TPayload>): void;
  logDebug(driverLog: LumberjackLogDriverLog<TPayload>): void;
  logError(driverLog: LumberjackLogDriverLog<TPayload>): void;
  logInfo(driverLog: LumberjackLogDriverLog<TPayload>): void;
  logTrace(driverLog: LumberjackLogDriverLog<TPayload>): void;
  logWarning(driverLog: LumberjackLogDriverLog<TPayload>): void;
}

The LumberjackLogDriverLog holds a formatted string representation of the LumberjackLog and the LumberjackLog itself.

export interface LumberjackLogDriverLog<TPayload extends LumberjackLogPayload | void = void> {
  readonly formattedLog: string;
  readonly log: LumberjackLog<TPayload>;
}

Log levels

Log drivers should make it possible to configure the logging levels on a per driver basis.

For example, we could use the default logging levels for the console driver, but only enable the critical and error levels for the HTTP driver as seen in the following example.

import { NgModule } from '@angular/core';
import { LumberjackLevel, LumberjackModule } from '@ngworker/lumberjack';
import { LumberjackConsoleDriverModule } from '@ngworker/lumberjack/console-driver';
import { LumberjackHttpDriverModule } from '@ngworker/lumberjack/http-driver';

@NgModule({
  imports: [
    LumberjackModule.forRoot({
      levels: [LumberjackLevel.Verbose],
    }),
    LumberjackConsoleDriverModule.forRoot(),
    LumberjackHttpDriverModule.forRoot({
      levels: [LumberjackLevel.Critical, LumberjackLevel.Error],
      origin: 'ForestApp',
      storeUrl: '/api/logs',
      retryOptions: { maxRetries: 5, delayMs: 250 },
    }),
    // (...)
  ],
  // (...)
})
export class AppModule {}

Creating a custom log driver

Note, you can use the ngworker/lumberjack-custom-driver template Git repository to start a separate Lumberjack log driver workspace.

Let's create a simple log driver for the browser console.

import { Inject, Injectable } from '@angular/core';

import { LumberjackLogDriver, LumberjackLogDriverConfig, LumberjackLogDriverLog } from '@ngworker/lumberjack';

import { consoleDriverConfigToken } from './console-driver-config.token';

@Injectable()
export class ConsoleDriver implements LumberjackLogDriver {
  constructor(@Inject(consoleDriverConfigToken) readonly config: LumberjackLogDriverConfig) {}

  logCritical({ formattedLog }: LumberjackLogDriverLog): void {
    console.error(formattedLog);
  }

  logDebug({ formattedLog }: LumberjackLogDriverLog): void {
    console.debug(formattedLog);
  }

  logError({ formattedLog }: LumberjackLogDriverLog): void {
    console.error(formattedLog);
  }

  logInfo({ formattedLog }: LumberjackLogDriverLog): void {
    console.info(formattedLog);
  }

  logTrace({ formattedLog }: LumberjackLogDriverLog): void {
    console.trace(formattedLog);
  }

  logWarning({ formattedLog }: LumberjackLogDriverLog): void {
    console.warn(formattedLog);
  }
}

In the above snippet, the config is passed down its constructor and assigned to the public config property. Lumberjack uses this configuration to determine which logs the log driver should handle.

Using a LumberjackLogPayload

We might want to add some extra data not present in the LumberjackLog to our log driver.

For such cases, Lumberjack exposes the LumberjackLog#payload property.

/**
 * A Lumberjack log entry
 */
export interface LumberjackLog<TPayload extends LumberjackLogPayload | void = void> {
  /**
   * Scope, for example domain, application, component, or service.
   */
  readonly scope?: string;
  /**
   * Unix epoch ticks (milliseconds) timestamp when log entry was created.
   */
  readonly createdAt: number;
  /**
   * Level of severity.
   */
  readonly level: LumberjackLogLevel;
  /**
   * Log message, for example describing an event that happened.
   */
  readonly message: string;

  /**
   * Holds any payload info
   */
  readonly payload?: TPayload;
}

We can modify the ConsoleDriver to handle such payload information

import { Inject, Injectable } from '@angular/core';

import {
  LumberjackLogDriver,
  LumberjackLogDriverConfig,
  LumberjackLogDriverLog,
  LumberjackLogPayload,
} from '@ngworker/lumberjack';

import { consoleDriverConfigToken } from './console-driver-config.token';

export interface AnalyticsPayload extends LumberjackLogPayload {
  angularVersion: string;
}

@Injectable()
export class ConsoleDriver implements LumberjackLogDriver<AnalyticsPayload> {
  constructor(@Inject(consoleDriverConfigToken) readonly config: LumberjackLogDriverConfig) {}

  logCritical({ formattedLog, log }: LumberjackLogDriverLog<AnalyticsPayload>): void {
    console.error(formattedLog, log.payload);
  }

  logDebug({ formattedLog, log }: LumberjackLogDriverLog<AnalyticsPayload>): void {
    console.debug(formattedLog, log.payload);
  }

  logError({ formattedLog, log }: LumberjackLogDriverLog<AnalyticsPayload>): void {
    console.error(formattedLog, log.payload);
  }

  logInfo({ formattedLog, log }: LumberjackLogDriverLog<AnalyticsPayload>): void {
    console.info(formattedLog, log.payload);
  }

  logTrace({ formattedLog, log }: LumberjackLogDriverLog<AnalyticsPayload>): void {
    console.trace(formattedLog, log.payload);
  }

  logWarning({ formattedLog, log }: LumberjackLogDriverLog<AnalyticsPayload>): void {
    console.warn(formattedLog, log.payload);
  }
}

Creating a custom log driver module

A driver module provides configuration and other dependencies to a log driver. It also provides the log driver, making it available to Lumberjack.

import { ModuleWithProviders, NgModule } from '@angular/core';
import { LumberjackLogDriverConfig, lumberjackLogDriverToken } from '@ngworker/lumberjack';

import { consoleDriverConfigToken } from './console-driver-config.token';

@NgModule({
  providers: [
    {
      provide: lumberjackLogDriverToken,
      useClass: ConsoleDriver,
      multi: true,
    },
  ],
})
export class ConsoleDriverModule {
  static forRoot(config?: LumberjackLogDriverConfig): ModuleWithProviders<ConsoleDriverModule> {
    return {
      ngModule: ConsoleDriverModule,
      providers: (config && [{ provide: consoleDriverConfigToken, useValue: config }]) || [],
    };
  }
}

The static forRoot() method provides the consoleDriverConfigToken.

If no configuration is passed, then the root LogDriverConfig is used.

import { InjectionToken } from '@angular/core';
import { LumberjackLogDriverConfig, lumberjackLogDriverConfigToken } from '@ngworker/lumberjack';

export const consoleDriverConfigToken = new InjectionToken<LumberjackLogDriverConfig>('__CONSOLE_DRIVER_CONFIG__', {
  factory: () => inject({ ...lumberjackLogDriverConfigToken, identifier: 'ConsoleDriver' }),
});

This is possible because the ConsoleDriver has the same configuration options as the LumberjackLogDriverConfig. We only have to include the driver identifier since it cannot be predefined.

For adding custom settings, see LumberjackHttpDriver.

The most important thing about the LumberjackConsoleDriverModule is that it provides the LumberjackConsoleDriver using the lumberjackLogDriverToken with the multi flag on. This allows us to provide multiple log drivers for Lumberjack at the same time.

Using a custom log driver

The last step is to import this module at the root module of our application, as seen in the first Usage section.

@NgModule({
  imports: [
    LumberjackModule.forRoot(),
    ConsoleDriverModule.forRoot(),
    // (...)
  ],
  // (...)
})
export class AppModule {}

HTTP driver

For a more advanced log driver implementation, see LumberjackHttpDriver

Community drivers

Note, you can use the ngworker/lumberjack-custom-driver template Git repository to start a separate Lumberjack log driver workspace.

If you want your driver listed here, open a PR and follow the same format.

The ngworkers teams offers hosting of a community log driver in the ngworker GitHub and @ngworker NPM organizations.

Best practices

Every log can be represented as a combination of its level, creation time, message, scope and payload. Using inline logs with the LumberjackService can cause structure duplication and/or denormalization.

Continue reading to know more about the recommended best practices designed to tackle this issue.

Loggers

The LumberjackLogger service is an abstract class that wraps the LumberjackService to help us create structured logs and reduce boilerplate. At the same time, it provides testing capabilities since we can easily spy on logger methods and control timestamps by replacing the LumberjackTimeService.

LumberjackLogger is used as the base class for any other logger that we need.

This is the abstract interface of LumberjackLogger:

/**
 * A logger holds methods that log a predefined log.
 *
 * Implement application- and library-specific loggers by extending this base
 * class. Optionally supports a log payload.
 *
 * Each protected method on this base class returns a logger builder.
 */
@Injectable()
export abstract class LumberjackLogger<TPayload extends LumberjackLogPayload | void = void> {
  protected lumberjack: LumberjackService<TPayload>;
  protected time: LumberjackTimeService;

  /**
   * Create a logger builder for a critical log with the specified message.
   */
  protected createCriticalLogger(message: string): LumberjackLoggerBuilder<TPayload>;

  /**
   * Create a logger builder for a debug log with the specified message.
   */
  protected createDebugLogger(message: string): LumberjackLoggerBuilder<TPayload>;

  /**
   * Create a logger builder for an error log with the specified message.
   */
  protected createErrorLogger(message: string): LumberjackLoggerBuilder<TPayload>;

  /**
   * Create a logger builder for an info log with the specified message.
   */
  protected createInfoLogger(message: string): LumberjackLoggerBuilder<TPayload>;

  /**
   * Create a logger builder for a trace log with the specified message.
   */
  protected createTraceLogger(message: string): LumberjackLoggerBuilder<TPayload>;

  /**
   * Create a logger builder for a warning log with the specified message.
   */
  protected createWarningLogger(message: string): LumberjackLoggerBuilder<TPayload>;

  /**
   * Create a logger builder for a log with the specified log level and message.
   */
  protected createLoggerBuilder(level: LumberjackLogLevel, message: string): LumberjackLoggerBuilder<TPayload>;

By extending LumberjackLogger, we only have to worry about our pre-defined logs' message and scope.

All logger factory methods are protected as it is recommended to create a custom logger per scope rather than using logger factories directly in a consumer.

As an example, let's create a custom logger for our example application.

import { Injectable } from '@angular/core';

import { LumberjackLogger, LumberjackService, LumberjackTimeService } from '@ngworker/lumberjack';

@Injectable({
  providedIn: 'root',
})
export class AppLogger extends LumberjackLogger {
  static scope = 'Forest App';

  forestOnFire = this.createCriticalLogger('The forest is on fire!').withScope(AppLogger.scope).build();

  helloForest = this.createInfoLogger('Hello, Forest!').withScope(AppLogger.scope).build();
}

Logger usage

Now that we have defined our first Lumberjack logger let's use it to log logs from our application.

import { Component, OnInit } from '@angular/core';
import { LumberjackLogger } from '@ngworker/lumberjack';

import { AppLogger } from './app.logger';
import { ForestService } from './forest.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {
  constructor(private logger: AppLogger, private forest: ForestService) {}

  ngOnInit(): void {
    this.logger.helloForest();

    this.forest.fire$.subscribe(() => this.logger.forestOnFire());
  }
}

The previous example logs Hello, Forest! when the application is initialized, then logs The forest is on fire! if a forest fire is detected.

Simplifying with ScopedLumberjackLogger

An alternative to the LumberjackLogger interface, where we need to specify the lumberjack log scope manually, we could use the ScopedLumberjackLogger.

The ScopedLumberjackLogger is a convenient Logger and an excellent example of how to create custom Loggers according to your situation.

/**
 * A scoped logger holds methods that log a predefined log sharing a scope.
 *
 * Implement application- and library-specific loggers by extending this base
 * class. Optionally supports a log payload.
 *
 * Each protected method on this base class returns a logger builder with a
 * predefined scope.
 */
@Injectable()
export abstract class ScopedLumberjackLogger<
  TPayload extends LumberjackLogPayload | void = void
> extends LumberjackLogger<TPayload> {
  abstract readonly scope: string;

  /**
   * Create a logger builder for a log with the shared scope as well as the
   * specified log level and message.
   */
  protected createLoggerBuilder(level: LumberjackLogLevel, message: string): LumberjackLoggerBuilder<TPayload> {
    return new LumberjackLoggerBuilder<TPayload>(this.lumberjack, this.time, level, message).withScope(this.scope);
  }
}

The resulting AppLogger after refactoring to using the ScopedLumberjackLogger would be:

import { Injectable } from '@angular/core';

import { LumberjackService, LumberjackTimeService, ScopedLumberjackLogger } from '@ngworker/lumberjack';

@Injectable({
  providedIn: 'root',
})
export class AppLogger extends ScopedLumberjackLogger {
  scope = 'Forest App';

  forestOnFire = this.createCriticalLogger('The forest is on fire!').build();

  helloForest = this.createInfoLogger('Hello, Forest!').build();
}

Notice that now every log written using the AppLogger will have the 'Forest App' scope

Using Loggers with a LumberjackLog payload

As seen in the Log drivers section, we can send extra info to our drivers using a LumberjackLog#payload.

The LumberjackLogger and ScopedLumberjackLogger provide a convenient interface for such a scenario.

import { Injectable, VERSION } from '@angular/core';

import {
  LumberjackLogPayload,
  LumberjackService,
  LumberjackTimeService,
  ScopedLumberjackLogger,
} from '@ngworker/lumberjack';

export interface LogPayload extends LumberjackLogPayload {
  readonly angularVersion: string;
}

@Injectable({
  providedIn: 'root',
})
export class AppLogger extends ScopedLumberjackLogger<LogPayload> {
  private static payload: LogPayload = {
    angularVersion: VERSION.full,
  };

  scope = 'Forest App';

  forestOnFire = this.createCriticalLogger('The forest is on fire!').build();

  helloForest = this.createInfoLogger('Hello, Forest!').withPayload(AppLogger.payload).build();
}

The AppLogger usage remains the same using a LumberjackLogger or ScopedLumberjackLogger, with payload or without.

LumberjackLogFactory

Lumberjack's recommended way of creating logs is by using a LumberjackLogger.

However, there are some times that we want to create logs manually and pass them to the LumberjackService.

The LumberjackLogFactory provides a robust way of creating logs. It's also useful for creating logs in unit tests.

This is how we create logs manually:

import { Component, OnInit, VERSION } from '@angular/core';

import { LumberjackLogFactory, LumberjackService } from '@ngworker/lumberjack';

import { LogPayload } from './log-payload';

@Component({
  selector: 'ngworker-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent implements OnInit {
  private payload: LogPayload = {
    angularVersion: VERSION.full,
  };
  private scope = 'Forest App';

  constructor(
    private lumberjack: LumberjackService<LogPayload>,
    private logFactory: LumberjackLogFactory<LogPayload>
  ) {}

  ngOnInit(): void {
    const helloForest = this.logFactory
      .createInfoLog('Hello, Forest!')
      .withScope(this.scope)
      .withPayload(this.payload)
      .build();

    this.lumberjack.log(helloForest);
  }
}

Wallaby.js

Wallaby.js

Contributors to this repository are welcome to use the Wallaby.js OSS License to get test results immediately as you type, and see the results in your editor right next to your code.

Contributors

Thanks goes to these wonderful people (emoji key):


Nacho Vazquez

🐛 💻 📖 💡 🤔 🧑‍🏫 🚧 📆 👀 ⚠️ 🔧 📓

Lars Gyrup Brink Nielsen

🐛 💻 📖 💡 🤔 🧑‍🏫 🚧 📆 👀 ⚠️ 🔧 📓

Santosh Yadav

💻 📖 💡 🚇 🔌 ⚠️

Dzhavat Ushev

📖

Alex Okrushko

💻 🤔 🧑‍🏫 🔬 💻

Bitcollage

🐛 💻 📖 🤔 📦 👀 ⚠️ 🔧

Arthur Groupp

🤔

This project follows the all-contributors specification. Contributions of any kind welcome!