From 3b100beeeade8c53eda4ccba4aca946b6c27f339 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Thu, 12 Sep 2024 19:31:10 +0200 Subject: [PATCH 1/8] feat(metrics): add ability to pass custom logger --- packages/commons/src/types/GenericLogger.ts | 17 +++++ packages/commons/src/types/index.ts | 1 + packages/metrics/src/Metrics.ts | 34 +++++++-- packages/metrics/src/types/Metrics.ts | 14 +++- packages/metrics/tests/unit/Metrics.test.ts | 10 ++- .../tests/unit/middleware/middy.test.ts | 3 + packages/metrics/tests/unit/repro.test.ts | 71 ------------------- 7 files changed, 70 insertions(+), 80 deletions(-) create mode 100644 packages/commons/src/types/GenericLogger.ts delete mode 100644 packages/metrics/tests/unit/repro.test.ts diff --git a/packages/commons/src/types/GenericLogger.ts b/packages/commons/src/types/GenericLogger.ts new file mode 100644 index 0000000000..0e5da7a716 --- /dev/null +++ b/packages/commons/src/types/GenericLogger.ts @@ -0,0 +1,17 @@ +// biome-ignore lint/suspicious/noExplicitAny: We intentionally use `any` here to represent any type of data and keep the logger is as flexible as possible. +type Anything = any[]; + +/** + * Interface for a generic logger object. + * + * This interface is used to define the shape of a logger object that can be passed to a Powertools for AWS utility. + * + * It can be an instance of Logger from Powertools for AWS, or any other logger that implements the same methods. + */ +export interface GenericLogger { + trace?: (...content: Anything) => void; + debug: (...content: Anything) => void; + info: (...content: Anything) => void; + warn: (...content: Anything) => void; + error: (...content: Anything) => void; +} diff --git a/packages/commons/src/types/index.ts b/packages/commons/src/types/index.ts index 24fb3b8032..080d13ed12 100644 --- a/packages/commons/src/types/index.ts +++ b/packages/commons/src/types/index.ts @@ -5,6 +5,7 @@ export type { MiddlewareFn, CleanupFunction, } from './middy.js'; +export type { GenericLogger } from './GenericLogger.js'; export type { SdkClient, MiddlewareArgsLike } from './awsSdk.js'; export type { JSONPrimitive, diff --git a/packages/metrics/src/Metrics.ts b/packages/metrics/src/Metrics.ts index 559ff71b91..b391af425f 100644 --- a/packages/metrics/src/Metrics.ts +++ b/packages/metrics/src/Metrics.ts @@ -1,6 +1,9 @@ import { Console } from 'node:console'; import { Utility } from '@aws-lambda-powertools/commons'; -import type { HandlerMethodDecorator } from '@aws-lambda-powertools/commons/types'; +import type { + GenericLogger, + HandlerMethodDecorator, +} from '@aws-lambda-powertools/commons/types'; import type { Callback, Context, Handler } from 'aws-lambda'; import { EnvironmentVariablesService } from './config/EnvironmentVariablesService.js'; import { @@ -159,6 +162,11 @@ class Metrics extends Utility implements MetricsInterface { */ private functionName?: string; + /** + * Custom logger object to be used for emitting debug, warning, and error messages. + */ + #logger?: GenericLogger; + /** * Flag indicating if this is a single metric instance * @default false @@ -192,6 +200,7 @@ class Metrics extends Utility implements MetricsInterface { super(); this.dimensions = {}; + this.#logger = options.logger; this.setOptions(options); } @@ -439,6 +448,13 @@ class Metrics extends Utility implements MetricsInterface { this.storedMetrics = {}; } + /** + * Check if there are stored metrics in the buffer. + */ + public hasStoredMetrics(): boolean { + return Object.keys(this.storedMetrics).length > 0; + } + /** * A class method decorator to automatically log metrics after the method returns or throws an error. * @@ -539,12 +555,16 @@ class Metrics extends Utility implements MetricsInterface { * ``` */ public publishStoredMetrics(): void { - const hasMetrics = Object.keys(this.storedMetrics).length > 0; + const hasMetrics = this.hasStoredMetrics(); if (!this.shouldThrowOnEmptyMetrics && !hasMetrics) { - console.warn( + const message = 'No application metrics to publish. The cold-start metric may be published if enabled. ' + - 'If application metrics should never be empty, consider using `throwOnEmptyMetrics`' - ); + 'If application metrics should never be empty, consider using `throwOnEmptyMetrics`'; + if (this.#logger?.warn) { + this.#logger.warn(message); + } else { + this.console.warn(message); + } } const emfOutput = this.serializeMetrics(); hasMetrics && this.console.log(JSON.stringify(emfOutput)); @@ -584,7 +604,9 @@ class Metrics extends Utility implements MetricsInterface { } if (!this.namespace) - console.warn('Namespace should be defined, default used'); + (this.#logger?.warn || this.console.warn)( + 'Namespace should be defined, default used' + ); // We reduce the stored metrics to a single object with the metric // name as the key and the value as the value. diff --git a/packages/metrics/src/types/Metrics.ts b/packages/metrics/src/types/Metrics.ts index 1c65dc5212..63437a133b 100644 --- a/packages/metrics/src/types/Metrics.ts +++ b/packages/metrics/src/types/Metrics.ts @@ -1,4 +1,7 @@ -import type { HandlerMethodDecorator } from '@aws-lambda-powertools/commons/types'; +import type { + GenericLogger, + HandlerMethodDecorator, +} from '@aws-lambda-powertools/commons/types'; import type { MetricResolution as MetricResolutions, MetricUnit as MetricUnits, @@ -57,6 +60,15 @@ type MetricsOptions = { * @see {@link MetricsInterface.setDefaultDimensions | `setDefaultDimensions()`} */ defaultDimensions?: Dimensions; + /** + * Logger object to be used for emitting debug, warning, and error messages. + * + * If not provided, debug messages will be suppressed, and warning and error messages will be sent to stdout. + * + * Note that EMF metrics are always sent directly to stdout, regardless of the logger + * to avoid any potential side effects from using a custom logger. + */ + logger?: GenericLogger; }; /** diff --git a/packages/metrics/tests/unit/Metrics.test.ts b/packages/metrics/tests/unit/Metrics.test.ts index b68d2b7e38..7a87cbe8ef 100644 --- a/packages/metrics/tests/unit/Metrics.test.ts +++ b/packages/metrics/tests/unit/Metrics.test.ts @@ -4,6 +4,7 @@ * @group unit/metrics/class */ import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; +import { Logger } from '@aws-lambda-powertools/logger'; import context from '@aws-lambda-powertools/testing-utils/context'; import type { Context, Handler } from 'aws-lambda'; import { EnvironmentVariablesService } from '../../src/config/EnvironmentVariablesService.js'; @@ -27,6 +28,8 @@ jest.mock('node:console', () => ({ ...jest.requireActual('node:console'), Console: jest.fn().mockImplementation(() => ({ log: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), })), })); jest.spyOn(console, 'warn').mockImplementation(() => ({})); @@ -1254,7 +1257,10 @@ describe('Class: Metrics', () => { describe('Methods: publishStoredMetrics', () => { test('it should log warning if no metrics are added & throwOnEmptyMetrics is false', () => { // Prepare - const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const metrics: Metrics = new Metrics({ + namespace: TEST_NAMESPACE, + logger: console, + }); const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); @@ -1355,7 +1361,7 @@ describe('Class: Metrics', () => { test('it should print warning, if no namespace provided in constructor or environment variable', () => { // Prepare process.env.POWERTOOLS_METRICS_NAMESPACE = ''; - const metrics: Metrics = new Metrics(); + const metrics: Metrics = new Metrics({ logger: console }); const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); // Act diff --git a/packages/metrics/tests/unit/middleware/middy.test.ts b/packages/metrics/tests/unit/middleware/middy.test.ts index fc9ddec7e8..8d2a17ec51 100644 --- a/packages/metrics/tests/unit/middleware/middy.test.ts +++ b/packages/metrics/tests/unit/middleware/middy.test.ts @@ -14,6 +14,8 @@ jest.mock('node:console', () => ({ ...jest.requireActual('node:console'), Console: jest.fn().mockImplementation(() => ({ log: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), })), })); jest.spyOn(console, 'warn').mockImplementation(() => ({})); @@ -68,6 +70,7 @@ describe('Middy middleware', () => { const metrics = new Metrics({ namespace: 'serverlessAirline', serviceName: 'orders', + logger: console, }); const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); const handler = middy(async (): Promise => undefined).use( diff --git a/packages/metrics/tests/unit/repro.test.ts b/packages/metrics/tests/unit/repro.test.ts deleted file mode 100644 index c1ac3dafed..0000000000 --- a/packages/metrics/tests/unit/repro.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import middy from '@middy/core'; -import type { Context } from 'aws-lambda'; -import { Metrics } from '../../src/Metrics.js'; -import { logMetrics } from '../../src/middleware/middy.js'; - -describe('Metrics', () => { - beforeAll(() => { - jest.spyOn(console, 'warn').mockImplementation(); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('does not log', () => { - process.env.POWERTOOLS_DEV = 'true'; - const metrics = new Metrics({ - serviceName: 'foo', - namespace: 'bar', - defaultDimensions: { - aws_account_id: '123456789012', - aws_region: 'us-west-2', - }, - }); - const logSpy = jest.spyOn(console, 'log').mockImplementation(); - - metrics.publishStoredMetrics(); - - expect(logSpy).toHaveBeenCalledTimes(0); - }); - - it('does log because of captureColdStartMetric enabled', () => { - process.env.POWERTOOLS_DEV = 'true'; - const metrics = new Metrics({ - serviceName: 'foo', - namespace: 'bar', - defaultDimensions: { - aws_account_id: '123456789012', - aws_region: 'us-west-2', - }, - }); - const logSpy = jest.spyOn(console, 'log').mockImplementation(); - const handler = middy(() => {}).use( - logMetrics(metrics, { captureColdStartMetric: true }) - ); - - handler({}, {} as Context); - - expect(logSpy).toHaveBeenCalledTimes(1); - }); - - it('does not log because of captureColdStartMetric disabled', () => { - process.env.POWERTOOLS_DEV = 'true'; - const metrics = new Metrics({ - serviceName: 'foo', - namespace: 'bar', - defaultDimensions: { - aws_account_id: '123456789012', - aws_region: 'us-west-2', - }, - }); - const logSpy = jest.spyOn(console, 'log').mockImplementation(); - const handler = middy(() => {}).use( - logMetrics(metrics, { captureColdStartMetric: false }) - ); - - handler({}, {} as Context); - - expect(logSpy).toHaveBeenCalledTimes(0); - }); -}); From db54e040dd88b492539844aec052be88886f62ad Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Thu, 12 Sep 2024 19:57:25 +0200 Subject: [PATCH 2/8] chore: remove unused import --- packages/metrics/tests/unit/Metrics.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/metrics/tests/unit/Metrics.test.ts b/packages/metrics/tests/unit/Metrics.test.ts index 7a87cbe8ef..22cdea6e89 100644 --- a/packages/metrics/tests/unit/Metrics.test.ts +++ b/packages/metrics/tests/unit/Metrics.test.ts @@ -4,7 +4,6 @@ * @group unit/metrics/class */ import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; -import { Logger } from '@aws-lambda-powertools/logger'; import context from '@aws-lambda-powertools/testing-utils/context'; import type { Context, Handler } from 'aws-lambda'; import { EnvironmentVariablesService } from '../../src/config/EnvironmentVariablesService.js'; From 3473f7394d92e12109876c147d5b8452fd81185d Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Fri, 27 Sep 2024 15:59:55 +0200 Subject: [PATCH 3/8] chore: switch private field to readonly --- packages/metrics/src/Metrics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metrics/src/Metrics.ts b/packages/metrics/src/Metrics.ts index b391af425f..9dff9d4981 100644 --- a/packages/metrics/src/Metrics.ts +++ b/packages/metrics/src/Metrics.ts @@ -165,7 +165,7 @@ class Metrics extends Utility implements MetricsInterface { /** * Custom logger object to be used for emitting debug, warning, and error messages. */ - #logger?: GenericLogger; + readonly #logger?: GenericLogger; /** * Flag indicating if this is a single metric instance From d16be1cbe676a4ad6d185579a309e38ae7f7b356 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Tue, 5 Nov 2024 09:50:50 +0100 Subject: [PATCH 4/8] chore: simplified logic --- packages/metrics/src/Metrics.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/metrics/src/Metrics.ts b/packages/metrics/src/Metrics.ts index 9dff9d4981..6b37669fea 100644 --- a/packages/metrics/src/Metrics.ts +++ b/packages/metrics/src/Metrics.ts @@ -163,9 +163,11 @@ class Metrics extends Utility implements MetricsInterface { private functionName?: string; /** - * Custom logger object to be used for emitting debug, warning, and error messages. + * Custom logger object used for emitting debug, warning, and error messages. + * + * Note that this logger is not used for emitting metrics which are emitted to standard output using the `Console` object. */ - readonly #logger?: GenericLogger; + readonly #logger: GenericLogger; /** * Flag indicating if this is a single metric instance @@ -200,8 +202,8 @@ class Metrics extends Utility implements MetricsInterface { super(); this.dimensions = {}; - this.#logger = options.logger; this.setOptions(options); + this.#logger = options.logger || this.console; } /** @@ -557,14 +559,10 @@ class Metrics extends Utility implements MetricsInterface { public publishStoredMetrics(): void { const hasMetrics = this.hasStoredMetrics(); if (!this.shouldThrowOnEmptyMetrics && !hasMetrics) { - const message = + this.#logger.warn( 'No application metrics to publish. The cold-start metric may be published if enabled. ' + - 'If application metrics should never be empty, consider using `throwOnEmptyMetrics`'; - if (this.#logger?.warn) { - this.#logger.warn(message); - } else { - this.console.warn(message); - } + 'If application metrics should never be empty, consider using `throwOnEmptyMetrics`' + ); } const emfOutput = this.serializeMetrics(); hasMetrics && this.console.log(JSON.stringify(emfOutput)); @@ -604,9 +602,7 @@ class Metrics extends Utility implements MetricsInterface { } if (!this.namespace) - (this.#logger?.warn || this.console.warn)( - 'Namespace should be defined, default used' - ); + this.#logger.warn('Namespace should be defined, default used'); // We reduce the stored metrics to a single object with the metric // name as the key and the value as the value. From 652a5c5455d5ac8d688397289b0c15986cfa17a2 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Tue, 5 Nov 2024 14:18:22 +0100 Subject: [PATCH 5/8] docs: improve docstring --- packages/metrics/src/types/Metrics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metrics/src/types/Metrics.ts b/packages/metrics/src/types/Metrics.ts index 63437a133b..f02f9b183a 100644 --- a/packages/metrics/src/types/Metrics.ts +++ b/packages/metrics/src/types/Metrics.ts @@ -66,7 +66,7 @@ type MetricsOptions = { * If not provided, debug messages will be suppressed, and warning and error messages will be sent to stdout. * * Note that EMF metrics are always sent directly to stdout, regardless of the logger - * to avoid any potential side effects from using a custom logger. + * to avoid compatibility issues with custom loggers. */ logger?: GenericLogger; }; From f78da0b5c4164ef43e355ce196ee4a79293cba96 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Wed, 6 Nov 2024 12:17:28 +0100 Subject: [PATCH 6/8] tests: use custom logger in tests --- packages/metrics/tests/unit/Metrics.test.ts | 22 +++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/metrics/tests/unit/Metrics.test.ts b/packages/metrics/tests/unit/Metrics.test.ts index 22cdea6e89..8b75a21faf 100644 --- a/packages/metrics/tests/unit/Metrics.test.ts +++ b/packages/metrics/tests/unit/Metrics.test.ts @@ -1256,12 +1256,17 @@ describe('Class: Metrics', () => { describe('Methods: publishStoredMetrics', () => { test('it should log warning if no metrics are added & throwOnEmptyMetrics is false', () => { // Prepare + const customLogger = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }; const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE, - logger: console, + logger: customLogger, }); - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + const consoleWarnSpy = jest.spyOn(customLogger, 'warn'); // Act metrics.publishStoredMetrics(); @@ -1271,7 +1276,6 @@ describe('Class: Metrics', () => { expect(consoleWarnSpy).toHaveBeenCalledWith( 'No application metrics to publish. The cold-start metric may be published if enabled. If application metrics should never be empty, consider using `throwOnEmptyMetrics`' ); - expect(consoleLogSpy).not.toHaveBeenCalled(); }); test('it should call serializeMetrics && log the stringified return value of serializeMetrics', () => { @@ -1360,8 +1364,14 @@ describe('Class: Metrics', () => { test('it should print warning, if no namespace provided in constructor or environment variable', () => { // Prepare process.env.POWERTOOLS_METRICS_NAMESPACE = ''; - const metrics: Metrics = new Metrics({ logger: console }); - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const customLogger = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }; + const metrics: Metrics = new Metrics({ logger: customLogger }); + const consoleWarnSpy = jest.spyOn(customLogger, 'warn'); // Act metrics.serializeMetrics(); From 0a1cdedfdd1feb86c9a6df1cf534d5660ca6dd91 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Wed, 6 Nov 2024 15:28:40 +0100 Subject: [PATCH 7/8] tests: update layer tests --- .../e2e/layerPublisher.class.test.functionCode.ts | 2 +- layers/tests/e2e/layerPublisher.test.ts | 15 +++++++-------- packages/metrics/src/Metrics.ts | 1 + 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/layers/tests/e2e/layerPublisher.class.test.functionCode.ts b/layers/tests/e2e/layerPublisher.class.test.functionCode.ts index a3ce0497dd..440875dfd1 100644 --- a/layers/tests/e2e/layerPublisher.class.test.functionCode.ts +++ b/layers/tests/e2e/layerPublisher.class.test.functionCode.ts @@ -17,7 +17,7 @@ import { SSMClient } from '@aws-sdk/client-ssm'; const logger = new Logger({ logLevel: 'DEBUG', }); -const metrics = new Metrics(); +const metrics = new Metrics({ logger }); const tracer = new Tracer(); // Instantiating these clients and the respective providers/persistence layers diff --git a/layers/tests/e2e/layerPublisher.test.ts b/layers/tests/e2e/layerPublisher.test.ts index 3cb7697031..edba71450d 100644 --- a/layers/tests/e2e/layerPublisher.test.ts +++ b/layers/tests/e2e/layerPublisher.test.ts @@ -17,6 +17,10 @@ import { TEARDOWN_TIMEOUT, } from './constants.js'; +vi.hoisted(() => { + process.env.AWS_PROFILE = 'aamorosi-Admin'; +}); + /** * This test has two stacks: * 1. LayerPublisherStack - publishes a layer version using the LayerPublisher construct and containing the Powertools utilities from the repo @@ -143,15 +147,10 @@ describe('Layers E2E tests', () => { const logs = invocationLogs.getFunctionLogs('WARN'); expect(logs.length).toBe(1); - expect( - invocationLogs.doesAnyFunctionLogsContains( - /Namespace should be defined, default used/, - 'WARN' - ) - ).toBe(true); - /* expect(logEntry.message).toEqual( + const logEntry = TestInvocationLogs.parseFunctionLog(logs[0]); + expect(logEntry.message).toEqual( 'Namespace should be defined, default used' - ); */ + ); } ); diff --git a/packages/metrics/src/Metrics.ts b/packages/metrics/src/Metrics.ts index 6b37669fea..abe8fac81b 100644 --- a/packages/metrics/src/Metrics.ts +++ b/packages/metrics/src/Metrics.ts @@ -749,6 +749,7 @@ class Metrics extends Utility implements MetricsInterface { serviceName: this.dimensions.service, defaultDimensions: this.defaultDimensions, singleMetric: true, + logger: this.#logger, }); } From 24272b2a458e1607c6f56f9b257fbb4686e9cdc2 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Wed, 6 Nov 2024 16:20:52 +0100 Subject: [PATCH 8/8] chore: remove unused env var --- layers/tests/e2e/layerPublisher.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/layers/tests/e2e/layerPublisher.test.ts b/layers/tests/e2e/layerPublisher.test.ts index edba71450d..cbffc8abfa 100644 --- a/layers/tests/e2e/layerPublisher.test.ts +++ b/layers/tests/e2e/layerPublisher.test.ts @@ -17,10 +17,6 @@ import { TEARDOWN_TIMEOUT, } from './constants.js'; -vi.hoisted(() => { - process.env.AWS_PROFILE = 'aamorosi-Admin'; -}); - /** * This test has two stacks: * 1. LayerPublisherStack - publishes a layer version using the LayerPublisher construct and containing the Powertools utilities from the repo