[!TAG] 0.1.1
A lightweight, powerful correlation ID tracking and context store written for Node.js that automatically propagates context through async operations using Node's async_hooks
. Perfect for distributed tracing, request tracking, and logging correlation in micro services architectures.
- Features
- Installation
- How It Works
- Flow Diagram Explanation
- API
- Usage
‼️ Caution - Memory Management Strategies‼️ Caution - Correlation ID withUUID
-
‼️ Limitations - Contributing
- License
- Acknowledgments
- Automatic context propagation through async operations
- Thread-local-like storage for Node.js
- UUID v4 based correlation IDs
- ️ Thread-safe context isolation
- Deep cloning of context data
- Automatic cleanup of contexts
- Zero dependencies (uses only Node.js built-ins)
NPM
npm install @psenger/async-context-id --save
YARN
yarn add @psenger/async-context-id
This library uses Node.js's async_hooks
module to track async operations:
stateDiagram-v2
[*] --> AsyncContextId: Create Instance
state AsyncContextId {
state "Async Hooks Setup" as setup {
[*] --> Hook
Hook --> Init: New Async Operation
Hook --> PromiseResolve: Promise Resolved
Hook --> Destroy: Operation Complete
}
state "Context Operations" as ops {
state "Context Store (Map)" as store {
SetContext --> Store: Update Context
GetContext --> Store: Retrieve Context
SetCorrelationId --> Store: Update ID
GetCorrelationId --> Store: Get/Generate ID
Clear --> Store: Delete Context
}
}
state fork_state <<fork>>
Init --> fork_state
fork_state --> CopyParentContext: Has Parent Context
fork_state --> CreateNewContext: No Parent Context
CopyParentContext --> Store: Set New AsyncID
CreateNewContext --> Store: Generate New ID
PromiseResolve --> Store: Copy Context to New AsyncID
Destroy --> Store: Delete Context for AsyncID
}
state "Example Flow" as example {
Request --> SetCorrelationId: Upstream ID
SetCorrelationId --> ProcessData: Async Operation
ProcessData --> GetContext: Get Results
GetContext --> Clear: Cleanup
}
AsyncContextId --> BeforeExit: Process Exit
BeforeExit --> [*]: Cleanup Hook & Store
- Program starts by creating a singleton
AsyncContextId
instance - Sets up async hooks and initializes the context store (Map)
- Hook listens for three main events:
-
init
: When new async operations are created -
promiseResolve
: When promises are resolved -
destroy
: When async operations complete
-
Custom data is stored in the meta
attribute. All context / meta
operations use deep cloning to ensure isolation between async operations.
- Main operations on the context store:
-
setContext
: Updates context for current async ID -
getContext
: Retrieves context for current async ID -
setCorrelationId
: Sets/updates correlation ID -
getCorrelationId
: Gets or generates correlation ID -
clear
: Removes context for current async ID
-
- When a new async operation starts:
- If parent context exists, it's copied to new async ID
- If no parent context, new context is created with generated ID
- When promises resolve, context is copied to new async ID
- When operations complete, context is cleaned up
- On process exit:
- Disables async hooks
- Clears context store
- Removes event listeners
- Shows typical request handling:
- Set correlation ID from upstream
- Process data asynchronously
- Retrieve context for logging
- Clear context when done
Module | Description |
---|---|
@psenger/async-context-id |
A lightweight, powerful correlation ID tracking and context store. |
Global | Description |
---|---|
AsyncContextId |
A singleton class that tracks correlation IDs across asynchronous operations in Node.js. Uses async_hooks to automatically propagate correlation context across async boundaries. |
LruMap ⇐ Map
|
A Map extension implementing Least Recently Used (LRU) caching strategy. Automatically removes oldest entries when size limit is reached. |
TimedMap ⇐ Map
|
A Map extension that automatically deletes entries after a specified time-to-live (TTL). |
A lightweight, powerful correlation ID tracking and context store.
A singleton class that tracks correlation IDs across asynchronous operations in Node.js. Uses async_hooks to automatically propagate correlation context across async boundaries.
Kind: global class
Properties
Name | Type | Description |
---|---|---|
contextStore | Map |
Storage for async context data |
hook | AsyncHook |
The async_hooks instance for tracking async operations |
cleanUpFn | function |
Cleanup function registered for process exit |
-
AsyncContextId
- new AsyncContextId([options])
- .cleanUpFn()
-
.getCorrelationId() ⇒
string
- .setCorrelationId(correlationId)
-
.getContext() ⇒
Object
|string
|number
|Object
- .setContext([context])
- .clear()
This class provides context propagation across async operations in Node.js applications. It maintains correlation IDs and metadata throughout the async execution chain.
Returns: AsyncContextId
- The singleton instance
Param | Type | Default | Description |
---|---|---|---|
[options] | Object |
{} |
Configuration options |
[options.store] | Map |
new Map() |
Optional Map instance for context storage, this package includes both a LRU ( Least Recently Used ) Map and a Timed Map. |
[options.correlationIdFn] | fn |
Optional function to override default UUID generation. Should return a string |
Example
// Using default Map and UUID generation
const tracker = new AsyncContextId();
Example
// Using custom LRU Map and correlation ID generator
const tracker = new AsyncContextId({
store: new LruMap(1000),
correlationIdFn: () => `custom-${Date.now()}`
});
remove the listener of itself to prevent memory leaks
Kind: instance method of AsyncContextId
Retrieves the correlation ID for the current async context. Creates a new context with generated ID if none exists.
Kind: instance method of AsyncContextId
Returns: string
- The current correlation ID
Throws:
-
Error
If async hooks are not enabled
Example
const correlationId = tracker.getCorrelationId();
res.setHeader('x-correlation-id', correlationId);
Sets the correlation ID for the current async context. Creates a new context if none exists.
Kind: instance method of AsyncContextId
Throws:
-
Error
If the correlationId is not a string -
Error
If async hooks are not enabled
Param | Type | Description |
---|---|---|
correlationId | string |
The correlation ID to set |
Example
const upstreamId = req.headers['x-correlation-id'];
if (upstreamId) {
tracker.setCorrelationId(upstreamId);
}
Retrieves the complete context object for the current async operation. Creates a new context if none exists.
Kind: instance method of AsyncContextId
Returns: Object
- The correlation contextstring
- context.correlationId - The correlation IDnumber
- context.startTime - Unix timestamp of context creationObject
- context.metadata - Custom metadata object
Throws:
-
Error
If async hooks are not enabled
Example
const context = tracker.getContext();
console.log({
correlationId: context.correlationId,
duration: Date.now() - context.startTime,
metadata: context.metadata
});
Updates the context for the current async operation. Creates a new context if none exists. Preserves existing correlationId and startTime unless explicitly overridden.
Kind: instance method of AsyncContextId
Throws:
-
Error
If async hooks are not enabled -
Error
If context is not an object
Param | Type | Default | Description |
---|---|---|---|
[context] | Object |
{} |
The context object to merge |
[context.correlationId] | string |
Optional correlation ID override | |
[context.metadata] | Object |
Optional metadata to merge |
Example
// Add request context
tracker.setContext({
metadata: {
operation: 'processData',
requestId: req.id,
userId: req.user.id
}
});
Removes the correlation context for the current async operation.
Kind: instance method of AsyncContextId
Throws:
-
Error
If async hooks are not enabled
Example
try {
await processRequest(data);
} finally {
tracker.clear();
}
A Map extension implementing Least Recently Used (LRU) caching strategy. Automatically removes oldest entries when size limit is reached.
Kind: global class
Extends: Map
-
LruMap ⇐
Map
Creates an LRU cache with specified maximum size.
Param | Type | Description |
---|---|---|
maxSize | number |
Maximum number of entries |
Example
const cache = new LruMap(3);
cache.set('a', 1).set('b', 2).set('c', 3);
cache.set('d', 4); // Removes 'a', now contains b,c,d
Sets a value, removing oldest entry if size limit reached.
Kind: instance method of LruMap
Returns: this
- The LruMap instance for chaining
Param | Type | Description |
---|---|---|
key | * |
The key to set |
value | * |
The value to store |
Example
const cache = new LruMap(2);
cache.set('key1', 'value1')
.set('key2', 'value2')
.set('key3', 'value3'); // Removes key1
A Map extension that automatically deletes entries after a specified time-to-live (TTL).
Kind: global class
Extends: Map
-
TimedMap ⇐
Map
- new TimedMap(ttl)
-
.set(key, value) ⇒
this
-
.delete(key) ⇒
boolean
Creates a new TimedMap instance.
Param | Type | Description |
---|---|---|
ttl | number |
Time-to-live in milliseconds for each key-value pair |
Example
const cache = new TimedMap(5000); // 5 second TTL
cache.set('key1', 'value1');
console.log(cache.get('key1')); // 'value1'
// After 5 seconds:
console.log(cache.get('key1')); // undefined
Example
// Resetting TTL on value update
const cache = new TimedMap(2000);
cache.set('user', { name: 'Alice' });
// 1 second later:
cache.set('user', { name: 'Alice', age: 30 }); // Resets the 2-second timer
Sets a value in the map with an automatic deletion timer. If the key already exists, its timer is reset.
Kind: instance method of TimedMap
Returns: this
- The TimedMap instance for chaining
Param | Type | Description |
---|---|---|
key | * |
The key to set |
value | * |
The value to store |
Example
const cache = new TimedMap(10000);
cache.set('apiKey', 'xyz123')
.set('timestamp', Date.now());
Deletes a key-value pair and its associated timer.
Kind: instance method of TimedMap
Returns: boolean
- True if the element was deleted, false if it didn't exist
Param | Type | Description |
---|---|---|
key | * |
The key to delete |
Example
const cache = new TimedMap(5000);
cache.set('temp', 'data');
cache.delete('temp'); // Manually delete before TTL expires
This example demonstrates how to automatically inject correlation IDs into console logs using monkey patching. The code below intercepts standard console methods and prepends each log message with its log level and correlation ID.
eg LOG-LEVEL CORRELATION-ID
const fs = require('fs')
const path = require('path')
const util = require('util')
const {AsyncContextId} = require('../dist/index')
const TRACKER = new AsyncContextId()
const logFile = path.join(__dirname, 'app.log')
const original = {
log: console.log,
error: console.error,
warn: console.warn,
debug: console.debug,
info: console.info
}
try {
if (fs.existsSync(logFile)) {
fs.writeFileSync(logFile, '')
}
} catch (err) {
console.error('Error handling log file:', err)
}
function formatApacheErrorTimestamp(date = new Date()) {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
return `[${days[date.getDay()]} ${months[date.getMonth()]} ${String(date.getDate()).padStart(2, '0')} ` +
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:` +
`${String(date.getSeconds()).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')} ` +
`${date.getFullYear()}]`
}
Object.keys(original).forEach(method => {
console[method] = function (...args) {
const prefix = `${formatApacheErrorTimestamp()} [${method.toLowerCase()}] [${TRACKER.getContext().correlationId}] ${TRACKER.getContext()?.metadata?.fullName} `
if (typeof args[0] === 'string') {
args[0] = prefix + args[0]
} else {
args.unshift(prefix)
}
original[method].apply(console, args)
fs.appendFileSync(logFile, util.format(...args) + '\n')
}
})
This Express middleware automatically manages correlation IDs across HTTP requests. It extracts the correlation ID from incoming request headers, propagates it through the request lifecycle, and includes it in the response headers.
Note: Register this middleware early in your Express application's middleware chain to ensure correlation IDs are available throughout the entire request lifecycle.
const {AsyncContextId} = require('../dist/index') // '@psenger/async-context-id'
const asyncContextId = new AsyncContextId()
const correlationMiddleware = () => (req, res, next) => {
try {
asyncContextId.clear()
let correlationId = req.headers['x-correlation-id']
correlationId = asyncContextId.setCorrelationId(correlationId)
res.setHeader('x-correlation-id', correlationId)
res.on('finish', () => {
asyncContextId.clear()
})
next()
} catch (error) {
next(error)
}
}
module.exports = correlationMiddleware
const express = require('express')
const app = express()
const router = express.Router()
const {systemTimer} = require('./timer')
const controller = require('./controller')
const correlationMiddleware = require('./correlation-middleware')
require('./monkey-patch-logs')
systemTimer()
app.use(express.json())
app.use(express.urlencoded({extended: false}))
app.use(correlationMiddleware())
router.post('/:id', controller)
app.use('/', router)
module.exports = app
const {AsyncContextId} = require('../dist/index') // '@psenger/async-context-id'
const TRACKER = new AsyncContextId()
module.exports = function (req, res) {
const id = req.params.id
const correlationId = TRACKER.getContext().correlationId
console.log(`${correlationId} saw ${id} in controller`)
TRACKER.setContext({
metadata: {
id,
}
})
// do something else here which will expose meta upstream
}
const winston = require('winston')
const {AsyncContextId} = require('../dist/index') // '@psenger/async-context-id'
const TRACKER = new AsyncContextId()
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
winston.format((info) => {
const context = TRACKER.getContext();
return {
...info,
correlationId: context?.correlationId || 'no-correlation-id',
metadata: context?.metadata || {}
};
})()
),
transports: [
new winston.transports.Console()
]
});
All scaling systems degrade when affected by memory leaks. This module hosts a single map and addresses such leaks through two configurable Map implementations for context tracking:
- LRU (Least Recently Used) Map
- Timed Map
These maps can be configured during AsyncContextId initialization. As AsyncContextId operates as a singleton, the map implementation must be set at creation and remains immutable.
The default implementation of correlation ID uses non-cryptographic UUID generation to prevent recursive
async hook triggers that would otherwise occur with Node's Crypto module (internal crypto operations
would initiate new async hooks). Since this is based on UUID v4, it may be necessary for consumers
to increase the complexity of the correlation ID. Therefore, this functionality has been exposed as
an option ( correlationIdFn
).
e.g.
// Safe UUID v4 implementation without Crypto
generateCorrelationId() {
if (this.correlationIdFn) {
return this.correlationIdFn()
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
The async hooks implementation tracks context through EventEmitters, Timers, and Node.js core callbacks. However, as async hooks remain experimental, context propagation should be tested extensively in production like scenarios. In the event that something does not work as expected, there are some untested patterns and suggested work around. Remember it is better to error on the side of caution rather than create a memory leak.
process.nextTick(() => {
// context may get lost
const context = tracker.getContext(); // will create new context
});
const fs = require('fs');
fs.readFile('somefile.txt', (err, data) => {
// context may get lost
const context = tracker.getContext(); // will create new context
});
Despite the untested patterns, these callbacks can be wrapped and bound. Word of caution, Arrow Functions
can not be bound therefore, you must use function
declaration.
class CorrelationTracker {
bindCallback(fn) {
const currentContext = this.getContext();
return (...args) => {
const asyncId = asyncHooks.executionAsyncId();
this.correlationStore.set(asyncId, JSON.parse(JSON.stringify(currentContext)));
try {
return fn(...args);
} finally {
this.correlationStore.delete(asyncId);
}
};
}
}
// Usage:
emitter.on('someEvent', tracker.bindCallback(() => {
// context is preserved
const context = tracker.getContext();
}));
- Or use the async_hooks executionAsyncResource when available:
const asyncHooks = require('async_hooks');
const { AsyncResource } = require('async_hooks');
class TrackedEmitter extends EventEmitter {
emit(event, ...args) {
const asyncResource = new AsyncResource(event);
return asyncResource.runInAsyncScope(() => {
return super.emit(event, ...args);
});
}
}
Thanks for contributing! 😁 Here are some rules that will make your change to markdown-fences fruitful.
- Raise a ticket to the feature or bug can be discussed
- Pull requests are welcome, but must be accompanied by a ticket approved by the repo owner
- You are expected to add a unit test or two to cover the proposed changes.
- Please run the tests and make sure tests are all passing before submitting your pull request
- Do as the Romans do and stick with existing whitespace and formatting conventions (i.e., tabs instead of spaces, etc)
- we have provided the following:
.editorconfig
and.eslintrc
- Don't tamper with or change
.editorconfig
and.eslintrc
- we have provided the following:
- Please consider adding an example under examples/ that demonstrates any new functionality
This module uses release-please which needs commit messages to look like the following Conventional Commits
<type>[optional scope]: <description>
[optional body]
type is typically fix
, feat
. When type ends with a !
or is BREAKING CHANGE
it indicates this is a breaking change.
type should be followed by a short description,
optional body can have more detail
- All tests are expected to work
- Tests are based off of
dist/index.js
NOT your src code. Therefore, you should BUILD it first. - Coverage should not go down, and I acknowledge it is very difficult to get the tests to 100%
MIT License
Copyright (c) 2025 Philip A Senger
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
This project directly uses the following open-source packages:
- None
- @psenger/markdown-fences - MIT License
- eslint-config-prettier - MIT License
- eslint-plugin-jest - MIT License
- eslint-plugin-prettier - MIT License
- eslint - MIT License
- jest-html-reporters - MIT License
- jest - MIT License
- jsdoc - Apache-2.0 License
- license-checker - BSD-3-Clause License
- markdown-toc - MIT License
- prettier - MIT License
- rimraf - ISC License
- rollup - MIT License
- standard-version - ISC License