1log
1log copied to clipboard
An unopinionated JS/TS logging framework
1log
Log function with superpowers.
Table of contents
-
Installing
-
Usage
-
Reading log messages
-
Using log snapshots in tests
-
Default config
-
Default Jest config
-
Building for production
-
Usage in libraries
-
Plugins included in this package
-
consoleHandlerPlugin -
mockHandlerPlugin -
functionPlugin -
promisePlugin -
iterablePlugin -
asyncIterablePlugin -
badgePlugin -
severityPlugin -
Shortcuts to set severity
-
-
Plugins from other packages
-
Writing plugins
Installing
-
Install the npm package:
yarn add 1logor
npm install 1log --save -
If you want to log messages to the console, add the default config to your top-level module (skip this step if you're building a library and only need to use logging in tests):
// ATTENTION: this line should come before imports of modules that call `log`. import '1log/defaultConfig'; -
If you want to use log snapshots in Jest tests, add the default Jest config to the file referenced by
setupFilesAfterEnvJest configuration option (in the case of Create React App this issrc/setupTests.ts/.js):import '1log/defaultJestConfig';
Usage
The library provides a function log that can be used just like the regular console.log, but has two superpowers:
-
If passed a single argument,
logreturns that argument or a proxy for it (you'll see what we mean by 'proxy' in a moment), meaning that it can be inserted into any expression without changing the behavior of your program, e.g.f(log(x)). -
It supports plugins. There are two ways to install a plugin: you can either pass a plugin as an argument to
log, in which caselogwill return a function just like itself, but with the plugin installed, or you can install a plugin globally by callinginstallPlugin(yourPlugin)(this must be done before callinglog). Here are a few examples:-
Log a message using
console.errorinstead of the defaultconsole.log:import { log } from '1log'; log(errorPlugin)('your message'); -
Prefix all messages logged by a certain module with a badge:
import { log as logExternal } from '1log'; const log = logExternal(badgePlugin('your caption')); // Rest of the module containing `log` calls. -
Log all messages using
console.debugby default:import { debugPlugin, installPlugin } from '1log'; installPlugin(debugPlugin);
-
Both of these superpowers are used by plugins such as functionPlugin. With this plugin installed, log(yourFunction) will return not the function passed as argument, but a proxy for it that acts in the same way as the original function, but additionally logs the arguments and the returned value each time it's called.
Reading log messages
As an example, let's log two functions one of which is calling the other:
import { log } from '1log';
const f1 = log((x: number) => x * 10);
const f2 = log((x: number) => f1(x));
f2(42);
This will produce the following log:
Time deltas that you see in this screenshot are computed using performance.now and exclude the time spent on logging itself. If the time delta is less than 10ms, it is muted, if greater than 1s, bold. For time deltas less than 1ms, we display up to 3 significant digits after the decimal point (on Node, you can see deltas like 0.00572ms), and for time deltas of 1ms and more we display time delta with millisecond precision, e.g. 1h 2m 3.450s.
Indentation indicates (synchronous) stack level.
If you mark each function with its own badge,
import { badgePlugin, log } from '1log';
const f1 = log(badgePlugin('f1'))((x: number) => x * 10);
// ^^^^^^^^^^^^^^^^^^^
const f2 = log(badgePlugin('f2'))((x: number) => f1(x));
// ^^^^^^^^^^^^^^^^^^^
f2(42);
[create 2] will become [create 1], because counters are specific to the combination of preceding badges:
:bulb: TIP
You can configure Chrome's Developer Tools to display file names and line numbers that point to your own source files instead of a library file by adding a pattern
/internal/in Settings -> Blackboxing, but currently this does not work in cases when non-blackboxed code can only be reached via the async stack, e.g. when logging Promise outcomes.
Using log snapshots in tests
Inspecting log messages can be useful in tests, especially in combination with Jest's snapshots feature. When running tests, instead of logging messages to the console, they are placed in a buffer, and by calling getMessages(), you can retrieve them and clear the buffer. Let's take a look at a sample test:
import { getMessages, log } from '1log';
/**
* The function that we'll be testing. Returns a promise resolving to 42 after a
* user-provided timeout.
*/
const timer = (duration: number) =>
new Promise((resolve) => {
setTimeout(() => resolve(42), duration);
});
test('timer', async () => {
const promise = log(timer)(500);
jest.runAllTimers();
await promise;
expect(getMessages()).toMatchInlineSnapshot(`
[create 1] +0ms [Function]
[create 1] [call 1] +0ms 500
[create 1] [call 1] [await] +0ms Promise {}
[create 1] [call 1] [resolve] +500ms 42
`);
});
Logging saved us the need to advance the time by 499ms, check that the promise is not resolved, then advance the time some more and check that the promise has resolved to the right value. Values that appear in log messages are serialized in the same way as in other snapshots, and where necessary Jest will use a custom serializer such as the one for JSX.
Default config
Importing '1log/defaultConfig' is the same as importing a file with the following contents (the plugins used here are documented below):
import {
asyncIterablePlugin,
consoleHandlerPlugin,
functionPlugin,
installPlugins,
iterablePlugin,
promisePlugin,
} from '1log';
installPlugins(
consoleHandlerPlugin(),
functionPlugin,
promisePlugin,
iterablePlugin,
asyncIterablePlugin,
);
Default Jest config
Importing '1log/defaultJestConfig' is the same as importing a file with the following contents:
import {
asyncIterablePlugin,
functionPlugin,
getMessages,
installPlugins,
iterablePlugin,
jestAsyncIterableSerializer,
jestIterableSerializer,
jestMessagesSerializer,
mockHandlerPlugin,
promisePlugin,
resetBadgeNumbers,
resetTimeDelta,
} from '1log';
// Add a Jest snapshot serializer that formats log messages.
expect.addSnapshotSerializer(jestMessagesSerializer);
// Add a Jest snapshot serializer that represents values proxied by
// iterablePlugin as [IterableIterator].
expect.addSnapshotSerializer(jestIterableSerializer);
// Add a Jest snapshot serializer that represents values proxied by
// asyncIterablePlugin as [AsyncIterableIterator].
expect.addSnapshotSerializer(jestAsyncIterableSerializer);
installPlugins(
mockHandlerPlugin(),
functionPlugin,
promisePlugin,
iterablePlugin,
asyncIterablePlugin,
);
beforeEach(() => {
// Use fake timers to make time deltas predicable.
jest.useFakeTimers('modern');
// Reset 1log's internal timer.
resetTimeDelta();
// Reset numbers in badges like [create <number>].
resetBadgeNumbers();
// Clear any prior log messages.
getMessages();
});
afterEach(() => {
// Restore real timers.
jest.useRealTimers();
});
Building for production
You can disable logging in production by not installing some of the plugins. For example, if you use Webpack, instead of importing '1log/defaultConfig', you can import a file with the following contents:
if (process.env.NODE_ENV !== 'production') {
require('1log/defaultConfig');
}
export {};
With this configuration,
-
Nothing will be logged in production, since
consoleHandlerPluginwon't be installed. -
The bulk of the 1log library will be tree-shaken in production.
-
Values that are proxied in development (e.g. when you have
functionPlugininstalled and writelog(yourFunction)) will be passed through thelogfunction unchanged in production, removing the performance cost of proxying.
Usage in libraries
Besides using the log function in unit tests, sometimes a library needs to log information for the benefit of the library user. In this case we recommend adding a module like this:
import { badgePlugin, log } from '1log';
export const prefixedLog = log(badgePlugin('<library name>'));
and using prefixedLog in any code that's included in the build. This way,
-
By default, the library will not log any messages.
-
The user can enable logging by configuring 1log.
-
Messages logged by the library will be prefixed with
[<library name>]badge. The user will be able to mute them by passing a filter toconsoleHandlerPlugin/mockHandlerPlugin.
Plugins included in this package
consoleHandlerPlugin
Returns a plugin that writes messages using console.log (default), console.debug, console.info, console.warn or console.error.
Can optionally be passed a predicate to mute messages for which the predicate returns false. E.g. to mute messages prefixed by [<caption>] badge unless the severity is error, replace consoleHandlerPlugin() from the default config with
consoleHandlerPlugin(
({ badges, severity }) =>
badges[0]?.caption !== '<caption>' || severity === Severity.error,
);
The plugin supports styled messages in modern browsers and Node. For Node, the output looks as follows:
mockHandlerPlugin
Returns a plugin that buffers log messages in memory. Like consoleHandlerPlugin, takes an optional parameter that can be used to mute some of the messages. Use function getMessages() to retrieve the messages and clear the buffer.
functionPlugin
If the piped value is a function (constructor property is Function or AsyncFunction), logs its creation and invocations, and if it returns a promise, fullfillment/rejection of that promise.
Example (sync function):
import { log } from '1log';
log((x: number) => x * 10)(42);
Example (async function):
import { log } from '1log';
log(async (x: number) => x * 10)(42);
promisePlugin
If the piped value is a promise, logs its creation and outcome.
Example (promise fullfilled):
import { log } from '1log';
log((async () => 42)());
Example (promise rejected):
import { log } from '1log';
log(
(async () => {
throw 42;
})(),
).catch(() => {});
iterablePlugin
For a value that satisfies
value !== undefined && value !== null && value[Symbol.iterator]?.() === value;
(e.g. one returned by a generator function or methods entries, keys, values of Map and Set), logs creation, nexts, yields, and done's.
Example:
import { log } from '1log';
[...log(new Set([1, 2]).values())];
The above condition excludes objects like arrays because they cannot be proxied by a plain iterable. To log such an object as an iterable, wrap it with toIterable utility function.
asyncIterablePlugin
For a value that satisfies
value !== undefined &&
value !== null &&
value[Symbol.asyncIterator]?.() === value;
(e.g. one returned by an async generator function), logs the following messages:
-
Initial message with a
createbadge. -
Just before calling the iterator's
nextmethod, a log message with anextbadge. -
Immediately after
nextreturns a promise, a log message withawaitbadge. -
If and when that promise resolves, a log message with either a
yieldor adonebadge depending on thedoneproperty of the promise result. -
If and when that promise rejects, a log message with a
rejectbadge.
Example:
import { log } from '1log';
const timer = (duration: number) =>
new Promise<number>((resolve) => {
setTimeout(() => resolve(42), duration);
});
async function* asyncGeneratorFunction() {
yield await timer(500);
return (await timer(500)) + 1;
}
(async () => {
for await (const value of log(asyncGeneratorFunction())) {
await timer(500);
}
})();
The above condition excludes objects like Node's readable streams because they cannot be proxied by a plain async iterable. To log such an object as an async iterable, wrap it with toAsyncIterable utility function.
badgePlugin
Prefixes messages with a blue-colored badge, taking badge caption as the single parameter. You can see sample output in the section Reading log messages.
severityPlugin
Sets severity level to a number provided as the single parameter. If multiple plugins set severity level, the highest severity wins.
Shortcuts to set severity
Although the library supports any number as severity level, plugins consoleHandlerPlugin and mockHandlerPlugin attach special meaning to severities from the following enum:
enum Severity {
debug = 10,
info = 20,
warn = 30,
error = 40,
}
For convenience, the library exports debugPlugin, infoPlugin, warnPlugin, and errorPlugin which are shortcuts for severityPlugin(Severity.debug), severityPlugin(Severity.info) etc.
Example:
import {
badgePlugin,
debugPlugin,
errorPlugin,
infoPlugin,
log as logExternal,
warnPlugin,
} from '1log';
const log = logExternal(badgePlugin('yourBadge'));
log(42);
log(debugPlugin)(42);
log(infoPlugin)(42);
log(warnPlugin)(42);
log(errorPlugin)(42);
Plugins from other packages
-
1log-antiutils: a plugin for Antiutils library.
Writing plugins
Plugin type signature is documented in file plugin.ts. If you are writing a proxy plugin, you can use package 1log-rxjs or 1log-antiutils as a starting point. There are a few things to keep in mind as regards proxy plugins:
-
transformfunction is run in anexcludeFromTimeDeltacallback to exclude that function's execution time from time deltas included in log messages. If your proxy creates a function and makes it available externally, you should yourself wrap that function inexcludeFromTimeDelta, and if you call a user-provided function, you should do the opposite and wrap it inincludeInTimeDelta. -
To keep track of stack level, user-provided functions should also be wrapped in
increaseStackLevel. -
If you use instances of class
Cas proxies, make sure thatscopecatches instances ofCbut not instances of a superclass ofCby checking thatvalue.constructor === C. This has to be done even when proxies are functions (CisFunction) or plain objects (CisObject). -
If you're proxying a user function, make sure to copy over any properties, because a function can couple as an object:
Object.assign(yourProxyFunction, originalFunction).