Repository: awslabs/aws-embedded-metrics-node (ts)

Issue:
Automatically flush for > 100 metrics per key
Currently, if `putMetric` is called > 100 times, it will fail silently on the backend. We should automatically flush client-side if this limit is hit.

Pull request:
Support more than 100 metrics
Closes #13


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/package-lock.json b/package-lock.json
index d0e00ce..576075f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "aws-embedded-metrics",
-  "version": "1.0.1",
+  "version": "1.1.0-rc1",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -1772,9 +1772,9 @@
       },
       "dependencies": {
         "acorn": {
-          "version": "7.1.0",
-          "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz",
-          "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==",
+          "version": "7.1.1",
+          "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz",
+          "integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==",
           "dev": true
         }
       }
diff --git a/package.json b/package.json
index 587b651..b4c07ac 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "aws-embedded-metrics",
-  "version": "1.0.1",
+  "version": "1.1.0-rc1",
   "description": "AWS Embedded Metrics Client Library",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
diff --git a/src/Constants.ts b/src/Constants.ts
index 2838abc..4114008 100644
--- a/src/Constants.ts
+++ b/src/Constants.ts
@@ -16,4 +16,5 @@
 export enum Constants {
   MAX_DIMENSIONS = 9,
   DEFAULT_NAMESPACE = 'aws-embedded-metrics',
+  MAX_METRICS_PER_EVENT = 100,
 }
diff --git a/src/config/EnvironmentConfigurationProvider.ts b/src/config/EnvironmentConfigurationProvider.ts
index 3e8e6ca..b3f5137 100644
--- a/src/config/EnvironmentConfigurationProvider.ts
+++ b/src/config/EnvironmentConfigurationProvider.ts
@@ -14,7 +14,7 @@
  */
 
 import { IConfiguration } from './IConfiguration';
-import Environments from "../environment/Environments";
+import Environments from '../environment/Environments';
 
 const ENV_VAR_PREFIX = 'AWS_EMF';
 
@@ -25,7 +25,7 @@ enum ConfigKeys {
   SERVICE_NAME = 'SERVICE_NAME',
   SERVICE_TYPE = 'SERVICE_TYPE',
   AGENT_ENDPOINT = 'AGENT_ENDPOINT',
-  ENVIRONMENT_OVERRIDE = 'ENVIRONMENT'
+  ENVIRONMENT_OVERRIDE = 'ENVIRONMENT',
 }
 
 export class EnvironmentConfigurationProvider {
@@ -39,7 +39,7 @@ export class EnvironmentConfigurationProvider {
         this.getEnvVariable(ConfigKeys.SERVICE_NAME) || this.getEnvVariableWithoutPrefix(ConfigKeys.SERVICE_NAME),
       serviceType:
         this.getEnvVariable(ConfigKeys.SERVICE_TYPE) || this.getEnvVariableWithoutPrefix(ConfigKeys.SERVICE_TYPE),
-      environmentOverride: this.getEnvironmentOverride()
+      environmentOverride: this.getEnvironmentOverride(),
     };
   }
 
@@ -55,7 +55,7 @@ export class EnvironmentConfigurationProvider {
     const configValue = this.getEnvVariable(configKey);
     return !configValue ? fallback : configValue.toLowerCase() === 'true';
   }
