-
Notifications
You must be signed in to change notification settings - Fork 154
Flush lower level logs to console #3178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -139,6 +139,9 @@ class Logger extends Utility implements LoggerInterface { | |
* immediately because the logger is not ready yet. This buffer stores those logs until the logger is ready. | ||
*/ | ||
#buffer: [number, Parameters<Logger['createAndPopulateLogItem']>][] = []; | ||
|
||
#context: Record<string, Array<[number, string]>> = {}; | ||
|
||
/** | ||
* Flag used to determine if the logger is initialized. | ||
*/ | ||
|
@@ -183,6 +186,7 @@ class Logger extends Utility implements LoggerInterface { | |
this.printLog(level, this.createAndPopulateLogItem(...log)); | ||
} | ||
this.#buffer = []; | ||
this.#context = {}; | ||
} | ||
|
||
/** | ||
|
@@ -835,9 +839,7 @@ class Logger extends Utility implements LoggerInterface { | |
* @param logLevel - The log level | ||
* @param log - The log item to print | ||
*/ | ||
private printLog(logLevel: number, log: LogItem): void { | ||
log.prepareForPrint(); | ||
|
||
private printLog(logLevel: number, log: LogItem | string): void { | ||
const consoleMethod = | ||
logLevel === LogLevelThreshold.CRITICAL | ||
? 'error' | ||
|
@@ -847,11 +849,17 @@ class Logger extends Utility implements LoggerInterface { | |
>); | ||
|
||
this.console[consoleMethod]( | ||
JSON.stringify( | ||
log.getAttributes(), | ||
this.getJsonReplacer(), | ||
this.logIndentation | ||
) | ||
typeof log === 'string' ? log : this.formatLog(log) | ||
); | ||
} | ||
|
||
private formatLog(log: LogItem): string { | ||
log.prepareForPrint(); | ||
|
||
return JSON.stringify( | ||
log.getAttributes(), | ||
this.getJsonReplacer(), | ||
this.logIndentation | ||
); | ||
} | ||
|
||
|
@@ -868,6 +876,22 @@ class Logger extends Utility implements LoggerInterface { | |
extraInput: LogItemExtraInput | ||
): void { | ||
if (logLevel >= this.logLevel) { | ||
// Only flush buffer when log level is higher than the configured log level | ||
if (logLevel > this.logLevel) { | ||
const xRayTraceId = this.envVarsService.getXrayTraceId() as string; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can work in theory but in practice I don't see Powertools relying on this value to uniquely identify requests. This value could be undefined in Lambda in rare occasions and would not exist at all outside of it, i.e. in testing environments or other compute platforms. It's true that we target primarily Lambda so this is not an immediate concern when it comes to other platforms, but introducing a requirement on this environment variable means now customers have to set it in their tests or CI, which is why for example the Tracer utility disables itself when running outside of Lambda. Overall, for this type of thing I would instead relying on something that follows a similar principle but more robust, like AsyncLocalStorage. With this being a language feature, we wouldn't have to worry about where Logger is running and context would be guaranteed at the process level. |
||
|
||
// Print all log items in the context | ||
if (this.#context[xRayTraceId]) { | ||
for (const contextItem of this.#context[xRayTraceId]) { | ||
this.printLog(...contextItem); | ||
} | ||
|
||
// Clear the context after flushing | ||
// This also removes entries from other X-Ray trace IDs | ||
this.#context = {}; | ||
} | ||
} | ||
|
||
if (this.#isInitialized) { | ||
this.printLog( | ||
logLevel, | ||
|
@@ -876,6 +900,23 @@ class Logger extends Utility implements LoggerInterface { | |
} else { | ||
this.#buffer.push([logLevel, [logLevel, input, extraInput]]); | ||
} | ||
} else { | ||
const xRayTraceId = this.envVarsService.getXrayTraceId() as string; | ||
|
||
// Add the log item to the context | ||
const context = this.#context[xRayTraceId] ?? []; | ||
context.push([ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is something I also touched upon in the original discussion, but for the sake of the review I'll include it here as well. Having an unbounded buffer is a risk as you could run out of memory depending on how much and what you're logging. For your specific use case this might not be a problem since you might know with relative certainty what you're logging and how much resources you're assigning to your Lambda functions, however from a library perspective this is a dangerous assumption to make. If we were to implement this feature, we'd most likely want to set a max size for the buffer with a sensible default and an option to configure it. Depending on the performance impact of doing this, we might also want to consider calculating the size of each log and allow customers to set a max size in kb/mb for the buffer rather than a number of buffered logs, since the latter might have less meaning. Alternatively, or additionally, we might also want to consider implementing a sliding window mechanism that deletes older logs as newer are added and the buffer reaches the limit. This above is the main reason why we haven't implemented the feature, as it requires some design and discussion. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I agree! I think byte size and/or count limit should be implemented. Since we store the stringified JSON (see comment on printLog/formatLog), we could easily calculate the UTF-8 byte size of each item and maintain a total byte size of the buffer. When a new log is added to the buffer and the total size exceeds the max allowed size, the buffer should drop the first N items until the total size is again below threshold. |
||
logLevel, | ||
this.formatLog( | ||
this.createAndPopulateLogItem(logLevel, input, extraInput) | ||
), | ||
]); | ||
|
||
// Assign the updated context to the context property | ||
// This also removes other X-Ray trace IDs from the context | ||
this.#context = { | ||
[xRayTraceId]: context, | ||
}; | ||
} | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
import { Logger } from '../../src/Logger.js'; | ||
import { LogLevel, LogLevelThreshold } from '../../src/constants.js'; | ||
import type { ConfigServiceInterface } from '../../src/types/ConfigServiceInterface.js'; | ||
import type { | ||
LogFunction, | ||
LogLevel as LogLevelType, | ||
} from '../../src/types/Logger.js'; | ||
|
||
/** | ||
* Helper function to get the console method for a given log level, we use this | ||
* for properly mocking the console methods in the tests and account for the | ||
* fact that `critical` is not a valid console method, which we proxy to `error`, | ||
* and `trace` is internally proxied to `log`. | ||
* | ||
* @param method - The method to get the console method for | ||
*/ | ||
const getConsoleMethod = ( | ||
method: string | ||
): keyof Omit<LogFunction, 'critical'> | 'log' => { | ||
const lowercaseMethod = method.toLowerCase(); | ||
switch (lowercaseMethod) { | ||
case 'trace': | ||
return 'log'; | ||
case 'critical': | ||
return 'error'; | ||
default: | ||
return lowercaseMethod as keyof Omit<LogFunction, 'critical'>; | ||
} | ||
}; | ||
|
||
describe('Log flush buffer', () => { | ||
const ENVIRONMENT_VARIABLES = process.env; | ||
|
||
beforeEach(() => { | ||
process.env = { ...ENVIRONMENT_VARIABLES, POWERTOOLS_DEV: 'true' }; | ||
vi.resetAllMocks(); | ||
}); | ||
|
||
it("doesn't flush buffer when calling a log level lower than the configured log level", () => { | ||
// Prepare | ||
const logger = new Logger({ logLevel: 'WARN' }); | ||
|
||
// Act | ||
logger.debug('debug'); | ||
logger.info('info'); | ||
|
||
// Assess | ||
expect(console.debug).not.toHaveBeenCalled(); | ||
expect(console.info).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it("doesn't flush buffer when calling a log level equal to the configured log level", () => { | ||
// Prepare | ||
const logger = new Logger({ logLevel: 'WARN' }); | ||
|
||
// Act | ||
logger.debug('debug'); | ||
logger.info('info'); | ||
logger.warn('warn'); | ||
|
||
// Assess | ||
expect(console.debug).not.toHaveBeenCalled(); | ||
expect(console.info).not.toHaveBeenCalled(); | ||
expect(console.warn).toHaveBeenCalled(); | ||
}); | ||
|
||
it('flushes buffer when calling a log level higher than the configured log level', () => { | ||
// Prepare | ||
const logger = new Logger({ logLevel: 'WARN' }); | ||
|
||
// Act | ||
logger.debug('debug'); | ||
logger.info('info'); | ||
logger.warn('warn'); | ||
logger.error('error'); | ||
|
||
// Assess | ||
expect(console.debug).toHaveBeenCalled(); | ||
expect(console.info).toHaveBeenCalled(); | ||
expect(console.warn).toHaveBeenCalled(); | ||
expect(console.error).toHaveBeenCalled(); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I understand this change correctly, we're bypassing the creation of an object, including all the attributes when passing a string message only.
If this is the case I don't think we would ever make this change, since the main value proposition of this logger is to emit JSON structured logs with keys, context, and all the other features.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I probably should have explained this change more thoroughly.
When saving logs into the buffer instead of emiting them to stdout, we cannot save the original object in the buffer because this object might be mutated later on in the Lambda function:
So I added a new function
formatLog
which is identical toprintLog
but doesn't emit to stdout:powertools-lambda-typescript/packages/logger/src/Logger.ts
Lines 856 to 864 in e6d0db9
I use this function to serialise the log object into JSON and save it to the buffer:
powertools-lambda-typescript/packages/logger/src/Logger.ts
Lines 907 to 913 in e6d0db9
Then when we flush the log, since we already have a JSON string we only need to print it to stdout:
powertools-lambda-typescript/packages/logger/src/Logger.ts
Lines 884 to 887 in e6d0db9
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see - makes sense, I missed this.
Thank you for explaining it.