DEV Community

Sergey Gultyayev
Sergey Gultyayev

Posted on

Building Angular logger in a scalable way

We often need to add logs to our applications. Some of them are for debugging on the client and some needs to be sent to a server to track bugs remotely.

For this purpose we will build a Logger class which will log our events to the console, then we will upgrade it to be able to log messages to the server and all of this by using strategy pattern!


The most straightforward way is to create a class called Logger with logging methods. Let’s create one with just one method called log for brevity.

@Injectable({
  providedIn: 'root'
})
export class Logger {
  public log(message: string): void {
    console.log(message);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now within the app whenever we need to log a message we would use not console.log, but instead this.logger.log(). For now it just uses console.log under the hood, nevertheless this is an abstraction in the first place which gives us some space for scaling.

Implementing strategy pattern

First let’s figure out what the strategy pattern is. To put simply the strategy pattern tells us to have one interface which can be implemented by N classes, so that we could replace the implementations on the fly and still get the application working. This way we can achieve different behaviors that have similar handles in common and do same job differently.

Let’s add some logging to a remote server, so we could keep track of user interactions with our app. For this we create another class called ServerLogger with the same methods implemented as in Logger.

@Injectable({
  providedIn: 'root'
})
export class ServerLogger {
  constructor(private http: HttpClient) {}  public log(message: string): void {
    this.http.post('/log', {
      level: 'log',
      message,
    }).subscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

Good. Now we can use the ServerLogger in our classes to post messages to the server. However, it’s not convenient to use multiple loggers separately and furthermore we would have more cases when we need to log messages both to the server and the console.

To handle that we will need to follow next steps:

  1. Create a BaseLogger which is an abstract class which will be implemented by all loggers and will also work as an injection token
  2. Move logic from Logger to ConsoleLogger for console logging implementation
  3. Make Logger work with implementations of BaseLogger
export abstract class BaseLogger {
  public abstract log(message: string): void;
}
Enter fullscreen mode Exit fullscreen mode

Here we only declare methods and declare the class as abstract so we don’t create an instance of it by accident.

Now, let’s update our ServerLogger by imlementing the class:

export class ServerLogger implements BaseLogger
Enter fullscreen mode Exit fullscreen mode

After this we create a ConsoleLogger and move the LoggerLogic there:

@Injectable({
  providedIn: 'root'
})
export class ConsoleLogger {
  public log(message: string): void {
    console.log(message);
  }
}
Enter fullscreen mode Exit fullscreen mode

As for the Logger we will do the following:

@Injectable({
  providedIn: 'root'
})
export class Logger {
  private loggerProviders: BaseLogger[];

  public log(message: string): void {
    this.loggerProviders.forEach(provider => provider.log(message));
  }
}
Enter fullscreen mode Exit fullscreen mode

Now all we have left is to fill loggerProviders array with actual providers we created.

The first thing that comes into mind is to simply instantiate them with the new keyword, however this isn’t the best approach as we would need to come to the class and modify it each time we need a new logger, perhaps add a new dependency to instantiate the logger provider. Also, this would break the I in SOLID as we would create instances manually in the consuming class, meanwhile it has to be outside of it.


Remember, when used Angular’s injection tokens like APP_INITIALIZER we also pass multi: true? Ever wondered what this is for and how it works?

This is exactly what we will use to make our Logger even more scalable and robust.

For this we need to replace Injectable object configuration in ServerLogger and ConsoleLogger with Injectable() as we don’t want those classes to be available as separate providers in DI system.

Then, we update the Logger to use the BaseLogger as a dependency

@Injectable({
  providedIn: 'root'
})
export class Logger {
  constructor(
    @Inject(BaseLogger)
    private loggerProviders: BaseLogger[]
  ) {}

  public log(message: string): void {
    this.loggerProviders.forEach(provider => provider.log(message));
  }
}
Enter fullscreen mode Exit fullscreen mode

We declare the type as BaseLogger[], however this is not a valid declaration for Angular, therefore we need to add @Inject(BaseLogger) to tell the DI that we want to inject this class. DI does see that we provided multi: true and therefore will give us an array instead of an object.

Let’s update the app.module.ts file

const loggerProviders: Provider[] = [
  { provide: BaseLogger, useClass: ConsoleLogger, multi: true },
  { provide: BaseLogger, useClass: ServerLogger, multi: true },
];

@NgModule({
  ...
  imports: [
    ...,
    HttpClient,
  ],
  providers: [
    ...
    loggerProviders
  ]
})
export class AppModule {
}
Enter fullscreen mode Exit fullscreen mode

From now on, whenever we need a new provider we just need create another class which implements the BaseLogger and update providers array. The Logger is safe as we don’t touch it to add new providers.


If we wanted even more flexibility we could make BaseLogger instances to accept a configuration using deps array, there we could pass logLevel for each provider separately. This way we could log everything to the console and only errors to the server.

Resulting code can be found here: https://stackblitz.com/edit/angular-ivy-pqspk7?embed=1&file=src/app/app.module.ts

Top comments (1)

Collapse
 
naucode profile image
Al - Naucode

Great article, keep the good work! Liked and followed! 🚀