Skip to content

Instantly share code, notes, and snippets.

@jcvidiri
Last active December 30, 2024 18:00
Show Gist options
  • Select an option

  • Save jcvidiri/f224e5b2b5769105cedca6d8bcbf3c61 to your computer and use it in GitHub Desktop.

Select an option

Save jcvidiri/f224e5b2b5769105cedca6d8bcbf3c61 to your computer and use it in GitHub Desktop.
Logging Standard in the context of TypeScript, Clean Code & Dependency Injection

Logging Standard

In most of our codebases we should use a library to log. When we do, it is useful to adhere to a standardised logging practice when using this library to make it easier to understand, query and analyse logs. This standardisation involves using two main parameters: message and data. The message parameter is structured to include the class name, method, and an optional error description, while the data parameter contains valuable information that aids in troubleshooting and analysis.

Creating the Logging Library

To implement a logging library that fits within our standard practices, we need it to support multiple log levels (such as INFO, WARN, and ERROR) and accept two key arguments: a message and a data object. Here's a breakdown of these requirements:

  • Log Levels: The library should allow us to easily categorize log entries based on their severity (e.g., INFO, WARN, ERROR), helping us prioritize which logs need immediate attention and which are simply informational.
  • Message Argument: The message parameter should be a string that provides a clear, structured description of the log entry. It typically includes the class name, method name, and an optional error description, providing a consistent way to identify where the log originated and the context of the issue.
  • Data Argument: The data parameter should be an object that contains dynamic information relevant to the log entry. This could include things like error details, user inputs, system state, or other contextual information necessary for troubleshooting.

The combination of these parameters ensures that the logging library is both flexible and standardized, making it easier for developers to log meaningful entries and for operations teams to monitor and analyze the system.

Note

While we generally avoid unnecessary or redundant comments in our code, adding comments to widely-used libraries—like a logging library—is beneficial. These comments serve as valuable documentation for developers who will interact with the library, especially those who may not be familiar with its design or purpose. In a shared context, clear comments help ensure that the intended usage and best practices are easily understood, reducing the risk of misuse and promoting consistency across the codebase. Well-documented code in widely-used libraries improves maintainability, collaboration, and helps new developers quickly understand the library’s functionality and structure.

interface ILogger {
    /**
     * Logs an informational message, typically used for general system status updates.
     * @param message - The log message, typically including class, method, and optional description.
     * @param data - Additional data to provide context to the log entry.
     */
    info(message: string, data?: object): void;

    /**
     * Logs a warning message, typically used for non-critical issues or potential risks.
     * @param message - The log message, typically including class, method, and optional description.
     * @param data - Additional data to provide context to the log entry.
     */
    warn(message: string, data?: object): void;

    /**
     * Logs an error message, typically used for critical issues or failures requiring attention.
     * @param message - The log message, typically including class, method, and optional description.
     * @param data - Additional data to provide context to the log entry.
     */
    error(message: string, data?: object): void;

    /**
     * Logs a debug message, typically used for verbose or detailed logging during development or troubleshooting.
     * @param message - The log message, typically including class, method, and optional description.
     * @param data - Additional data to provide context to the log entry.
     */
    debug(message: string, data?: object): void;

    /**
     * Logs a trace message, typically used for very detailed logging, usually for deep debugging.
     * @param message - The log message, typically including class, method, and optional description.
     * @param data - Additional data to provide context to the log entry.
     */
    trace(message: string, data?: object): void;
}

Understanding Log Levels

Using different methods for log levels (INFO, WARN, ERROR, TRACE, DEBUG) allows for clear categorization of log messages based on their severity and purpose, making it easier to filter and prioritize during troubleshooting. Here’s a breakdown of the most relevant ones:

  • INFO:

    • Used to reflect the state of the system. These logs provide visibility into routine operations and the overall flow of processes. While useful for context, consider using metrics when possible to track system performance or behavior more efficiently.
  • WARN:

    • Indicates that something unusual or noteworthy is happening, but it does not require immediate action. Used to highlight potential issues or conditions that, while not critical, might warrant attention. Warnings serve as proactive indicator of situations that may require action if a certain threshold is reached, serving as a "heads-up" without signaling an urgent issue.
  • ERROR:

    • Reserved for issues that require action. An error indicates that something critical or unexpected has occurred, such as a failure or disruption that needs to be addressed. Avoid using ERROR for events that don’t necessitate intervention, as this can dilute the significance of critical logs and make urgent issues harder to identify.

Tip

By ensuring that each log level aligns with its purpose, logs remain meaningful and actionable, improving both observability and incident response.

Message & Data

message

  • Should be a static string that adheres to a predictable pattern.
  • Must include:
    • Class / Module: The name of the class where the log is generated.
    • Method / Function-Action: The function or action being logged.
    • Description: An optional short summary of the issue in kebab-case.

Why static messages?
Static messages are easy to search and allow engineers to identify specific log entries based on keywords, class names, methods, or error descriptions. This improves the ability to locate related logs and identify patterns or recurring issues.

