Logging is a widely discussed subject across software development, yet it rarely receives attention in client-side discussions, often being relegated to server-side content.
Still, the practice holds just as much relevance for front-end applications.
In this piece, we'll explore the nature of logging and how to introduce it into a contemporary Angular setup using various strategies.
🧪 Follow Along with the Demo
You can optionally clone the repository I prepared for this guide, which enables you to work through the exercises in parallel with me.
If you choose this path, make sure to start from the initial-setup tag; otherwise, feel free to skip ahead to the upcoming sections.
Each step of the hands-on tutorial appears within a collapsible block labeled with 🧪.
Once the application is running, you should see the following interface:
Table of Contents
Why Log at All?
Let us start by asking a simple question: why bother adding logging to an Angular application in the first place?
Two dimensions come into play here:
- The environment where the app runs
- The kind of information we want to inspect
The Environment
As developers, we often wish to see as many log entries as possible, spread across different parts of the codebase, to understand what is happening.
From the end user's standpoint, though, a flood of console noise or a steady stream of HTTP requests to some backend is hardly desirable.
The environment — production, UAT, or any other stage — can shape how logging behaves and what kind of insight it reveals about app usage and purpose.
The Type of Information
A log entry carries two essential ingredients: its level (is it an error, a routine action, or something out of the ordinary?) and its payload — the actual message content.
These two properties allow us to produce either functional logs describing user activity (for instance, New todo created by John) or technical logs such as Cache refreshed, 486 todo items synchronized.
Combining different log types and adjusting them to the environment gives us better observability, which in turn improves debugging and the overall developer experience.
The Built-In Way
In JavaScript, the go-to logging utility is console.log, which outputs a message to the browser console:

Beyond log, other console methods exist to present information in various formats:

ℹ There are many others that you might want to read about
🧪 Adding Logs
Right now, our application produces no log output at all; the only way to figure out what happened is to dig through the source code.
Using console.log, we can introduce logging into the TodoService:
@Injectable({ providedIn: "root" })
export class TodoService {
// ...
delete(idToDelete: number): void {
// ...
console.log("Todo Item #%d deleted", idToDelete);
}
setComplete(idToSet: number, isDone: boolean): void {
// ...
console.log(
"Todo Item #%d status set to %s",
idToSet,
isDone ? "done" : "pending"
);
}
}
ℹ In Visual Studio Code, typing
logfollowed by TAB will auto-complete the call toconsole.log()for you:
Now, using the app gives us a clearer picture of what happens under the hood:

⚗ Why not try adding your own? For instance, we could emit a warning whenever an unknown id is passed in!
💡 When you are finished, compare your work against the solution
Limitations
While having logs is helpful, there is a catch: they will be shipped to production alongside the deployed site.
What is more, direct calls to console.xxx are scattered all over the code, which makes them difficult to track down and easy to overlook during code reviews or before a release.
These log calls also lack capabilities we might want, such as sending output to a different destination — an HTTP endpoint, the console, or even another Angular service.
All of this suggests it would be wise to collect our logging logic inside one dedicated component.
Leveraging Angular Services
We can start by building a small wrapper around the console calls:
@Injectable({ providedIn: "root" })
export class LoggerService {
info(template: string, ...optionalParams: any[]): void {
console.log(template, ...optionalParams);
}
warning(template: string, ...optionalParams: any[]): void {
console.warn(template, ...optionalParams);
}
error(template: string, ...optionalParams: any[]): void {
console.error(template, ...optionalParams);
}
}
With this new layer in place, we can tweak the logging behavior in a single spot.
For example, we might want to silence all informational logs in a production build:
export class LoggerService {
info(template: string, ...optionalParams: any[]): void {
+ if (!isDevMode()) return;
console.log(template, ...optionalParams);
}
// ...
}
Or we could enforce a unified format, like prepending the current timestamp:
export class LoggerService {
+ #withDate(template: string): string {
+ return `${new Date().toLocaleTimeString()} | ${template}`;
+ }
info(template: string, ...optionalParams: any[]): void {
if (!isDevMode()) return;
- console.log(template, ...optionalParams);
+ console.log(this.#withDate(template), ...optionalParams);
}
// ...
}
🧪 Using the LoggerService
Create the LoggerService in a new logger.service.ts file, populating it with the code that adds the date to each log message.
Afterwards, search for every call to console. inside the TodoService and swap them out for calls to the new LoggerService.
Once these adjustments are done, you should see the logs appear along with the time they were emitted:

💡 You can compare your changes with the provided solution
Going Further
Now that our service lays the groundwork, there is plenty of room to take our logging to the next level.
Restricting the Log Level
Logging systems conventionally define six levels of severity:
| Name | Meaning | Example |
|---|---|---|
| TRACE | Tracing of the execution flow | Starting DoStuff() |
| DEBUG | Information helpful for debugging purposes | Value of x: 42 |
| INFO | General information about program execution | Application started |
| WARNING | Indication of potential issues or anomalies | Id not found |
| ERROR | Describes an error that occurred | Unable to connect to the database |
| FATAL | Indicates a critical failure in the program | System crashed |
We can model these levels using an enum:
export enum LogLevel {
NEVER = Number.MAX_SAFE_INTEGER,
TRACE = 0,
DEBUG = 1,
INFO = 2,
WARNING = 3,
ERROR = 4,
FATAL = 5,
}
Then, an InjectionToken can be exposed so the application can specify a default log level:
export const MIN_LOG_LEVEL = new InjectionToken<LogLevel>("Minimum log level");
bootstrapApplication(AppComponent, {
providers: [
{
provide: MIN_LOG_LEVEL,
useValue: isDevMode() ? LogLevel.INFO : LogLevel.NEVER,
},
],
});
ℹ Alternatively, this value could come straight from environment variables
The LoggerService can consume this token and filter its behavior accordingly:
export class LoggerService {
+ readonly #minLogLevel = inject(MIN_LOG_LEVEL) ?? LogLevel.NEVER;
+ #canLog(logLevel: LogLevel): boolean {
+ return logLevel >= this.#minLogLevel;
+ }
info(template: string, ...optionalParams: any[]): void {
- if (!isDevMode()) return;
+ if (!this.#canLog(LogLevel.INFO)) return;
console.log(this.#withDate(template), ...optionalParams);
}
// ...
}
🧪 Using the LogLevel
To adapt the LoggerService so it relies on log levels instead of checking isDevMode, start by creating the LogLevel enum in a new file called loglevel.enum.ts.
Next, define an InjectionToken named "MIN_LOG_LEVEL" as shown above, and register it in the main.ts file.
Finally, update the existing LoggerService logic to inject the token, implement the #canLog method, and swap every call to isDevMode for a call to #canLog.
Once finished, the app should behave exactly the same — but if you switch MIN_LOG_LEVEL to LogLevel.NEVER, no logs should appear at all.
💡 The solution is available for comparison
Logging to Other Providers
Angular's dependency injection system is quite powerful, especially with the arrival of the inject function.
This lets us compose services with ease by providing them in the right place.
For our logger, this opens up the possibility of swapping the actual logging implementation based on the environment, without littering the codebase with conditionals.
The first step is to define an interface that describes what a log provider should look like:
export interface LoggerProvider {
info(template: string, ...optionalParams: any[]): void;
warning(template: string, ...optionalParams: any[]): void;
error(template: string, ...optionalParams: any[]): void;
}
A simple implementation could lean on the console API:
@Injectable()
export class ConsoleProvider implements LoggerProvider {
info(template: string, ...optionalParams: any[]): void {
console.log(template, ...optionalParams);
}
warning(template: string, ...optionalParams: any[]): void {
console.warn(template, ...optionalParams);
}
error(template: string, ...optionalParams: any[]): void {
console.error(template, ...optionalParams);
}
}
ℹ Because this is an
Injectable, we could also inject theHttpClienthere and forward logs to a dedicated backend
Next, we can define a new InjectionToken that collects all registered LoggerProvider instances:
export const LOGGER_PROVIDERS = new InjectionToken<LoggerProvider[]>(
"Providers for the logger"
);
Our implementation can then be registered:
+ function registerLoggerProviders(): EnvironmentProviders {
+ return makeEnvironmentProviders(
+ isDevMode()
+ ? [{ provide: LOGGER_PROVIDERS, useClass: ConsoleProvider, multi: true }]
+ : []
+ );
+}
bootstrapApplication(AppComponent, {
providers: [
+ registerLoggerProviders(),
{
provide: MIN_LOG_LEVEL,
useValue: isDevMode() ? LogLevel.INFO : LogLevel.NEVER,
},
],
});
ℹ The registration depends on the current environment — switching the providers used by the app at runtime only requires a change right here!
The LoggerService can now consume that token, delegate the actual logging to the underlying LoggerProvider implementations, and stay focused on deciding when to call them:
export class LoggerService {
readonly #minLogLevel = inject(MIN_LOG_LEVEL) ?? LogLevel.NEVER;
+ readonly #providers = inject(LOGGER_PROVIDERS) ?? [];
#canLog(logLevel: LogLevel): boolean {
return logLevel >= this.#minLogLevel;
}
info(template: string, ...optionalParams: any[]): void {
if (!this.#canLog(LogLevel.INFO)) return;
+ this.#providers.forEach((provider) =>
+ provider.info(template, ...optionalParams)
+ );
}
// ...
}
🧪 Adding a custom LoggerProvider
Work through the previous section so that the LoggerService depends entirely on the injected LoggerProvider instances.
At this point, no visible difference should be noticeable — except that the timestamp we added earlier is now missing from the log lines. Let us restore that!
Write a class called TimedConsoleProvider that implements LoggerProvider. It should reuse the earlier #withDate helper to format the message.
Once implemented, provide it at the root level in the main.ts file, alongside the ConsoleProvider.
ℹ Remember to set
multi: trueso that several implementations can bind to the same token
If everything is wired up correctly, each action should trigger two log lines: one from the ConsoleProvider and another from the TimedConsoleProvider:

