Designing a Custom Logger Tool

Considerations and Implementation

payment processor graphic
Timeline1 Month (August 2023 - September 2023)
RoleSenior Software Engineer
Core ResponsibilitiesFrontend

HealthSafe ID (HSID) is an authentication product that provides login and registration services to millions of users a month. Due to the significance and scale of this system, my team dedicates a good amount of effort to ensure resilience by improving the developer experience and enhancing error handling. Our custom logger contributes to these two areas and I will be discussing implementation details below.

Functionality

In the simplest form, a logger logs data from one place to another. You may be familiar withconsole.log(), which has the function of logging passed-in data from the code to the browser console.

Similarly, our custom logger is also a public interface that can called with certain data to perform additional functionalities such as capturing, sharing, or logging information. To help visualize, below is an example instance of a logger API that we can export and utilize within other files:

snippet of createLogger code

The default publicly accessible API is created when calling createLogger() with no arguments.

There are many use cases for this logger. Originally, it was implemented as a way to extend error-handling functionalities cleanly. Imagine we have an Axios call that fails. The catch block may contain a switch case that deals with multiple known errors and defaults to a certain case for an unexpected error. We may want to handle something like a network issue differently from the other issues, or perhaps we want to send unexpected errors to a backend logging service while warning the user in the case of an expected error. The logger makes this flexibility possible and allows for these additional features to be easily adjustable.

snippet of error handling code with logger

A simplified example of how we may use the logger to handle a failed network request.

Other uses of the logger include being able to easily send data to different services outside of network calls, extending the functionality of default browser console loggers, and allowing for alerts and notifications to pop up on the developer’s UI.

Implementation Overview

Let’s take a closer look at the createLogger() function from above.

snippet of createLogger code

Code for createLogger(). Info, warn, error, and fatal follow a similar pattern to debug and have thus been condensed.

In the initial design, we narrowed the requirements of the logger down to two: classifying the type of log entry and determining where to send that log entry.

These two main elements are present in the above function as LogLevel and LogFn. The former consists of debug, error, warn, andfatal. These are similar to HTTP method/verbs (e.g. GET, POST, etc.) in the sense that one key piece of information determines the “type” of call to make while the rest of the data for the log entry is otherwise the same.

A passed-in LogFn determines the destinations logging entries will go. Most commonly, these are the browser console, STDOUT for scripts and servers, and log aggregator services such as Splunk. Here’s a basic example:

snippet of composeLogFunctions code

A functional programming pattern allows us to compose log functions together like so.

During the research phase, we found that many logger libraries are built with Java-like class instantiation. However, we decided to lean into the functional programming nature of JavaScript to gain advantages from patterns such as higher-order functions and composable units of functionality. With a common function interface, we gain access to the above side-effect composition utility function that can sequentially call logger functions for each destination with the same data.

Our implementation highlights a few other advantages that functional programming holds over object-oriented programming. By breaking down units of composition into smaller chunks, we were able to verify all functionality through easy-to-write unit tests. Functions also have clearer boundaries as opposed to classes which may involve various potential permutations that affect many different features. In line with this thinking, we can avoid the fragile base class problem by opting in and out of shared features we want to access, decreasing the risk of bugs as more features are added over time. As a bonus, the finished code is a lot more readable and less verbose than the class-based counterpart.

The createLogger() function accepts a LogFn as an argument and supplies a default one if none are provided. The other optional argument on createLogger() is the current time using new Date(). We chose to make this dependency injectable rather than creating it within so that for testing we may provide an external time stamp to measure against.

Logging Based Off Environment

The next consideration was controlling which log destination receives log entries per deploy environment as well as which LogLevels are sent and which are swallowed. This can be done by wrapping each LogFn for each destination in a higher-order function that takes a LogFnand returns a LogFn that only calls the wrapped LogFn when the specified LogLevel or above is triggered. For example, given log destinations for an aggregator, the browser console, and the app UI, we can define the following configurations:

logging based on environments

How logging should be handled based on the environment.

snippet of logAtLevel code

The higher-order function that manages which log destination receives log entries.

Batching Log Calls

To avoid potentially sending a new network request for each log, we implemented a configurable function that flushes a queue of batched logs into a network request either after a certain number or a certain timeframe. We also attached an event listener to the page to run on a visibility change in case we need to send any queued-up log entries when a user exits the browser.

snippet of batching code

Function for batching log calls.

Designing Better TypeScript Enums

TypeScript enums

How TypeScript enums work beneath the hood.

You can see that the return value of the immediately invoked function essentially maps theLogLevel string to the number and the number back to the string. The problem is, if you wanted to get the string value of the LogLevel to send to the backend for example, you would need to do something like LogLevel[LogLevel.DEBUG].

Another issue is that in practice we would want enums keys to be unique values within the namespace that only get compared to each other. However, with the above representation ofLogLevel, equating LogLevel.DEBUG to its value 10 would yield true when it should be false. We also get an issue due to the structural typing nature of TypeScript. An empty object can be considered a LogLevel which would defeat the purpose of even having LogLevel types. With some digging and inspiration from here, We arrived at a solution involving JavaScript symbols. Symbols are a frequently overlooked data type in JavaScript that guarantees values to be unique. In short, following the idea from the code below, a LogLevel type must be an object with the specific Symbol instance above defined as a property key.

snippet of symbol code

How symbols are used to guarantee uniqueness.

To further establish type safety, we built a from() function within LogLevel that checks if the LogLevel is a defined value. This way instead of using TypeScript to coerce with “as LogLevel” we can check using the from() function to control possible errors instead.

snippet of .from code

A better alternative to TypeScript coercing.

The results are referentially identical LogLevels. Furthermore, LogLevel enums can now only be compared to each other and not other values like numbers like with TypeScript enums. Using custom functions within LogLevel, we are also able to parse the LogLevel enum and output either string or number representation for use when we see fit.

Reflection

By starting from bare requirements and planning deeply about the features needed, we were able to successfully design a tool that improves application resiliency and the developer experience. A bonus was that I got to play around with symbols, something I had learned about quite early on but rarely came in contact with. Here’s to consistently working with new concepts and always expanding your repertoire!

Copyright © 2024 Bertrand Shao | All Rights Reserved