-  
+
   getEnvironmentOverride(): Environments {
     const overrideValue = this.getEnvVariable(ConfigKeys.ENVIRONMENT_OVERRIDE);
     const environment = Environments[overrideValue as keyof typeof Environments];
diff --git a/src/config/IConfiguration.ts b/src/config/IConfiguration.ts
index a8083af..37391f1 100644
--- a/src/config/IConfiguration.ts
+++ b/src/config/IConfiguration.ts
@@ -13,7 +13,7 @@
  * limitations under the License.
  */
 
-import Environments from "../environment/Environments";
+import Environments from '../environment/Environments';
 
 export interface IConfiguration {
   /**
@@ -53,7 +53,7 @@ export interface IConfiguration {
    * Valid values include:
    * - Local: no decoration and sends over stdout
    * - Lambda: decorates logs with Lambda metadata and sends over stdout
-   * - Agent: no decoration and sends over TCP 
+   * - Agent: no decoration and sends over TCP
    * - EC2: decorates logs with EC2 metadata and sends over TCP
    */
   environmentOverride: Environments | undefined;
diff --git a/src/environment/DefaultEnvironment.ts b/src/environment/DefaultEnvironment.ts
index 5c97d09..8907f96 100644
--- a/src/environment/DefaultEnvironment.ts
+++ b/src/environment/DefaultEnvironment.ts
@@ -43,11 +43,11 @@ export class DefaultEnvironment implements IEnvironment {
   }
 
   public getLogGroupName(): string {
-    // if the caller explicitly overrides logGroupName to 
-    // be empty, we should honor that rather than providing 
+    // if the caller explicitly overrides logGroupName to
+    // be empty, we should honor that rather than providing
     // the default behavior.
-    if (config.logGroupName === "") {
-      return "";
+    if (config.logGroupName === '') {
+      return '';
     }
     return config.logGroupName ? config.logGroupName : `${this.getName()}-metrics`;
   }
diff --git a/src/environment/EnvironmentDetector.ts b/src/environment/EnvironmentDetector.ts
index 769030d..89538ac 100644
--- a/src/environment/EnvironmentDetector.ts
+++ b/src/environment/EnvironmentDetector.ts
@@ -29,7 +29,7 @@ const ec2Environment = new EC2Environment();
 const defaultEnvironment = new DefaultEnvironment();
 const environments = [lambdaEnvironment, ec2Environment];
 
-let environment : IEnvironment | undefined = defaultEnvironment;
+let environment: IEnvironment | undefined = undefined;
 
 const getEnvironmentFromOverride = (): IEnvironment | undefined => {
   // short-circuit environment detection and use override
@@ -46,9 +46,10 @@ const getEnvironmentFromOverride = (): IEnvironment | undefined => {
     default:
       return undefined;
   }
-}
+};
 
 const discoverEnvironment = async (): Promise<IEnvironment> => {
+  LOG(`Discovering environment`);
   for (const envUnderTest of environments) {
     LOG(`Testing: ${envUnderTest.constructor.name}`);
 
@@ -61,31 +62,29 @@ const discoverEnvironment = async (): Promise<IEnvironment> => {
     }
   }
   return defaultEnvironment;
-}
+};
 
 const _resolveEnvironment: EnvironmentProvider = async (): Promise<IEnvironment> => {
+  LOG('Resolving environment');
   if (environment) {
     return environment;
   }
 
   if (config.environmentOverride) {
-    LOG("Environment override supplied", config.environmentOverride);
+    LOG('Environment override supplied', config.environmentOverride);
     // this will be falsy if an invalid configuration value is provided
-    environment = getEnvironmentFromOverride()
+    environment = getEnvironmentFromOverride();
     if (environment) {
       return environment;
-    }
-    else {
+    } else {
       LOG('Invalid environment provided. Falling back to auto-discovery.', config.environmentOverride);
     }
   }
-  
+
   environment = await discoverEnvironment(); // eslint-disable-line require-atomic-updates
   return environment;
 };
 
-
-
 // pro-actively begin resolving the environment
 // this will allow us to kick off any async tasks
 // at module load time to reduce any blocking that
@@ -96,7 +95,7 @@ const resolveEnvironment: EnvironmentProvider = async (): Promise<IEnvironment>
 };
 
 const cleanResolveEnvironment = async (): Promise<IEnvironment> => {
-  environment = undefined; 
+  environment = undefined;
   return await _resolveEnvironment();
 };
 
diff --git a/src/environment/Environments.ts b/src/environment/Environments.ts
index 5462782..306fafc 100644
--- a/src/environment/Environments.ts
+++ b/src/environment/Environments.ts
@@ -1,9 +1,9 @@
 enum Environments {
-    Local = "Local",
-    Lambda = "Lambda",
-    Agent = "Agent",
-    EC2 = "EC2",
-    Unknown = ""
-};
+  Local = 'Local',
+  Lambda = 'Lambda',
+  Agent = 'Agent',
+  EC2 = 'EC2',
+  Unknown = '',
+}
 