Why human-readable?
Logs should be straightforward and free of unnecessary technical shorthand. Avoid cryptic codenames or acronyms without explanation. A clear and descriptive log ensures that it is accessible to all stakeholders.

data

  • Should contain dynamic values and extra context relevant to the log event.
  • Examples of useful data include:
    • User input
    • System states
    • Error objects or stack traces
    • Additional metadata

Why detailed data?
Including comprehensive context simplifies troubleshooting and provides engineers with the necessary insights to understand the underlying issue.

Example: AssetCheckout Service Class

Let’s take a look at the AssetCheckout Service Class. This class is responsible for more than just checking out "Assets"; it also ensures that assets are not "re-checked out" if they already exist, prevents interrupting asset checouts that are in progress, and validates that the project name adheres to specific constraints. While this example may appear a bit complex, it demonstrates various logging scenarios within the same Service Class.

Warning

This class does too much and could benefit from some refactoring. It’s intentionally bloated to illustrate a variety of logging situations, but in practice, it would be better to follow SOLID principles and the clean code approach. Each class should have a single responsibility, and handling too many concerns in one class can lead to maintenance challenges and decreased readability.

export class AssetCheckout implements IAssetCheckout {
    constructor(
        private directoryHandler: IDirectoryHandler,
        private versionControlSystem: IVersionControlSystem,
        private readonly logger: ILogger,
    ) {}

    public async checkout(projects: IAssetProject[]): Promise<{ [projectName: string]: string }> {
        const checkoutResponse = {};

        for (const project of projects) {
            if (!this.isValidName(project.name)) {
                checkoutResponse[project.name] = 'ERROR: invalid `name`';
                this.logger.warn('AssetCheckout.checkout.isValidName.invalid-name', { project });
                continue;
            }

            
            const isInProgress = this.versionControlSystem.isRunning(project.name);
            if (isInProgress) {
                checkoutResponse[project.name] = 'WARNING: checkout or cleanup/switch already in progress';
                this.logger.warn('AssetCheckout.checkout.in-progress', { project });
                continue;
            }

            const projectExists = await this.directoryHandler.exists(project.name);
            if (projectExists) {
                checkoutResponse[project.name] = 'WARNING: project already exists';
                this.logger.warn('AssetCheckout.checkout.project-exists', { project });
                continue;
            }

            this.versionControlSystem
                .checkout(project.svn_path, project.name, project.branch, project.revision, project.name)
                .catch((error) => this.logger.error('AssetCheckout.checkout', { error, project }))
                .then(() => this.logger.info('AssetCheckout.checkout', { project }));
        }

        return checkoutResponse;
    }

    private isValidName(projectName: string): boolean {
        if (typeof projectName !== 'string') {
            return false;
        }

        // Check for a maximum of 1 '/'
        if (projectName.split('/').length > 2) {
            return false;
        }

        if (!VALID_PROJECT_NAME.test(projectName)) {
            return false;
        }

        return true;
    }
}

Tip

Detailed Breakdown

  • Class / Module: AssetCheckout.
  • Method / Action-Function: checkout.
  • Short description of the issue: project-exists, in-progress or invalid-name.

So what did I gain by using this standard in this example?:

  • Easy to Query: By having all logs start with the classname, we can just query logs for *AssetCheckout* and get a lot of logs indicating if this is something happening all the time, which are sucessfull, if all emit the same kind of warnings or if some is the project-exists, in-progress or invalid-name.

  • Human Readability: Logs are free of unexplained acronyms or internal codenames, making them clear to both technical and non-technical audiences. They also bring their own. It is also easy to understand on which scenarios we would exepct some action to be taken.

  • Debugging Context: Logs provide both a static summary (message) and dynamic insights (data), enabling more efficient issue resolution.

Default Context

To enhance the context of each log entry, the logging library should automatically include a default context with relevant information. This default context, managed entirely within the logging library, should contain essential details about the environment, application, and system state that would otherwise be redundant or inconvenient to manage in every "Service Class." Examples of components that could be included in the default context are:

  • App Name / Deployment Name: The name of the application, especially since multiple applications may use the library.
  • Environment: The deployment environment, which should default to "development" if not specified.
  • Other Deployment / Environment Information: Information such as the deployment region, Kubernetes pod, process type, and hostname.
  • Version Information: Details about the application version, such as the Git SHA. This is important for tracking issues related to specific versions and verifying that the correct version is running, particularly if the issue is fixed in a specific release.

Things to Look Out For

  • Empty Values: logging potentially empty values (e.g., (key=) or (key=null)) can be dangerous, since they can possibly not be present in the log data/context if, for example, the entry is serialized in JSON format.
  • Large Data: Be cautious about adding excessive data to logs. Large payloads can get truncated if there’s a size limit on the logs. Additionally, irrelevant context can create unnecessary noise, making it harder to focus on the important information.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment