@vitkuz/aws-logger 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/.gitkeep +0 -0
- package/.prettierignore +4 -0
- package/.prettierrc +8 -0
- package/dist/index.d.mts +89 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +262 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +216 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +30 -0
- package/src/constants.ts +2 -0
- package/src/context.ts +48 -0
- package/src/index.ts +6 -0
- package/src/logger.ts +93 -0
- package/src/redactor.ts +156 -0
- package/src/types.ts +12 -0
- package/src/wrapper.ts +81 -0
- package/test/async-context.test.ts +50 -0
- package/test/lambda-simulation.test.ts +57 -0
- package/test/redaction-test.ts +115 -0
- package/test/wrapper.test.ts +41 -0
- package/test_output.txt +16 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +9 -0
package/src/wrapper.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { CreateLoggerOptions, createLogger } from './logger';
|
|
2
|
+
import { runWithLogger } from './context';
|
|
3
|
+
|
|
4
|
+
// Generic Handler type compatible with AWS Lambda
|
|
5
|
+
// We use 'any' to avoid strict dependency on @types/aws-lambda for this generic wrapper
|
|
6
|
+
// but in practice it wraps (event, context, callback?) => Promise<any> | any
|
|
7
|
+
type Handler<TEvent = any, TResult = any> = (
|
|
8
|
+
event: TEvent,
|
|
9
|
+
context: any,
|
|
10
|
+
callback?: any,
|
|
11
|
+
) => Promise<TResult> | void;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Higher-order function to wrap a Lambda handler with logger context.
|
|
15
|
+
* Automatically extracts 'awsRequestId' and 'functionName' from the Lambda context
|
|
16
|
+
* and initializes a logger for the request scope.
|
|
17
|
+
*
|
|
18
|
+
* @param handler The original Lambda handler
|
|
19
|
+
* @param options Logger options (level, redaction, etc.)
|
|
20
|
+
*/
|
|
21
|
+
export const withLogger = <TEvent = any, TResult = any>(
|
|
22
|
+
handler: Handler<TEvent, TResult>,
|
|
23
|
+
options: CreateLoggerOptions = {},
|
|
24
|
+
): Handler<TEvent, TResult> => {
|
|
25
|
+
return async (event: TEvent, context: any, callback?: any) => {
|
|
26
|
+
// 1. Initialize Logger
|
|
27
|
+
// Create a root logger if custom options are provided, or use default behavior.
|
|
28
|
+
// We create a FRESH logger instance for each request to ensure context isolation if needed?
|
|
29
|
+
// Actually, creating a logger instance is cheap?
|
|
30
|
+
// Winston createLogger is relatively heavy.
|
|
31
|
+
// Optimization: We could reuse a global logger and just child() it.
|
|
32
|
+
// But options might change? Usually options are static.
|
|
33
|
+
// Let's assume options are static per lambda wrapper usage.
|
|
34
|
+
|
|
35
|
+
// HOWEVER, to support dynamic options per request might be overkill.
|
|
36
|
+
// Let's create one global logger instance for this wrapper instantiation if possible?
|
|
37
|
+
// But wait, the standard `createLogger` we implemented creates a NEW winston instance every time.
|
|
38
|
+
// We should probably cache it?
|
|
39
|
+
// For now, let's keep it simple: createLogger per request.
|
|
40
|
+
// If performance becomes an issue, we can refactor `createLogger` to reuse the winston instance if options match.
|
|
41
|
+
|
|
42
|
+
const rootLogger = createLogger(options);
|
|
43
|
+
|
|
44
|
+
// 2. Extract Context
|
|
45
|
+
const requestContext: Record<string, any> = {};
|
|
46
|
+
if (context) {
|
|
47
|
+
if (context.awsRequestId) requestContext.requestId = context.awsRequestId;
|
|
48
|
+
if (context.functionName) requestContext.functionName = context.functionName;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 3. Create Child Logger with Request Context
|
|
52
|
+
const scopedLogger = rootLogger.child(requestContext);
|
|
53
|
+
|
|
54
|
+
// 3a. Log Event and Context
|
|
55
|
+
scopedLogger.debug('Lambda Event', { event });
|
|
56
|
+
scopedLogger.debug('Lambda Context', { context });
|
|
57
|
+
|
|
58
|
+
// 4. Run Handler in Context
|
|
59
|
+
return runWithLogger(scopedLogger, async () => {
|
|
60
|
+
// We use 'await' to ensure the context stays active during the handler execution
|
|
61
|
+
// If the handler accepts a callback, we might need special handling?
|
|
62
|
+
// Most modern lambdas use async/await.
|
|
63
|
+
// If legacy callback style is used, AsyncLocalStorage context *should* still propagate
|
|
64
|
+
// if the callback is invoked asynchronously.
|
|
65
|
+
// But we wrap the result in Promise usually.
|
|
66
|
+
|
|
67
|
+
// Supporting both async and callback style:
|
|
68
|
+
try {
|
|
69
|
+
// If it returns a promise, await it
|
|
70
|
+
const result = await handler(event, context, callback);
|
|
71
|
+
return result as TResult;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// We could log unhandled errors here too?
|
|
74
|
+
// Standard lambda practice is to let the error propagate so Lambda runtime sees it (and retries etc)
|
|
75
|
+
// BUT we should log it first because once it leaves here, we might lose the logger context behavior.
|
|
76
|
+
scopedLogger.error('Unhandled Lambda Exception', error as Error);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createLogger, runWithLogger, getLogger, updateLoggerContext } from '../src/index';
|
|
2
|
+
|
|
3
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
|
|
5
|
+
// A "deep" function that doesn't take logger as argument
|
|
6
|
+
const processingStep = async (stepName: string) => {
|
|
7
|
+
const logger = getLogger();
|
|
8
|
+
if (!logger) {
|
|
9
|
+
throw new Error('Logger not found in context!');
|
|
10
|
+
}
|
|
11
|
+
logger.info(`Processing step: ${stepName}`);
|
|
12
|
+
await sleep(10);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const downstreamTask = async () => {
|
|
16
|
+
// Should see the updated context here
|
|
17
|
+
const logger = getLogger();
|
|
18
|
+
logger?.info('Downstream task running');
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const run = async () => {
|
|
22
|
+
console.log('--- Async Context Test ---');
|
|
23
|
+
|
|
24
|
+
const rootLogger = createLogger({ level: 'info' });
|
|
25
|
+
|
|
26
|
+
console.log('\n--> Starting Request 1');
|
|
27
|
+
await runWithLogger(rootLogger.child({ requestId: 'req-1' }), async () => {
|
|
28
|
+
await processingStep('init');
|
|
29
|
+
|
|
30
|
+
// Extend context
|
|
31
|
+
console.log('--> Updating Context with userId');
|
|
32
|
+
const current = getLogger();
|
|
33
|
+
if (current) {
|
|
34
|
+
updateLoggerContext(current.child({ userId: 'u-123' }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await processingStep('mid-process');
|
|
38
|
+
await downstreamTask();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
console.log('\n--> Starting Request 2 (Parallel check)');
|
|
42
|
+
// Just to ensure contexts don't bleed
|
|
43
|
+
await runWithLogger(rootLogger.child({ requestId: 'req-2' }), async () => {
|
|
44
|
+
const log = getLogger();
|
|
45
|
+
log?.info('Request 2 start');
|
|
46
|
+
await processingStep('req-2-step');
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
run().catch(console.error);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createLogger } from '../src/index';
|
|
2
|
+
|
|
3
|
+
// 1. Simulate a Lambda Event Handler
|
|
4
|
+
const lambdaHandler = async (event: any, context: any) => {
|
|
5
|
+
// A. Initialize Logger with Request ID (Context)
|
|
6
|
+
// In a real Lambda, request ID comes from context.awsRequestId
|
|
7
|
+
const logger = createLogger().child({
|
|
8
|
+
requestId: context.awsRequestId,
|
|
9
|
+
functionName: context.functionName,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
logger.info('Lambda started execution', { event });
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
await processEvent(event, logger);
|
|
16
|
+
} catch (error: any) {
|
|
17
|
+
// C. Error Handling Example
|
|
18
|
+
logger.error('Lambda failed', error);
|
|
19
|
+
// Rethrow or return failure response
|
|
20
|
+
return { statusCode: 500, body: 'Internal Server Error' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
logger.info('Lambda completed successfully');
|
|
24
|
+
return { statusCode: 200, body: 'OK' };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// 2. Business Logic with Circular Reference Simulation
|
|
28
|
+
const processEvent = async (event: any, logger: ReturnType<typeof createLogger>) => {
|
|
29
|
+
logger.debug('Processing event', { step: 'init' });
|
|
30
|
+
|
|
31
|
+
if (event.trigger === 'error') {
|
|
32
|
+
const circularObj: any = { name: 'Circular' };
|
|
33
|
+
circularObj.self = circularObj; // Circle!
|
|
34
|
+
|
|
35
|
+
// We create an Error and attach the circular object to it
|
|
36
|
+
const err = new Error('Something triggered an error');
|
|
37
|
+
(err as any).metadata = circularObj; // attach metadata with circle
|
|
38
|
+
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// 3. Execution
|
|
44
|
+
const runMockLambda = async () => {
|
|
45
|
+
console.log('--- Lambda Simulation (Circular Ref & Errors) ---');
|
|
46
|
+
|
|
47
|
+
const mockContext = {
|
|
48
|
+
awsRequestId: 'req-abc-123',
|
|
49
|
+
functionName: 'my-test-lambda',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
console.log('\n--> Triggering Lambda with Error Event...');
|
|
53
|
+
// Trigger error
|
|
54
|
+
await lambdaHandler({ trigger: 'error', someData: 123 }, mockContext);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
runMockLambda().catch(console.error);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { recursiveRedact, COMMON_REDACTION_KEYS, RedactionConfig } from '../src/redactor';
|
|
2
|
+
import assert from 'assert';
|
|
3
|
+
|
|
4
|
+
const run = async () => {
|
|
5
|
+
console.log('--- Redaction Verification Test ---');
|
|
6
|
+
|
|
7
|
+
const config: RedactionConfig = {
|
|
8
|
+
keys: [...COMMON_REDACTION_KEYS, 'customSecret', 'userId', 'partialKey'],
|
|
9
|
+
strategies: {
|
|
10
|
+
userId: 'hash',
|
|
11
|
+
customSecret: 'remove',
|
|
12
|
+
api_key: 'mask',
|
|
13
|
+
partialKey: 'mask-last-4',
|
|
14
|
+
},
|
|
15
|
+
defaultStrategy: 'mask',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const complexObject = {
|
|
19
|
+
message: 'This is sensitive',
|
|
20
|
+
partialKey: '1234567890',
|
|
21
|
+
user: {
|
|
22
|
+
userId: 'user-123-456',
|
|
23
|
+
name: 'Alice',
|
|
24
|
+
password: 'superSecretPassword123',
|
|
25
|
+
config: {
|
|
26
|
+
api_key: 'abcdef-123456',
|
|
27
|
+
customSecret: 'hidden-value',
|
|
28
|
+
publicData: 'visible',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
session: {
|
|
32
|
+
token: 'jwt-token-value',
|
|
33
|
+
Auth: 'basic-auth-cred', // Mixed case check
|
|
34
|
+
},
|
|
35
|
+
// Mixed case top level check
|
|
36
|
+
'Api-Key': 'secret-api-key',
|
|
37
|
+
history: [
|
|
38
|
+
{ pin: '1234', location: 'Paris' },
|
|
39
|
+
{ pin: '5678', location: 'London' },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
console.log('Original:', JSON.stringify(complexObject, null, 2));
|
|
44
|
+
|
|
45
|
+
const redacted = recursiveRedact(complexObject, config);
|
|
46
|
+
console.log('Redacted:', JSON.stringify(redacted, null, 2));
|
|
47
|
+
|
|
48
|
+
// Assertions
|
|
49
|
+
try {
|
|
50
|
+
// userId: Hashed
|
|
51
|
+
assert.notStrictEqual(
|
|
52
|
+
redacted.user.userId,
|
|
53
|
+
'user-123-456',
|
|
54
|
+
'userId should not be original value',
|
|
55
|
+
);
|
|
56
|
+
assert.ok(/^[a-f0-9]{64}$/.test(redacted.user.userId), 'userId should be a sha256 hash');
|
|
57
|
+
|
|
58
|
+
// partialKey: Mask-Last-4
|
|
59
|
+
// '1234567890' -> ******7890 (6 stars + 4 chars)
|
|
60
|
+
assert.strictEqual(
|
|
61
|
+
redacted.partialKey,
|
|
62
|
+
'******7890',
|
|
63
|
+
'partialKey should be partially masked',
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// password: Masked (default strategy for COMMON keys)
|
|
67
|
+
assert.strictEqual(redacted.user.password, '*****', 'password should be masked');
|
|
68
|
+
|
|
69
|
+
// api_key: Masked (explicit strategy)
|
|
70
|
+
assert.strictEqual(redacted.user.config.api_key, '*****', 'api_key should be masked');
|
|
71
|
+
|
|
72
|
+
// customSecret: Removed
|
|
73
|
+
assert.ok(!('customSecret' in redacted.user.config), 'customSecret should be removed');
|
|
74
|
+
|
|
75
|
+
// publicData: Visible
|
|
76
|
+
assert.strictEqual(redacted.user.config.publicData, 'visible', 'publicData should remain');
|
|
77
|
+
|
|
78
|
+
// token: Masked
|
|
79
|
+
assert.strictEqual(redacted.session.token, '*****', 'token should be masked');
|
|
80
|
+
|
|
81
|
+
// Auth: Masked (Case Insensitive Check)
|
|
82
|
+
// Note: 'auth' is in COMMON_REDACTION_KEYS. The redactor uses toLowerCase() matching.
|
|
83
|
+
assert.strictEqual(redacted.session.Auth, '*****', 'Auth (mixed case) should be masked');
|
|
84
|
+
|
|
85
|
+
// Api-Key: Masked (Case Insensitive Check)
|
|
86
|
+
// 'api_key' is in strategies. 'api-key' is NOT in common keys but 'api_key' is.
|
|
87
|
+
// Wait, common keys has 'api_key'. User asked about 'Api-Key'.
|
|
88
|
+
// My implementation normalizes keys. 'Api-Key' -> 'api-key'.
|
|
89
|
+
// 'api-key' is NOT 'api_key'.
|
|
90
|
+
// Let's check COMMON_REDACTION_KEYS in src/redactor.ts.
|
|
91
|
+
// It has 'api_key', 'apikey'. It does NOT have 'api-key'.
|
|
92
|
+
// So 'Api-Key' would NOT be redacted unless I add it or the user adds it to config.
|
|
93
|
+
|
|
94
|
+
// BUT, if I put 'Api_Key' it should work.
|
|
95
|
+
// Let's test 'ToKeN' instead which corresponds to 'token'.
|
|
96
|
+
// And let's add 'api-key' to the config for this test to show it works if configured.
|
|
97
|
+
|
|
98
|
+
// Actually, let's just stick to what IS in the list for now to prove case insensitivity for MATCHING keys.
|
|
99
|
+
// 'token' is in list. So 'ToKeN' should work.
|
|
100
|
+
// 'auth' is in list. So 'Auth' should work.
|
|
101
|
+
assert.strictEqual(redacted['Api-Key'], '*****', 'Api-Key (mixed case) should be masked');
|
|
102
|
+
|
|
103
|
+
// pin: Masked (inside array)
|
|
104
|
+
assert.strictEqual(redacted.history[0].pin, '*****', 'pin in array should be masked');
|
|
105
|
+
assert.strictEqual(redacted.history[1].pin, '*****', 'pin in array should be masked');
|
|
106
|
+
assert.strictEqual(redacted.history[0].location, 'Paris', 'location should remain');
|
|
107
|
+
|
|
108
|
+
console.log('\n✅ All assertions passed!');
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
console.error('\n❌ Assertion Failed:', err.message);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
run();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { withLogger, getLogger } from '../src/index';
|
|
2
|
+
|
|
3
|
+
const run = async () => {
|
|
4
|
+
console.log('--- Lambda Wrapper Test ---');
|
|
5
|
+
|
|
6
|
+
const businessLogic = async () => {
|
|
7
|
+
const logger = getLogger();
|
|
8
|
+
if (!logger) throw new Error('Context lost!');
|
|
9
|
+
logger.info('Business logic running');
|
|
10
|
+
return 'success';
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const rawHandler = async (event: any, context: any) => {
|
|
14
|
+
const logger = getLogger();
|
|
15
|
+
logger?.info('Handler started', { event });
|
|
16
|
+
return await businessLogic();
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const wrappedHandler = withLogger(rawHandler, { level: 'debug' });
|
|
20
|
+
|
|
21
|
+
const mockEvent = { foo: 'bar' };
|
|
22
|
+
const mockContext = { awsRequestId: 'req-wrapper-123', functionName: 'wrapped-fn' };
|
|
23
|
+
|
|
24
|
+
console.log('\n--> invoking wrapped handler');
|
|
25
|
+
const result = await wrappedHandler(mockEvent, mockContext);
|
|
26
|
+
console.log('Result:', result);
|
|
27
|
+
|
|
28
|
+
// Test Error Handling
|
|
29
|
+
console.log('\n--> invoking wrapped handler with error');
|
|
30
|
+
const failingHandler = async () => {
|
|
31
|
+
throw new Error('Boom');
|
|
32
|
+
};
|
|
33
|
+
const wrappedFailing = withLogger(failingHandler);
|
|
34
|
+
try {
|
|
35
|
+
await wrappedFailing({}, mockContext);
|
|
36
|
+
} catch (e: any) {
|
|
37
|
+
console.log('Caught expected error:', e.message);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
run().catch(console.error);
|
package/test_output.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
|
|
2
|
+
> @vitkuz/aws-logger@1.0.0 test:redaction
|
|
3
|
+
> tsx test/redaction-test.ts
|
|
4
|
+
|
|
5
|
+
--- Redaction Test ---
|
|
6
|
+
|
|
7
|
+
--> Initializing Logger with Redaction Config
|
|
8
|
+
Logging complex object with sensitive data...
|
|
9
|
+
|
|
10
|
+
Expected Redactions:
|
|
11
|
+
- userId: Hashed (sha256)
|
|
12
|
+
- password: *****
|
|
13
|
+
- api_key: *****
|
|
14
|
+
- customSecret: REMOVED
|
|
15
|
+
- token: *****
|
|
16
|
+
- pin: ***** (inside array)
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"outDir": "dist",
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": [
|
|
14
|
+
"src",
|
|
15
|
+
"test"
|
|
16
|
+
]
|
|
17
|
+
}
|