console-dictation

published on July 10, 2023 - 6 minute read

Node.js TypeScript Rollup.js node:fs node:path


hero image for console-dictation

A Node.js package to conveniently write terminal logs to persistent files. Portable for CommonJS and ES Module JavaScript.


🎯Project goals

This project was brought about by an embarrassing story that other young developers may relate to. When my office space reservation project (Hoosin a.k.a PAA Booking) was still in development I had little concern for robust security. Despite one of my main interests during my undergraduate degree being cybersecurity, I doubted that an administrative application that held boring office data that would be targeted. My main concern was getting the project off the ground.

Long story short, my backend server was cyberattacked. Although my project was still in beta and didn’t contain any sensitive data, it exposed some clear flaws in my system. A big problem I noticed was that I didn’t log enough transactions to catch the attack early. Even when I did log transactions to the console, the logs were ephemeral: they were tied to the terminal’s lifecycle. Obviously, this isn’t ideal. The suitable way to log something is to make a persistent record of that transaction. I decided to create a module for my backend to easily log to a file using node:fs. When I found myself copying and pasting this module across multiple projects I realized:

“I should just turn this into a Node module.”

So I created a dead simple, lightweight Node module to conveniently persist terminal logs that comes in at 1.3kb. Something to dictate for the console.


⚙️How it works

Logging

The main use case for console-dictation is to write to a file. There are three initial log categories a user can log to:

functionlog path
system<CWD>/logs/sys/sys.log
error <CWD>/logs/error/err.log
misc<CWD>/logs/misc/misc.log

Folder paths are only created as the log functions are used, so if you use all of the functions you’ll find yourself with a file structure like:

Terminal window
[project]
├── logs
│   ├── sys
│   │   ├── sys.log
│   ├── error
│   │   ├── err.log
└── └── misc
        └── misc.log

Each function has the same signature:

1
function system(message: string, path ?: {PATH: string, LOG_NAME: string}, isDependency ?: boolean) => void

An example of usage would be:

1
system("Hello, world!")
path

You can use some of the optional parameters to customize behaviour like which path the log is written to:

1
system("Hello, world but in a test log", {
2
PATH: "./logs/test",
3
LOG_NAME: "test.log",
4
})

The above code would create the file structure:

Terminal window
[project]
├── logs
│   ├── sys
│   │   ├── sys.log
│   ├── error
│   │   ├── err.log
│   ├── misc
│   │   ├── misc.log
└── └── test
        └── test.log

This technique of changing the log path should be used for one-off redirects. Let’s say you decide you want the system log to be in a file called system.log instead of the default sys.log. You could hypothetically pass a new path on each system call, but making sure the path argument is properly passed on each system call would get pretty annoying. For that reason, you should rely on using the config function instead of using the optional path parameter if you want to change the log path for the majority of logs (discussed later).

isDependency

isDependency is an internal parameter for dependency writing. It’s accessible through the function, but it should be used with caution:  it’s not intended to be used in external calls.

Configuring

The config function should be used to configure module logs at a global level. config has the signature:

1
config(
2
    paths?: {
3
        sys | err | misc ?: string
4
    },
5
    log_names?: {
6
        sys | err | misc ?: string
7
    },
8
    indexing_config?: {
9
        indexing?: boolean,
10
        dependencies?: {
11
            [log_slug: string]: (system | error | misc)[]
12
        }
13
    }
14
)
paths

You can customize the file paths while preserving the file names by passing an argument that correlates to the target log function:

1
config({
2
paths: {
3
SYS: "./logs/system",
4
},
5
})
log_names

You can customize the file names while preserving the file paths by passing an argument that correlates to the target log function:

1
config({
2
log_names: {
3
SYS: "system.log",
4
},
5
})
indexing_config

By default, logs take the format of:

1
YYYY-MM-DD-HH:MM:SS      MESSAGE

console-dictation also supports an indexing system that takes the form of:

1
YYYY-MM-DD-HH:MM:SS      issue #    MESSAGE

You can optionally opt-in to an indexing system:

1
config({
2
indexing_config: {
3
indexing: true,
4
},
5
})

If you opt-in to console-dictation’s indexing system, by default you also opt-in to a dependency logging system (i.e., X makes a log whenever a log is made in Y):

logs/misc/misc.log
1
YYYY-MM-DD-HH:MM:SS      issue #1   MESSAGE
logs/sys/sys.log
1
YYYY-MM-DD-HH:MM:SS      misc issued: issue #1

By default, the dependencies are:

System logError logMiscellaneous log
[][system][system]

You can change the log dependencies:

