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..cbffc8abfa 100644 --- a/layers/tests/e2e/layerPublisher.test.ts +++ b/layers/tests/e2e/layerPublisher.test.ts @@ -143,15 +143,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/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..abe8fac81b 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,13 @@ class Metrics extends Utility implements MetricsInterface { */ private functionName?: string; + /** + * 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; + /** * Flag indicating if this is a single metric instance * @default false @@ -193,6 +203,7 @@ class Metrics extends Utility implements MetricsInterface { this.dimensions = {}; this.setOptions(options); + this.#logger = options.logger || this.console; } /** @@ -439,6 +450,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,9 +557,9 @@ 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( + 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`' ); @@ -584,7 +602,7 @@ class Metrics extends Utility implements MetricsInterface { } if (!this.namespace) - 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. @@ -731,6 +749,7 @@ class Metrics extends Utility implements MetricsInterface { serviceName: this.dimensions.service, defaultDimensions: this.defaultDimensions, singleMetric: true, + logger: this.#logger, }); } diff --git a/packages/metrics/src/types/Metrics.ts b/packages/metrics/src/types/Metrics.ts index 1c65dc5212..f02f9b183a 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 compatibility issues with custom loggers. + */ + logger?: GenericLogger; }; /** diff --git a/packages/metrics/tests/unit/Metrics.test.ts b/packages/metrics/tests/unit/Metrics.test.ts index b68d2b7e38..8b75a21faf 100644 --- a/packages/metrics/tests/unit/Metrics.test.ts +++ b/packages/metrics/tests/unit/Metrics.test.ts @@ -27,6 +27,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,9 +1256,17 @@ 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 consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + const customLogger = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }; + const metrics: Metrics = new Metrics({ + namespace: TEST_NAMESPACE, + logger: customLogger, + }); + const consoleWarnSpy = jest.spyOn(customLogger, 'warn'); // Act metrics.publishStoredMetrics(); @@ -1266,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', () => { @@ -1355,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(); - 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(); 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<void> => 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); - }); -});