Logging is a fairly common topic in the software industry. Unfortunately it is not so much in the front-end world and frequently mentioned in back-end-related articles.
However, this topic also applies to front-end projects.
In this article, we will see what logging is and various ways to implement it in an modern Angular application.
🧪 Code Along With Me
For this article, you have the option to clone the project I used. This allows you to actively follow and code along with me through the different steps.
If you decide to do so, please begin from the initial-setup
tag, if not, you can simply jump to the next headings.
Each section detailing the progression of the hands-on lab will be enclosed within a collapsible section marked by 🧪.
Once the application served, you should see this page:
Table of Contents
Logging, What For?
First of all, why would we like to log anything in our Angular application?
We can differentiate two aspects here:
- The environment
- The type of information we would like to read
The Environment
From a developer perspective, we may want to have as many log as possible from what is going on in various places of our application.
However, from a user perspective, I would prefer not seeing a bunch of logs in the devtools or many HTTP calls to a backend.
From the environment (whether it is production, UAT, or something else), the logging might change and give different insights of what for or how the application is used.
The Type of Information
Logs carry two pieces of information: their level (is it an error? a regular action? something unusual?) and their payload, the actual content.
By using those two pieces, we can provide insights of things the user does, a functional log (e.g. New todo created by John
) or a technical one (e.g. Cache refreshed, 486 todo items synchronized
).
By combining the types of logs and differentiating them depending of the environment, we can achieve a better observability of our application, improving our troubleshooting capability and overall development experience.
The Built-In Way
In JavaScript, the most known logging method is the call to the console.log
method, which displays a log into the console:
In addition to the log
method, others can also be used to show the information differently:
ℹ There are also many others that you might want to read about
🧪 Adding Logs
For now, we don't have any logs and knowing what happened can only be achieve by browsing the sources.
By taking advantage of the console.log
method, we can now add some logs to 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"
);
}
}
ℹ If you type
log
+ TAB, Visual Studio Code will automatically write theconsole.log()
for you:
We can now see a bit more clearly what is going on when using our application:
⚗ Why not try to add your own? We could use a warning when an unknown id is provided!
💡 Once done, you can check out the solution to compare your results
Limitations
Although having logs is great, those will be shipped to production when the site will be deployed.
Worse, console.xxx
are not centralized, making them harder to spot and easier to forget when reviewing or pushing code.
Those logs also lack of some features we might want such as logging to a different provider (HTTP, console, why not even another Angular service?).
For all of those reasons, it could be preferable to centralized them within an dedicated (software) component.
Leveraging Angular Services
We can create a simple abstraction to wrap 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);
}
}
Just by using this new layer, we can now modify the behavior of our logs in one place.
For example, we might want to disable all information logs if we are in production:
export class LoggerService {
info(template: string, ...optionalParams: any[]): void {
+ if (!isDevMode()) return;
console.log(template, ...optionalParams);
}
// ...
}
We could also enforce a specific format, like prepending the current time:
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 with and add the previous code to log the date alongside the message.
Once done, replace any occurrences of console.
from the TodoService
and replace them with a call to our new LoggerService
.
Once those changes has been made, you should now see your logs with the time they were emitted at:
💡 You can check out the solution to compare your results
Going Further
While the foundations has been setup through our service, there is now much we can do if we would like to strengthen our logs.
Restricting the Log Level
Usually, there are six log levels:
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 represent those levels by an enum:
export enum LogLevel {
NEVER = Number.MAX_SAFE_INTEGER,
TRACE = 0,
DEBUG = 1,
INFO = 2,
WARNING = 3,
ERROR = 4,
FATAL = 5,
}
And expose an InjectionToken
for our application to provide 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,
},
],
});
ℹ This can also be set directly from the environment variables instead
By consuming this token, we can restrict the behavior of the LoggerService
based on it:
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
For our LoggerService
to use log levels instead of a check with isDevMode
, create the LogLevel
enum in a new loglevel.enum.ts
.
Then, define an InjectionToken
"MIN_LOG_LEVEL
" as described before and provide it in the main.ts
file.
Finally, take the previous LoggerService
snippet to consume the token, define the #canLog
method and replace the calls to isDevMode
with calls to the newly defined #canLog
method.
Once done, you should see no difference in the behavior of the application, but try changing the MIN_LOG_LEVEL
to LogLevel.NEVER
: nothing should be logged anymore.
💡 You can check out the solution to compare your results
Logging to Other Providers
Angular has a very powerful dependency injection system, even more since the introduction of the inject
function.
Using it, we can seamlessly compose services by providing them appropriately.
In the context of our logger, we can take advantage of it to easily alter the behavior of the logger depending of the environment without spreading the logic everywhere.
To do so, we could define an interface exposing a provider for our logging system:
export interface LoggerProvider {
info(template: string, ...optionalParams: any[]): void;
warning(template: string, ...optionalParams: any[]): void;
error(template: string, ...optionalParams: any[]): void;
}
A straightforward implementation could then be one relying on the console
:
@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);
}
}
ℹ Since this is an
Injectable
, we could absolutely use theHttpClient
for example, to send logs to a dedicated back-end too
We can then leverage the flexibility of the DI system to define a new InjectionToken
, providing all the registered LoggerProvider
:
export const LOGGER_PROVIDERS = new InjectionToken<LoggerProvider[]>(
"Providers for the logger"
);
And register our implementation:
+ 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,
},
],
});
ℹ Note that the registration is composed based on the current environment: changing the providers used by the application at runtime only needs a change here!
Our logger may now consume the token and rely on the underlying implementations of the LoggerProvider
instead of implementing its own logic, and solely take care of 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
Follow the previous part in order for the LoggerService
to rely on the provided LoggerProvider
.
Once again, no changes should be seen from the application, aside from the fact that we do no longer have the time prepended on our logs: let's bring that back!
Create a new implementation of the LoggerProvider
named TimedConsoleProvider
. This implementation should use the previous #withDate
method to format the template.
When implemented, provide it at the root level, in the main.ts
file so it can be injected along with the ConsoleProvider
.
ℹ Don't forget to set
multi: true
to register several classes for the same token
If you followed those steps, you should now see two logs for each action: one written by the ConsoleProvider
, the other by the TimedConsoleProvider
:
💡 You can check out the solution to compare your results
Takeaways
In this article, we saw what logging means for front-end application and examined a range of techniques to integrate logging in Angular, spanning from straightforward methods to more sophisticated approaches.
In real-life application, you might prefer to rely on a standardized third party library, offering more options in terms of configuration such as @ngworker/lumberjack or ngx-logger
Logging can help you achieve a better observability of your application. However, maintaining a user-centric approach is essential.
Excessive logging of technical details in a production environment or excessive use of HTTP calls can potentially degrade the overall user experience. Striking the right balance between informative logging and user experience is the key to ensuring the seamless operation of your application.
I hope you learned something useful there!
Photo by CHUTTERSNAP on Unsplash
Top comments (7)
Great write-up, and I love that you used Pico CSS for this!
For others looking for existing solutions, have a look at Lumberjack!
Great article!
Thanks
Thanks, good article btw
Thanks Ion, glad you enjoyed it!
Nice take, you also can add that according to many who benchmarked it, console.log is far from being either fast or cheap in memory.
Cheers :)
Great article! Detailed and super useful! thanks a lot
I wander if it's possible to write a logging service (with console driver) which will preserve the line number of "form where it was called form".
e.g. in your gif "see your logs with the time they were emitted at", it writes that the code is at: "logger.server.ts:#..:#", and I would want to see the file#L of TodoService file instead
Some comments may only be visible to logged-in visitors. Sign in to view all comments.