From c9d7a4dceab427cf3580ea7df04c97094f69e29b Mon Sep 17 00:00:00 2001 From: Matteo Figus Date: Fri, 30 May 2025 02:18:54 +0100 Subject: [PATCH 1/8] docs: update metrics documentation to explain addDimensions() behavior for issue #3777 --- docs/features/metrics.md | 12 +++++++++ examples/snippets/metrics/dimensionSets.ts | 30 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 examples/snippets/metrics/dimensionSets.ts diff --git a/docs/features/metrics.md b/docs/features/metrics.md index e47c6fc791..0af3209add 100644 --- a/docs/features/metrics.md +++ b/docs/features/metrics.md @@ -119,6 +119,18 @@ You can create metrics using the `addMetric` method, and you can create dimensio --8<-- "examples/snippets/metrics/customDimensions.ts" ``` +### Creating dimension sets + +You can create separate dimension sets for your metrics using the `addDimensions` method. This allows you to group metrics by different dimension combinations. + +When you call `addDimensions()`, it creates a new dimension set rather than adding to the existing dimensions. This is useful when you want to track the same metric across different dimension combinations. + +=== "handler.ts" + + ```typescript hl_lines="9 12-15 18-21" + --8<-- "examples/snippets/metrics/dimensionSets.ts" + ``` + !!! tip "Autocomplete Metric Units" Use the `MetricUnit` enum to easily find a supported metric unit by CloudWatch. Alternatively, you can pass the value as a string if you already know them e.g. "Count". diff --git a/examples/snippets/metrics/dimensionSets.ts b/examples/snippets/metrics/dimensionSets.ts new file mode 100644 index 0000000000..b1b1853951 --- /dev/null +++ b/examples/snippets/metrics/dimensionSets.ts @@ -0,0 +1,30 @@ +import { MetricUnit, Metrics } from '@aws-lambda-powertools/metrics'; + +const metrics = new Metrics({ + namespace: 'serverlessAirline', + serviceName: 'orders', +}); + +export const handler = async ( + _event: unknown, + _context: unknown +): Promise => { + // Add a single dimension + metrics.addDimension('environment', 'prod'); + + // Add a new dimension set + metrics.addDimensions({ + dimension1: '1', + dimension2: '2', + }); + + // Add another dimension set + metrics.addDimensions({ + region: 'us-east-1', + category: 'books', + }); + + // Add metrics + metrics.addMetric('successfulBooking', MetricUnit.Count, 1); + metrics.publishStoredMetrics(); +}; From 841f08258705d1fde0cccb7b141d4f4b65bd2ef6 Mon Sep 17 00:00:00 2001 From: Matteo Figus Date: Fri, 30 May 2025 02:21:26 +0100 Subject: [PATCH 2/8] fix: addDimensions() now creates a new dimension set (fixes #3777) --- packages/metrics/src/Metrics.ts | 54 ++++++-- packages/metrics/src/types/Metrics.ts | 2 +- .../metrics/tests/unit/dimensions.test.ts | 118 ++++++++++++++++++ 3 files changed, 164 insertions(+), 10 deletions(-) diff --git a/packages/metrics/src/Metrics.ts b/packages/metrics/src/Metrics.ts index a3a743e96d..f09911a216 100644 --- a/packages/metrics/src/Metrics.ts +++ b/packages/metrics/src/Metrics.ts @@ -154,6 +154,12 @@ class Metrics extends Utility implements MetricsInterface { */ private dimensions: Dimensions = {}; + /** + * Additional dimension sets for the current metrics context + * @default [] + */ + private dimensionSets: Dimensions[] = []; + /** * Service for accessing environment variables */ @@ -267,9 +273,15 @@ class Metrics extends Utility implements MetricsInterface { * @param dimensions - An object with key-value pairs of dimensions */ public addDimensions(dimensions: Dimensions): void { - for (const [name, value] of Object.entries(dimensions)) { - this.addDimension(name, value); + if ( + Object.keys(dimensions).length + this.getCurrentDimensionsCount() >= + MAX_DIMENSION_COUNT + ) { + throw new RangeError( + `The number of metric dimensions must be lower than ${MAX_DIMENSION_COUNT}` + ); } + this.dimensionSets.push(dimensions); } /** @@ -447,6 +459,7 @@ class Metrics extends Utility implements MetricsInterface { */ public clearDimensions(): void { this.dimensions = {}; + this.dimensionSets = []; } /** @@ -692,12 +705,34 @@ class Metrics extends Utility implements MetricsInterface { {} ); - const dimensionNames = [ - ...new Set([ - ...Object.keys(this.defaultDimensions), - ...Object.keys(this.dimensions), - ]), - ]; + const dimensionNames = []; + + if (Object.keys(this.dimensions).length > 0) { + dimensionNames.push([ + ...new Set([ + ...Object.keys(this.defaultDimensions), + ...Object.keys(this.dimensions), + ]), + ]); + } + + for (const dimensionSet of this.dimensionSets) { + dimensionNames.push([ + ...new Set([ + ...Object.keys(this.defaultDimensions), + ...Object.keys(dimensionSet), + ]), + ]); + } + + if ( + dimensionNames.length === 0 && + Object.keys(this.defaultDimensions).length > 0 + ) { + dimensionNames.push([ + ...new Set([...Object.keys(this.defaultDimensions)]), + ]); + } return { _aws: { @@ -705,13 +740,14 @@ class Metrics extends Utility implements MetricsInterface { CloudWatchMetrics: [ { Namespace: this.namespace || DEFAULT_NAMESPACE, - Dimensions: [dimensionNames], + Dimensions: dimensionNames as [string[]], Metrics: metricDefinitions, }, ], }, ...this.defaultDimensions, ...this.dimensions, + ...this.dimensionSets.reduce((acc, dims) => Object.assign(acc, dims), {}), ...metricValues, ...this.metadata, }; diff --git a/packages/metrics/src/types/Metrics.ts b/packages/metrics/src/types/Metrics.ts index ccd914351e..3ea7cf595e 100644 --- a/packages/metrics/src/types/Metrics.ts +++ b/packages/metrics/src/types/Metrics.ts @@ -190,8 +190,8 @@ interface MetricsInterface { * included in all metrics, use the {@link MetricsInterface.setDefaultDimensions | `setDefaultDimensions()`} method. * * @param dimensions - An object with key-value pairs of dimensions + * @returns The dimensions object that was passed in */ - addDimensions(dimensions: Dimensions): void; /** * A metadata key-value pair to be included with metrics. * diff --git a/packages/metrics/tests/unit/dimensions.test.ts b/packages/metrics/tests/unit/dimensions.test.ts index fd5fbdb7c3..6ff69172a5 100644 --- a/packages/metrics/tests/unit/dimensions.test.ts +++ b/packages/metrics/tests/unit/dimensions.test.ts @@ -14,6 +14,42 @@ describe('Working with dimensions', () => { vi.clearAllMocks(); }); + it('creates a new dimension set', () => { + // Prepare + const metrics = new Metrics({ + namespace: DEFAULT_NAMESPACE, + }); + + // Act + metrics.addDimension('environment', 'prod'); + + metrics.addDimensions({ + dimension1: '1', + dimension2: '2', + }); + + metrics.addMetric('foo', MetricUnit.Count, 1); + metrics.publishStoredMetrics(); + + // Assess + expect(console.log).toHaveEmittedEMFWith( + expect.objectContaining({ + environment: 'prod', + dimension1: '1', + dimension2: '2', + foo: 1, + }) + ); + expect(console.log).toHaveEmittedMetricWith( + expect.objectContaining({ + Dimensions: [ + ['service', 'environment'], + ['service', 'dimension1', 'dimension2'], + ], + }) + ); + }); + it('adds default dimensions to the metric via constructor', () => { // Prepare const metrics = new Metrics({ @@ -284,6 +320,88 @@ describe('Working with dimensions', () => { ); }); + it('throws when adding dimension sets would exceed the limit', () => { + // Prepare + const metrics = new Metrics({ + singleMetric: true, + defaultDimensions: { + environment: 'test', + }, + }); + + // Act & Assess + let i = 1; + // We start with 2 dimensions because the default dimension & service name are already added + for (i = 2; i < MAX_DIMENSION_COUNT - 2; i++) { + metrics.addDimension(`dimension-${i}`, 'test'); + } + + // Adding a dimension set with 3 dimensions would exceed the limit + expect(() => + metrics.addDimensions({ + 'dimension-extra-1': 'test', + 'dimension-extra-2': 'test', + 'dimension-extra-3': 'test', + }) + ).toThrowError( + `The number of metric dimensions must be lower than ${MAX_DIMENSION_COUNT}` + ); + }); + + it('handles dimension overrides across multiple dimension sets', () => { + // Prepare + const metrics = new Metrics({ + namespace: DEFAULT_NAMESPACE, + }); + + // Act + // First add a single dimension + metrics.addDimension('d', '3'); + + // First dimension set + metrics.addDimensions({ + a: '1', + b: '2', + }); + + // Second dimension set with some overlapping keys + metrics.addDimensions({ + a: '3', + c: '5', + d: '8', + }); + + // Third dimension set with more overlapping keys + metrics.addDimensions({ + b: '5', + d: '1', + }); + + metrics.addMetric('foo', MetricUnit.Count, 1); + metrics.publishStoredMetrics(); + + // Assess + expect(console.log).toHaveEmittedEMFWith( + expect.objectContaining({ + a: '3', // Last value from second set + b: '5', // Last value from third set + c: '5', // Only value from second set + d: '1', // Last value from third set (overriding both the initial d:3 and second set's d:8) + foo: 1, + }) + ); + expect(console.log).toHaveEmittedMetricWith( + expect.objectContaining({ + Dimensions: [ + ['service', 'd'], + ['service', 'a', 'b'], + ['service', 'a', 'c', 'd'], + ['service', 'b', 'd'], + ], + }) + ); + }); + it.each([ { value: undefined, name: 'undefined' }, { value: null, name: 'null' }, From 7dd85eac2b6b1a393495bc3b6a4c62349c661a8b Mon Sep 17 00:00:00 2001 From: Matteo Figus Date: Fri, 30 May 2025 02:26:54 +0100 Subject: [PATCH 3/8] fix: revert incorrect changes to Metrics interface --- packages/metrics/src/types/Metrics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metrics/src/types/Metrics.ts b/packages/metrics/src/types/Metrics.ts index 3ea7cf595e..ccd914351e 100644 --- a/packages/metrics/src/types/Metrics.ts +++ b/packages/metrics/src/types/Metrics.ts @@ -190,8 +190,8 @@ interface MetricsInterface { * included in all metrics, use the {@link MetricsInterface.setDefaultDimensions | `setDefaultDimensions()`} method. * * @param dimensions - An object with key-value pairs of dimensions - * @returns The dimensions object that was passed in */ + addDimensions(dimensions: Dimensions): void; /** * A metadata key-value pair to be included with metrics. * From 62e6f20b908ba50ae11a9c3b3a10189091fd7237 Mon Sep 17 00:00:00 2001 From: Matteo Figus Date: Fri, 30 May 2025 02:43:11 +0100 Subject: [PATCH 4/8] test: add test for default dimensions with addDimensions --- .../metrics/tests/unit/dimensions.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/metrics/tests/unit/dimensions.test.ts b/packages/metrics/tests/unit/dimensions.test.ts index 6ff69172a5..176ca9c50d 100644 --- a/packages/metrics/tests/unit/dimensions.test.ts +++ b/packages/metrics/tests/unit/dimensions.test.ts @@ -101,6 +101,45 @@ describe('Working with dimensions', () => { ); }); + it('handles dimension sets with default dimensions and overrides', () => { + // Prepare + const metrics = new Metrics({ + namespace: DEFAULT_NAMESPACE, + defaultDimensions: { + environment: 'prod', + region: 'us-east-1', + }, + }); + + // Act + // Add a dimension set that overrides one of the default dimensions + metrics.addDimensions({ + environment: 'staging', // This should override the default 'prod' value + feature: 'search', + }); + + metrics.addMetric('api_calls', MetricUnit.Count, 1); + metrics.publishStoredMetrics(); + + // Assess + expect(console.log).toHaveEmittedEMFWith( + expect.objectContaining({ + service: 'hello-world', + environment: 'staging', // Should use the overridden value + region: 'us-east-1', // Should keep the default value + feature: 'search', // Should add the new dimension + api_calls: 1, + }) + ); + expect(console.log).toHaveEmittedMetricWith( + expect.objectContaining({ + Dimensions: [ + ['service', 'environment', 'region', 'feature'], // Should include all dimensions + ], + }) + ); + }); + it('adds one dimension to the metric', () => { // Prepare const metrics = new Metrics({ From 2f38802fd1d12faba94cddeaf34dc9bc19fdbe4b Mon Sep 17 00:00:00 2001 From: Matteo Figus Date: Fri, 30 May 2025 03:02:26 +0100 Subject: [PATCH 5/8] perf(metrics): optimize dimension handling and reduce object creation --- packages/metrics/src/Metrics.ts | 44 ++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/metrics/src/Metrics.ts b/packages/metrics/src/Metrics.ts index f09911a216..2349c0cd81 100644 --- a/packages/metrics/src/Metrics.ts +++ b/packages/metrics/src/Metrics.ts @@ -273,8 +273,12 @@ class Metrics extends Utility implements MetricsInterface { * @param dimensions - An object with key-value pairs of dimensions */ public addDimensions(dimensions: Dimensions): void { + const newDimensionKeys = Object.keys(dimensions).filter( + (key) => !this.defaultDimensions[key] && !this.dimensions[key] + ); + if ( - Object.keys(dimensions).length + this.getCurrentDimensionsCount() >= + newDimensionKeys.length + this.getCurrentDimensionsCount() >= MAX_DIMENSION_COUNT ) { throw new RangeError( @@ -707,31 +711,28 @@ class Metrics extends Utility implements MetricsInterface { const dimensionNames = []; + const allDimensionKeys = new Set([ + ...Object.keys(this.defaultDimensions), + ...Object.keys(this.dimensions), + ]); + if (Object.keys(this.dimensions).length > 0) { - dimensionNames.push([ - ...new Set([ - ...Object.keys(this.defaultDimensions), - ...Object.keys(this.dimensions), - ]), - ]); + dimensionNames.push([...allDimensionKeys]); } for (const dimensionSet of this.dimensionSets) { - dimensionNames.push([ - ...new Set([ - ...Object.keys(this.defaultDimensions), - ...Object.keys(dimensionSet), - ]), + const dimensionSetKeys = new Set([ + ...Object.keys(this.defaultDimensions), + ...Object.keys(dimensionSet), ]); + dimensionNames.push([...dimensionSetKeys]); } if ( dimensionNames.length === 0 && Object.keys(this.defaultDimensions).length > 0 ) { - dimensionNames.push([ - ...new Set([...Object.keys(this.defaultDimensions)]), - ]); + dimensionNames.push([...Object.keys(this.defaultDimensions)]); } return { @@ -745,11 +746,14 @@ class Metrics extends Utility implements MetricsInterface { }, ], }, - ...this.defaultDimensions, - ...this.dimensions, - ...this.dimensionSets.reduce((acc, dims) => Object.assign(acc, dims), {}), - ...metricValues, - ...this.metadata, + ...Object.assign( + {}, + this.defaultDimensions, + this.dimensions, + this.dimensionSets.reduce((acc, dims) => Object.assign(acc, dims), {}), + metricValues, + this.metadata + ), }; } From fa51dd445ea4802694a2353e485650a677f4eb95 Mon Sep 17 00:00:00 2001 From: Matteo Figus Date: Tue, 1 Jul 2025 00:33:05 +0100 Subject: [PATCH 6/8] fix: address PR review comments - Align addDimensions() validation with addDimension() method - Add proper value validation and duplicate dimension warnings - Update getCurrentDimensionsCount() to include dimensionSets - Fix dimension count validation logic --- packages/metrics/src/Metrics.ts | 52 ++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/metrics/src/Metrics.ts b/packages/metrics/src/Metrics.ts index 2349c0cd81..c53116d2bc 100644 --- a/packages/metrics/src/Metrics.ts +++ b/packages/metrics/src/Metrics.ts @@ -273,19 +273,35 @@ class Metrics extends Utility implements MetricsInterface { * @param dimensions - An object with key-value pairs of dimensions */ public addDimensions(dimensions: Dimensions): void { - const newDimensionKeys = Object.keys(dimensions).filter( - (key) => !this.defaultDimensions[key] && !this.dimensions[key] - ); + const newDimensionSet: Dimensions = {}; + for (const [key, value] of Object.entries(dimensions)) { + if (!value) { + this.#logger.warn( + `The dimension ${key} doesn't meet the requirements and won't be added. Ensure the dimension name and value are non empty strings` + ); + continue; + } + if ( + Object.hasOwn(this.dimensions, key) || + Object.hasOwn(this.defaultDimensions, key) || + Object.hasOwn(newDimensionSet, key) + ) { + this.#logger.warn( + `Dimension "${key}" has already been added. The previous value will be overwritten.` + ); + } + newDimensionSet[key] = value; + } - if ( - newDimensionKeys.length + this.getCurrentDimensionsCount() >= - MAX_DIMENSION_COUNT - ) { + const currentCount = this.getCurrentDimensionsCount(); + const newSetCount = Object.keys(newDimensionSet).length; + if (currentCount + newSetCount >= MAX_DIMENSION_COUNT) { throw new RangeError( `The number of metric dimensions must be lower than ${MAX_DIMENSION_COUNT}` ); } - this.dimensionSets.push(dimensions); + + this.dimensionSets.push(newDimensionSet); } /** @@ -746,14 +762,11 @@ class Metrics extends Utility implements MetricsInterface { }, ], }, - ...Object.assign( - {}, - this.defaultDimensions, - this.dimensions, - this.dimensionSets.reduce((acc, dims) => Object.assign(acc, dims), {}), - metricValues, - this.metadata - ), + ...this.defaultDimensions, + ...this.dimensions, + ...this.dimensionSets.reduce((acc, dims) => Object.assign(acc, dims), {}), + ...metricValues, + ...this.metadata, }; } @@ -864,9 +877,14 @@ class Metrics extends Utility implements MetricsInterface { * Gets the current number of dimensions count. */ private getCurrentDimensionsCount(): number { + const dimensionSetsCount = this.dimensionSets.reduce( + (total, dimensionSet) => total + Object.keys(dimensionSet).length, + 0 + ); return ( Object.keys(this.dimensions).length + - Object.keys(this.defaultDimensions).length + Object.keys(this.defaultDimensions).length + + dimensionSetsCount ); } From 1e8f479fb0f8cb88d2d20a9f8da9aaa48ec15737 Mon Sep 17 00:00:00 2001 From: Matteo Figus Date: Tue, 1 Jul 2025 00:36:54 +0100 Subject: [PATCH 7/8] test: add missing test cases for addDimensions validation - Add tests for invalid dimension values (empty, null, undefined) - Add tests for dimension overwrite warnings - Achieve 100% code coverage for addDimensions method --- .../metrics/tests/unit/dimensions.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/metrics/tests/unit/dimensions.test.ts b/packages/metrics/tests/unit/dimensions.test.ts index 176ca9c50d..75ad23e6b1 100644 --- a/packages/metrics/tests/unit/dimensions.test.ts +++ b/packages/metrics/tests/unit/dimensions.test.ts @@ -470,4 +470,70 @@ describe('Working with dimensions', () => { expect.not.objectContaining({ Dimensions: [['test']] }) ); }); + + it.each([ + { value: undefined, name: 'undefined' }, + { value: null, name: 'null' }, + { + value: '', + name: 'empty string', + }, + ])('skips invalid dimension values in addDimensions ($name)', ({ value }) => { + // Prepare + const metrics = new Metrics({ + singleMetric: true, + namespace: DEFAULT_NAMESPACE, + }); + + // Act & Assess + metrics.addDimensions({ + validDimension: 'valid', + invalidDimension: value as string, + }); + metrics.addMetric('test', MetricUnit.Count, 1); + metrics.publishStoredMetrics(); + + expect(console.warn).toHaveBeenCalledWith( + `The dimension invalidDimension doesn't meet the requirements and won't be added. Ensure the dimension name and value are non empty strings` + ); + expect(console.log).toHaveEmittedEMFWith( + expect.objectContaining({ validDimension: 'valid' }) + ); + expect(console.log).toHaveEmittedEMFWith( + expect.not.objectContaining({ invalidDimension: value }) + ); + }); + + it('warns when addDimensions overwrites existing dimensions', () => { + // Prepare + const metrics = new Metrics({ + namespace: DEFAULT_NAMESPACE, + defaultDimensions: { environment: 'prod' }, + }); + + // Act + metrics.addDimension('region', 'us-east-1'); + metrics.addDimensions({ + environment: 'staging', // overwrites default dimension + region: 'us-west-2', // overwrites regular dimension + newDim: 'value', + }); + metrics.addMetric('test', MetricUnit.Count, 1); + metrics.publishStoredMetrics(); + + // Assess + expect(console.warn).toHaveBeenCalledWith( + 'Dimension "environment" has already been added. The previous value will be overwritten.' + ); + expect(console.warn).toHaveBeenCalledWith( + 'Dimension "region" has already been added. The previous value will be overwritten.' + ); + expect(console.log).toHaveEmittedEMFWith( + expect.objectContaining({ + environment: 'staging', + region: 'us-west-2', + newDim: 'value', + }) + ); + }); }); From 4fb32b77c6681957d5ab4910f281f33f0e3784fd Mon Sep 17 00:00:00 2001 From: Matteo Figus Date: Tue, 1 Jul 2025 00:50:34 +0100 Subject: [PATCH 8/8] chore: clean up and optimize code structure --- packages/metrics/tests/unit/dimensions.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/metrics/tests/unit/dimensions.test.ts b/packages/metrics/tests/unit/dimensions.test.ts index 75ad23e6b1..24ceb798c2 100644 --- a/packages/metrics/tests/unit/dimensions.test.ts +++ b/packages/metrics/tests/unit/dimensions.test.ts @@ -332,12 +332,13 @@ describe('Working with dimensions', () => { }, }); - // Act & Assess - let i = 1; + // Act // We start with 2 dimensions because the default dimension & service name are already added - for (i = 2; i < MAX_DIMENSION_COUNT; i++) { + for (let i = 2; i < MAX_DIMENSION_COUNT; i++) { metrics.addDimension(`dimension-${i}`, 'test'); } + + // Assess expect(() => metrics.addDimension('extra', 'test')).toThrowError( `The number of metric dimensions must be lower than ${MAX_DIMENSION_COUNT}` ); @@ -354,6 +355,8 @@ describe('Working with dimensions', () => { for (let i = 1; i < MAX_DIMENSION_COUNT - 1; i++) { metrics.setDefaultDimensions({ [`dimension-${i}`]: 'test' }); } + + // Assess expect(() => metrics.setDefaultDimensions({ extra: 'test' })).toThrowError( 'Max dimension count hit' ); @@ -368,13 +371,13 @@ describe('Working with dimensions', () => { }, }); - // Act & Assess - let i = 1; + // Act // We start with 2 dimensions because the default dimension & service name are already added - for (i = 2; i < MAX_DIMENSION_COUNT - 2; i++) { + for (let i = 2; i < MAX_DIMENSION_COUNT; i++) { metrics.addDimension(`dimension-${i}`, 'test'); } + // Assess // Adding a dimension set with 3 dimensions would exceed the limit expect(() => metrics.addDimensions({