Skip to content

Commit af064dd

Browse files
author
Daniel Bot
committed
major: switch from AsyncLocalStorage to In Memory Cache, to avoid lost contexts when dealing with correlation ids
1 parent 9457129 commit af064dd

File tree

4 files changed

+270
-22
lines changed

4 files changed

+270
-22
lines changed

jest.config.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module.exports = {
2+
roots: ['<rootDir>'],
3+
transform: {
4+
'^.+\\.ts?$': 'ts-jest'
5+
},
6+
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.ts?$',
7+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
8+
coveragePathIgnorePatterns: ['__tests__', 'node_modules'],
9+
modulePathIgnorePatterns: ['__tests__', 'node_modules'],
10+
transformIgnorePatterns: ['node_modules'],
11+
testEnvironment: 'node',
12+
silent: true,
13+
verbose: true
14+
}

src/correlation.it.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
2+
import middy from '@middy/core';
3+
import { APIGatewayProxyEventV2, Context, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
4+
import { Logger as PowertoolsLogger } from '@aws-lambda-powertools/logger';
5+
import { enableCorrelationIds, injectCorrelationIds } from './correlation';
6+
7+
describe('when both middy middlewares are applied for a handler', () => {
8+
// Create a dummy Lambda handler that simply returns a successful response.
9+
const dummyHandler = async (
10+
event: APIGatewayProxyEventV2,
11+
context: Context
12+
): Promise<APIGatewayProxyStructuredResultV2> => {
13+
return {
14+
statusCode: 200,
15+
body: JSON.stringify({
16+
message: 'Hello, world!',
17+
event,
18+
}),
19+
};
20+
};
21+
22+
// We'll create a fake logger that we can spy on.
23+
let fakeLogger: PowertoolsLogger;
24+
25+
const withMiddlewares = (
26+
handler?: (
27+
event: APIGatewayProxyEventV2,
28+
context: Context
29+
) => Promise<APIGatewayProxyStructuredResultV2>
30+
) => {
31+
return middy(handler).use(enableCorrelationIds())
32+
.before(() => {
33+
injectCorrelationIds(fakeLogger)
34+
})
35+
}
36+
37+
beforeEach(() => {
38+
fakeLogger = {
39+
addPersistentLogAttributes: jest.fn(),
40+
} as unknown as PowertoolsLogger;
41+
});
42+
43+
it('should run all middlewares and inject correlation ids into the logger', async () => {
44+
// Wrap the dummyHandler with your middleware chain.
45+
const wrappedHandler = withMiddlewares(dummyHandler);
46+
47+
// Create a fake event and context.
48+
const fakeEvent: APIGatewayProxyEventV2 = {
49+
version: '2.0',
50+
routeKey: 'ANY /test',
51+
rawPath: '/test',
52+
rawQueryString: '',
53+
headers: {},
54+
requestContext: {} as any,
55+
isBase64Encoded: false,
56+
body: undefined,
57+
};
58+
59+
const fakeContext: Context = {
60+
awsRequestId: 'test-123',
61+
callbackWaitsForEmptyEventLoop: false,
62+
functionName: 'test-function',
63+
functionVersion: '1',
64+
invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function',
65+
memoryLimitInMB: '128',
66+
logGroupName: '/aws/lambda/test-function',
67+
logStreamName: '2020/01/01/[$LATEST]abcdef1234567890',
68+
done: () => {},
69+
fail: () => {},
70+
getRemainingTimeInMillis: () => 3000,
71+
succeed: () => {},
72+
};
73+
74+
// Execute the wrapped handler.
75+
const response = await wrappedHandler(fakeEvent, fakeContext);
76+
77+
// Parse the returned body.
78+
const parsedBody = JSON.parse(response.body ?? '{}');
79+
80+
// Assert that the dummy handler ran and returned the expected message.
81+
expect(response.statusCode).toBe(200);
82+
expect(parsedBody.message).toBe('Hello, world!');
83+
84+
// we expect that method to have been called.
85+
expect(fakeLogger.addPersistentLogAttributes).toHaveBeenCalledWith({
86+
'awsRequestId': 'test-123',
87+
'call-chain-length': '1',
88+
'x-correlation-id': 'test-123',
89+
});
90+
});
91+
92+
it('should propagate errors via the error middleware', async () => {
93+
// Create a dummy handler that throws an error.
94+
const errorThrowingHandler = async () => {
95+
throw new Error('Test error');
96+
};
97+
98+
const wrappedErrorHandler = withMiddlewares(errorThrowingHandler);
99+
100+
const fakeEvent: APIGatewayProxyEventV2 = {
101+
version: '2.0',
102+
routeKey: 'ANY /error',
103+
rawPath: '/error',
104+
rawQueryString: '',
105+
headers: {},
106+
requestContext: {} as any,
107+
isBase64Encoded: false,
108+
body: undefined,
109+
};
110+
111+
const fakeContext: Context = {
112+
awsRequestId: 'error-123',
113+
callbackWaitsForEmptyEventLoop: false,
114+
functionName: 'error-function',
115+
functionVersion: '1',
116+
invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:error-function',
117+
memoryLimitInMB: '128',
118+
logGroupName: '/aws/lambda/error-function',
119+
logStreamName: '2020/01/01/[$LATEST]errorabcdef',
120+
done: () => {},
121+
fail: () => {},
122+
getRemainingTimeInMillis: () => 3000,
123+
succeed: () => {},
124+
};
125+
126+
// We expect the wrapped handler to reject with the error.
127+
await expect(wrappedErrorHandler(fakeEvent, fakeContext)).rejects.toThrow('Test error');
128+
});
129+
});

src/correlation.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
2+
import { Logger as PowertoolsLogger } from '@aws-lambda-powertools/logger';
3+
import { Context } from 'aws-lambda';
4+
import {
5+
enableCorrelationIds,
6+
injectCorrelationIds,
7+
useCorrelationIds
8+
} from './correlation'; // adjust the import path accordingly
9+
10+
// For testing, we need access to the AsyncLocalStorage instance.
11+
// If it's not exported in your module, consider exporting it conditionally for tests.
12+
// For this example, we'll assume you exported it as "asyncLocalStorage".
13+
import { __testExports } from './correlation';
14+
15+
const CORRELATION_KEY = '__X_POWERTOOLS_CORRELATION_IDS__';
16+
17+
describe('Correlation Middleware', () => {
18+
describe('enableCorrelationIds', () => {
19+
it('should set default correlation ids when none are extracted (awsDefaults true)', () => {
20+
// Arrange: simulate a handler with an empty event (so extractCorrelationIds returns {})
21+
const fakeEvent = {}; // an event that does not match any supported matcher
22+
const fakeContext = { awsRequestId: '123' } as Context;
23+
const fakeHandler = {
24+
event: fakeEvent,
25+
context: fakeContext,
26+
} as any;
27+
28+
// Act: Run the middleware before hook
29+
const middleware = enableCorrelationIds();
30+
middleware.before(fakeHandler);
31+
32+
const store = __testExports.store
33+
expect(store.get(CORRELATION_KEY)).toBeDefined();
34+
expect(store.get(CORRELATION_KEY)).toEqual({
35+
awsRequestId: '123',
36+
'x-correlation-id': '123',
37+
'call-chain-length': '1',
38+
});
39+
40+
});
41+
42+
it('should initialize minimal correlation ids when awsDefaults is false', () => {
43+
const fakeEvent = { some: 'value' } as any;
44+
const fakeContext = { awsRequestId: '456' } as Context;
45+
const fakeHandler = {
46+
event: fakeEvent,
47+
context: fakeContext,
48+
} as any;
49+
50+
const middleware = enableCorrelationIds({ awsDefaults: false });
51+
middleware.before(fakeHandler)
52+
const store = __testExports.store
53+
expect(store.get(CORRELATION_KEY)).toBeDefined();
54+
expect(store.get(CORRELATION_KEY)).toEqual({
55+
'call-chain-length': '1',
56+
});
57+
58+
});
59+
});
60+
61+
describe('injectCorrelationIds', () => {
62+
it('should add persistent log attributes to the logger using the current store', () => {
63+
// Arrange: initialize a test store with known correlation ids
64+
const store = __testExports.store
65+
store.set(CORRELATION_KEY, { testKey: 'testValue' })
66+
67+
// Create a fake logger with a spy on addPersistentLogAttributes
68+
const fakeLogger = {
69+
addPersistentLogAttributes: jest.fn(),
70+
} as unknown as PowertoolsLogger;
71+
72+
73+
injectCorrelationIds(fakeLogger);
74+
75+
// Assert: the logger's persistent log attributes should match our test store
76+
expect(fakeLogger.addPersistentLogAttributes).toHaveBeenCalledWith({ testKey: 'testValue' });
77+
});
78+
});
79+
80+
describe('useCorrelationIds', () => {
81+
it('should return the correlation ids from the current store', () => {
82+
// Arrange: define a store with some correlation ids
83+
const store = __testExports.store
84+
store.set(CORRELATION_KEY, {key1: 'value1' })
85+
86+
const ids = useCorrelationIds();
87+
// Assert: we expect to retrieve the same correlation ids
88+
expect(ids).toEqual({ key1: 'value1' });
89+
});
90+
91+
it('should warn and return an empty object if no store is found', () => {
92+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
93+
const store = __testExports.store
94+
store.delete(CORRELATION_KEY)
95+
96+
// Act: Call useCorrelationIds outside of any asyncLocalStorage context
97+
const ids = useCorrelationIds();
98+
99+
// Assert: It should warn and return an empty object
100+
expect(ids).toEqual({});
101+
expect(warnSpy).toHaveBeenCalledWith('No correlation ids found. You must enable correlation ids via enableCorrelationIds first');
102+
103+
warnSpy.mockRestore();
104+
});
105+
});
106+
});

src/correlation.ts

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { AsyncLocalStorage } from 'node:async_hooks';
2-
31
import { Logger as PowertoolsLogger } from '@aws-lambda-powertools/logger';
42
import middy from '@middy/core';
53
import * as Lambda from 'aws-lambda';
@@ -11,29 +9,35 @@ export type CorrelationProps = {
119
awsDefaults?: boolean;
1210
};
1311

14-
const asyncLocalStorage = new AsyncLocalStorage();
12+
const inMemoryCache = new Map();
13+
14+
export const __testExports = {
15+
store: inMemoryCache,
16+
};
1517

1618
const CORRELATION_KEY = '__X_POWERTOOLS_CORRELATION_IDS__';
1719

1820
export const enableCorrelationIds = (props?: CorrelationProps) => { return {
19-
before: (handler: middy.Request) => {
21+
before: async (handler: middy.Request) => {
22+
// Clear the store before each invocation
23+
inMemoryCache.delete(CORRELATION_KEY);
24+
2025
if (!props || props.awsDefaults === true) {
2126
const event = handler.event as SupportedEventTypes;
2227
const context = handler.context as Lambda.Context;
28+
29+
context.clientContext
2330
const correlationIds = extractCorrelationIds(event);
2431
const isEmpty = !correlationIds || Object.keys(correlationIds).length === 0;
2532
const correlationIdsToUse = isEmpty ? defaultCorrelationIds(context) : increaseChainLength(correlationIds);
2633

27-
asyncLocalStorage.enterWith({
28-
[CORRELATION_KEY]: correlationIdsToUse,
29-
});
34+
inMemoryCache.set(CORRELATION_KEY, correlationIdsToUse)
3035
} else {
3136
// no need to track default correlation ids -> initialize asyncLocalStorage with empty object
32-
asyncLocalStorage.enterWith({
33-
CORRELATION_KEY: {
34-
'call-chain-length': '1',
35-
},
36-
});
37+
inMemoryCache.set(CORRELATION_KEY,{
38+
'call-chain-length': '1',
39+
})
40+
3741
}
3842
},
3943
};
@@ -44,20 +48,15 @@ export const injectCorrelationIds = (logger: PowertoolsLogger) => {
4448
};
4549

4650
export const useCorrelationIds = (): CorrelationIds => {
47-
const store = asyncLocalStorage.getStore() as { [CORRELATION_KEY]: CorrelationIds };
48-
if (!store) {
49-
console.warn('No asyncLocalStorage store found');
50-
51-
return {};
52-
}
53-
54-
if (!store[CORRELATION_KEY] || Object.keys(store[CORRELATION_KEY]).length === 0) {
55-
console.warn('No correlation ids found in asyncLocalStorage store');
51+
const store = inMemoryCache.get(CORRELATION_KEY) as CorrelationIds;
52+
if (!store || Object.keys(store).length === 0) {
53+
console.warn('No correlation ids found. You must enable correlation ids via enableCorrelationIds first');
5654

5755
return {};
5856
}
5957

60-
return store[CORRELATION_KEY];
58+
// return a fresh copy of the correlation ids, to prevent accidental mutations by consumers
59+
return JSON.parse(JSON.stringify(store));
6160
};
6261

6362
const extractCorrelationIds = (event: SupportedEventTypes): CorrelationIds => {

0 commit comments

Comments
 (0)