-export default Environments;
\ No newline at end of file
+export default Environments;
diff --git a/src/logger/MetricsContext.ts b/src/logger/MetricsContext.ts
index 9fdb3a1..3f40127 100644
--- a/src/logger/MetricsContext.ts
+++ b/src/logger/MetricsContext.ts
@@ -99,7 +99,7 @@ export class MetricsContext {
 
       // check for duplicate dimensions when putting
       // this is an O(n^2) operation, but since we never expect to have more than
-      // 10 dimensions, this is acceptable for almost all cases. 
+      // 10 dimensions, this is acceptable for almost all cases.
       // This makes re-using loggers much easier.
       const existingDimensionSetKeys = Object.keys(existingDimensionSet);
       const incomingDimensionSetKeys = Object.keys(incomingDimensionSet);
diff --git a/src/serializers/LogSerializer.ts b/src/serializers/LogSerializer.ts
index 48c0195..dc25aee 100644
--- a/src/serializers/LogSerializer.ts
+++ b/src/serializers/LogSerializer.ts
@@ -25,12 +25,12 @@ export class LogSerializer implements ISerializer {
   /**
    * Retrieve the current context as a JSON string
    */
-  public serialize(context: MetricsContext): string {
+  public serialize(context: MetricsContext): string[] {
     const dimensionKeys: string[][] = [];
     let dimensionProperties = {};
 
     context.getDimensions().forEach(d => {
-      // we can only take the first 10 defined dimensions
+      // we can only take the first 9 defined dimensions
       // the reason we do this in the serializer is because
       // it is possible that other sinks or formats can
       // support more dimensions
@@ -42,28 +42,50 @@ export class LogSerializer implements ISerializer {
     });
 
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const body: any = {
-      ...dimensionProperties,
-      ...context.properties,
-      _aws: {
-        ...context.meta,
-        CloudWatchMetrics: [
-          {
-            Dimensions: dimensionKeys,
-            Metrics: [],
-            Namespace: context.namespace,
-          },
-        ],
-      },
+    const createBody = (): any => {
+      return {
+        ...dimensionProperties,
+        ...context.properties,
+        _aws: {
+          ...context.meta,
+          CloudWatchMetrics: [
+            {
+              Dimensions: dimensionKeys,
+              Metrics: [],
+              Namespace: context.namespace,
+            },
+          ],
+        },
+      };
+    };
+
+    const eventBatches: string[] = [];
+    let currentBody = createBody();
+
+    const currentMetricsInBody = (): number => currentBody._aws.CloudWatchMetrics[0].Metrics.length;
+    const shouldSerialize = (): boolean => currentMetricsInBody() === Constants.MAX_METRICS_PER_EVENT;
+
+    // converts the body to JSON and pushes it into the batches
+    const serializeCurrentBody = (): void => {
+      eventBatches.push(JSON.stringify(currentBody));
+      currentBody = createBody();
     };
 
     for (const [key, metric] of context.metrics) {
       // if there is only one metric value, unwrap it to make querying easier
       const metricValue = metric.values.length === 1 ? metric.values[0] : metric.values;
-      body[key] = metricValue;
-      body._aws.CloudWatchMetrics[0].Metrics.push({ Name: key, Unit: metric.unit });
+      currentBody[key] = metricValue;
+      currentBody._aws.CloudWatchMetrics[0].Metrics.push({ Name: key, Unit: metric.unit });
+
+      if (shouldSerialize()) {
+        serializeCurrentBody();
+      }
+    }
+
+    if (eventBatches.length === 0 || currentMetricsInBody() > 0) {
+      serializeCurrentBody();
     }
 
-    return JSON.stringify(body);
+    return eventBatches;
   }
 }
diff --git a/src/serializers/Serializer.ts b/src/serializers/Serializer.ts
index 5c70585..8a746e7 100644
--- a/src/serializers/Serializer.ts
+++ b/src/serializers/Serializer.ts
@@ -21,5 +21,5 @@ export interface ISerializer {
    *
    * @param context The MetricsContext
    */
-  serialize(context: MetricsContext): string;
+  serialize(context: MetricsContext): string[];
 }
diff --git a/src/sinks/AgentSink.ts b/src/sinks/AgentSink.ts
index 06bcfbc..a1b6317 100644
--- a/src/sinks/AgentSink.ts
+++ b/src/sinks/AgentSink.ts
@@ -92,15 +92,18 @@ export class AgentSink implements ISink {
     if (this.logGroupName) {
       context.meta.LogGroupName = this.logGroupName;
     }
-   
+
     if (this.logStreamName) {
       context.meta.LogStreamName = this.logStreamName;
     }
 
-    const message = this.serializer.serialize(context) + '\n';
-    const bytes = Buffer.from(message);
-
-    await this.socketClient.sendMessage(bytes);
+    const events = this.serializer.serialize(context);
+    for (let index = 0; index < events.length; index++) {
+      const event = events[index];
+      const message = event + '\n';
+      const bytes = Buffer.from(message);
+      await this.socketClient.sendMessage(bytes);
+    }
   }
 
   private getSocketClient(endpoint: IEndpoint): ISocketClient {
diff --git a/src/sinks/ConsoleSink.ts b/src/sinks/ConsoleSink.ts
index 59468de..67e3b79 100644
--- a/src/sinks/ConsoleSink.ts
+++ b/src/sinks/ConsoleSink.ts
@@ -33,7 +33,8 @@ export class ConsoleSink implements ISink {
 
   public accept(context: MetricsContext): Promise<void> {
     // tslint:disable-next-line
-    console.log(this.serializer.serialize(context));
+    const events = this.serializer.serialize(context);
+    events.forEach(event => console.log(event));
     return Promise.resolve();
   }
 }
diff --git a/src/sinks/connections/TcpClient.ts b/src/sinks/connections/TcpClient.ts
index 7d0077c..ede7472 100644
--- a/src/sinks/connections/TcpClient.ts
+++ b/src/sinks/connections/TcpClient.ts
@@ -46,7 +46,6 @@ export class TcpClient implements ISocketClient {
     await new Promise((resolve, reject) => {
       const onSendError = (err: Error): void => {
         LOG('Failed to write', err);
-        LOG('Socket', this.socket);
         reject(err);
       };
       const wasFlushedToKernel = this.socket.once('error', onSendError).write(message, (err?: Error) => {


Test patch:
diff --git a/src/config/__tests__/EnvironmentConfigurationProvider.test.ts b/src/config/__tests__/EnvironmentConfigurationProvider.test.ts
index 8882abc..6c29648 100644
--- a/src/config/__tests__/EnvironmentConfigurationProvider.test.ts
+++ b/src/config/__tests__/EnvironmentConfigurationProvider.test.ts
@@ -175,7 +175,7 @@ test('can set agent endpoint from environment', () => {
 
 test('can set environment override from environment', () => {
   // arrange
-  const expectedValue = "Local"
+  const expectedValue = 'Local';
   process.env.AWS_EMF_ENVIRONMENT = expectedValue;
 
   // act
@@ -188,7 +188,7 @@ test('can set environment override from environment', () => {
 
 test('if environment override is not set, default to unknown', () => {
   // arrange
-  process.env.AWS_EMF_ENVIRONMENT = "";
+  process.env.AWS_EMF_ENVIRONMENT = '';
   // act
   const config = getConfig();
 
diff --git a/src/environment/__tests__/DefaultEnvironment.test.ts b/src/environment/__tests__/DefaultEnvironment.test.ts
index 3cba88c..f9fa938 100644
--- a/src/environment/__tests__/DefaultEnvironment.test.ts
+++ b/src/environment/__tests__/DefaultEnvironment.test.ts
@@ -90,7 +90,7 @@ test('getLogGroupName() returns <ServiceName>-metrics if not configured', () =>
 
 test('getLogGroupName() returns empty if explicitly set to empty', () => {
   // arrange
-  config.logGroupName = "";
+  config.logGroupName = '';
   const env = new DefaultEnvironment();
 
   // act
diff --git a/src/environment/__tests__/EnvironmentDetector.test.ts b/src/environment/__tests__/EnvironmentDetector.test.ts
index d68fb93..7661362 100644
--- a/src/environment/__tests__/EnvironmentDetector.test.ts
+++ b/src/environment/__tests__/EnvironmentDetector.test.ts
@@ -41,7 +41,7 @@ test('resolveEnvironment() honors configured override', async () => {
 test('resolveEnvironment() ignores invalid override and falls back to discovery', async () => {
   // arrange
   // @ts-ignore
-  config.environmentOverride = "Invalid";
+  config.environmentOverride = 'Invalid';
   process.env.AWS_LAMBDA_FUNCTION_NAME = faker.random.word();
 
   // act
@@ -49,4 +49,4 @@ test('resolveEnvironment() ignores invalid override and falls back to discovery'
 
   // assert
   expect(result.constructor.name).toBe('LambdaEnvironment');
-});
\ No newline at end of file
+});
diff --git a/src/serializers/__tests__/LogSerializer.test.ts b/src/serializers/__tests__/LogSerializer.test.ts
index 8bd216a..c7a037a 100644
--- a/src/serializers/__tests__/LogSerializer.test.ts
+++ b/src/serializers/__tests__/LogSerializer.test.ts
@@ -15,7 +15,7 @@ test('serializes dimensions', () => {
   const context = getContext();
   context.putDimensions(dimensions);
   // act
-  const resultJson = serializer.serialize(context);
+  const resultJson = serializer.serialize(context)[0];
 
   // assert
   assertJsonEquality(resultJson, expected);
@@ -42,7 +42,7 @@ test('cannot serialize more than 9 dimensions', () => {
   const context = getContext();
   context.putDimensions(dimensions);
   // act
-  const resultJson = serializer.serialize(context);
+  const resultJson = serializer.serialize(context)[0];
 
   // assert
   assertJsonEquality(resultJson, expected);
@@ -60,7 +60,7 @@ test('serializes properties', () => {
   context.setProperty(expectedKey, expectedValue);
 
   // act
-  const resultJson = serializer.serialize(context);
+  const resultJson = serializer.serialize(context)[0];
 
   // assert
   assertJsonEquality(resultJson, expected);
@@ -82,7 +82,7 @@ test('serializes metrics with single datapoint', () => {
   context.putMetric(expectedKey, expectedValue);
 
   // act
-  const resultJson = serializer.serialize(context);
+  const resultJson = serializer.serialize(context)[0];
 
   // assert
   assertJsonEquality(resultJson, expected);
@@ -105,12 +105,43 @@ test('serializes metrics with multiple datapoints', () => {
   context.putMetric(expectedKey, expectedValues[1]);
 
   // act
-  const resultJson = serializer.serialize(context);
+  const resultJson = serializer.serialize(context)[0];
 
   // assert
   assertJsonEquality(resultJson, expected);
 });
 
+test('serializes more than 100 metrics into multiple events', () => {
+  // arrange
+  const expectedValue = 1;
+  const metrics = 275;
+  const expectedBatches = 3;
+
+  const context = getContext();
+  for (let index = 0; index < metrics; index++) {
+    const expectedKey = `Metric-${index}`;
+    context.putMetric(expectedKey, expectedValue);
+  }
+
+  // act
+  const results = serializer.serialize(context);
+
+  // assert
+  expect(results.length).toBe(expectedBatches);
+  let metricIndex = 0;
+  for (let batchIndex = 0; batchIndex < expectedBatches; batchIndex++) {
+    const resultJson = results[batchIndex];
+    const expectedMetricCount = batchIndex === expectedBatches - 1 ? metrics % 100 : 100;
+
+    const resultObj = JSON.parse(resultJson);
+    expect(resultObj._aws.CloudWatchMetrics[0].Metrics.length).toBe(expectedMetricCount);
+    for (let index = 0; index < expectedMetricCount; index++) {
+      expect(resultObj[`Metric-${metricIndex}`]).toBe(expectedValue);
+      metricIndex++;
+    }
+  }
+});
+
 const assertJsonEquality = (resultJson: string, expectedObj: any) => {
   const actual = JSON.parse(resultJson);
   expect(actual).toStrictEqual(expectedObj);
diff --git a/src/sinks/__tests__/AgentSink.test.ts b/src/sinks/__tests__/AgentSink.test.ts
index b1a4cc9..ff9f5f5 100644
--- a/src/sinks/__tests__/AgentSink.test.ts
+++ b/src/sinks/__tests__/AgentSink.test.ts
@@ -4,7 +4,6 @@ import { MetricsContext } from '../../logger/MetricsContext';
 import { AgentSink } from '../AgentSink';
 import { TcpClient } from '../connections/TcpClient';
 
-
 test('default endpoint is tcp', () => {
   // arrange
   const logGroupName = faker.random.word();
@@ -56,10 +55,10 @@ test('LogGroup is stored in metadata', async () => {
   const context = MetricsContext.empty();
 
   let receivedMessage: any = {};
-  TcpClient.prototype.sendMessage = (message: Buffer) : Promise<void> => {
+  TcpClient.prototype.sendMessage = (message: Buffer): Promise<void> => {
     receivedMessage = JSON.parse(message.toString('utf8'));
     return Promise.resolve();
-  }
+  };
 
   const sink = new AgentSink(expectedLogGroup);
 
@@ -75,12 +74,12 @@ test('empty LogGroup is not stored in metadata', async () => {
   const context = MetricsContext.empty();
 
   let receivedMessage: any = {};
-  TcpClient.prototype.sendMessage = (message: Buffer) : Promise<void> => {
+  TcpClient.prototype.sendMessage = (message: Buffer): Promise<void> => {
     receivedMessage = JSON.parse(message.toString('utf8'));
     return Promise.resolve();
-  }
+  };
 
-  const sink = new AgentSink("");
+  const sink = new AgentSink('');
 
   // act
   await sink.accept(context);
@@ -88,3 +87,27 @@ test('empty LogGroup is not stored in metadata', async () => {
   // assert
   expect(receivedMessage._aws.LogGroupName).toBe(undefined);
 });
+
+test('more than max number of metrics will send multiple messages', async () => {
+  // arrange
+  const context = MetricsContext.empty();
+  const expectedMetrics = 101;
+  const expectedMessages = 2;
+  for (let index = 0; index < expectedMetrics; index++) {
+    context.putMetric(`${index}`, 1);
+  }
+
+  let sentMessages = 0;
+  TcpClient.prototype.sendMessage = (message: Buffer): Promise<void> => {
+    sentMessages++;
+    return Promise.resolve();
+  };
+
+  const sink = new AgentSink('');
+
+  // act
+  await sink.accept(context);
+
+  // assert
+  expect(sentMessages).toBe(expectedMessages);
+});
diff --git a/src/sinks/__tests__/ConsoleSink.test.ts b/src/sinks/__tests__/ConsoleSink.test.ts
index 00a3454..8310d3f 100644
--- a/src/sinks/__tests__/ConsoleSink.test.ts
+++ b/src/sinks/__tests__/ConsoleSink.test.ts
@@ -10,7 +10,7 @@ test('accept serializes and writes result to stdout', () => {
   // arrange
   const expected = faker.random.alphaNumeric(20);
   const serializer: any = {
-    serialize: jest.fn(() => expected),
+    serialize: jest.fn(() => [expected]),
   };
 
   const sink = new ConsoleSink(serializer);
@@ -21,3 +21,21 @@ test('accept serializes and writes result to stdout', () => {
   // assert
   expect(console.log).toBeCalledWith(expected);
 });
+
+test('accept writes multiple messages to stdout', () => {
+  // arrange
+  const expectedMessages = faker.random.number({ min: 2, max: 100 });
+  const expected = new Array(expectedMessages).fill(null).map(() => faker.random.alphaNumeric(20));
+  const serializer: any = {
+    serialize: jest.fn(() => expected),
+  };
+
+  const sink = new ConsoleSink(serializer);
+
+  // act
+  sink.accept(MetricsContext.empty());
+
+  // assert
+  expect(console.log).toBeCalledTimes(expectedMessages);
+  expected.forEach(e => expect(console.log).toBeCalledWith(e));
+});