Repository: awslabs/aws-embedded-metrics-node
Pull request #69 (2020-11-23 12:25:27)

Allow setting timestamp of metrics.
*Issue #, if available:* 61
https://github.com/awslabs/aws-embedded-metrics-node/issues/61

*Description of changes:*
Adds the MetricsLogger.setTimestamp() method.

One aspect I think core maintainers will have a much better sense of, if I set the timestamp should that be preserved across calls to `flush()`? I wasn't 100% sure what would be desired behaviour, so I took a guess and went for "yes, if set explicitly, preserve". For my own use case described in #61, I will be calling `setTimeout` in 100% of `MetricsContext` instances generated, so it would be irrelevant anyway.

Another guess I made was that if the timestamp is not set explicitly, the `MetricsContext` should reset the `meta.Timestamp` field to `new Date()`. I think this is less surprising behaviour for users who never call `setTimestamp`. Plus if I preserved the timestamp when not explicitly set, that would change the behaviour for all existing users, I thought it would only make sense to change behaviour for callers who are "opting in" by using `setTimeout`. 

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.



Patch:
diff --git a/README.md b/README.md
index 7b39f4f..4c97c18 100644
--- a/README.md
+++ b/README.md
@@ -187,13 +187,29 @@ Requirements:
 
 Examples:
 
