diff --git a/docs/core/metrics.md b/docs/core/metrics.md index d47a6416a5..30480520ca 100644 --- a/docs/core/metrics.md +++ b/docs/core/metrics.md @@ -229,6 +229,18 @@ You can add default dimensions to your metrics by passing them as parameters in If you'd like to remove them at some point, you can use the `clearDefaultDimensions` method. +### Changing default timestamp + +When creating metrics, we use the current timestamp. If you want to change the timestamp of all the metrics you create, utilize the `setTimestamp` function. You can specify a datetime object or an integer representing an epoch timestamp in milliseconds. + +Note that when specifying the timestamp using an integer, it must adhere to the epoch timezone format in milliseconds. + +=== "setTimestamp method" + + ```typescript hl_lines="13" + --8<-- "examples/snippets/metrics/setTimestamp.ts" + ``` + ### Flushing metrics As you finish adding all your metrics, you need to serialize and "flush them" by calling `publishStoredMetrics()`. This will print the metrics to standard output. diff --git a/examples/snippets/metrics/setTimestamp.ts b/examples/snippets/metrics/setTimestamp.ts new file mode 100644 index 0000000000..5f6d6f69a4 --- /dev/null +++ b/examples/snippets/metrics/setTimestamp.ts @@ -0,0 +1,15 @@ +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 => { + const metricTimestamp = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24 hours ago + metrics.setTimestamp(metricTimestamp); + metrics.addMetric('successfulBooking', MetricUnit.Count, 1); +}; diff --git a/packages/metrics/src/Metrics.ts b/packages/metrics/src/Metrics.ts index abe8fac81b..94eef05c8b 100644 --- a/packages/metrics/src/Metrics.ts +++ b/packages/metrics/src/Metrics.ts @@ -1,5 +1,6 @@ import { Console } from 'node:console'; -import { Utility } from '@aws-lambda-powertools/commons'; +import { isDate } from 'node:util/types'; +import { Utility, isIntegerNumber } from '@aws-lambda-powertools/commons'; import type { GenericLogger, HandlerMethodDecorator, @@ -9,6 +10,8 @@ import { EnvironmentVariablesService } from './config/EnvironmentVariablesServic import { COLD_START_METRIC, DEFAULT_NAMESPACE, + EMF_MAX_TIMESTAMP_FUTURE_AGE, + EMF_MAX_TIMESTAMP_PAST_AGE, MAX_DIMENSION_COUNT, MAX_METRICS_SIZE, MAX_METRIC_VALUES_SIZE, @@ -198,6 +201,11 @@ class Metrics extends Utility implements MetricsInterface { */ private storedMetrics: StoredMetrics = {}; + /** + * Custom timestamp for the metrics + */ + #timestamp?: number; + public constructor(options: MetricsOptions = {}) { super(); @@ -571,6 +579,46 @@ class Metrics extends Utility implements MetricsInterface { this.clearMetadata(); } + /** + * Sets the timestamp for the metric. + * + * If an integer is provided, it is assumed to be the epoch time in milliseconds. + * If a Date object is provided, it will be converted to epoch time in milliseconds. + * + * The timestamp must be a Date object or an integer representing an epoch time. + * This should not exceed 14 days in the past or be more than 2 hours in the future. + * Any metrics failing to meet this criteria will be skipped by Amazon CloudWatch. + * + * See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html + * See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CloudWatch-Logs-Monitoring-CloudWatch-Metrics.html + * + * @example + * ```typescript + * import { MetricUnit, Metrics } from '@aws-lambda-powertools/metrics'; + * + * const metrics = new Metrics({ + * namespace: 'serverlessAirline', + * serviceName: 'orders', + * }); + * + * export const handler = async () => { + * const metricTimestamp = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24 hours ago + * metrics.setTimestamp(metricTimestamp); + * metrics.addMetric('successfulBooking', MetricUnit.Count, 1); + * }; + * ``` + * @param timestamp - The timestamp to set, which can be a number or a Date object. + */ + public setTimestamp(timestamp: number | Date): void { + if (!this.#validateEmfTimestamp(timestamp)) { + this.#logger.warn( + "This metric doesn't meet the requirements and will be skipped by Amazon CloudWatch. " + + 'Ensure the timestamp is within 14 days in the past or up to 2 hours in the future and is also a valid number or Date object.' + ); + } + this.#timestamp = this.#convertTimestampToEmfFormat(timestamp); + } + /** * Serialize the stored metrics into a JSON object compliant with the Amazon CloudWatch EMF (Embedded Metric Format) schema. * @@ -627,7 +675,7 @@ class Metrics extends Utility implements MetricsInterface { return { _aws: { - Timestamp: new Date().getTime(), + Timestamp: this.#timestamp ?? new Date().getTime(), CloudWatchMetrics: [ { Namespace: this.namespace || DEFAULT_NAMESPACE, @@ -940,6 +988,51 @@ class Metrics extends Utility implements MetricsInterface { } } } + + /** + * Validates a given timestamp based on CloudWatch Timestamp guidelines. + * + * Timestamp must meet CloudWatch requirements. + * The time stamp can be up to two weeks in the past and up to two hours into the future. + * See [Timestamps](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_concepts.html#about_timestamp) + * for valid values. + * + * @param timestamp - Date object or epoch time in milliseconds representing the timestamp to validate. + */ + #validateEmfTimestamp(timestamp: number | Date): boolean { + if (!isDate(timestamp) && !isIntegerNumber(timestamp)) { + return false; + } + + const timestampMs = isDate(timestamp) ? timestamp.getTime() : timestamp; + const currentTime = new Date().getTime(); + + const minValidTimestamp = currentTime - EMF_MAX_TIMESTAMP_PAST_AGE; + const maxValidTimestamp = currentTime + EMF_MAX_TIMESTAMP_FUTURE_AGE; + + return timestampMs >= minValidTimestamp && timestampMs <= maxValidTimestamp; + } + + /** + * Converts a given timestamp to EMF compatible format. + * + * @param timestamp - The timestamp to convert, which can be either a number (in milliseconds) or a Date object. + * @returns The timestamp in milliseconds. If the input is invalid, returns 0. + */ + #convertTimestampToEmfFormat(timestamp: number | Date): number { + if (isIntegerNumber(timestamp)) { + return timestamp; + } + if (isDate(timestamp)) { + return timestamp.getTime(); + } + /** + * If this point is reached, it indicates timestamp was neither a valid number nor Date + * Returning zero represents the initial date of epoch time, + * which will be skipped by Amazon CloudWatch. + **/ + return 0; + } } export { Metrics }; diff --git a/packages/metrics/src/constants.ts b/packages/metrics/src/constants.ts index 18edeb0516..0228ce5215 100644 --- a/packages/metrics/src/constants.ts +++ b/packages/metrics/src/constants.ts @@ -18,6 +18,16 @@ const MAX_METRIC_VALUES_SIZE = 100; * The maximum number of dimensions that can be added to a metric (0-indexed). */ const MAX_DIMENSION_COUNT = 29; +/** + * The maximum age of a timestamp in milliseconds that can be emitted in a metric. + * This is set to 14 days. + */ +const EMF_MAX_TIMESTAMP_PAST_AGE = 14 * 24 * 60 * 60 * 1000; +/** + * The maximum age of a timestamp in milliseconds that can be emitted in a metric. + * This is set to 2 hours. + */ +const EMF_MAX_TIMESTAMP_FUTURE_AGE = 2 * 60 * 60 * 1000; /** * The unit of the metric. @@ -73,4 +83,6 @@ export { MAX_DIMENSION_COUNT, MetricUnit, MetricResolution, + EMF_MAX_TIMESTAMP_PAST_AGE, + EMF_MAX_TIMESTAMP_FUTURE_AGE, }; diff --git a/packages/metrics/src/types/Metrics.ts b/packages/metrics/src/types/Metrics.ts index f02f9b183a..da71711501 100644 --- a/packages/metrics/src/types/Metrics.ts +++ b/packages/metrics/src/types/Metrics.ts @@ -475,6 +475,37 @@ interface MetricsInterface { * @param enabled - Whether to throw an error if no metrics are emitted */ setThrowOnEmptyMetrics(enabled: boolean): void; + /** + * Sets the timestamp for the metric. + * + * If an integer is provided, it is assumed to be the epoch time in milliseconds. + * If a Date object is provided, it will be converted to epoch time in milliseconds. + * + * The timestamp must be a Date object or an integer representing an epoch time. + * This should not exceed 14 days in the past or be more than 2 hours in the future. + * Any metrics failing to meet this criteria will be skipped by Amazon CloudWatch. + * + * See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html + * See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CloudWatch-Logs-Monitoring-CloudWatch-Metrics.html + * + * @example + * ```typescript + * import { MetricUnit, Metrics } from '@aws-lambda-powertools/metrics'; + * + * const metrics = new Metrics({ + * namespace: 'serverlessAirline', + * serviceName: 'orders', + * }); + * + * export const handler = async () => { + * const metricTimestamp = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24 hours ago + * metrics.setTimestamp(metricTimestamp); + * metrics.addMetric('successfulBooking', MetricUnit.Count, 1); + * }; + * ``` + * @param timestamp - The timestamp to set, which can be a number or a Date object. + */ + setTimestamp(timestamp: number | Date): void; /** * Create a new Metrics instance configured to immediately flush a single metric. * diff --git a/packages/metrics/tests/unit/Metrics.test.ts b/packages/metrics/tests/unit/Metrics.test.ts index 8b75a21faf..b65ebab212 100644 --- a/packages/metrics/tests/unit/Metrics.test.ts +++ b/packages/metrics/tests/unit/Metrics.test.ts @@ -10,6 +10,8 @@ import { EnvironmentVariablesService } from '../../src/config/EnvironmentVariabl import { COLD_START_METRIC, DEFAULT_NAMESPACE, + EMF_MAX_TIMESTAMP_FUTURE_AGE, + EMF_MAX_TIMESTAMP_PAST_AGE, MAX_DIMENSION_COUNT, MAX_METRICS_SIZE, MAX_METRIC_VALUES_SIZE, @@ -32,8 +34,20 @@ jest.mock('node:console', () => ({ })), })); jest.spyOn(console, 'warn').mockImplementation(() => ({})); +const OriginalDate = Date; const mockDate = new Date(1466424490000); -const dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate); +/** + * If the constructor is called without arguments, it returns a predefined mock date. + * Otherwise, it delegates to the original Date constructor with the provided arguments. + */ +const dateSpy = jest + .spyOn(global, 'Date') + .mockImplementation((...args: ConstructorParameters) => { + if ((args as unknown[]).length === 0) { + return mockDate; + } + return new OriginalDate(...args); + }); jest.spyOn(console, 'log').mockImplementation(); jest.spyOn(console, 'warn').mockImplementation(); @@ -2242,4 +2256,249 @@ describe('Class: Metrics', () => { expect(metrics['console']).toEqual(console); }); }); + + describe('Method: setTimestamp', () => { + const testCases = [ + { + format: 'milliseconds', + getTimestamp: (timestampMs: number) => timestampMs, + }, + { + format: 'Date object', + getTimestamp: (timestampMs: number) => new Date(timestampMs), + }, + ]; + + for (const { format, getTimestamp } of testCases) { + describe(`when timestamp is provided as ${format}`, () => { + test('should set the timestamp if provided in the future', () => { + // Prepare + const testMetric = 'test-metric'; + const metrics: Metrics = new Metrics(); + const timestampMs = mockDate.getTime() + 10 * 60 * 1000; // Add 10 minutes in milliseconds + + // Act + metrics.addMetric(testMetric, MetricUnit.Count, 10); + metrics.setTimestamp(getTimestamp(timestampMs)); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(loggedData).toEqual( + expect.objectContaining({ + _aws: expect.objectContaining({ + Timestamp: timestampMs, + }), + }) + ); + }); + + test('should set the timestamp if provided in the past', () => { + // Prepare + const testMetric = 'test-metric'; + const metrics: Metrics = new Metrics(); + const timestampMs = mockDate.getTime() - 10 * 60 * 1000; // Subtract 10 minutes in milliseconds + + // Act + metrics.addMetric(testMetric, MetricUnit.Count, 10); + metrics.setTimestamp(getTimestamp(timestampMs)); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(loggedData).toEqual( + expect.objectContaining({ + _aws: expect.objectContaining({ + Timestamp: timestampMs, + }), + }) + ); + }); + + test('should not log a warning if the timestamp is within valid future range', () => { + // Prepare + const testMetric = 'test-metric'; + const timestampMs = mockDate.getTime() + EMF_MAX_TIMESTAMP_FUTURE_AGE; + const customLogger = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }; + const metrics: Metrics = new Metrics({ logger: customLogger }); + const consoleWarnSpy = jest.spyOn(customLogger, 'warn'); + + // Act + metrics.addMetric(testMetric, MetricUnit.Count, 10); + metrics.setTimestamp(getTimestamp(timestampMs)); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(loggedData).toEqual( + expect.objectContaining({ + _aws: expect.objectContaining({ + Timestamp: timestampMs, + }), + }) + ); + }); + + test('should log a warning if the timestamp is more than the future range but still set the timestamp', () => { + // Prepare + const testMetric = 'test-metric'; + const timestampMs = + mockDate.getTime() + EMF_MAX_TIMESTAMP_FUTURE_AGE + 1; + const customLogger = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }; + const metrics: Metrics = new Metrics({ logger: customLogger }); + const consoleWarnSpy = jest.spyOn(customLogger, 'warn'); + + // Act + metrics.addMetric(testMetric, MetricUnit.Count, 10); + metrics.setTimestamp(getTimestamp(timestampMs)); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(consoleWarnSpy).toHaveBeenCalledWith( + "This metric doesn't meet the requirements and will be skipped by Amazon CloudWatch. " + + 'Ensure the timestamp is within 14 days in the past or up to 2 hours in the future and is also a valid number or Date object.' + ); + expect(loggedData).toEqual( + expect.objectContaining({ + _aws: expect.objectContaining({ + Timestamp: timestampMs, + }), + }) + ); + }); + + test('should not log a warning if the timestamp is within past range and set the timestamp', () => { + // Prepare + const testMetric = 'test-metric'; + const timestampMs = mockDate.getTime() - EMF_MAX_TIMESTAMP_PAST_AGE; + const customLogger = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }; + const metrics: Metrics = new Metrics({ logger: customLogger }); + const consoleWarnSpy = jest.spyOn(customLogger, 'warn'); + + // Act + metrics.addMetric(testMetric, MetricUnit.Count, 10); + metrics.setTimestamp(getTimestamp(timestampMs)); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(loggedData).toEqual( + expect.objectContaining({ + _aws: expect.objectContaining({ + Timestamp: timestampMs, + }), + }) + ); + }); + + test('should log a warning if the timestamp is more than past range but still set the timestamp', () => { + // Prepare + const testMetric = 'test-metric'; + const timestampMs = + mockDate.getTime() - EMF_MAX_TIMESTAMP_PAST_AGE - 1; + const customLogger = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }; + const metrics: Metrics = new Metrics({ logger: customLogger }); + const consoleWarnSpy = jest.spyOn(customLogger, 'warn'); + + // Act + metrics.addMetric(testMetric, MetricUnit.Count, 10); + metrics.setTimestamp(getTimestamp(timestampMs)); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(consoleWarnSpy).toHaveBeenCalledWith( + "This metric doesn't meet the requirements and will be skipped by Amazon CloudWatch. " + + 'Ensure the timestamp is within 14 days in the past or up to 2 hours in the future and is also a valid number or Date object.' + ); + expect(loggedData).toEqual( + expect.objectContaining({ + _aws: expect.objectContaining({ + Timestamp: timestampMs, + }), + }) + ); + }); + }); + } + + test('should log warning and set timestamp to 0 if not a number provided', () => { + // Prepare + const testMetric = 'test-metric'; + const customLogger = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }; + const metrics: Metrics = new Metrics({ logger: customLogger }); + const consoleWarnSpy = jest.spyOn(customLogger, 'warn'); + + // Act + metrics.addMetric(testMetric, MetricUnit.Count, 10); + metrics.setTimestamp(Number.NaN); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(consoleWarnSpy).toHaveBeenCalledWith( + "This metric doesn't meet the requirements and will be skipped by Amazon CloudWatch. " + + 'Ensure the timestamp is within 14 days in the past or up to 2 hours in the future and is also a valid number or Date object.' + ); + expect(loggedData).toEqual( + expect.objectContaining({ + _aws: expect.objectContaining({ + Timestamp: 0, + }), + }) + ); + }); + + test('should log warning and set timestamp to 0 if not a integer number provided', () => { + // Prepare + const testMetric = 'test-metric'; + const customLogger = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + }; + const metrics: Metrics = new Metrics({ logger: customLogger }); + const consoleWarnSpy = jest.spyOn(customLogger, 'warn'); + + // Act + metrics.addMetric(testMetric, MetricUnit.Count, 10); + metrics.setTimestamp(1.1); + const loggedData = metrics.serializeMetrics(); + + // Assess + expect(consoleWarnSpy).toHaveBeenCalledWith( + "This metric doesn't meet the requirements and will be skipped by Amazon CloudWatch. " + + 'Ensure the timestamp is within 14 days in the past or up to 2 hours in the future and is also a valid number or Date object.' + ); + expect(loggedData).toEqual( + expect.objectContaining({ + _aws: expect.objectContaining({ + Timestamp: 0, + }), + }) + ); + }); + }); });