@psenger/async-context-id
v0.1.2
Published
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
Downloads
46
Maintainers
Readme
async-context-id
[!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.
Table of Contents
- Features
- Installation
- How It Works
- Flow Diagram Explanation
- API
- Usage
- ‼️ Caution - Memory Management Strategies
- ‼️ Caution - Correlation ID with
UUID
- ‼️ Limitations
- Contributing
- License
- Acknowledgments
Features
- 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)
Installation
NPM
npm install @psenger/async-context-id --save
YARN
yarn add @psenger/async-context-id
How It Works
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
Flow Diagram Explanation
Instance Creation
- Program starts by creating a singleton
AsyncContextId
instance - Sets up async hooks and initializes the context store (Map)
Async Hooks Setup
- Hook listens for three main events:
init
: When new async operations are createdpromiseResolve
: When promises are resolveddestroy
: When async operations complete
Context Operations
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 IDgetContext
: Retrieves context for current async IDsetCorrelationId
: Sets/updates correlation IDgetCorrelationId
: Gets or generates correlation IDclear
: Removes context for current async ID
Context Propagation
- 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
Cleanup
- On process exit:
- Disables async hooks
- Clears context store
- Removes event listeners
Example Flow
- Shows typical request handling:
- Set correlation ID from upstream
- Process data asynchronously
- Retrieve context for logging
- Clear context when done
API
Modules
Classes
@psenger/async-context-id
A lightweight, powerful correlation ID tracking and context store.
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.
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()
new AsyncContextId([options])
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()}`
});
asyncContextId.cleanUpFn()
remove the listener of itself to prevent memory leaks
Kind: instance method of AsyncContextId
asyncContextId.getCorrelationId() ⇒ string
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);
asyncContextId.setCorrelationId(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);
}
asyncContextId.getContext() ⇒ Object | string | number | Object
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
});
asyncContextId.setContext([context])
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
}
});
asyncContextId.clear()
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();
}
LruMap ⇐ Map
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
new LruMap(maxSize)
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
lruMap.set(key, value) ⇒ this
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
TimedMap ⇐ Map
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
new TimedMap(ttl)
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
timedMap.set(key, value) ⇒ this
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());
timedMap.delete(key) ⇒ boolean
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
Usage
Enhanced Logging with Console Monkey Patching
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')
}
})
Express Middleware
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.
correlation-middleware.js
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
app.js
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
simple-controller.js
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
}
Winston Logger Integration
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()
]
});
‼️ Caution - Memory Management Strategies
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.
‼️ Caution - Correlation ID with UUID
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);
});
}
‼️ Limitations
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.
Untested pattern - Process Next Tick
process.nextTick(() => {
// context may get lost
const context = tracker.getContext(); // will create new context
});
Untested pattern - Node.js Core Module Callbacks (like fs, http, etc.):
const fs = require('fs');
fs.readFile('somefile.txt', (err, data) => {
// context may get lost
const context = tracker.getContext(); // will create new context
});
Solution to Untested pattern
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);
});
}
}
Contributing
Thanks for contributing! 😁 Here are some rules that will make your change to markdown-fences fruitful.
Rules
- 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
Commit Message
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
Testing
- 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%
License
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.
Acknowledgments
This project directly uses the following open-source packages:
Dependencies
- None
Development Dependencies
- @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