-```py
+```js
 setNamespace("MyApplication");
 ```
 
+- **setTimestamp**(Date | number timestamp)
+
+Sets the CloudWatch [timestamp](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_concepts.html#about_timestamp) that extracted metrics are associated with. If not set a default value of `new Date()` will be used.
+
+If set for a given `MetricsLogger`, timestamp will be preserved across calls to flush().
+
+Requirements: 
+ * Date or Unix epoch millis, up to two weeks in the past and up to two hours in the future, as enforced by [CloudWatch](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_concepts.html#about_timestamp). 
+
+Examples:
+
+```js
+setTimestamp(new Date())
+setTimestamp(new Date().getTime())
+```
+
 - **flush**()
 
-Flushes the current MetricsContext to the configured sink and resets all properties, dimensions and metric values. The namespace and default dimensions will be preserved across flushes.
+Flushes the current MetricsContext to the configured sink and resets all properties, dimensions and metric values. The namespace and default dimensions will be preserved across flushes. Timestamp will be preserved if set explicitly via `setTimestamp()`.
 
 ## Configuration
 
diff --git a/src/logger/MetricsContext.ts b/src/logger/MetricsContext.ts
index 430c179..72434d6 100644
--- a/src/logger/MetricsContext.ts
+++ b/src/logger/MetricsContext.ts
@@ -39,6 +39,7 @@ export class MetricsContext {
   private dimensions: Array<Record<string, string>>;
   private defaultDimensions: Record<string, string>;
   private shouldUseDefaultDimensions = true;
+  private timestamp: Date | number | undefined;
 
   /**
    * Constructor used to create child instances.
@@ -56,14 +57,26 @@ export class MetricsContext {
     properties?: IProperties,
     dimensions?: Array<Record<string, string>>,
     defaultDimensions?: Record<string, string>,
+    timestamp?: Date | number,
   ) {
     this.namespace = namespace || Configuration.namespace
     this.properties = properties || {};
     this.dimensions = dimensions || [];
-    this.meta.Timestamp = new Date().getTime();
+    this.timestamp = timestamp;
+    this.meta.Timestamp = MetricsContext.resolveMetaTimestamp(timestamp);
     this.defaultDimensions = defaultDimensions || {};
   }
 
+  private static resolveMetaTimestamp(timestamp?: Date | number): number {
+    if (timestamp instanceof Date) {
+      return timestamp.getTime()
+    } else if (timestamp) {
+      return timestamp;
+    } else {
+      return new Date().getTime();
+    }
+  }
+
   public setNamespace(value: string): void {
     this.namespace = value;
   }
@@ -72,6 +85,11 @@ export class MetricsContext {
     this.properties[key] = value;
   }
 
+  public setTimestamp(timestamp: Date | number) {
+    this.timestamp = timestamp;
+    this.meta.Timestamp = MetricsContext.resolveMetaTimestamp(timestamp);
+  }
+
   /**
    * Sets default dimensions for the Context.
    * A dimension set will be created with just the default dimensions
@@ -173,6 +191,7 @@ export class MetricsContext {
       Object.assign({}, this.properties),
       Object.assign([], this.dimensions),
       this.defaultDimensions,
+      this.timestamp
     );
   }
 }
diff --git a/src/logger/MetricsLogger.ts b/src/logger/MetricsLogger.ts
index fe5ee84..0af57e5 100644
--- a/src/logger/MetricsLogger.ts
+++ b/src/logger/MetricsLogger.ts
@@ -114,6 +114,21 @@ export class MetricsLogger {
     return this;
   }
 
+  /**
+   * Set the timestamp of metrics emitted in this context.
+   *
+   * If not set, the timestamp will default to new Date() at the point
+   * the context is constructed.
+   *
+   * If set, timestamp will preserved across calls to flush().
+   *
+   * @param timestamp
+   */
+  public setTimestamp(timestamp: Date | number): MetricsLogger {
+    this.context.setTimestamp(timestamp);
+    return this;
+  }
+
   /**
    * Creates a new logger using the same contextual data as
    * the previous logger. This allows you to flush the instances


Test patch:
diff --git a/src/logger/__tests__/MetricsLogger.test.ts b/src/logger/__tests__/MetricsLogger.test.ts
index 984ca79..698a50e 100644
--- a/src/logger/__tests__/MetricsLogger.test.ts
+++ b/src/logger/__tests__/MetricsLogger.test.ts
@@ -199,6 +199,76 @@ test('can set namespace', async () => {
   expect(actualValue).toBe(expectedValue);
 });
 
+test('defaults timestamp to now', async () => {
+  // arrange
+  const before = new Date();
+  // recreate logger to regenerate meta.Timestamp set to now
+  sink = createSink();
+  environment = createEnvironment(sink);
+  logger = createLogger(() => Promise.resolve(environment));
+
+  // act
+  logger.putMetric(faker.random.word(), faker.random.number());
+  await logger.flush();
+
+  //assert
+  const after = new Date();
+  const lastEvent = sink.events.slice(-1)[0];
+  expectTimestampWithin(lastEvent, [before, after]);
+});
+
+test('can set timestamp', async () => {
+  // arrange
+  const timestamp = faker.date.recent();
+  logger.setTimestamp(timestamp)
+
+  // act
+  logger.putMetric(faker.random.word(), faker.random.number());
+  await logger.flush();
+
+  //assert
+  expect(sink.events.length).toEqual(1);
+  expect(sink.events[0].meta.Timestamp).toEqual(timestamp.getTime());
+});
+
+test('flush() preserves timestamp if set explicitly', async () => {
+  // arrange
+  const timestamp = faker.date.recent();
+  logger.setTimestamp(timestamp)
+
+  // act
+  logger.putMetric(faker.random.word(), faker.random.number());
+  await logger.flush();
+  logger.putMetric(faker.random.word(), faker.random.number());
+  await logger.flush();
+
+  //assert
+  expect(sink.events.length).toEqual(2);
+  expect(sink.events[1].meta.Timestamp).toEqual(timestamp.getTime());
+});
+
+test('flush() resets timestamp to now if not set explicitly', async () => {
+  // arrange
+  const before = new Date();
+  // recreate logger to regenerate meta.Timestamp set to now
+  sink = createSink();
+  environment = createEnvironment(sink);
+  logger = createLogger(() => Promise.resolve(environment));
+  // act
+  logger.putMetric(faker.random.word(), faker.random.number());
+  await logger.flush();
+  const afterFirstFlush = new Date();
+  logger.putMetric(faker.random.word(), faker.random.number());
+  await logger.flush();
+  const afterSecondFlush = new Date();
+
+  //assert
+  expect(sink.events.length).toEqual(2);
+
+  expectTimestampWithin(sink.events[0], [before, afterFirstFlush]);
+  expectTimestampWithin(sink.events[1], [afterFirstFlush, afterSecondFlush]);
+});
+
 test('flush() uses configured serviceName for default dimension if provided', async () => {
   // arrange
   const expected = faker.random.word();
@@ -268,6 +338,7 @@ test('context is preserved across flush() calls', async () => {
   const expectedDimensionKey = 'Dim';
   const expectedPropertyKey = 'Prop';
   const expectedValues = 'Value';
+  const expectedTimestamp = faker.date.recent();
 
   const dimensions: Record<string, string> = {};
   dimensions[expectedDimensionKey] = expectedValues;
@@ -275,6 +346,7 @@ test('context is preserved across flush() calls', async () => {
   logger.setNamespace(expectedNamespace);
   logger.setProperty(expectedPropertyKey, expectedValues);
   logger.setDimensions(dimensions);
+  logger.setTimestamp(expectedTimestamp);
 
   // act
   logger.putMetric(metricKey, 0);
@@ -287,10 +359,11 @@ test('context is preserved across flush() calls', async () => {
   expect(sink.events).toHaveLength(2);
   for (let i = 0; i < sink.events.length; i++) {
     const evt = sink.events[i];
-    // namespace, properties, dimensions should survive flushes
+    // namespace, properties, dimensions, timestamp should survive flushes
     expect(evt.namespace).toBe(expectedNamespace);
     expect(evt.getDimensions()[0][expectedDimensionKey]).toBe(expectedValues);
     expect(evt.properties[expectedPropertyKey]).toBe(expectedValues);
+    expect(evt.meta.Timestamp).toEqual(expectedTimestamp.getTime());
     // metric values should not survive flushes
     // @ts-ignore
     expect(evt.metrics.get(metricKey).values).toStrictEqual([i]);
@@ -306,3 +379,8 @@ const expectDimension = (key: string, value: string) => {
   const actualValue = dimension[key];
   expect(actualValue).toBe(value);
 };
+
+const expectTimestampWithin = (context: MetricsContext, range: [Date, Date]) => {
+  expect(context.meta.Timestamp).toBeGreaterThanOrEqual(range[0].getTime());
+  expect(context.meta.Timestamp).toBeLessThanOrEqual(range[1].getTime());
+}