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

Issue:
Add option to disable agent communication during unit tests
I'd like to block the SDK from trying to communicate with a CloudWatch Agent during certain unit tests. I don't see a documented way to do this right now. 

Ideally, I'd also be able to run assertions against the information that the SDK would have logged. (Did the dimensions, properties, and metrics logged during this test match my expectations?) So one possible approach to this problem could be to "report" logs to some in-memory object that can be inspected, instead of the CloudWatch Agent. At a minimum, though, I'd like to be able turn off Agent communication, instead of having to mock the module's API whenever I want to run my code without an Agent.



Pull request description:
Allow overriding environment and provide LocalEnvironment
Closes #18 

*Description of changes:*

- adds LocalEnvironment
- renames LambdaSink to ConsoleSink
- short-circuits environment discovery if override is specified
- adds environment override
- fixes environment detection so that it only runs once after module load

TODO:

- [ ] ~Refactor environment mapping out of the detector~ deferred to #22 
- [x] Add tests for detection
- [x] Add documentation

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 ce7876c..6e9ea7d 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ Generate CloudWatch Metrics embedded within structured log events. The embedded
 * [Installation](#installation)
 * [Usage](#usage)
 * [API](#api)
+* [Configuration](#configuration)
 * [Examples](#examples)
 * [Development](#development)
 
@@ -193,7 +194,7 @@ setNamespace("MyApplication");
 
 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.
 
-### Configuration
+## Configuration
 
 All configuration values can be set using environment variables with the prefix (`AWS_EMF_`). Configuration should be performed as close to application start up as possible.
 
@@ -268,6 +269,52 @@ Configuration.logStreamName = "LogStreamName";
 AWS_EMF_LOG_STREAM_NAME=LogStreamName
 ```
 
+**AgentEndpoint**: For agent-based platforms, you may optionally configure the endpoint to reach the agent on.
+
+Example:
+
+```js
+// in process
+const { Configuration } = require("aws-embedded-metrics");
+Configuration.agentEndpoint = "udp://127.0.0.1:1000";
+
+// environment
+AWS_EMF_AGENT_ENDPOINT="udp://127.0.0.1:1000"
+```
+
+**EnvironmentOverride**: Short circuit auto-environment detection by explicitly defining how events should be sent.
+
+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
+- EC2: decorates logs with EC2 metadata and sends over TCP
+
+Example:
+
+```js
+// in process
+const { Configuration } = require("aws-embedded-metrics");
+Configuration.environmentOverride = "Local";
+
+// environment
+AWS_EMF_AGENT_ENDPOINT=Local
+```
+
+**EnableDebugLogging**: Enable debug logging for the library. If the library is not behaving as expected, you can set this to true to log to console.
+
+Example:
+
+```js
+// in process
+const { Configuration } = require("aws-embedded-metrics");
+Configuration.debuggingLoggingEnabled = true;
+
+// environment
+AWS_EMF_ENABLE_DEBUG_LOGGING=true
+```
+
 ## Examples
 
 Check out the [examples](https://github.com/awslabs/aws-embedded-metrics-node/tree/master/examples) directory to get started.
diff --git a/src/config/EnvironmentConfigurationProvider.ts b/src/config/EnvironmentConfigurationProvider.ts
index f3b0f43..3e8e6ca 100644
--- a/src/config/EnvironmentConfigurationProvider.ts
+++ b/src/config/EnvironmentConfigurationProvider.ts
@@ -14,6 +14,7 @@
  */
 
 import { IConfiguration } from './IConfiguration';
+import Environments from "../environment/Environments";
 
 const ENV_VAR_PREFIX = 'AWS_EMF';
 
@@ -24,6 +25,7 @@ enum ConfigKeys {
   SERVICE_NAME = 'SERVICE_NAME',
   SERVICE_TYPE = 'SERVICE_TYPE',
   AGENT_ENDPOINT = 'AGENT_ENDPOINT',
+  ENVIRONMENT_OVERRIDE = 'ENVIRONMENT'
 }
 
 export class EnvironmentConfigurationProvider {
@@ -37,6 +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()
     };
   }
 
@@ -52,4 +55,13 @@ 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];
+    if (environment === undefined) {
+      return Environments.Unknown;
+    }
+    return environment;
+  }
 }
diff --git a/src/config/IConfiguration.ts b/src/config/IConfiguration.ts
index adc8abd..a8083af 100644
--- a/src/config/IConfiguration.ts
+++ b/src/config/IConfiguration.ts
@@ -13,6 +13,8 @@
  * limitations under the License.
  */
 
+import Environments from "../environment/Environments";
+
 export interface IConfiguration {
   /**
    * Whether or not internal logging should be enabled.
@@ -45,4 +47,14 @@ export interface IConfiguration {
    * The endpoint to use to connect to the CloudWatch Agent
    */
   agentEndpoint: string | undefined;
+
+  /**
+   * Environment override. This will short circuit auto-environment detection.
+   * 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 
+   * - EC2: decorates logs with EC2 metadata and sends over TCP
+   */
+  environmentOverride: Environments | undefined;
 }
diff --git a/src/environment/EnvironmentDetector.ts b/src/environment/EnvironmentDetector.ts
index dd3ac3f..769030d 100644
--- a/src/environment/EnvironmentDetector.ts
+++ b/src/environment/EnvironmentDetector.ts
@@ -18,49 +18,86 @@ import { DefaultEnvironment } from './DefaultEnvironment';
 import { EC2Environment } from './EC2Environment';
 import { IEnvironment } from './IEnvironment';
 import { LambdaEnvironment } from './LambdaEnvironment';
+import config from '../config/Configuration';
+import Environments from './Environments';
+import { LocalEnvironment } from './LocalEnvironment';
 
 type EnvironmentProvider = () => Promise<IEnvironment>;
 
-const environments = [new LambdaEnvironment(), new EC2Environment()];
+const lambdaEnvironment = new LambdaEnvironment();
+const ec2Environment = new EC2Environment();
 const defaultEnvironment = new DefaultEnvironment();
+const environments = [lambdaEnvironment, ec2Environment];
 
-let environment: IEnvironment | undefined;
-const resolveEnvironment: EnvironmentProvider = async (): Promise<IEnvironment> => {
-  if (environment) {
-    return environment;
+let environment : IEnvironment | undefined = defaultEnvironment;
+
+const getEnvironmentFromOverride = (): IEnvironment | undefined => {
+  // short-circuit environment detection and use override
+  switch (config.environmentOverride) {
+    case Environments.Agent:
+      return defaultEnvironment;
+    case Environments.EC2:
+      return ec2Environment;
+    case Environments.Lambda:
+      return lambdaEnvironment;
+    case Environments.Local:
+      return new LocalEnvironment();
+    case Environments.Unknown:
+    default:
+      return undefined;
   }
+}
 
+const discoverEnvironment = async (): Promise<IEnvironment> => {
   for (const envUnderTest of environments) {
     LOG(`Testing: ${envUnderTest.constructor.name}`);
 
-    let isEnvironment = false;
     try {
-      isEnvironment = await envUnderTest.probe();
+      if (await envUnderTest.probe()) {
+        return envUnderTest;
+      }
     } catch (e) {
       LOG(`Failed probe: ${envUnderTest.constructor.name}`);
     }
-
-    if (isEnvironment) {
-      environment = envUnderTest;
-      break;
-    }
   }
+  return defaultEnvironment;
+}
 
-  if (!environment) {
-    environment = defaultEnvironment;
+const _resolveEnvironment: EnvironmentProvider = async (): Promise<IEnvironment> => {
+  if (environment) {
+    return environment;
   }
 
-  LOG(`Using Environment: ${environment.constructor.name}`);
-
+  if (config.environmentOverride) {
+    LOG("Environment override supplied", config.environmentOverride);
+    // this will be falsy if an invalid configuration value is provided
+    environment = getEnvironmentFromOverride()
+    if (environment) {
+      return environment;
+    }
+    else {
+      LOG('Invalid environment provided. Falling back to auto-discovery.', config.environmentOverride);
+    }
+  }
+  
+  environment = await discoverEnvironment(); // eslint-disable-line require-atomic-updates
   return environment;
 };
 
-const resetEnvironment = (): void => (environment = undefined);
+
 
 // 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
 // may occur on the initial flush()
-resolveEnvironment();
+const environmentPromise = _resolveEnvironment();
+const resolveEnvironment: EnvironmentProvider = async (): Promise<IEnvironment> => {
+  return environmentPromise;
+};
+
+const cleanResolveEnvironment = async (): Promise<IEnvironment> => {
+  environment = undefined; 
+  return await _resolveEnvironment();
+};
 
-export { EnvironmentProvider, resolveEnvironment, resetEnvironment };
+export { EnvironmentProvider, resolveEnvironment, cleanResolveEnvironment };
diff --git a/src/environment/Environments.ts b/src/environment/Environments.ts
new file mode 100644
index 0000000..5462782
--- /dev/null
+++ b/src/environment/Environments.ts
@@ -0,0 +1,9 @@
+enum Environments {
+    Local = "Local",
+    Lambda = "Lambda",
+    Agent = "Agent",
+    EC2 = "EC2",
+    Unknown = ""
+};
+
+export default Environments;
\ No newline at end of file
diff --git a/src/environment/LambdaEnvironment.ts b/src/environment/LambdaEnvironment.ts
index ca87c2c..e53e98f 100644
--- a/src/environment/LambdaEnvironment.ts
+++ b/src/environment/LambdaEnvironment.ts
@@ -14,7 +14,7 @@
  */
 
 import { MetricsContext } from '../logger/MetricsContext';
-import { LambdaSink } from '../sinks/LambdaSink';
+import { ConsoleSink } from '../sinks/ConsoleSink';
 import { ISink } from '../sinks/Sink';
 import { IEnvironment } from './IEnvironment';
 
@@ -51,7 +51,7 @@ export class LambdaEnvironment implements IEnvironment {
 
   public getSink(): ISink {
     if (!this.sink) {
-      this.sink = new LambdaSink();
+      this.sink = new ConsoleSink();
     }
     return this.sink;
   }
diff --git a/src/environment/LocalEnvironment.ts b/src/environment/LocalEnvironment.ts
new file mode 100644
index 0000000..cc66f56
--- /dev/null
+++ b/src/environment/LocalEnvironment.ts
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2019 Amazon.com, Inc. or its affiliates.
+ * Licensed under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import config from '../config/Configuration';
+import { ISink } from '../sinks/Sink';
+import { LOG } from '../utils/Logger';
+import { IEnvironment } from './IEnvironment';
+import { ConsoleSink } from '../sinks/ConsoleSink';
+
+export class LocalEnvironment implements IEnvironment {
+  private sink: ISink | undefined;
+
+  public probe(): Promise<boolean> {
+    // probe is not intended to be used in the LocalEnvironment
+    // To use the local environment you should set the environment
+    // override
+    return Promise.resolve(false);
+  }
+
+  public getName(): string {
+    if (!config.serviceName) {
+      LOG('Unknown ServiceName.');
+      return 'Unknown';
+    }
+    return config.serviceName;
+  }
+
+  public getType(): string {
+    if (!config.serviceType) {
+      LOG('Unknown ServiceType.');
+      return 'Unknown';
+    }
+    return config.serviceType;
+  }
+
+  public getLogGroupName(): string {
+    return config.logGroupName ? config.logGroupName : `${this.getName()}-metrics`;
+  }
+
+  public configureContext(): void {
+    // no-op
+  }
+
+  public getSink(): ISink {
+    if (!this.sink) {
+      this.sink = new ConsoleSink();
+    }
+    return this.sink;
+  }
+}
diff --git a/src/index.ts b/src/index.ts
index d6f0bb9..883c260 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -14,7 +14,7 @@
  */
 
 export { MetricsLogger } from './logger/MetricsLogger';
-export { LambdaSink } from './sinks/LambdaSink';
+export { ConsoleSink as LocalSink } from './sinks/ConsoleSink';
 export { AgentSink } from './sinks/AgentSink';
 export { metricScope } from './logger/MetricScope';
 export { createMetricsLogger } from './logger/MetricsLoggerFactory';
diff --git a/src/sinks/LambdaSink.ts b/src/sinks/ConsoleSink.ts
similarity index 93%
rename from src/sinks/LambdaSink.ts
rename to src/sinks/ConsoleSink.ts
index ff981bc..59468de 100644
--- a/src/sinks/LambdaSink.ts
+++ b/src/sinks/ConsoleSink.ts
@@ -22,8 +22,8 @@ import { ISink } from './Sink';
  * A sink that flushes log data to stdout.
  * This is the preferred sink for Lambda functions.
  */
-export class LambdaSink implements ISink {
-  public readonly name: string = 'LambdaSink';
+export class ConsoleSink implements ISink {
+  public readonly name: string = 'ConsoleSink';
 
   private serializer: ISerializer;
 


Test patch:
diff --git a/src/config/__tests__/EnvironmentConfigurationProvider.test.ts b/src/config/__tests__/EnvironmentConfigurationProvider.test.ts
index 461c24d..8882abc 100644
--- a/src/config/__tests__/EnvironmentConfigurationProvider.test.ts
+++ b/src/config/__tests__/EnvironmentConfigurationProvider.test.ts
@@ -1,4 +1,5 @@
 import * as faker from 'faker';
+import Environments from '../../environment/Environments';
 
 beforeEach(() => {
   jest.resetModules();
@@ -171,3 +172,38 @@ test('can set agent endpoint from environment', () => {
   const result = config.agentEndpoint;
   expect(result).toBe(expectedValue);
 });
+
+test('can set environment override from environment', () => {
+  // arrange
+  const expectedValue = "Local"
+  process.env.AWS_EMF_ENVIRONMENT = expectedValue;
+
+  // act
+  const config = getConfig();
+
+  // assert
+  const result = config.environmentOverride;
+  expect(result).toBe(Environments.Local);
+});
+
+test('if environment override is not set, default to unknown', () => {
+  // arrange
+  process.env.AWS_EMF_ENVIRONMENT = "";
+  // act
+  const config = getConfig();
+
+  // assert
+  const result = config.environmentOverride;
+  expect(result).toBe(Environments.Unknown);
+});
+
+test('if environment override cannot be parsed, default to unknown', () => {
+  // arrange
+  process.env.AWS_EMF_ENVIRONMENT = faker.random.alphaNumeric();
+  // act
+  const config = getConfig();
+
+  // assert
+  const result = config.environmentOverride;
+  expect(result).toBe(Environments.Unknown);
+});
diff --git a/src/environment/__tests__/EnvironmentDetector.test.ts b/src/environment/__tests__/EnvironmentDetector.test.ts
index f6af77e..d68fb93 100644
--- a/src/environment/__tests__/EnvironmentDetector.test.ts
+++ b/src/environment/__tests__/EnvironmentDetector.test.ts
@@ -1,10 +1,10 @@
 import * as faker from 'faker';
-import { resetEnvironment, resolveEnvironment } from '../EnvironmentDetector';
+import { cleanResolveEnvironment } from '../EnvironmentDetector';
+import config from '../../config/Configuration';
+import Environments from '../Environments';
 
 beforeEach(() => {
   process.env = {};
-  resetEnvironment();
-  jest.resetModules();
 });
 
 test('resolveEnvironment() returns LambdaEnvironment if AWS_LAMBDA_FUNCTION_NAME specified', async () => {
@@ -12,7 +12,7 @@ test('resolveEnvironment() returns LambdaEnvironment if AWS_LAMBDA_FUNCTION_NAME
   process.env.AWS_LAMBDA_FUNCTION_NAME = faker.random.word();
 
   // act
-  const result = await resolveEnvironment();
+  const result = await cleanResolveEnvironment();
 
   // assert
   expect(result.constructor.name).toBe('LambdaEnvironment');
@@ -21,8 +21,32 @@ test('resolveEnvironment() returns LambdaEnvironment if AWS_LAMBDA_FUNCTION_NAME
 test('resolveEnvironment() returns DefaultEnvironment if nothing else was detected', async () => {
   // arrange
   // act
-  const result = await resolveEnvironment();
+  const result = await cleanResolveEnvironment();
 
   // assert
   expect(result.constructor.name).toBe('DefaultEnvironment');
 }, 10000);
+
+test('resolveEnvironment() honors configured override', async () => {
+  // arrange
+  config.environmentOverride = Environments.Local;
+
+  // act
+  const result = await cleanResolveEnvironment();
+
+  // assert
+  expect(result.constructor.name).toBe('LocalEnvironment');
+});
+
+test('resolveEnvironment() ignores invalid override and falls back to discovery', async () => {
+  // arrange
+  // @ts-ignore
+  config.environmentOverride = "Invalid";
+  process.env.AWS_LAMBDA_FUNCTION_NAME = faker.random.word();
+
+  // act
+  const result = await cleanResolveEnvironment();
+
+  // assert
+  expect(result.constructor.name).toBe('LambdaEnvironment');
+});
\ No newline at end of file
diff --git a/src/environment/__tests__/LambdaEnvironment.test.ts b/src/environment/__tests__/LambdaEnvironment.test.ts
index 2c3e2c4..f9e6dff 100644
--- a/src/environment/__tests__/LambdaEnvironment.test.ts
+++ b/src/environment/__tests__/LambdaEnvironment.test.ts
@@ -51,9 +51,9 @@ test('getLogGroupName() returns function name', () => {
   expect(result).toBe(expectedName);
 });
 
-test('createSink() creates a LambdaSink', () => {
+test('createSink() creates a ConsoleSink', () => {
   // arrange
-  const expectedSink = 'LambdaSink';
+  const expectedSink = 'ConsoleSink';
   const env = new LambdaEnvironment();
 
   // act
diff --git a/src/environment/__tests__/LocalEnvironment.test.ts b/src/environment/__tests__/LocalEnvironment.test.ts
new file mode 100644
index 0000000..09410e1
--- /dev/null
+++ b/src/environment/__tests__/LocalEnvironment.test.ts
@@ -0,0 +1,101 @@
+import * as faker from 'faker';
+import config from '../../config/Configuration';
+import { LocalEnvironment } from '../LocalEnvironment';
+
+test('probe() always returns false', async () => {
+  // arrange
+  const env = new LocalEnvironment();
+
+  // act
+  const result = await env.probe();
+
+  // assert
+  expect(result).toBe(false);
+});
+
+test('getName() returns "Unknown" if not specified', () => {
+  // arrange
+  const env = new LocalEnvironment();
+
+  // act
+  const result = env.getName();
+
+  // assert
+  expect(result).toBe('Unknown');
+});
+
+test('getType() returns "Unknown" if not specified', () => {
+  // arrange
+  const env = new LocalEnvironment();
+
+  // act
+  const result = env.getType();
+
+  // assert
+  expect(result).toBe('Unknown');
+});
+
+test('getName() returns name if configured', () => {
+  // arrange
+  const expectedName = faker.random.word();
+  config.serviceName = expectedName;
+  const env = new LocalEnvironment();
+
+  // act
+  const result = env.getName();
+
+  // assert
+  expect(result).toBe(expectedName);
+});
+
+test('getType() returns type if configured', () => {
+  // arrange
+  const expectedType = faker.random.word();
+  config.serviceType = expectedType;
+  const env = new LocalEnvironment();
+
+  // act
+  const result = env.getType();
+
+  // assert
+  expect(result).toBe(expectedType);
+});
+
+test('getLogGroupName() returns logGroup if configured', () => {
+  // arrange
+  const name = faker.random.word();
+  config.logGroupName = name;
+  const env = new LocalEnvironment();
+
+  // act
+  const result = env.getLogGroupName();
+
+  // assert
+  expect(result).toBe(name);
+});
+
+test('getLogGroupName() returns <ServiceName>-metrics if not configured', () => {
+  // arrange
+  const serviceName = faker.random.word();
+  config.logGroupName = undefined;
+  config.serviceName = serviceName;
+  const env = new LocalEnvironment();
+
+  // act
+  const result = env.getLogGroupName();
+
+  // assert
+  expect(result).toBe(`${serviceName}-metrics`);
+});
+
+test('getSink() creates a ConsoleSink', () => {
+  // arrange
+  const expectedSink = 'ConsoleSink';
+  const env = new LocalEnvironment();
+
+  // act
+  const sink = env.getSink();
+
+  // assert
+  expect(sink.name).toBe(expectedSink);
+});
diff --git a/src/sinks/__tests__/LambdaSink.test.ts b/src/sinks/__tests__/ConsoleSink.test.ts
similarity index 83%
rename from src/sinks/__tests__/LambdaSink.test.ts
rename to src/sinks/__tests__/ConsoleSink.test.ts
index 33f8350..00a3454 100644
--- a/src/sinks/__tests__/LambdaSink.test.ts
+++ b/src/sinks/__tests__/ConsoleSink.test.ts
@@ -1,6 +1,6 @@
 import * as faker from 'faker';
 import { MetricsContext } from '../../logger/MetricsContext';
-import { LambdaSink } from '../LambdaSink';
+import { ConsoleSink } from '../ConsoleSink';
 
 beforeEach(() => {
   console.log = jest.fn();
@@ -13,7 +13,7 @@ test('accept serializes and writes result to stdout', () => {
     serialize: jest.fn(() => expected),
   };
 
-  const sink = new LambdaSink(serializer);
+  const sink = new ConsoleSink(serializer);
 
   // act
   sink.accept(MetricsContext.empty());