Skip to content

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

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions packages/logger/src/Logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -183,6 +186,7 @@ class Logger extends Utility implements LoggerInterface {
this.printLog(level, this.createAndPopulateLogItem(...log));
}
this.#buffer = [];
this.#context = {};
}

/**
Expand Down Expand Up @@ -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'
Expand All @@ -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)
Copy link
Contributor

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.

Copy link
Contributor Author

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:

const user = { name: 'John' };
logger.debug('User', { user } );
user.name = 'Jane';

So I added a new function formatLog which is identical to printLog but doesn't emit to stdout:

private formatLog(log: LogItem): string {
log.prepareForPrint();
return JSON.stringify(
log.getAttributes(),
this.getJsonReplacer(),
this.logIndentation
);
}

I use this function to serialise the log object into JSON and save it to the buffer:

const context = this.#context[xRayTraceId] ?? [];
context.push([
logLevel,
this.formatLog(
this.createAndPopulateLogItem(logLevel, input, extraInput)
),
]);

Then when we flush the log, since we already have a JSON string we only need to print it to stdout:

if (this.#context[xRayTraceId]) {
for (const contextItem of this.#context[xRayTraceId]) {
this.printLog(...contextItem);
}

Copy link
Contributor

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.

);
}

private formatLog(log: LogItem): string {
log.prepareForPrint();

return JSON.stringify(
log.getAttributes(),
this.getJsonReplacer(),
this.logIndentation
);
}

Expand All @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The 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,
Expand All @@ -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([
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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,
};
}
}

Expand Down
84 changes: 84 additions & 0 deletions packages/logger/tests/unit/logFlushBuffer.test.ts
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();
});
});