💡 The solution is available for verification
Takeaways
Throughout this guide, we explored what logging means for a front-end application and worked through several approaches for adding it to an Angular project — from the most basic to far more flexible designs.
In a real-world codebase, you might prefer to adopt a battle-tested third-party library, which offers broader configuration options — for instance @ngworker/lumberjack or ngx-logger
Logging is a valuable tool for improving an application's observability. At the same time, however, keeping the user in mind is crucial.
Too many technical log entries in production, or an excess of HTTP calls, can sour the experience. Finding the right balance between useful logging and a smooth user journey is what keeps your app running steadily.
I hope this walkthrough taught you something useful!

Decoding the Magic: A Practical Look at Angular Logging
Once you understand the source of the logging output, it becomes much easier to follow the data flow. By relying on Angular's built-in dependency injection (DI) system, I was able to construct a logger that doesn't rely on any specific framework details. This makes it portable and easy to test in isolation, without worrying about the browser environment or the classic console methods.
The key to this approach is the use of the InjectionToken. Instead of injecting a service class that might be tightly coupled to its implementation, I use an InjectionToken to define a contract for the logger. The actual structure of the logging method is then provided at a higher level, keeping the consuming class free from these details.
Defining the Contract
First, I establish what the logger should look like. I set up an interface that describes the expected behavior. This interface simply requires a single method, log, which accepts the message content and a tag to identify the source of the log.
This contract is then exported from a core module, and its implementation is provided in a separate, framework-specific module. This creates a nice separation of concerns: the core business logic only knows about the LogInterface, while the application shell decides how to fulfill it. This makes the core module testable with just a mock function.
Configuring the Provider
Inside the functional module, I provide a new LoggerToken to Angular's DI. The provider uses the useFactory method to create a wrapper around the native console.log. This wrapper lets me control the output format and add a layer of abstraction, ensuring that logging calls in the business logic never directly touch the console.
This setup means that if the company later switches to a remote logging service, only the provider needs to change. The core logic stays untouched, which is a clear win for maintenance and helps keep the codebase healthy.
Important note: Accessing the
consoledirectly can be risky if you are testing in environments where it is not available. Using this injection pattern keeps that dependency at the application edge where you can control it.
Wiring It Together
At the component level, you do not need to know which specific implementation is being used. You simply inject the logger using the @Inject decorator and the LoggerToken. From that point, the log() method is available across all your components.
Angular's DI scans its tree from the root to the component, resolving the LoggerToken with your provided factory. This mechanism is what powers the flexibility here, and understanding how it flows through the injector hierarchy is crucial when designing scalable applications.
If the requirement is to have multiple loggers with different tags, you can even use the same useFactory with different values by passing parameters through the provider, showcasing the power of factory providers in Angular.