1
config({
2
indexing_config: {
3
dependencies: {
4
SYS: ["misc", "error"],
5
},
6
},
7
})

🔍Taking a look under the hood

State

Module scope can get a little confusing, but here’s the basics:

  • Node.js uses a wrapper function to encapsulate module code (source)
  • when the wrapper function is called, the encapsulated variables are assigned, including instantiating objects and stored on the heap

When console-dictation is imported or required into a project for the first time, all objects, including the dictator wrapper object, tracker object, and config objects are instantiated once and stored on the heap.

Because object variables point to their object by reference, every subsequent import or require in any module in the same project will reference the same objects at runtime.

For some developers, this behaviour is an obstacle, but for the purposes of this project, this behaviour means that the configuration and tracking will be consistent throughout the project’s lifecycle! (source)

Writing

There’s an internal module called write used to write all messages to the given file path with signature

1
function write(message: string, file_path: string, log_name: string) => void

The logic is very simple, using the node:fs library:

  • check whether the provided file path exists     - if yes, append to file     - if no, make folder directory, open file, write to file

The log functions are essentially wrapper functions for the internal write function with the following logic:

  • construct a message with a timestamp
  • add an index to the message if     1. indexing = true is stored in memory     2. isDependency is not passed
  • check whether a valid path passed as an argument     - if yes, write with the passed path     - if no, write with the path stored in memory for that log type
  • parse through the log type’s dependency array and write in the format “[log type] issued: issue #“

Time Stamping

Timestamps are created with a JavaScript Date object that instantiated on function call in UTC-5 time zone in the format of:

1
YYYY-MM-DD-HH:MM:SS

Indexing

Indexing configuration indexing and dependencies are stored as an object in a config module.

The indexing functionality comes in two parts: the tracker and the logging

The tracker is an object that stores the current index for each log function and an increment function to increase any log type’s index:

1
{
2
    sys: number,
3
    err: number,
4
    misc: number,
5
    increment: (type: number | undefined) => number | undefined
6
}

As described in writing, each log function checks to see if it isDependency. If isDependency = false then the tracker object’s increment function is called and returns the current index value for the log.

Validating

It’s important to validate that custom configurations are correctly passed to log or config functions.

An example where this could cause a system error would be if a custom path was passed into a log function such as:

1
system("Hello, world!", {
2
PATH: "./logs/test",
3
})

In this case, the internal write function would try writing to a folder ./logs/test and resolve as an error similar to:

Terminal window
./logs/test: Is a directory

To ensure the correct arguments were passed to a function, each argument is passed through an isRequestValid internal function of signature:

1
function isRequestValid({ [request_key: string]: any }, string[]) => { isValid: boolean, message: string }

where the incoming request is the first parameter and the keys to validate against are the second parameter.

The logic checks:

  1. whether the request keys are of the same length as the validator keys
  2. whether each request key exists in the validator keys array

⚒️How to start using console-dictation

console-dictation is a TypeScript-compatible Node.js module. console-dictation is compatible with both CommonJS and ES Module JavaScript.

Installation

Terminal window
npm i console-dictation

Quick Start (CommonJS)

  1. Require the package

    1
    const dictator = require("console-dictation")
  2. Use package methods to start logging

    1
    dictator.system("Hello, world!")

Quick Start (ESM JavaScript)

  1. Require the package

    1
    import dictator from "console-dictator"
  2. Use package methods to start logging

    1
    dictator.system("Hello, world!")

Usage

Example 1: simple express logging
1
const dictator = require("console-dictation")
2
const express = require("express")
3
const app = express()
4
5
app.listen(3000, () =>
6
dictator.system(`connected to booking server: port ${port}`)
7
)
Example 2: utilize destructuring
1
const { system } = require("console-dictation")
2
const express = require("express")
3
const app = express()
4
5
app.listen(3000, () => system(`connected to booking server: port ${port}`))

Demos

This module has two demos prepared: one for an ES Module JavaScript/MJS environment and one for a CommonJS environment.

ES Module JavaScript

ESM demo code can be found here.

  1. Clone this repo

    Terminal window
    git clone https://github.com/spiltbeans/console-dictation.git
  2. CD into the demo folder

    Terminal window
    cd ./demos/mjs
  3. Run start script

    Terminal window
    npm start
  4. Inspect logs

CommonJS

CJS demo code can be found here.

  1. Clone this repo

    Terminal window
    git clone https://github.com/spiltbeans/console-dictation.git
  2. CD into the demo folder

    Terminal window
    cd ./demos/cjs
  3. Run start script

    Terminal window
    npm start
  4. Inspect logs