diff --git a/packages/metrics/src/Metrics.ts b/packages/metrics/src/Metrics.ts index f881bf8dd7..ad5e64345b 100644 --- a/packages/metrics/src/Metrics.ts +++ b/packages/metrics/src/Metrics.ts @@ -2,6 +2,7 @@ import { Callback, Context, Handler } from 'aws-lambda'; import { Utility } from '@aws-lambda-powertools/commons'; import { MetricsInterface } from '.'; import { ConfigServiceInterface, EnvironmentVariablesService } from './config'; +import { MAX_DIMENSION_COUNT, MAX_METRICS_SIZE, DEFAULT_NAMESPACE, COLD_START_METRIC } from './constants'; import { MetricsOptions, Dimensions, @@ -16,10 +17,6 @@ import { StoredMetric, } from './types'; -const MAX_METRICS_SIZE = 100; -const MAX_DIMENSION_COUNT = 29; -const DEFAULT_NAMESPACE = 'default_namespace'; - /** * ## Intro * Metrics creates custom metrics asynchronously by logging metrics to standard output following Amazon CloudWatch Embedded Metric Format (EMF). @@ -228,7 +225,7 @@ class Metrics extends Utility implements MetricsInterface { if (this.functionName != null) { singleMetric.addDimension('function_name', this.functionName); } - singleMetric.addMetric('ColdStart', MetricUnits.Count, 1); + singleMetric.addMetric(COLD_START_METRIC, MetricUnits.Count, 1); } public clearDefaultDimensions(): void { diff --git a/packages/metrics/src/constants.ts b/packages/metrics/src/constants.ts new file mode 100644 index 0000000000..f8cf76c567 --- /dev/null +++ b/packages/metrics/src/constants.ts @@ -0,0 +1,4 @@ +export const COLD_START_METRIC = 'ColdStart'; +export const DEFAULT_NAMESPACE = 'default_namespace'; +export const MAX_METRICS_SIZE = 100; +export const MAX_DIMENSION_COUNT = 29; \ No newline at end of file diff --git a/packages/metrics/tests/helpers/metricsUtils.ts b/packages/metrics/tests/helpers/metricsUtils.ts index d585b777a0..77ad46b345 100644 --- a/packages/metrics/tests/helpers/metricsUtils.ts +++ b/packages/metrics/tests/helpers/metricsUtils.ts @@ -1,5 +1,9 @@ import { CloudWatch } from 'aws-sdk'; import promiseRetry from 'promise-retry'; +import { Metrics } from '../../src'; +import { ExtraOptions, MetricUnits } from '../../src/types'; +import { Context, Handler } from 'aws-lambda'; +import { LambdaInterface } from '@aws-lambda-powertools/commons'; const getMetrics = async (cloudWatchClient: CloudWatch, namespace: string, metric: string, expectedMetrics: number): Promise => { const retryOptions = { retries: 20, minTimeout: 5_000, maxTimeout: 10_000, factor: 1.25 }; @@ -21,4 +25,27 @@ const getMetrics = async (cloudWatchClient: CloudWatch, namespace: string, metri }, retryOptions); }; -export { getMetrics }; \ No newline at end of file +const setupDecoratorLambdaHandler = (metrics: Metrics, options: ExtraOptions = {}): Handler => { + + class LambdaFunction implements LambdaInterface { + @metrics.logMetrics(options) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + public async handler(_event: TEvent, _context: Context): Promise { + metrics.addMetric('decorator-lambda-test-metric', MetricUnits.Count, 1); + + return 'Lambda invoked!'; + } + } + + const handlerClass = new LambdaFunction(); + const handler = handlerClass.handler.bind(handlerClass); + + return handler; +}; + +export { + getMetrics, + setupDecoratorLambdaHandler +}; + diff --git a/packages/metrics/tests/unit/Metrics.test.ts b/packages/metrics/tests/unit/Metrics.test.ts index e854615b52..9fcd932efb 100644 --- a/packages/metrics/tests/unit/Metrics.test.ts +++ b/packages/metrics/tests/unit/Metrics.test.ts @@ -3,863 +3,2053 @@ * * @group unit/metrics/class */ - -import { ContextExamples as dummyContext, Events as dummyEvent, LambdaInterface } from '@aws-lambda-powertools/commons'; -import { Context, Callback } from 'aws-lambda'; - -import { Metrics, MetricUnits, MetricResolution } from '../../src/'; - -const MAX_METRICS_SIZE = 100; -const MAX_DIMENSION_COUNT = 29; -const DEFAULT_NAMESPACE = 'default_namespace'; - -const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); +import { + LambdaInterface, + ContextExamples as dummyContext, + Events as dummyEvent +} from '@aws-lambda-powertools/commons'; +import { MetricResolution, MetricUnits, Metrics } from '../../src/'; +import { Context, Handler } from 'aws-lambda'; +import { Dimensions, EmfOutput, MetricsOptions } from '../../src/types'; +import { COLD_START_METRIC, DEFAULT_NAMESPACE, MAX_DIMENSION_COUNT, MAX_METRICS_SIZE } from '../../src/constants'; +import { setupDecoratorLambdaHandler } from '../helpers/metricsUtils'; +import { ConfigServiceInterface, EnvironmentVariablesService } from '../../src/config'; + +const mockDate = new Date(1466424490000); +const dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate); interface LooseObject { [key: string]: string } describe('Class: Metrics', () => { + const ENVIRONMENT_VARIABLES = process.env; + const TEST_NAMESPACE = 'test'; const context = dummyContext.helloworldContext; const event = dummyEvent.Custom.CustomEvent; beforeEach(() => { jest.clearAllMocks(); - }); - - beforeAll(() => { + jest.resetModules(); + dateSpy.mockClear(); process.env = { ...ENVIRONMENT_VARIABLES }; }); - describe('Feature: Dimensions logging', () => { - test('should log service dimension correctly when passed', () => { - const serviceName = 'testing_name'; - - const metrics = new Metrics({ namespace: 'test', serviceName: serviceName }); - metrics.addMetric('test_name', MetricUnits.Seconds, 14); - const loggedData = metrics.serializeMetrics(); - - expect(loggedData.service).toEqual(serviceName); - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0].length).toEqual(1); - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0][0]).toEqual('service'); - }); - - test('should log service dimension correctly from env var when not passed', () => { - const serviceName = 'hello-world-service'; - process.env.POWERTOOLS_SERVICE_NAME = serviceName; + describe('Method: constructor', () => { - const metrics = new Metrics({ namespace: 'test' }); - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - const loggedData = metrics.serializeMetrics(); + test('when no constructor parameters are set, creates instance with the options set in the environment variables', () => { - expect(loggedData.service).toEqual(serviceName); - }); + // Prepare + const metricsOptions = undefined; - test('Additional dimensions should be added correctly', () => { - const additionalDimension = { name: 'metric2', value: 'metric2Value' }; - const metrics = new Metrics({ namespace: 'test' }); + // Act + const metrics: Metrics = new Metrics(metricsOptions); - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - metrics.addDimension(additionalDimension.name, additionalDimension.value); - const loggedData = metrics.serializeMetrics(); + // Assess + expect(metrics).toEqual(expect.objectContaining({ + coldStart: true, + customConfigService: undefined, + defaultDimensions: { + service: 'service_undefined', + }, + defaultServiceName: 'service_undefined', + dimensions: {}, + envVarsService: expect.any(EnvironmentVariablesService), + isSingleMetric: false, + metadata: {}, + namespace: 'hello-world', + shouldThrowOnEmptyMetrics: false, + storedMetrics: {} + })); - // Expect the additional dimension, and the service dimension - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0].length).toEqual(2); - expect(loggedData[additionalDimension.name]).toEqual(additionalDimension.value); }); + + test('when no constructor parameters and no environment variables are set, creates instance with the default properties', () => { - test('Publish Stored Metrics should clear added dimensions', async () => { - const metrics = new Metrics({ namespace: 'test' }); - const dimensionItem = { name: 'dimensionName', value: 'dimensionValue' }; - - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics() - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - metrics.addMetric('test_name_1', MetricUnits.Count, 1); - metrics.addDimension(dimensionItem.name, dimensionItem.value); - metrics.publishStoredMetrics(); - } - } + // Prepare + const metricsOptions = undefined; + process.env = {}; - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); - const loggedData = [ JSON.parse(consoleSpy.mock.calls[0][0]), JSON.parse(consoleSpy.mock.calls[1][0]) ]; + // Act + const metrics: Metrics = new Metrics(metricsOptions); - expect(console.log).toBeCalledTimes(2); - expect(loggedData[0][dimensionItem.name]).toEqual(dimensionItem.value); - // Expect the additional dimension, and the service dimension - expect(loggedData[0]._aws.CloudWatchMetrics[0].Dimensions[0].length).toEqual(2); - expect(loggedData[1][dimensionItem.name]).toBeUndefined(); - // Expect just the service dimension - expect(loggedData[1]._aws.CloudWatchMetrics[0].Dimensions[0].length).toEqual(1); - }); + // Assess + expect(metrics).toEqual(expect.objectContaining({ + coldStart: true, + customConfigService: undefined, + defaultDimensions: { + service: 'service_undefined', + }, + defaultServiceName: 'service_undefined', + dimensions: {}, + envVarsService: expect.any(EnvironmentVariablesService), + isSingleMetric: false, + metadata: {}, + namespace: '', + shouldThrowOnEmptyMetrics: false, + storedMetrics: {} + })); - test('Adding more than max dimensions should throw error', () => { - expect.assertions(1); - const metrics = new Metrics(); - // The service dimension is already set, so start from 1 - for (let x = 1; x < MAX_DIMENSION_COUNT; x++) { - metrics.addDimension(`Dimension-${x}`, `value-${x}`); - } - try { - metrics.addDimension(`Dimension-${MAX_DIMENSION_COUNT}`, `value-${MAX_DIMENSION_COUNT}`); - } catch (e) { - expect((e).message).toBe(`The number of metric dimensions must be lower than ${MAX_DIMENSION_COUNT}`); - } }); + + test('when constructor parameters are set, creates instance with the options set in the constructor parameters', () => { - test('Additional bulk dimensions should be added correctly', () => { - const additionalDimensions: LooseObject = { dimension2: 'dimension2Value', dimension3: 'dimension3Value' }; - const metrics = new Metrics({ namespace: 'test' }); + // Prepare + const metricsOptions: MetricsOptions = { + customConfigService: new EnvironmentVariablesService(), + namespace: TEST_NAMESPACE, + serviceName: 'test-service', + singleMetric: true, + defaultDimensions: { + service: 'order', + }, + }; - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - metrics.addDimensions(additionalDimensions); - const loggedData = metrics.serializeMetrics(); + // Act + const metrics: Metrics = new Metrics(metricsOptions); - // Expect the additional dimensions, and the service dimension - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0].length).toEqual(3); - Object.keys(additionalDimensions).forEach((key) => { - expect(loggedData[key]).toEqual(additionalDimensions[key]); - }); + // Assess + expect(metrics).toEqual(expect.objectContaining({ + coldStart: true, + customConfigService: expect.any(EnvironmentVariablesService), + defaultDimensions: metricsOptions.defaultDimensions, + defaultServiceName: 'service_undefined', + dimensions: {}, + envVarsService: expect.any(EnvironmentVariablesService), + isSingleMetric: true, + metadata: {}, + namespace: metricsOptions.namespace, + shouldThrowOnEmptyMetrics: false, + storedMetrics: {} + })); }); - - test('Bulk Adding more than max dimensions should throw error', () => { - expect.assertions(1); - const metrics = new Metrics(); - const additionalDimensions: LooseObject = {}; - - metrics.addDimension('Dimension-Initial', 'Dimension-InitialValue'); - for (let x = 0; x < MAX_DIMENSION_COUNT; x++) { - additionalDimensions[`dimension${x}`] = `dimension${x}Value`; - } - - try { - metrics.addDimensions(additionalDimensions); - } catch (e) { - expect((e).message).toBe( - `Unable to add ${ - Object.keys(additionalDimensions).length - } dimensions: the number of metric dimensions must be lower than ${MAX_DIMENSION_COUNT}`, - ); - } + + test('when custom namespace is passed, creates instance with the correct properties', () => { + + // Prepare + const metricsOptions: MetricsOptions = { + namespace: TEST_NAMESPACE, + }; + + // Act + const metrics: Metrics = new Metrics(metricsOptions); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + coldStart: true, + customConfigService: undefined, + defaultDimensions: { + service: 'service_undefined', + }, + defaultServiceName: 'service_undefined', + dimensions: {}, + envVarsService: expect.any(EnvironmentVariablesService), + isSingleMetric: false, + metadata: {}, + namespace: metricsOptions.namespace, + shouldThrowOnEmptyMetrics: false, + storedMetrics: {} + })); + }); - }); - - describe('Feature: Metadata', () => { - test('Metadata should be added correctly', () => { - const metadataItem = { name: 'metaName', value: 'metaValue' }; - - const metrics = new Metrics({ namespace: 'test' }); - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - metrics.addMetadata(metadataItem.name, metadataItem.value); - - const loggedData = metrics.serializeMetrics(); - metrics.clearMetadata(); - const postClearLoggedData = metrics.serializeMetrics(); - expect(loggedData[metadataItem.name]).toEqual(metadataItem.value); - expect(postClearLoggedData[metadataItem.name]).toBeUndefined(); + test('when custom defaultDimensions is passed, creates instance with the correct properties', () => { + + // Prepare + const metricsOptions: MetricsOptions = { + defaultDimensions: { + service: 'order', + }, + }; + + // Act + const metrics: Metrics = new Metrics(metricsOptions); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + coldStart: true, + customConfigService: undefined, + defaultDimensions: metricsOptions.defaultDimensions, + defaultServiceName: 'service_undefined', + dimensions: {}, + envVarsService: expect.any(EnvironmentVariablesService), + isSingleMetric: false, + metadata: {}, + namespace: 'hello-world', + shouldThrowOnEmptyMetrics: false, + storedMetrics: {} + })); + }); - test('Publish Stored Metrics should clear metadata', async () => { - const metrics = new Metrics({ namespace: 'test' }); - const metadataItem = { name: 'metaName', value: 'metaValue' }; + test('when singleMetric is passed, creates instance with the correct properties', () => { + + // Prepare + const metricsOptions: MetricsOptions = { + singleMetric: true, + }; + + // Act + const metrics: Metrics = new Metrics(metricsOptions); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + coldStart: true, + customConfigService: undefined, + defaultDimensions: { + service: 'service_undefined', + }, + defaultServiceName: 'service_undefined', + dimensions: {}, + envVarsService: expect.any(EnvironmentVariablesService), + isSingleMetric: true, + metadata: {}, + namespace: 'hello-world', + shouldThrowOnEmptyMetrics: false, + storedMetrics: {} + })); + + }); - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics() - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - metrics.addMetric('test_name_1', MetricUnits.Count, 1); - metrics.addMetadata(metadataItem.name, metadataItem.value); - metrics.publishStoredMetrics(); + test('when custom customConfigService is passed, creates instance with the correct properties', () => { + + // Prepare + const configService: ConfigServiceInterface = { + get(name: string): string { + return `a-string-from-${name}`; + }, + getNamespace(): string{ + return TEST_NAMESPACE; + }, + getServiceName(): string{ + return 'test-service'; } - } - - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); - const loggedData = [ JSON.parse(consoleSpy.mock.calls[0][0]), JSON.parse(consoleSpy.mock.calls[1][0]) ]; + }; + const metricsOptions: MetricsOptions = { + customConfigService: configService, + }; + + // Act + const metrics: Metrics = new Metrics(metricsOptions); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + coldStart: true, + customConfigService: configService, + defaultDimensions: { + service: 'test-service' + }, + defaultServiceName: 'service_undefined', + dimensions: {}, + envVarsService: expect.any(EnvironmentVariablesService), + isSingleMetric: false, + metadata: {}, + namespace: TEST_NAMESPACE, + shouldThrowOnEmptyMetrics: false, + storedMetrics: {} + })); + + }); - expect(console.log).toBeCalledTimes(2); - expect(loggedData[0][metadataItem.name]).toEqual(metadataItem.value); - expect(loggedData[1][metadataItem.name]).toBeUndefined(); + test('when custom serviceName is passed, creates instance with the correct properties', () => { + + // Prepare + const metricsOptions: MetricsOptions = { + serviceName: 'test-service', + }; + + // Act + const metrics: Metrics = new Metrics(metricsOptions); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + coldStart: true, + customConfigService: undefined, + defaultDimensions: { + service: 'test-service' + }, + defaultServiceName: 'service_undefined', + dimensions: {}, + envVarsService: expect.any(EnvironmentVariablesService), + isSingleMetric: false, + metadata: {}, + namespace: 'hello-world', + shouldThrowOnEmptyMetrics: false, + storedMetrics: {} + })); + }); + }); - describe('Feature: Default Dimensions', () => { - test('Adding more than max default dimensions should throw error', async () => { - expect.assertions(1); + describe('Method: addDimension', () => { + + test('when called, it should store dimensions', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const dimensionName = 'test-dimension'; + const dimensionValue= 'test-value'; + + // Act + metrics.addDimension(dimensionName, dimensionValue); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + dimensions: { + [dimensionName]: dimensionValue + }, + })); + + }); - const defaultDimensions: LooseObject = {}; - for (let x = 0; x < MAX_DIMENSION_COUNT + 1; x++) { - defaultDimensions[`dimension-${x}`] = `value-${x}`; - } + test('it should update existing dimension value if same dimension is added again', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const dimensionName = 'test-dimension'; + + // Act + metrics.addDimension(dimensionName, 'test-value-1'); + metrics.addDimension(dimensionName, 'test-value-2'); - const metrics = new Metrics({ namespace: 'test' }); - - try { - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics({ defaultDimensions: defaultDimensions }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - return; - } + // Assess + expect(metrics).toEqual(expect.objectContaining({ + dimensions: { + [dimensionName]: 'test-value-2' } + })); - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); - } catch (e) { - expect((e).message).toBe('Max dimension count hit'); - } }); - test('Clearing dimensions should only remove added dimensions, not default', async () => { - const metrics = new Metrics({ namespace: 'test' }); - const additionalDimension = { name: 'metric2', value: 'metric2Value' }; - - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics({ defaultDimensions: { default: 'defaultValue' } }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - metrics.addDimension(additionalDimension.name, additionalDimension.value); - const loggedData = metrics.serializeMetrics(); - // Expect the additional dimensions, and the service dimension - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0].length).toEqual(3); - expect(loggedData[additionalDimension.name]).toEqual(additionalDimension.value); - metrics.clearDimensions(); + test('it should throw error if the number of dimensions exceeds the maximum allowed', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const dimensionName = 'test-dimension'; + const dimensionValue = 'test-value'; + + // Act & Assess + // Starts from 1 because the service dimension is already added by default + expect(() => { + for (let i = 1; i < MAX_DIMENSION_COUNT; i++) { + metrics.addDimension(`${dimensionName}-${i}`, `${dimensionValue}-${i}`); } - } - - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); - const loggedData = JSON.parse(consoleSpy.mock.calls[0][0]); - - expect(console.log).toBeCalledTimes(1); - // Expect the additional dimension, and the service dimension - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0].length).toEqual(2); - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0]).toContain('default'); - expect(loggedData.default).toContain('defaultValue'); + }).not.toThrowError(); + expect(Object.keys(metrics['defaultDimensions']).length).toBe(1); + expect(Object.keys(metrics['dimensions']).length).toBe(MAX_DIMENSION_COUNT - 1); + expect(() => metrics.addDimension('another-dimension', 'another-dimension-value')) + .toThrowError(`The number of metric dimensions must be lower than ${MAX_DIMENSION_COUNT}`); + }); - test('Clearing default dimensions should only remove default dimensions, not added', async () => { - const metrics = new Metrics({ namespace: 'test' }); - const additionalDimension = { name: 'metric2', value: 'metric2Value' }; - - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics({ defaultDimensions: { default: 'defaultValue' } }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - metrics.addDimension(additionalDimension.name, additionalDimension.value); - metrics.clearDefaultDimensions(); + test('it should take consideration of defaultDimensions while throwing error if number of dimensions exceeds the maximum allowed', () => { + + // Prepare + const defaultDimensions: LooseObject = { + 'environment': 'dev', + 'foo': 'bar' + }; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE, defaultDimensions }); + const dimensionName = 'test-dimension'; + const dimensionValue = 'test-value'; + + // Act & Assess + // Starts from 3 because three default dimensions are already set (service, environment, foo) + expect(() => { + for (let i = 3; i < MAX_DIMENSION_COUNT; i++) { + metrics.addDimension(`${dimensionName}-${i}`, `${dimensionValue}-${i}`); } - } + }).not.toThrowError(); + expect(Object.keys(metrics['defaultDimensions']).length).toBe(3); + expect(Object.keys(metrics['dimensions']).length).toBe(MAX_DIMENSION_COUNT - 3); + expect(() => metrics.addDimension('another-dimension', 'another-dimension-value')) + .toThrowError(`The number of metric dimensions must be lower than ${MAX_DIMENSION_COUNT}`); - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); - const loggedData = JSON.parse(consoleSpy.mock.calls[0][0]); - - expect(console.log).toBeCalledTimes(1); - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0].length).toEqual(1); - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0]).toContain(additionalDimension.name); - expect(loggedData[additionalDimension.name]).toContain(additionalDimension.value); }); - }); - describe('Feature: Cold Start', () => { - test('Cold start metric should only be written out once and flushed automatically', async () => { - const metrics = new Metrics({ namespace: 'test' }); + }); - const handler = async (_event: unknown, _context: Context): Promise => { - // Should generate only one log - metrics.captureColdStartMetric(); + describe('Method: addDimensions', () => { + + test('it should add multiple dimensions', () => { + + // Prepare + const dimensionsToBeAdded: LooseObject = { + 'test-dimension-1': 'test-value-1', + 'test-dimension-2': 'test-value-2', }; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); - await handler(event, context); - await handler(event, context); - const loggedData = [JSON.parse(consoleSpy.mock.calls[0][0])]; + // Act + metrics.addDimensions(dimensionsToBeAdded); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + dimensions: dimensionsToBeAdded + })); - expect(console.log).toBeCalledTimes(1); - expect(loggedData[0]._aws.CloudWatchMetrics[0].Metrics.length).toBe(1); - expect(loggedData[0]._aws.CloudWatchMetrics[0].Metrics[0].Name).toBe('ColdStart'); - expect(loggedData[0]._aws.CloudWatchMetrics[0].Metrics[0].Unit).toBe('Count'); - expect(loggedData[0].ColdStart).toBe(1); }); - test('Cold start metric should only be written out once', async () => { - const metrics = new Metrics({ namespace: 'test' }); + test('it should update existing dimension value if same dimension is added again', () => { + + // Prepare + const dimensionsToBeAdded: LooseObject = { + 'test-dimension-1': 'test-value-1', + 'test-dimension-2': 'test-value-2', + }; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics({ captureColdStartMetric: true }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - } - } + // Act + metrics.addDimensions(dimensionsToBeAdded); + metrics.addDimensions({ 'test-dimension-1': 'test-value-3' }); - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked again!')); - const loggedData = [ JSON.parse(consoleSpy.mock.calls[0][0]), JSON.parse(consoleSpy.mock.calls[1][0]) ]; + // Assess + expect(metrics).toEqual(expect.objectContaining({ + dimensions: { + 'test-dimension-1': 'test-value-3', + 'test-dimension-2': 'test-value-2', + } + })); - expect(console.log).toBeCalledTimes(3); - expect(loggedData[0]._aws.CloudWatchMetrics[0].Metrics.length).toBe(1); - expect(loggedData[0]._aws.CloudWatchMetrics[0].Metrics[0].Name).toBe('ColdStart'); - expect(loggedData[0]._aws.CloudWatchMetrics[0].Metrics[0].Unit).toBe('Count'); - expect(loggedData[0].ColdStart).toBe(1); }); - test('Cold should have service and function name if present', async () => { - const serviceName = 'test-service'; - const metrics = new Metrics({ namespace: 'test', serviceName: serviceName }); - - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics({ captureColdStartMetric: true }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - } + test('it should successfully add up to maximum allowed dimensions without throwing error', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const dimensionName = 'test-dimension'; + const dimensionValue = 'test-value'; + const dimensionsToBeAdded: LooseObject = {}; + for (let i = 0; i < MAX_DIMENSION_COUNT; i++) { + dimensionsToBeAdded[`${dimensionName}-${i}`] = `${dimensionValue}-${i}`; } - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); - const loggedData = JSON.parse(consoleSpy.mock.calls[0][0]); - - expect(console.log).toBeCalledTimes(2); - expect(loggedData._aws.CloudWatchMetrics[0].Metrics.length).toBe(1); - expect(loggedData._aws.CloudWatchMetrics[0].Metrics[0].Name).toBe('ColdStart'); - expect(loggedData._aws.CloudWatchMetrics[0].Metrics[0].Unit).toBe('Count'); - expect(loggedData.service).toBe(serviceName); - expect(loggedData.function_name).toBe(context.functionName); - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0]).toContain('service'); - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0]).toContain('function_name'); - expect(loggedData.ColdStart).toBe(1); + + // Act & Assess + expect(() => metrics.addDimensions(dimensionsToBeAdded)).not.toThrowError(); + expect(Object.keys(metrics['dimensions']).length).toBe(MAX_DIMENSION_COUNT); + }); - test('Cold should still log, without a function name', async () => { - const serviceName = 'test-service'; - const metrics = new Metrics({ namespace: 'test', serviceName: serviceName }); - const newContext = JSON.parse(JSON.stringify(context)); - delete newContext.functionName; - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics({ captureColdStartMetric: true }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - } + test('it should throw error if number of dimensions exceeds the maximum allowed', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const dimensionName = 'test-dimension'; + const dimensionValue = 'test-value'; + const dimensionsToBeAdded: LooseObject = {}; + for (let i = 0; i < MAX_DIMENSION_COUNT; i++) { + dimensionsToBeAdded[`${dimensionName}-${i}`] = `${dimensionValue}-${i}`; } - - await new LambdaFunction().handler(event, newContext, () => console.log('Lambda invoked!')); - const loggedData = JSON.parse(consoleSpy.mock.calls[0][0]); - - expect(console.log).toBeCalledTimes(2); - expect(loggedData._aws.CloudWatchMetrics[0].Metrics.length).toBe(1); - expect(loggedData._aws.CloudWatchMetrics[0].Metrics[0].Name).toBe('ColdStart'); - expect(loggedData._aws.CloudWatchMetrics[0].Metrics[0].Unit).toBe('Count'); - expect(loggedData.service).toBe(serviceName); - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0]).toContain('service'); - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0]).not.toContain('function_name'); - expect(loggedData.ColdStart).toBe(1); + + // Act & Assess + expect(() => metrics.addDimensions(dimensionsToBeAdded)).not.toThrowError(); + expect(Object.keys(metrics['dimensions']).length).toBe(MAX_DIMENSION_COUNT); + expect(() => + metrics.addDimensions({ 'another-dimension': 'another-dimension-value' }) + ).toThrowError(`Unable to add 1 dimensions: the number of metric dimensions must be lower than ${MAX_DIMENSION_COUNT}`); + }); + }); - describe('Feature: throwOnEmptyMetrics', () => { - test('Error should be thrown on empty metrics when throwOnEmptyMetrics is passed', async () => { - expect.assertions(1); + describe('Method: addMetadata', () => { - const metrics = new Metrics({ namespace: 'test' }); - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics({ throwOnEmptyMetrics: true }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - return; - } - } + test('it should add metadata', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + + // Act + metrics.addMetadata('foo', 'bar'); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + metadata: { 'foo': 'bar' } + })); + + }); - try { - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); - } catch (e) { - expect((e).message).toBe('The number of metrics recorded must be higher than zero'); - } + test('it should update existing metadata value if same metadata is added again', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + + // Act + metrics.addMetadata('foo', 'bar'); + metrics.addMetadata('foo', 'baz'); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + metadata: { 'foo': 'baz' } + })); + }); + }); - test('Error should not be thrown on empty metrics if throwOnEmptyMetrics is set to false', async () => { - expect.assertions(1); + describe('Method: addMetric', () => { + + test('it should store metrics when called', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const metricName = 'test-metric'; - const metrics = new Metrics({ namespace: 'test' }); - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics({ throwOnEmptyMetrics: false }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - return; - } - } + // Act + metrics.addMetric(metricName, MetricUnits.Count, 1, MetricResolution.High); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + storedMetrics: { + [metricName]: { + name: metricName, + resolution: MetricResolution.High, + unit: MetricUnits.Count, + value: 1 + } + }, + })); - try { - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); - } catch (e) { - fail(`Should not throw but got the following Error: ${e}`); - } - const loggedData = JSON.parse(consoleSpy.mock.calls[0][0]); - expect(loggedData._aws.CloudWatchMetrics[0].Metrics.length).toBe(0); }); - test('Error should be thrown on empty metrics when throwOnEmptyMetrics() is called', async () => { - expect.assertions(1); + test('it should store multiple metrics when called with multiple metric name', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); - const metrics = new Metrics({ namespace: 'test' }); - const handler = async (_event: unknown, _context: Context): Promise => { - metrics.throwOnEmptyMetrics(); - // Logic goes here - metrics.publishStoredMetrics(); - }; + // Act + metrics.addMetric('test-metric-1', MetricUnits.Count, 1, MetricResolution.High); + metrics.addMetric('test-metric-2', MetricUnits.Count, 3, MetricResolution.High); + metrics.addMetric('test-metric-3', MetricUnits.Count, 6, MetricResolution.High); - try { - await handler(event, context); - } catch (e) { - expect((e).message).toBe('The number of metrics recorded must be higher than zero'); - } + // Assess + expect(metrics).toEqual(expect.objectContaining({ + storedMetrics: { + 'test-metric-1': { + name: 'test-metric-1', + resolution: MetricResolution.High, + unit: MetricUnits.Count, + value: 1 + }, + 'test-metric-2': { + name: 'test-metric-2', + resolution: MetricResolution.High, + unit: MetricUnits.Count, + value: 3 + }, + 'test-metric-3': { + name: 'test-metric-3', + resolution: MetricResolution.High, + unit: MetricUnits.Count, + value: 6 + } + }, + })); + }); - test('when decorator is used with throwOnEmptyMetrics set to false, a warning should be logged', async () => { - + test('it should store metrics with standard resolution when called without resolution', () => { + // Prepare - const metrics = new Metrics({ namespace: 'test' }); - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics({ throwOnEmptyMetrics: false }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public async handler( - _event: TEvent, - _context: Context, - ): Promise { - return; - } - } - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); // Act - await new LambdaFunction().handler(event, context); + metrics.addMetric('test-metric-1', MetricUnits.Count, 1); + metrics.addMetric('test-metric-2', MetricUnits.Seconds, 3); // Assess - expect(consoleWarnSpy).toBeCalledTimes(1); - expect(consoleWarnSpy).toBeCalledWith( - '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(metrics).toEqual(expect.objectContaining({ + storedMetrics: { + 'test-metric-1': { + name: 'test-metric-1', + resolution: MetricResolution.Standard, + unit: MetricUnits.Count, + value: 1 + }, + 'test-metric-2': { + name: 'test-metric-2', + resolution: MetricResolution.Standard, + unit: MetricUnits.Seconds, + value: 3 + } + }, + })); }); - test('when decorator is used with throwOnEmptyMetrics set to false & captureColdStartMetric set to true, a warning should be logged', async () => { + test('it should group the metric values together in an array when trying to add same metric with different values', () => { // Prepare - const metrics = new Metrics({ namespace: 'test' }); - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics({ - throwOnEmptyMetrics: false, - captureColdStartMetric: true - }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public async handler( - _event: TEvent, - _context: Context, - ): Promise { - return; - } - } - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const metricName = 'test-metric'; // Act - await new LambdaFunction().handler(event, context); - + metrics.addMetric(metricName, MetricUnits.Count, 1); + metrics.addMetric(metricName, MetricUnits.Count, 5); + metrics.addMetric(metricName, MetricUnits.Count, 1); + metrics.addMetric(metricName, MetricUnits.Count, 4); + // Assess - expect(consoleWarnSpy).toBeCalledTimes(1); - expect(consoleWarnSpy).toBeCalledWith( - 'No application metrics to publish. The cold-start metric may be published if enabled. If application metrics should never be empty, consider using \'throwOnEmptyMetrics\'', - ); - - }); - }); - - describe('Feature: Auto log at limit', () => { - test('Logger should write out block when limit is reached', async () => { - const metrics = new Metrics({ namespace: 'test' }); - const extraCount = 10; - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics() - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - for (let x = 0; x < MAX_METRICS_SIZE + extraCount; x++) { - metrics.addMetric(`test_name_${x}`, MetricUnits.Count, x); + expect(metrics).toEqual(expect.objectContaining({ + storedMetrics: { + [metricName]: { + name: metricName, + resolution: MetricResolution.Standard, + unit: MetricUnits.Count, + value: [ 1, 5, 1, 4 ] } - } - } + }, + })); - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); - const loggedData = [ JSON.parse(consoleSpy.mock.calls[0][0]), JSON.parse(consoleSpy.mock.calls[1][0]) ]; - - expect(console.log).toBeCalledTimes(2); - expect(loggedData[0]._aws.CloudWatchMetrics[0].Metrics.length).toBe(100); - expect(loggedData[1]._aws.CloudWatchMetrics[0].Metrics.length).toBe(extraCount); }); - }); - describe('Feature: Output validation ', () => { + test('it should throw an error when trying to add same metric with different unit', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const metricName = 'test-metric'; - beforeEach(() => { - consoleSpy.mockClear(); - }); + // Act & Assess + expect(() => { + metrics.addMetric(metricName, MetricUnits.Count, 1); + metrics.addMetric(metricName, MetricUnits.Kilobits, 5); + }).toThrowError(`Metric "${metricName}" has already been added with unit "${MetricUnits.Count}", but we received unit "${MetricUnits.Kilobits}". Did you mean to use metric unit "${MetricUnits.Count}"?`); - test('Should use default namespace if no namespace is set', () => { - delete process.env.POWERTOOLS_METRICS_NAMESPACE; - const metrics = new Metrics(); + }); - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - const serializedMetrics = metrics.serializeMetrics(); + test('it should publish metrics if stored metrics count has already reached max metric size threshold & then store remaining metric', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const publishStoredMetricsSpy = jest.spyOn(metrics, 'publishStoredMetrics'); + const metricName = 'test-metric'; + + // Act & Assess + expect(() => { + for (let i = 0; i < MAX_METRICS_SIZE; i++) { + metrics.addMetric(`${metricName}-${i}`, MetricUnits.Count, i); + } + }).not.toThrowError(); + expect(Object.keys(metrics['storedMetrics']).length).toEqual(MAX_METRICS_SIZE); + metrics.addMetric('another-metric', MetricUnits.Count, MAX_METRICS_SIZE + 1); + expect(publishStoredMetricsSpy).toHaveBeenCalledTimes(1); + expect(metrics).toEqual(expect.objectContaining({ + storedMetrics: { + 'another-metric': { + name: 'another-metric', + resolution: MetricResolution.Standard, + unit: MetricUnits.Count, + value: MAX_METRICS_SIZE + 1 + } + }, + })); - expect(serializedMetrics._aws.CloudWatchMetrics[0].Namespace).toBe(DEFAULT_NAMESPACE); - expect(console.warn).toHaveBeenNthCalledWith(1, 'Namespace should be defined, default used'); }); - test('Should contain a metric value if added once', ()=> { - const metrics = new Metrics(); - - metrics.addMetric('test_name', MetricUnits.Count, 1); - const serializedMetrics = metrics.serializeMetrics(); + test('it should not publish metrics if stored metrics count has not reached max metric size threshold', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const publishStoredMetricsSpy = jest.spyOn(metrics, 'publishStoredMetrics'); + const metricName = 'test-metric'; + + // Act & Assess + expect(() => { + for (let i = 0; i < MAX_METRICS_SIZE - 1; i++) { + metrics.addMetric(`${metricName}-${i}`, MetricUnits.Count, i); + } + }).not.toThrowError(); + expect(Object.keys(metrics['storedMetrics']).length).toEqual(MAX_METRICS_SIZE - 1); + metrics.addMetric('another-metric', MetricUnits.Count, MAX_METRICS_SIZE); + expect(publishStoredMetricsSpy).toHaveBeenCalledTimes(0); + expect(Object.keys(metrics['storedMetrics']).length).toEqual(MAX_METRICS_SIZE); - expect(serializedMetrics._aws.CloudWatchMetrics[0].Metrics.length).toBe(1); - - expect(serializedMetrics['test_name']).toBe(1); }); - test('Should convert multiple metrics with the same name and unit into an array', ()=> { - const metrics = new Metrics(); - - metrics.addMetric('test_name', MetricUnits.Count, 2); - metrics.addMetric('test_name', MetricUnits.Count, 1); - const serializedMetrics = metrics.serializeMetrics(); + test('it should publish metrics on every call if singleMetric is set to true', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE, singleMetric: true }); + const publishStoredMetricsSpy = jest.spyOn(metrics, 'publishStoredMetrics'); + + // Act + metrics.addMetric('test-metric-1', MetricUnits.Count, 1); + metrics.addMetric('test-metric-2', MetricUnits.Bits, 100); + + // Assess + expect(publishStoredMetricsSpy).toHaveBeenCalledTimes(2); - expect(serializedMetrics._aws.CloudWatchMetrics[0].Metrics.length).toBe(1); - expect(serializedMetrics['test_name']).toStrictEqual([ 2, 1 ]); }); - test('Should throw an error if the same metric name is added again with a different unit', ()=> { - expect.assertions(1); - const metrics = new Metrics(); - - metrics.addMetric('test_name', MetricUnits.Count, 2); - try { - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - } catch (e) { - expect((e).message).toBe('Metric "test_name" has already been added with unit "Count", but we received unit "Seconds". Did you mean to use metric unit "Count"?'); - } + test('it should not publish metrics on every call if singleMetric is set to false', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE, singleMetric: false }); + const publishStoredMetricsSpy = jest.spyOn(metrics, 'publishStoredMetrics'); + + // Act + metrics.addMetric('test-metric-1', MetricUnits.Count, 1); + metrics.addMetric('test-metric-2', MetricUnits.Bits, 100); + + // Assess + expect(publishStoredMetricsSpy).toHaveBeenCalledTimes(0); + }); - test('Should contain multiple metric values if added with multiple names', ()=> { - const metrics = new Metrics(); - - metrics.addMetric('test_name', MetricUnits.Count, 1); - metrics.addMetric('test_name2', MetricUnits.Count, 2); - const serializedMetrics = metrics.serializeMetrics(); + test('it should not publish metrics on every call if singleMetric is not provided', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const publishStoredMetricsSpy = jest.spyOn(metrics, 'publishStoredMetrics'); + + // Act + metrics.addMetric('test-metric-1', MetricUnits.Count, 1); + metrics.addMetric('test-metric-2', MetricUnits.Bits, 100); + + // Assess + expect(publishStoredMetricsSpy).toHaveBeenCalledTimes(0); - expect(serializedMetrics._aws.CloudWatchMetrics[0].Metrics).toStrictEqual([ - { Name: 'test_name', Unit: 'Count' }, - { Name: 'test_name2', Unit: 'Count' }, - ]); + }); + + }); + + describe('Methods: captureColdStartMetric', () => { - expect(serializedMetrics['test_name']).toBe(1); - expect(serializedMetrics['test_name2']).toBe(2); + test('it should call addMetric with correct parameters', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const singleMetricMock: Metrics = new Metrics({ namespace: TEST_NAMESPACE, singleMetric: true }); + const singleMetricSpy = jest.spyOn(metrics, 'singleMetric').mockImplementation(() => singleMetricMock); + const addMetricSpy = jest.spyOn(singleMetricMock, 'addMetric'); + + // Act + metrics.captureColdStartMetric(); + + // Assess + expect(singleMetricSpy).toBeCalledTimes(1); + expect(addMetricSpy).toBeCalledTimes(1); + expect(addMetricSpy).toBeCalledWith(COLD_START_METRIC, MetricUnits.Count, 1); + }); + + test('it should call setDefaultDimensions with correct parameters', () => { + + // Prepare + const defaultDimensions: Dimensions = { + 'foo': 'bar', + 'service': 'order' + }; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE, defaultDimensions }); + const singleMetricMock: Metrics = new Metrics({ namespace: TEST_NAMESPACE, singleMetric: true }); + const singleMetricSpy = jest.spyOn(metrics, 'singleMetric').mockImplementation(() => singleMetricMock); + const setDefaultDimensionsSpy = jest.spyOn(singleMetricMock, 'setDefaultDimensions'); + + // Act + metrics.captureColdStartMetric(); + + // Assess + expect(singleMetricSpy).toBeCalledTimes(1); + expect(setDefaultDimensionsSpy).toBeCalledTimes(1); + expect(setDefaultDimensionsSpy).toBeCalledWith({ service: defaultDimensions.service }); + + }); + + test('it should call setDefaultDimensions with correct parameters when defaultDimensions are not set', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const singleMetricMock: Metrics = new Metrics({ namespace: TEST_NAMESPACE, singleMetric: true }); + const singleMetricSpy = jest.spyOn(metrics, 'singleMetric').mockImplementation(() => singleMetricMock); + const setDefaultDimensionsSpy = jest.spyOn(singleMetricMock, 'setDefaultDimensions'); + + // Act + metrics.captureColdStartMetric(); + + // Assess + expect(singleMetricSpy).toBeCalledTimes(1); + expect(setDefaultDimensionsSpy).toBeCalledTimes(1); + expect(setDefaultDimensionsSpy).toBeCalledWith({ service: 'service_undefined' }); + + }); + + test('it should call addDimension, if functionName is set', () => { + + // Prepare + const functionName = 'coldStart'; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + metrics.setFunctionName(functionName); + const singleMetricMock: Metrics = new Metrics({ namespace: TEST_NAMESPACE, singleMetric: true }); + const singleMetricSpy = jest.spyOn(metrics, 'singleMetric').mockImplementation(() => singleMetricMock); + const addDimensionSpy = jest.spyOn(singleMetricMock, 'addDimension'); + + // Act + metrics.captureColdStartMetric(); + + // Assess + expect(singleMetricSpy).toBeCalledTimes(1); + expect(addDimensionSpy).toBeCalledTimes(1); + expect(addDimensionSpy).toBeCalledWith('function_name', functionName); + + }); + + test('it should not call addDimension, if functionName is not set', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const singleMetricMock: Metrics = new Metrics({ namespace: TEST_NAMESPACE, singleMetric: true }); + const singleMetricSpy = jest.spyOn(metrics, 'singleMetric').mockImplementation(() => singleMetricMock); + const addDimensionSpy = jest.spyOn(singleMetricMock, 'addDimension'); + + // Act + metrics.captureColdStartMetric(); + + // Assess + expect(singleMetricSpy).toBeCalledTimes(1); + expect(addDimensionSpy).toBeCalledTimes(0); + + }); + + test('it should not call any function, if there is no cold start', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + jest.spyOn(metrics, 'isColdStart').mockImplementation(() => false); + + const singleMetricMock: Metrics = new Metrics({ namespace: TEST_NAMESPACE, singleMetric: true }); + const singleMetricSpy = jest.spyOn(metrics, 'singleMetric').mockImplementation(() => singleMetricMock); + const addMetricSpy = jest.spyOn(singleMetricMock, 'addMetric'); + const setDefaultDimensionsSpy = jest.spyOn(singleMetricMock, 'setDefaultDimensions'); + const addDimensionSpy = jest.spyOn(singleMetricMock, 'addDimension'); + + // Act + metrics.captureColdStartMetric(); + + // Assess + expect(singleMetricSpy).toBeCalledTimes(0); + expect(setDefaultDimensionsSpy).toBeCalledTimes(0); + expect(addDimensionSpy).toBeCalledTimes(0); + expect(addMetricSpy).toBeCalledTimes(0); + + }); + }); - describe('Feature: Resolution of Metrics', ()=>{ + describe('Method: clearDefaultDimensions', () => { + + test('it should clear all default dimensions', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + metrics.setDefaultDimensions({ 'foo': 'bar' }); + + // Act + metrics.clearDefaultDimensions(); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + defaultDimensions: {} + })); + + }); - test('serialized metrics in EMF format should not contain `StorageResolution` as key if none is set', () => { - const metrics = new Metrics(); - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - const serializedMetrics = metrics.serializeMetrics(); + test('it should only clear default dimensions', () => { - expect(Object.keys(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0])).not.toContain('StorageResolution'); - expect(Object.keys(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0])).toContain('Name'); - expect(Object.keys(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0])).toContain('Unit'); + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + metrics.setDefaultDimensions({ 'foo': 'bar' }); + metrics.addDimension('environment', 'dev'); + // Act + metrics.clearDefaultDimensions(); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + defaultDimensions: {}, + dimensions: { + 'environment': 'dev' + } + })); + }); - test('serialized metrics in EMF format should not contain `StorageResolution` as key if `Standard` is set', () => { - const metrics = new Metrics(); - metrics.addMetric('test_name', MetricUnits.Seconds, 10, MetricResolution.Standard); - const serializedMetrics = metrics.serializeMetrics(); + + }); - // expect(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0].StorageResolution).toBe(MetricResolution.Standard); - // expect(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0].StorageResolution).toBe(60); + describe('Method: clearDimensions', () => { + + test('it should clear all dimensions', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + metrics.addDimension('foo', 'bar'); + + // Act + metrics.clearDimensions(); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + dimensions: {} + })); + + }); - expect(Object.keys(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0])).not.toContain('StorageResolution'); - expect(Object.keys(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0])).toContain('Name'); - expect(Object.keys(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0])).toContain('Unit'); + test('it should only clear dimensions', () => { + + // Prepare + const metrics: Metrics = new Metrics({ defaultDimensions: { 'environment': 'dev' } }); + metrics.addDimension('foo', 'bar'); + + // Act + metrics.clearDimensions(); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + dimensions: {}, + defaultDimensions: { + 'environment': 'dev', + 'service': 'service_undefined' + } + })); + }); - test('serialized metrics in EMF format should not contain `StorageResolution` as key if `60` is set',()=>{ - const metrics = new Metrics(); - metrics.addMetric('test_name', MetricUnits.Seconds, 10, 60); - const serializedMetrics = metrics.serializeMetrics(); + }); - expect(Object.keys(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0])).not.toContain('StorageResolution'); - expect(Object.keys(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0])).toContain('Name'); - expect(Object.keys(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0])).toContain('Unit'); + describe('Method: clearMetadata', () => { + + test('it should clear all metadata', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + metrics.addMetadata('foo', 'bar'); + metrics.addMetadata('test', 'baz'); + + // Act + metrics.clearMetadata(); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + metadata: {} + })); + }); - test('Should be StorageResolution `1` if MetricResolution is set to `High`',()=>{ - const metrics = new Metrics(); - metrics.addMetric('test_name', MetricUnits.Seconds, 10, MetricResolution.High); - const serializedMetrics = metrics.serializeMetrics(); + }); - expect(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0].StorageResolution).toBe(MetricResolution.High); - expect(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0].StorageResolution).toBe(1); + describe('Method: clearMetrics', () => { + + test('it should clear stored metrics', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const metricName = 'test-metric'; + + // Act + metrics.addMetric(metricName, MetricUnits.Count, 1); + metrics.clearMetrics(); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + storedMetrics: {}, + })); + + }); + + }); + + describe('Method: logMetrics', () => { + + let metrics: Metrics; + let publishStoredMetricsSpy: jest.SpyInstance; + let addMetricSpy: jest.SpyInstance; + let captureColdStartMetricSpy: jest.SpyInstance; + let throwOnEmptyMetricsSpy: jest.SpyInstance; + let setDefaultDimensionsSpy: jest.SpyInstance; + const decoratorLambdaExpectedReturnValue = 'Lambda invoked!'; + const decoratorLambdaMetric= 'decorator-lambda-test-metric'; + + beforeEach(() => { + metrics = new Metrics({ namespace: TEST_NAMESPACE }); + publishStoredMetricsSpy = jest.spyOn(metrics, 'publishStoredMetrics'); + addMetricSpy = jest.spyOn(metrics, 'addMetric'); + captureColdStartMetricSpy = jest.spyOn(metrics, 'captureColdStartMetric'); + throwOnEmptyMetricsSpy = jest.spyOn(metrics, 'throwOnEmptyMetrics'); + setDefaultDimensionsSpy = jest.spyOn(metrics, 'setDefaultDimensions'); }); - test('Should be StorageResolution `1` if MetricResolution is set to `1`',()=>{ - const metrics = new Metrics(); - metrics.addMetric('test_name', MetricUnits.Seconds, 10, 1); - const serializedMetrics = metrics.serializeMetrics(); + test('it should execute lambda function & publish stored metrics', async () => { + + // Prepare + const handler: Handler = setupDecoratorLambdaHandler(metrics); + + // Act + const actualResult = await handler(event, context, () => console.log('callback')); + + // Assess + expect(actualResult).toEqual(decoratorLambdaExpectedReturnValue); + expect(addMetricSpy).toHaveBeenNthCalledWith(1, decoratorLambdaMetric, MetricUnits.Count, 1); + expect(publishStoredMetricsSpy).toBeCalledTimes(1); + expect(captureColdStartMetricSpy).not.toBeCalled(); + expect(throwOnEmptyMetricsSpy).not.toBeCalled(); + expect(setDefaultDimensionsSpy).not.toBeCalled(); - expect(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0].StorageResolution).toBe(MetricResolution.High); - expect(serializedMetrics._aws.CloudWatchMetrics[0].Metrics[0].StorageResolution).toBe(1); - }); - }); - describe('Feature: Clearing Metrics ', () => { - test('Clearing metrics should return empty', async () => { - const metrics = new Metrics({ namespace: 'test' }); - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics({ defaultDimensions: { default: 'defaultValue' } }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - const loggedData = metrics.serializeMetrics(); - metrics.clearMetrics(); - const afterClearingLoggedData = metrics.serializeMetrics(); - - expect(loggedData._aws.CloudWatchMetrics[0].Metrics.length).toEqual(1); - expect(afterClearingLoggedData._aws.CloudWatchMetrics[0].Metrics.length).toEqual(0); - } - } + test('it should capture cold start metrics, if passed in the options as true', async () => { + + // Prepare + const handler: Handler = setupDecoratorLambdaHandler(metrics, { captureColdStartMetric: true }); - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); + // Act + const actualResult = await handler(event, context, () => console.log('callback')); + + // Assess + expect(actualResult).toEqual(decoratorLambdaExpectedReturnValue); + expect(addMetricSpy).toHaveBeenNthCalledWith(1, decoratorLambdaMetric, MetricUnits.Count, 1); + expect(captureColdStartMetricSpy).toBeCalledTimes(1); + expect(publishStoredMetricsSpy).toBeCalledTimes(1); + expect(throwOnEmptyMetricsSpy).not.toBeCalled(); + expect(setDefaultDimensionsSpy).not.toBeCalled(); + + }); + + test('it should call throwOnEmptyMetrics, if passed in the options as true', async () => { + + // Prepare + const handler: Handler = setupDecoratorLambdaHandler(metrics, { throwOnEmptyMetrics: true }); + + // Act + const actualResult = await handler(event, context, () => console.log('callback')); + + // Assess + expect(actualResult).toEqual(decoratorLambdaExpectedReturnValue); + expect(addMetricSpy).toHaveBeenNthCalledWith(1, decoratorLambdaMetric, MetricUnits.Count, 1); + expect(throwOnEmptyMetricsSpy).toBeCalledTimes(1); + expect(publishStoredMetricsSpy).toBeCalledTimes(1); + expect(captureColdStartMetricSpy).not.toBeCalled(); + expect(setDefaultDimensionsSpy).not.toBeCalled(); + }); - test('Publish Stored Metrics should log and clear', async () => { - const metrics = new Metrics({ namespace: 'test' }); + test('it should set default dimensions if passed in the options as true', async () => { + + // Prepare + const defaultDimensions = { + 'foo': 'bar', + 'service': 'order' + }; + const handler: Handler = setupDecoratorLambdaHandler(metrics, { defaultDimensions }); + + // Act + const actualResult = await handler(event, context, () => console.log('callback')); + + // Assess + expect(actualResult).toEqual(decoratorLambdaExpectedReturnValue); + expect(addMetricSpy).toHaveBeenNthCalledWith(1, decoratorLambdaMetric, MetricUnits.Count, 1); + expect(setDefaultDimensionsSpy).toHaveBeenNthCalledWith(1, defaultDimensions); + expect(publishStoredMetricsSpy).toBeCalledTimes(1); + expect(throwOnEmptyMetricsSpy).not.toBeCalled(); + expect(captureColdStartMetricSpy).not.toBeCalled(); + + }); + + test('it should throw error if lambda handler throws any error', async () => { + + // Prepare + const errorMessage = 'Unexpected error occurred!'; class LambdaFunction implements LambdaInterface { + @metrics.logMetrics() // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - metrics.addMetric('test_name_1', MetricUnits.Count, 1); - metrics.publishStoredMetrics(); + public async handler(_event: TEvent, _context: Context): Promise { + throw new Error(errorMessage); } + } + const handlerClass = new LambdaFunction(); + const handler = handlerClass.handler.bind(handlerClass); + + // Act & Assess + await expect(handler(event, context)).rejects.toThrowError(errorMessage); + + }); - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); - const loggedData = [ JSON.parse(consoleSpy.mock.calls[0][0]), JSON.parse(consoleSpy.mock.calls[1][0]) ]; + }); - expect(console.log).toBeCalledTimes(2); - expect(loggedData[0]._aws.CloudWatchMetrics[0].Metrics.length).toBe(1); - expect(loggedData[1]._aws.CloudWatchMetrics[0].Metrics.length).toBe(0); + 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(); + + // Act + metrics.publishStoredMetrics(); + + // Assess + expect(consoleWarnSpy).toBeCalledTimes(1); + expect(consoleWarnSpy).toBeCalledWith( + 'No application metrics to publish. The cold-start metric may be published if enabled. If application metrics should never be empty, consider using \'throwOnEmptyMetrics\'', + ); + }); - test('Using decorator, it returns a function with the correct scope of the decorated class', async () => { + test('it should call serializeMetrics && log the stringified return value of serializeMetrics', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + metrics.addMetric('test-metric', MetricUnits.Count, 10); + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + const mockData: EmfOutput = { + '_aws': { + 'Timestamp': mockDate.getTime(), + 'CloudWatchMetrics': [ + { + 'Namespace': 'test', + 'Dimensions': [ + [ + 'service' + ] + ], + 'Metrics': [ + { + 'Name': 'test-metric', + 'Unit': MetricUnits.Count + } + ] + } + ] + }, + 'service': 'service_undefined', + 'test-metric': 10 + }; + const serializeMetricsSpy = jest.spyOn(metrics, 'serializeMetrics').mockImplementation(() => mockData); + + // Act + metrics.publishStoredMetrics(); + + // Assess + expect(serializeMetricsSpy).toBeCalledTimes(1); + expect(consoleLogSpy).toBeCalledTimes(1); + expect(consoleLogSpy).toBeCalledWith(JSON.stringify(mockData)); + + }); + test('it should call clearMetrics function', () => { + // Prepare - const metrics = new Metrics({ namespace: 'test' }); - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics({ defaultDimensions: { default: 'defaultValue' } }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public async handler( - _event: TEvent, - _context: Context): Promise { - this.myMethod(); + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + metrics.addMetric('test-metric', MetricUnits.Count, 10); + const clearMetricsSpy = jest.spyOn(metrics, 'clearMetrics'); + + // Act + metrics.publishStoredMetrics(); + + // Assess + expect(clearMetricsSpy).toBeCalledTimes(1); + + }); + + test('it should call clearDimensions function', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + metrics.addMetric('test-metric', MetricUnits.Count, 10); + const clearDimensionsSpy = jest.spyOn(metrics, 'clearDimensions'); + + // Act + metrics.publishStoredMetrics(); + + // Assess + expect(clearDimensionsSpy).toBeCalledTimes(1); + + }); + + test('it should call clearMetadata function', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + metrics.addMetric('test-metric', MetricUnits.Count, 10); + const clearMetadataSpy = jest.spyOn(metrics, 'clearMetadata'); + + // Act + metrics.publishStoredMetrics(); + + // Assess + expect(clearMetadataSpy).toBeCalledTimes(1); + + }); + + }); - return 'Lambda invoked!'; + describe('Method: serializeMetrics', () => { + + const defaultServiceName = 'service_undefined'; + + 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(); + + // Act + metrics.serializeMetrics(); + + // Assess + expect(consoleWarnSpy).toBeCalledWith('Namespace should be defined, default used'); + + }); + + test('it should return right object compliant with Cloudwatch EMF', () => { + + // Prepare + const metrics: Metrics = new Metrics({ + namespace: TEST_NAMESPACE, + serviceName: 'test-service', + defaultDimensions: { + 'environment': 'dev' } + }); - private myMethod(): void { - metrics.addMetric('test_name', MetricUnits.Seconds, 10); + // Act + metrics.addMetric('successfulBooking', MetricUnits.Count, 1); + metrics.addMetric('successfulBooking', MetricUnits.Count, 3); + metrics.addMetric('failedBooking', MetricUnits.Count, 1, MetricResolution.High); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(loggedData).toEqual( + { + '_aws': { + 'Timestamp': mockDate.getTime(), + 'CloudWatchMetrics': [ + { + 'Namespace': TEST_NAMESPACE, + 'Dimensions': [ + [ + 'service', + 'environment' + ] + ], + 'Metrics': [ + { + 'Name': 'successfulBooking', + 'Unit': MetricUnits.Count + }, + { + 'Name': 'failedBooking', + 'Unit': MetricUnits.Count, + 'StorageResolution': 1 + } + ] + } + ] + }, + 'environment': 'dev', + 'service': 'test-service', + 'successfulBooking': [ 1, 3 ], + 'failedBooking': 1 } - } + ); + }); + + test('it should log service dimension correctly when passed', () => { + + // Prepare + const serviceName = 'test-service'; + const testMetric = 'test-metric'; + const metrics: Metrics = new Metrics({ serviceName: serviceName, namespace: TEST_NAMESPACE }); // Act - await new LambdaFunction().handler(event, context); + metrics.addMetric(testMetric, MetricUnits.Count, 10); + const loggedData = metrics.serializeMetrics(); // Assess - expect(console.log).toBeCalledTimes(1); + expect(loggedData.service).toEqual(serviceName); + expect(loggedData).toEqual({ + '_aws': { + 'CloudWatchMetrics': [ + { + 'Dimensions': [ + [ + 'service' + ] + ], + 'Metrics': [ + { + 'Name': testMetric, + 'Unit': MetricUnits.Count + } + ], + 'Namespace': TEST_NAMESPACE + } + ], + 'Timestamp': mockDate.getTime() + }, + 'service': serviceName, + [testMetric]: 10 + }); }); - test('Using decorator on async handler (without callback) should work fine', async () => { - const metrics = new Metrics({ namespace: 'test' }); - const additionalDimension = { name: 'metric2', value: 'metric2Value' }; + test('it should log service dimension correctly using environment variable when not specified in constructor', () => { - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics({ defaultDimensions: { default: 'defaultValue' } }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public async handler( - _event: TEvent, - _context: Context): Promise { - metrics.addMetric('test_name', MetricUnits.Seconds, 10); - metrics.addDimension(additionalDimension.name, additionalDimension.value); - const loggedData = metrics.serializeMetrics(); - // Expect the additional dimensions, and the service dimension - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0].length).toEqual(3); - expect(loggedData[additionalDimension.name]).toEqual(additionalDimension.value); - metrics.clearDimensions(); - - return 'Lambda invoked!'; - } - } + // Prepare + const serviceName = 'hello-world-service'; + process.env.POWERTOOLS_SERVICE_NAME = serviceName; + const testMetric = 'test-metric'; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); - await new LambdaFunction().handler(event, context); - const loggedData = JSON.parse(consoleSpy.mock.calls[0][0]); + // Act + metrics.addMetric(testMetric, MetricUnits.Count, 10); + const loggedData = metrics.serializeMetrics(); - expect(console.log).toBeCalledTimes(1); - // Expect the additional dimension, and the service dimension + // Assess + expect(loggedData.service).toEqual(serviceName); + expect(loggedData).toEqual({ + '_aws': { + 'CloudWatchMetrics': [ + { + 'Dimensions': [ + [ + 'service' + ] + ], + 'Metrics': [ + { + 'Name': testMetric, + 'Unit': MetricUnits.Count + } + ], + 'Namespace': TEST_NAMESPACE + } + ], + 'Timestamp': mockDate.getTime() + }, + 'service': serviceName, + [testMetric]: 10 + }); + + }); + + test('it should log default dimensions correctly', () => { + + // Prepare + const additionalDimensions = { + 'foo': 'bar', + 'env': 'dev' + }; + const testMetric = 'test-metric'; + const metrics: Metrics = new Metrics({ defaultDimensions: additionalDimensions, namespace: TEST_NAMESPACE }); + + // Act + metrics.addMetric(testMetric, MetricUnits.Count, 10); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0].length).toEqual(3); + expect(loggedData.service).toEqual(defaultServiceName); + expect(loggedData.foo).toEqual(additionalDimensions.foo); + expect(loggedData.env).toEqual(additionalDimensions.env); + expect(loggedData).toEqual({ + '_aws': { + 'CloudWatchMetrics': [ + { + 'Dimensions': [ + [ + 'service', + 'foo', + 'env' + ] + ], + 'Metrics': [ + { + 'Name': testMetric, + 'Unit': MetricUnits.Count + } + ], + 'Namespace': TEST_NAMESPACE + } + ], + 'Timestamp': mockDate.getTime() + }, + 'service': 'service_undefined', + [testMetric]: 10, + 'env': 'dev', + 'foo': 'bar', + }); + + }); + + test('it should log additional dimensions correctly', () => { + + // Prepare + const testMetric = 'test-metric'; + const additionalDimension = { name: 'metric2', value: 'metric2Value' }; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + + // Act + metrics.addMetric('test-metric', MetricUnits.Count, 10, MetricResolution.High); + metrics.addDimension(additionalDimension.name, additionalDimension.value); + const loggedData = metrics.serializeMetrics(); + + // Assess expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0].length).toEqual(2); - expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0]).toContain('default'); - expect(loggedData.default).toContain('defaultValue'); + expect(loggedData.service).toEqual(defaultServiceName); + expect(loggedData[additionalDimension.name]).toEqual(additionalDimension.value); + expect(loggedData).toEqual({ + '_aws': { + 'CloudWatchMetrics': [ + { + 'Dimensions': [ + [ + 'service', + 'metric2' + ] + ], + 'Metrics': [ + { + 'Name': testMetric, + 'StorageResolution': 1, + 'Unit': MetricUnits.Count + } + ], + 'Namespace': TEST_NAMESPACE + } + ], + 'Timestamp': mockDate.getTime() + }, + 'service': 'service_undefined', + [testMetric]: 10, + 'metric2': 'metric2Value' + }); + }); - test('Using decorator should log even if exception thrown', async () => { - const metrics = new Metrics({ namespace: 'test' }); - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics() - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - metrics.addMetric('test_name_1', MetricUnits.Count, 1); - throw new Error('Test Error'); - } - } + test('it should log additional bulk dimensions correctly', () => { + + // Prepare + const testMetric = 'test-metric'; + const additionalDimensions: LooseObject = { + metric2: 'metric2Value', + metric3: 'metric3Value' + }; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + + // Act + metrics.addMetric(testMetric, MetricUnits.Count, 10, MetricResolution.High); + metrics.addDimensions(additionalDimensions); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(loggedData._aws.CloudWatchMetrics[0].Dimensions[0].length).toEqual(3); + expect(loggedData.service).toEqual(defaultServiceName); + Object.keys(additionalDimensions).forEach((key) => { + expect(loggedData[key]).toEqual(additionalDimensions[key]); + }); + expect(loggedData).toEqual({ + '_aws': { + 'CloudWatchMetrics': [ + { + 'Dimensions': [ + [ + 'service', + 'metric2', + 'metric3', + ] + ], + 'Metrics': [ + { + 'Name': testMetric, + 'StorageResolution': 1, + 'Unit': MetricUnits.Count + } + ], + 'Namespace': TEST_NAMESPACE + } + ], + 'Timestamp': mockDate.getTime() + }, + 'service': 'service_undefined', + [testMetric]: 10, + 'metric2': 'metric2Value', + 'metric3': 'metric3Value' + }); + + }); - try { - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); - } catch (error) { - // DO NOTHING - } + test('it should log metadata correctly', () => { + + // Prepare + const testMetric = 'test-metric'; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + + // Act + metrics.addMetric(testMetric, MetricUnits.Count, 10); + metrics.addMetadata('foo', 'bar'); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(loggedData.foo).toEqual('bar'); + expect(loggedData).toEqual({ + '_aws': { + 'CloudWatchMetrics': [ + { + 'Dimensions': [ + [ + 'service' + ] + ], + 'Metrics': [ + { + 'Name': testMetric, + 'Unit': MetricUnits.Count + } + ], + 'Namespace': TEST_NAMESPACE + } + ], + 'Timestamp': mockDate.getTime() + }, + 'service': 'service_undefined', + [testMetric]: 10, + 'foo': 'bar' + }); + + }); + + test('it should throw error on empty metrics when throwOnEmptyMetrics is true', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + + // Act + metrics.throwOnEmptyMetrics(); - expect(console.log).toBeCalledTimes(1); + // Assess + expect(() => metrics.serializeMetrics()).toThrow('The number of metrics recorded must be higher than zero'); + }); - test('Using decorator should preserve `this` in decorated class', async () => { + test('it should use the default namespace when no namespace is provided in constructor or found in environment variable', () => { + // Prepare - const metrics = new Metrics({ namespace: 'test' }); + process.env.POWERTOOLS_METRICS_NAMESPACE = ''; + const testMetric = 'test-metric'; + const metrics: Metrics = new Metrics(); + + // Act + metrics.addMetric(testMetric, MetricUnits.Count, 10); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(loggedData._aws.CloudWatchMetrics[0].Namespace).toEqual(DEFAULT_NAMESPACE); + expect(loggedData).toEqual({ + '_aws': { + 'CloudWatchMetrics': [ + { + 'Dimensions': [ + [ + 'service' + ] + ], + 'Metrics': [ + { + 'Name': testMetric, + 'Unit': MetricUnits.Count + } + ], + 'Namespace': DEFAULT_NAMESPACE + } + ], + 'Timestamp': mockDate.getTime() + }, + 'service': 'service_undefined', + [testMetric]: 10 + }); + + }); + test('it should use namespace provided in constructor', () => { + + // Prepare + const testMetric = 'test-metric'; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + // Act - class LambdaFunction implements LambdaInterface { - @metrics.logMetrics() - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - public handler( - _event: TEvent, - _context: Context, - _callback: Callback, - ): void | Promise { - this.dummyMethod(); + metrics.addMetric(testMetric, MetricUnits.Count, 10); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(loggedData._aws.CloudWatchMetrics[0].Namespace).toEqual(TEST_NAMESPACE); + expect(loggedData).toEqual({ + '_aws': { + 'CloudWatchMetrics': [ + { + 'Dimensions': [ + [ + 'service' + ] + ], + 'Metrics': [ + { + 'Name': testMetric, + 'Unit': MetricUnits.Count + } + ], + 'Namespace': TEST_NAMESPACE + } + ], + 'Timestamp': mockDate.getTime() + }, + 'service': 'service_undefined', + [testMetric]: 10 + }); + + }); + + test('it should contain a metric value if added once', () => { + + // Prepare + const metricName = 'test-metric'; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + + // Act + metrics.addMetric(metricName, MetricUnits.Count, 10); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(loggedData._aws.CloudWatchMetrics[0].Metrics.length).toBe(1); + expect(loggedData[metricName]).toEqual(10); + expect(loggedData).toEqual({ + '_aws': { + 'CloudWatchMetrics': [ + { + 'Dimensions': [ + [ + 'service' + ] + ], + 'Metrics': [ + { + 'Name': metricName, + 'Unit': MetricUnits.Count + } + ], + 'Namespace': TEST_NAMESPACE + } + ], + 'Timestamp': mockDate.getTime() + }, + 'service': 'service_undefined', + [metricName]: 10 + }); + + }); + + test('it should convert metric value with the same name and unit to array if added multiple times', () => { + + // Prepare + const metricName = 'test-metric'; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + + // Act + metrics.addMetric(metricName, MetricUnits.Count, 10); + metrics.addMetric(metricName, MetricUnits.Count, 20); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(loggedData._aws.CloudWatchMetrics[0].Metrics.length).toBe(1); + expect(loggedData[metricName]).toEqual([ 10, 20 ]); + expect(loggedData).toEqual({ + '_aws': { + 'CloudWatchMetrics': [ + { + 'Dimensions': [ + [ + 'service' + ] + ], + 'Metrics': [ + { + 'Name': metricName, + 'Unit': MetricUnits.Count + } + ], + 'Namespace': TEST_NAMESPACE + } + ], + 'Timestamp': mockDate.getTime() + }, + 'service': 'service_undefined', + [metricName]: [ 10, 20 ] + }); + + }); + + test('it should create multiple metric values if added multiple times', () => { + + // Prepare + const metricName1 = 'test-metric-1'; + const metricName2 = 'test-metric-2'; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + + // Act + metrics.addMetric(metricName1, MetricUnits.Count, 10); + metrics.addMetric(metricName2, MetricUnits.Seconds, 20); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(loggedData._aws.CloudWatchMetrics[0].Metrics.length).toBe(2); + expect(loggedData[metricName1]).toEqual(10); + expect(loggedData[metricName2]).toEqual(20); + expect(loggedData).toEqual({ + '_aws': { + 'CloudWatchMetrics': [ + { + 'Dimensions': [ + [ + 'service' + ] + ], + 'Metrics': [ + { + 'Name': metricName1, + 'Unit': MetricUnits.Count + }, + { + 'Name': metricName2, + 'Unit': MetricUnits.Seconds + } + ], + 'Namespace': TEST_NAMESPACE + } + ], + 'Timestamp': mockDate.getTime() + }, + 'service': 'service_undefined', + [metricName1]: 10, + [metricName2]: 20 + }); + + }); + + test('it should not contain `StorageResolution` as key for non-high resolution metrics', () => { + + // Prepare + const metricName = 'test-metric'; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + + // Act + metrics.addMetric(metricName, MetricUnits.Count, 10); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(loggedData._aws.CloudWatchMetrics[0].Metrics.length).toBe(1); + expect(loggedData._aws.CloudWatchMetrics[0].Metrics[0].StorageResolution).toBeUndefined(); + expect(loggedData).toEqual({ + '_aws': { + 'CloudWatchMetrics': [ + { + 'Dimensions': [ + [ + 'service' + ] + ], + 'Metrics': [ + { + 'Name': metricName, + 'Unit': MetricUnits.Count + } + ], + 'Namespace': TEST_NAMESPACE + } + ], + 'Timestamp': mockDate.getTime() + }, + 'service': 'service_undefined', + [metricName]: 10 + }); + + }); + + test('it should contain `StorageResolution` as key & high metric resolution as value for high resolution metrics', () => { + + // Prepare + const metricName1 = 'test-metric'; + const metricName2 = 'test-metric-2'; + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + + // Act + metrics.addMetric(metricName1, MetricUnits.Count, 10); + metrics.addMetric(metricName2, MetricUnits.Seconds, 10, MetricResolution.High); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(loggedData._aws.CloudWatchMetrics[0].Metrics.length).toBe(2); + expect(loggedData._aws.CloudWatchMetrics[0].Metrics[0].StorageResolution).toBeUndefined(); + expect(loggedData._aws.CloudWatchMetrics[0].Metrics[1].StorageResolution).toEqual(MetricResolution.High); + expect(loggedData).toEqual({ + '_aws': { + 'CloudWatchMetrics': [ + { + 'Dimensions': [ + [ + 'service' + ] + ], + 'Metrics': [ + { + 'Name': metricName1, + 'Unit': MetricUnits.Count + }, + { + 'Name': metricName2, + 'StorageResolution': 1, + 'Unit': MetricUnits.Seconds + } + ], + 'Namespace': TEST_NAMESPACE + } + ], + 'Timestamp': mockDate.getTime() + }, + 'service': 'service_undefined', + [metricName1]: 10, + [metricName2]: 10 + }); + + }); + + }); + + describe('Method: setDefaultDimensions', () => { + + test('it should set default dimensions correctly when service name is provided', () => { + + // Prepare + const serviceName = 'test-service'; + const metrics: Metrics = new Metrics({ serviceName: serviceName }); + const defaultDimensionsToBeAdded = { + 'environment': 'dev', + 'foo': 'bar', + }; + + // Act + metrics.setDefaultDimensions(defaultDimensionsToBeAdded); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + defaultDimensions: { + ...defaultDimensionsToBeAdded, + service: serviceName + } + })); + + }); + + test('it should set default dimensions correctly when service name is not provided', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const defaultDimensionsToBeAdded = { + 'environment': 'dev', + 'foo': 'bar', + }; + + // Act + metrics.setDefaultDimensions(defaultDimensionsToBeAdded); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + defaultDimensions: { + ...defaultDimensionsToBeAdded, + service: 'service_undefined' + } + })); + + }); + + test('it should add default dimensions', () => { + + // Prepare + const serviceName = 'test-service'; + const metrics: Metrics = new Metrics({ + namespace: TEST_NAMESPACE, + serviceName, + defaultDimensions: { 'test-dimension': 'test-dimension-value' } + }); + const defaultDimensionsToBeAdded = { + 'environment': 'dev', + 'foo': 'bar', + }; + + // Act + metrics.setDefaultDimensions(defaultDimensionsToBeAdded); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + defaultDimensions: { + ...defaultDimensionsToBeAdded, + service: serviceName, + 'test-dimension': 'test-dimension-value' } + })); + + }); - private dummyMethod(): void { - metrics.addMetric('test_name', MetricUnits.Seconds, 1); + test('it should update already added default dimensions values', () => { + + // Prepare + const serviceName = 'test-service'; + const metrics: Metrics = new Metrics({ + namespace: TEST_NAMESPACE, + serviceName, + defaultDimensions: { + environment: 'dev' } + }); + const defaultDimensionsToBeAdded = { + 'environment': 'prod', + 'foo': 'bar', + }; + + // Act + metrics.setDefaultDimensions(defaultDimensionsToBeAdded); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + defaultDimensions: { + foo: 'bar', + service: serviceName, + environment: 'prod' + } + })); + + }); + + test('it should throw error if number of default dimensions reaches the maximum allowed', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + const dimensionName = 'test-dimension'; + const dimensionValue = 'test-value'; + const defaultDimensions: LooseObject = {}; + + // Starts from 1 because the service dimension is already added by default + for (let i = 1; i < MAX_DIMENSION_COUNT - 1; i++) { + defaultDimensions[`${dimensionName}-${i}`] = `${dimensionValue}-${i}`; + } + + // Act & Assess + expect(() => metrics.setDefaultDimensions(defaultDimensions)).not.toThrowError(); + expect(Object.keys(metrics['defaultDimensions']).length).toBe(MAX_DIMENSION_COUNT - 1); + expect(() => { + metrics.setDefaultDimensions({ 'another-dimension': 'another-dimension-value' }); + }).toThrowError('Max dimension count hit'); + + }); + + test('it should consider default dimensions provided in constructor, while throwing error if number of default dimensions reaches the maximum allowed', () => { + + // Prepare + const initialDefaultDimensions: LooseObject = { + 'test-dimension': 'test-value', + 'environment': 'dev' + }; + const metrics: Metrics = new Metrics({ + namespace: TEST_NAMESPACE, + defaultDimensions: initialDefaultDimensions + }); + const dimensionName = 'test-dimension'; + const dimensionValue = 'test-value'; + const defaultDimensions: LooseObject = {}; + + // Starts from 3 because the service dimension is already added by default & two dimensions are already added in the constructor + for (let i = 3; i < MAX_DIMENSION_COUNT - 1; i++) { + defaultDimensions[`${dimensionName}-${i}`] = `${dimensionValue}-${i}`; } - await new LambdaFunction().handler(event, context, () => console.log('Lambda invoked!')); - const loggedData = JSON.parse(consoleSpy.mock.calls[0][0]); + + // Act & Assess + expect(() => metrics.setDefaultDimensions(defaultDimensions)).not.toThrowError(); + expect(Object.keys(metrics['defaultDimensions']).length).toBe(MAX_DIMENSION_COUNT - 1); + expect(() => { + metrics.setDefaultDimensions({ 'another-dimension': 'another-dimension-value' }); + }).toThrowError('Max dimension count hit'); + + }); + + }); + + describe('Method: setFunctionName', () => { + + test('it should set the function name', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + + // Act + metrics.setFunctionName('test-function'); // Assess - expect(console.log).toBeCalledTimes(1); - expect(loggedData._aws.CloudWatchMetrics[0].Metrics.length).toEqual(1); - expect(loggedData._aws.CloudWatchMetrics[0].Metrics[0].Name).toEqual('test_name'); - expect(loggedData._aws.CloudWatchMetrics[0].Metrics[0].Unit).toEqual('Seconds'); + expect(metrics).toEqual(expect.objectContaining({ + functionName: 'test-function' + })); + }); + }); - describe('Feature: Custom Config Service', () => { - test('Custom Config Service should be called for service', () => { - const serviceName = 'Custom Provider Service Name'; - const namespace = 'Custom Provider namespace'; - const customConfigService = { - getServiceName: () => serviceName, - getNamespace: () => namespace, + describe('Method: singleMetric', () => { + + test('it should return a single Metric object', () => { + + // Prepare + const defaultDimensions = { + 'foo': 'bar', + 'service': 'order' }; + const metrics: Metrics = new Metrics({ + namespace: TEST_NAMESPACE, + defaultDimensions, + singleMetric: false + }); - const metrics = new Metrics({ customConfigService: customConfigService }); - const loggedData = metrics.serializeMetrics(); + // Act + const singleMetric = metrics.singleMetric(); + + //Asses + expect(singleMetric).toEqual(expect.objectContaining({ + isSingleMetric: true, + namespace: TEST_NAMESPACE, + defaultDimensions + })); - expect(loggedData.service).toEqual(serviceName); - expect(loggedData._aws.CloudWatchMetrics[0].Namespace).toEqual(namespace); }); + }); + + describe('Method: throwOnEmptyMetrics', () => { + + test('it should set the throwOnEmptyMetrics flag to true', () => { + + // Prepare + const metrics: Metrics = new Metrics({ namespace: TEST_NAMESPACE }); + + // Act + metrics.throwOnEmptyMetrics(); + + // Assess + expect(metrics).toEqual(expect.objectContaining({ + shouldThrowOnEmptyMetrics: true + })); + + }); + + }); + });