diff --git a/docs/features/metrics.md b/docs/features/metrics.md index e47c6fc79..8289e3fd7 100644 --- a/docs/features/metrics.md +++ b/docs/features/metrics.md @@ -502,3 +502,43 @@ When `POWERTOOLS_DEV` is enabled, Metrics uses the global `console` to emit metr ```typescript hl_lines="4-5 12" --8<-- "examples/snippets/metrics/testingMetrics.ts" ``` + +## Multiple dimension sets + +You can create multiple dimension sets for your metrics using the `addDimensions` or `addDimensionSet` methods. This allows you to aggregate metrics across various dimensions, providing more granular insights into your application. + +=== "Creating multiple dimension sets" + + ```typescript hl_lines="10-25" + --8<-- "examples/snippets/metrics/multiDimensionSets.ts" + ``` + +=== "Example CloudWatch Logs excerpt" + + ```json + { + "successfulBooking": 1.0, + "_aws": { + "Timestamp": 1592234975665, + "CloudWatchMetrics": [{ + "Namespace": "serverlessAirline", + "Dimensions": [ + ["service", "environment", "region"], + ["service", "dimension1", "dimension2"], + ["service", "feature", "version"] + ], + "Metrics": [{ + "Name": "successfulBooking", + "Unit": "Count" + }] + }] + }, + "service": "orders", + "environment": "prod", + "region": "us-west-2", + "dimension1": "1", + "dimension2": "2", + "feature": "booking", + "version": "v1" + } + ``` diff --git a/examples/snippets/metrics/multiDimensionSets.ts b/examples/snippets/metrics/multiDimensionSets.ts new file mode 100644 index 000000000..6157021f6 --- /dev/null +++ b/examples/snippets/metrics/multiDimensionSets.ts @@ -0,0 +1,28 @@ +import { MetricUnit, Metrics } from '@aws-lambda-powertools/metrics'; + +const metrics = new Metrics({ + namespace: 'serverlessAirline', + serviceName: 'orders', + defaultDimensions: { environment: 'prod' }, +}); + +export const handler = async () => { + // Add a single dimension to the default dimension set + metrics.addDimension('region', 'us-west-2'); + + // Add a new dimension set + metrics.addDimensions({ + dimension1: '1', + dimension2: '2', + }); + + // Add another dimension set (addDimensionSet is an alias for addDimensions) + metrics.addDimensionSet({ + feature: 'booking', + version: 'v1', + }); + + // This will create three dimension sets in the EMF output + metrics.addMetric('successfulBooking', MetricUnit.Count, 1); + metrics.publishStoredMetrics(); +}; diff --git a/packages/metrics/src/Metrics.ts b/packages/metrics/src/Metrics.ts index a3a743e96..81110b18b 100644 --- a/packages/metrics/src/Metrics.ts +++ b/packages/metrics/src/Metrics.ts @@ -19,6 +19,7 @@ import { } from './constants.js'; import type { ConfigServiceInterface, + DimensionSet, Dimensions, EmfOutput, ExtraOptions, @@ -200,6 +201,12 @@ class Metrics extends Utility implements MetricsInterface { */ private storedMetrics: StoredMetrics = {}; + /** + * Storage for dimension sets + * @default [] + */ + private dimensionSets: DimensionSet[] = []; + /** * Whether to disable metrics */ @@ -255,23 +262,90 @@ class Metrics extends Utility implements MetricsInterface { } /** - * Add multiple dimensions to the metrics. + * Add multiple dimensions to the metrics as a new dimension set. * - * This method is useful when you want to add multiple dimensions to the metrics at once. + * This method creates a new dimension set that will be included in the EMF output. * Invalid dimension values are skipped and a warning is logged. * * When calling the {@link Metrics.publishStoredMetrics | `publishStoredMetrics()`} method, the dimensions are cleared. This type of * dimension is useful when you want to add request-specific dimensions to your metrics. If you want to add dimensions that are * included in all metrics, use the {@link Metrics.setDefaultDimensions | `setDefaultDimensions()`} method. * + * @example + * ```typescript + * import { Metrics, MetricUnit } from '@aws-lambda-powertools/metrics'; + * + * const metrics = new Metrics({ + * namespace: 'serverlessAirline', + * serviceName: 'orders', + * }); + * + * // Add a single dimension + * metrics.addDimension('environment', 'prod'); + * + * // Add a new dimension set + * metrics.addDimensions({ + * dimension1: "1", + * dimension2: "2" + * }); + * + * // This will create two dimension sets in the EMF output: + * // [["service", "environment"]] and [["service", "dimension1", "dimension2"]] + * metrics.addMetric('successfulBooking', MetricUnit.Count, 1); + * metrics.publishStoredMetrics(); + * ``` + * * @param dimensions - An object with key-value pairs of dimensions */ public addDimensions(dimensions: Dimensions): void { + const dimensionSet: string[] = []; + + // Add default dimensions to the set + for (const name of Object.keys(this.defaultDimensions)) { + dimensionSet.push(name); + } + + // Add new dimensions to both the dimension set and the dimensions object for (const [name, value] of Object.entries(dimensions)) { - this.addDimension(name, value); + if (!value) { + this.#logger.warn( + `The dimension ${name} doesn't meet the requirements and won't be added. Ensure the dimension name and value are non empty strings` + ); + continue; + } + + if (MAX_DIMENSION_COUNT <= this.getCurrentDimensionsCount() + 1) { + throw new RangeError( + `The number of metric dimensions must be lower than ${MAX_DIMENSION_COUNT}` + ); + } + + // Add to dimensions object for value storage + this.dimensions[name] = value; + + // Add to dimension set if not already included + if (!dimensionSet.includes(name)) { + dimensionSet.push(name); + } + } + + // Only add the dimension set if it has dimensions beyond the defaults + if (dimensionSet.length > Object.keys(this.defaultDimensions).length) { + this.dimensionSets.push(dimensionSet); } } + /** + * Add a dimension set to metrics. + * + * This is an alias for {@link Metrics.addDimensions | `addDimensions()`} for consistency with other Powertools for AWS Lambda implementations. + * + * @param dimensions - An object with key-value pairs of dimensions + */ + public addDimensionSet(dimensions: Dimensions): void { + this.addDimensions(dimensions); + } + /** * A metadata key-value pair to be included with metrics. * @@ -447,6 +521,7 @@ class Metrics extends Utility implements MetricsInterface { */ public clearDimensions(): void { this.dimensions = {}; + this.dimensionSets = []; } /** @@ -692,20 +767,29 @@ class Metrics extends Utility implements MetricsInterface { {} ); - const dimensionNames = [ + // Create the default dimension set from default dimensions and current dimensions + const defaultDimensionNames = [ ...new Set([ ...Object.keys(this.defaultDimensions), ...Object.keys(this.dimensions), ]), ]; + // Prepare all dimension sets for the EMF output + const allDimensionSets: DimensionSet[] = [defaultDimensionNames]; + + // Add any additional dimension sets created via addDimensions() + if (this.dimensionSets.length > 0) { + allDimensionSets.push(...this.dimensionSets); + } + return { _aws: { Timestamp: this.#timestamp ?? new Date().getTime(), CloudWatchMetrics: [ { Namespace: this.namespace || DEFAULT_NAMESPACE, - Dimensions: [dimensionNames], + Dimensions: allDimensionSets as [string[]], Metrics: metricDefinitions, }, ], diff --git a/packages/metrics/src/types/Metrics.ts b/packages/metrics/src/types/Metrics.ts index ccd914351..479fa197c 100644 --- a/packages/metrics/src/types/Metrics.ts +++ b/packages/metrics/src/types/Metrics.ts @@ -13,6 +13,11 @@ import type { ConfigServiceInterface } from './ConfigServiceInterface.js'; */ type Dimensions = Record; +/** + * A dimension set is an array of dimension names + */ +type DimensionSet = string[]; + /** * Options to configure the Metrics class. * @@ -541,6 +546,7 @@ interface MetricsInterface { export type { MetricsOptions, Dimensions, + DimensionSet, EmfOutput, ExtraOptions, StoredMetrics, diff --git a/packages/metrics/src/types/index.ts b/packages/metrics/src/types/index.ts index 6cb26881a..b6caab797 100644 --- a/packages/metrics/src/types/index.ts +++ b/packages/metrics/src/types/index.ts @@ -1,6 +1,7 @@ export type { MetricsOptions, Dimensions, + DimensionSet, EmfOutput, ExtraOptions, StoredMetrics, diff --git a/packages/metrics/tests/unit/dimensions.test.ts b/packages/metrics/tests/unit/dimensions.test.ts index fd5fbdb7c..4b30d280c 100644 --- a/packages/metrics/tests/unit/dimensions.test.ts +++ b/packages/metrics/tests/unit/dimensions.test.ts @@ -52,11 +52,7 @@ describe('Working with dimensions', () => { // Assess expect(console.log).toHaveEmittedEMFWith( - expect.objectContaining({ - service: 'hello-world', - environment: 'test', - commit: '1234', - }) + expect.objectContaining({ service: 'hello-world', environment: 'test' }) ); expect(console.log).toHaveEmittedMetricWith( expect.objectContaining({ @@ -111,6 +107,56 @@ describe('Working with dimensions', () => { ); }); + it('adds empty dimension set when no dimensions are provided', () => { + // Prepare + const metrics = new Metrics({ + singleMetric: true, + }); + + // Act + metrics.addDimensions({}); + metrics.addMetric('test', MetricUnit.Count, 1); + + // Assess + expect(console.log).toHaveEmittedEMFWith( + expect.objectContaining({ + service: 'hello-world', + }) + ); + // With empty dimensions, we should only have the default dimension set + expect(console.log).toHaveEmittedMetricWith( + expect.objectContaining({ + Dimensions: [['service']], + }) + ); + }); + + it('supports addDimensionSet as an alias for addDimensions', () => { + // Prepare + const metrics = new Metrics({ + singleMetric: true, + }); + + // Act + metrics.addDimension('environment', 'test'); + metrics.addDimensionSet({ region: 'us-west-2' }); + metrics.addMetric('test', MetricUnit.Count, 1); + + // Assess + expect(console.log).toHaveEmittedEMFWith( + expect.objectContaining({ + service: 'hello-world', + environment: 'test', + region: 'us-west-2', + }) + ); + expect(console.log).toHaveEmittedMetricWith( + expect.objectContaining({ + Dimensions: [['service', 'environment', 'region']], + }) + ); + }); + it('overrides an existing dimension with the same name', () => { // Prepare const metrics = new Metrics({ @@ -151,25 +197,19 @@ describe('Working with dimensions', () => { }); // Act - metrics.addDimension('commit', '1234'); - metrics.clearDefaultDimensions(); + metrics.setDefaultDimensions({}); metrics.addMetric('test', MetricUnit.Count, 1); // Assess expect(console.log).toHaveEmittedEMFWith( - expect.not.objectContaining({ - environment: 'test', - service: 'hello-world', - }) + expect.objectContaining({ service: 'hello-world' }) ); expect(console.log).toHaveEmittedEMFWith( - expect.objectContaining({ - commit: '1234', - }) + expect.not.objectContaining({ environment: 'test' }) ); expect(console.log).toHaveEmittedMetricWith( expect.objectContaining({ - Dimensions: [['commit']], + Dimensions: [['service']], }) ); }); @@ -190,15 +230,10 @@ describe('Working with dimensions', () => { // Assess expect(console.log).toHaveEmittedEMFWith( - expect.not.objectContaining({ - commit: '1234', - }) + expect.objectContaining({ service: 'hello-world', environment: 'test' }) ); expect(console.log).toHaveEmittedEMFWith( - expect.objectContaining({ - environment: 'test', - service: 'hello-world', - }) + expect.not.objectContaining({ commit: '1234' }) ); expect(console.log).toHaveEmittedMetricWith( expect.objectContaining({ @@ -248,7 +283,7 @@ describe('Working with dimensions', () => { ); }); - it('throws when the number of dimensions exceeds the limit', () => { + it('clears dimension sets after publishing the metric', () => { // Prepare const metrics = new Metrics({ singleMetric: true, @@ -257,41 +292,84 @@ describe('Working with dimensions', () => { }, }); + // Act + metrics.addDimensions({ region: 'us-west-2' }); + metrics.addMetric('test', MetricUnit.Count, 1); + metrics.addMetric('test', MetricUnit.Count, 1); + + // Assess + expect(console.log).toHaveEmittedNthEMFWith( + 1, + expect.objectContaining({ region: 'us-west-2', environment: 'test' }) + ); + // With the new implementation, we expect two dimension sets in the first metric + expect(console.log).toHaveEmittedNthMetricWith( + 1, + expect.objectContaining({ + Dimensions: expect.arrayContaining([ + expect.arrayContaining(['service', 'environment', 'region']), + ]), + }) + ); + expect(console.log).toHaveEmittedNthEMFWith( + 2, + expect.not.objectContaining({ region: 'us-west-2' }) + ); + expect(console.log).toHaveEmittedNthEMFWith( + 2, + expect.objectContaining({ environment: 'test' }) + ); + // And only one dimension set in the second metric + expect(console.log).toHaveEmittedNthMetricWith( + 2, + expect.objectContaining({ + Dimensions: [['service', 'environment']], + }) + ); + }); + + it('throws when the number of dimensions exceeds the limit', () => { + // Prepare + const metrics = new Metrics({ + singleMetric: true, + }); + // 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; i++) { - metrics.addDimension(`dimension-${i}`, 'test'); + const dimensions: Record = {}; + for (let i = 0; i < MAX_DIMENSION_COUNT; i++) { + dimensions[`dimension${i}`] = `value${i}`; } - expect(() => metrics.addDimension('extra', 'test')).toThrowError( - `The number of metric dimensions must be lower than ${MAX_DIMENSION_COUNT}` - ); + + expect(() => { + metrics.addDimensions(dimensions); + }).toThrow(RangeError); }); it('throws when the number of dimensions exceeds the limit after adding default dimensions', () => { // Prepare const metrics = new Metrics({ singleMetric: true, + defaultDimensions: { + environment: 'test', + }, }); - // Act - // We start with 1 dimension because service name is already added - for (let i = 1; i < MAX_DIMENSION_COUNT - 1; i++) { - metrics.setDefaultDimensions({ [`dimension-${i}`]: 'test' }); + // Act & Assess + const dimensions: Record = {}; + for (let i = 0; i < MAX_DIMENSION_COUNT - 1; i++) { + dimensions[`dimension${i}`] = `value${i}`; } - expect(() => metrics.setDefaultDimensions({ extra: 'test' })).toThrowError( - 'Max dimension count hit' - ); + + expect(() => { + metrics.addDimensions(dimensions); + }).toThrow(RangeError); }); it.each([ - { value: undefined, name: 'undefined' }, - { value: null, name: 'null' }, - { - value: '', - name: 'empty string', - }, - ])('skips invalid dimension values ($name)', ({ value }) => { + ['undefined', undefined], + ['null', null], + ['empty string', ''], + ])('skips invalid dimension values (%s)', (_, value) => { // Prepare const metrics = new Metrics({ singleMetric: true, @@ -314,3 +392,118 @@ describe('Working with dimensions', () => { ); }); }); + +it('adds empty dimension set when no dimensions are provided', () => { + // Prepare + const metrics = new Metrics({ + singleMetric: true, + }); + + // Act + metrics.addDimensions({}); + metrics.addMetric('test', MetricUnit.Count, 1); + + // Assess + expect(console.log).toHaveEmittedEMFWith( + expect.objectContaining({ + service: 'hello-world', + }) + ); + // With empty dimensions, we should only have the default dimension set + expect(console.log).toHaveEmittedMetricWith( + expect.objectContaining({ + Dimensions: [['service']], + }) + ); +}); + +it('adds multiple dimension sets to the metric', () => { + // Prepare + const metrics = new Metrics({ + singleMetric: true, + }); + + // Act - First add a dimension, then add a dimension set + metrics.addDimension('environment', 'test'); + metrics.addDimensions({ dimension1: '1', dimension2: '2' }); + + // Verify the dimension sets are stored correctly + expect((metrics as unknown).dimensionSets).toHaveLength(1); + expect((metrics as unknown).dimensionSets[0]).toEqual([ + 'service', + 'dimension1', + 'dimension2', + ]); + + // Emit the metric + metrics.addMetric('test', MetricUnit.Count, 1); + + // Assess the EMF output + expect(console.log).toHaveEmittedEMFWith( + expect.objectContaining({ + service: 'hello-world', + environment: 'test', + dimension1: '1', + dimension2: '2', + }) + ); + + // With the new implementation, we expect two dimension sets in the output + expect(console.log).toHaveEmittedMetricWith( + expect.objectContaining({ + Dimensions: expect.arrayContaining([ + expect.arrayContaining(['service', 'environment']), + expect.arrayContaining(['service', 'dimension1', 'dimension2']), + ]), + }) + ); +}); + +it('skips adding dimension set when all values are invalid', () => { + // Prepare + const metrics = new Metrics({ + singleMetric: true, + }); + + // Act + metrics.addDimensions({ + dimension1: '', + dimension2: null as unknown as string, + }); + metrics.addMetric('test', MetricUnit.Count, 1); + + // Assess + expect(console.log).toHaveEmittedEMFWith( + expect.objectContaining({ + service: 'hello-world', + }) + ); + // With all invalid dimensions, we should only have the default dimension set + expect(console.log).toHaveEmittedMetricWith( + expect.objectContaining({ + Dimensions: [['service']], + }) + ); +}); + +it('throws when adding dimensions would exceed the maximum dimension count', () => { + // Prepare + const metrics = new Metrics({ + singleMetric: true, + }); + + // Mock getCurrentDimensionsCount to return a value close to the limit + const getCurrentDimensionsCountSpy = vi.spyOn( + metrics, + 'getCurrentDimensionsCount' + ); + getCurrentDimensionsCountSpy.mockReturnValue(MAX_DIMENSION_COUNT - 1); + + // Act & Assert + expect(() => { + metrics.addDimensions({ oneMore: 'tooMany' }); + }).toThrow(RangeError); + + // Restore the mock + getCurrentDimensionsCountSpy.mockRestore(); +});