diff --git a/src/logger/MetricValues.ts b/src/logger/MetricValues.ts index d7c9ba0..87b885d 100644 --- a/src/logger/MetricValues.ts +++ b/src/logger/MetricValues.ts @@ -16,11 +16,11 @@ import { Unit } from '..'; */ export class MetricValues { - public values: number[]; + public values: number[] = []; public unit: string; - constructor(value: number, unit?: Unit | string) { - this.values = [value]; + constructor(value: number | number[], unit?: Unit | string) { + this.values = this.values.concat(value); this.unit = unit || 'None'; } diff --git a/src/logger/MetricsContext.ts b/src/logger/MetricsContext.ts index 430c179..cf5997a 100644 --- a/src/logger/MetricsContext.ts +++ b/src/logger/MetricsContext.ts @@ -23,6 +23,12 @@ interface IProperties { } type Metrics = Map; +type DimensionSet = Record; +type MetricsDirective = { + namespace: string; + dimensions: DimensionSet[] + metrics: Metrics +} export class MetricsContext { /** @@ -32,11 +38,10 @@ export class MetricsContext { return new MetricsContext(); } - public namespace: string; public properties: IProperties; - public metrics: Metrics = new Map(); public meta: Record = {}; - private dimensions: Array>; + private defaultMetricsDirective: MetricsDirective; + private metricDirectives: MetricsDirective[]; private defaultDimensions: Record; private shouldUseDefaultDimensions = true; @@ -57,15 +62,19 @@ export class MetricsContext { dimensions?: Array>, defaultDimensions?: Record, ) { - this.namespace = namespace || Configuration.namespace this.properties = properties || {}; - this.dimensions = dimensions || []; this.meta.Timestamp = new Date().getTime(); this.defaultDimensions = defaultDimensions || {}; + this.defaultMetricsDirective = { + namespace: namespace || Configuration.namespace, + metrics: new Map(), + dimensions: dimensions || [] + }; + this.metricDirectives = [ this.defaultMetricsDirective ]; } public setNamespace(value: string): void { - this.namespace = value; + this.defaultMetricsDirective.namespace = value; } public setProperty(key: string, value: unknown): void { @@ -89,13 +98,13 @@ export class MetricsContext { * @param dimensions */ public putDimensions(incomingDimensionSet: Record): void { - if (this.dimensions.length === 0) { - this.dimensions.push(incomingDimensionSet); + if (this.defaultMetricsDirective.dimensions.length === 0) { + this.defaultMetricsDirective.dimensions.push(incomingDimensionSet); return; } - for (let i = 0; i < this.dimensions.length; i++) { - const existingDimensionSet = this.dimensions[i]; + for (let i = 0; i < this.defaultMetricsDirective.dimensions.length; i++) { + const existingDimensionSet = this.defaultMetricsDirective.dimensions[i]; // check for duplicate dimensions when putting // this is an O(n^2) operation, but since we never expect to have more than @@ -104,20 +113,28 @@ export class MetricsContext { const existingDimensionSetKeys = Object.keys(existingDimensionSet); const incomingDimensionSetKeys = Object.keys(incomingDimensionSet); if (existingDimensionSetKeys.length !== incomingDimensionSetKeys.length) { - this.dimensions.push(incomingDimensionSet); + this.defaultMetricsDirective.dimensions.push(incomingDimensionSet); return; } for (let j = 0; j < existingDimensionSetKeys.length; j++) { if (!incomingDimensionSetKeys.includes(existingDimensionSetKeys[j])) { // we're done now because we know that the dimensions keys are not identical - this.dimensions.push(incomingDimensionSet); + this.defaultMetricsDirective.dimensions.push(incomingDimensionSet); return; } } } } + public putMetricDirective(metrics: Metrics, dimensions: DimensionSet[], namespace?: string): void { + this.metricDirectives.push({ + namespace: namespace || Configuration.namespace, + metrics, + dimensions + }); + } + /** * Overwrite all dimensions. * @@ -125,42 +142,49 @@ export class MetricsContext { */ public setDimensions(dimensionSets: Array>): void { this.shouldUseDefaultDimensions = false; - this.dimensions = dimensionSets; + this.defaultMetricsDirective.dimensions = dimensionSets; } /** - * Get the current dimensions. + * Get the current dimensions on the default metric directive. */ public getDimensions(): Array> { // caller has explicitly called setDimensions if (this.shouldUseDefaultDimensions === false) { - return this.dimensions; + return this.defaultMetricsDirective.dimensions; } // if there are no default dimensions, return the custom dimensions if (Object.keys(this.defaultDimensions).length === 0) { - return this.dimensions; + return this.defaultMetricsDirective.dimensions; } // if default dimensions have been provided, but no custom dimensions, use the defaults - if (this.dimensions.length === 0) { + if (this.defaultMetricsDirective.dimensions.length === 0) { return [this.defaultDimensions]; } // otherwise, merge the dimensions // we do this on the read path because default dimensions // may get updated asynchronously by environment detection - return this.dimensions.map(custom => { + return this.defaultMetricsDirective.dimensions.map(custom => { return { ...this.defaultDimensions, ...custom }; }); } + /** + * Add a metric to the default metric directive. + * + * @param key The name of the metric + * @param value The metric value. Note that percentiles are only supported on positive vales. + * @param unit The metric unit. Must be a valid CloudWatch metric. + */ public putMetric(key: string, value: number, unit?: Unit | string): void { - const currentMetric = this.metrics.get(key); + const currentMetric = this.defaultMetricsDirective.metrics.get(key); if (currentMetric) { currentMetric.addValue(value); } else { - this.metrics.set(key, new MetricValues(value, unit)); + this.defaultMetricsDirective.metrics.set(key, new MetricValues(value, unit)); } } @@ -169,9 +193,9 @@ export class MetricsContext { */ public createCopyWithContext(): MetricsContext { return new MetricsContext( - this.namespace, + this.defaultMetricsDirective.namespace, Object.assign({}, this.properties), - Object.assign([], this.dimensions), + Object.assign([], this.defaultMetricsDirective.dimensions), this.defaultDimensions, ); } diff --git a/src/logger/MetricsLogger.ts b/src/logger/MetricsLogger.ts index fe5ee84..ca1552c 100644 --- a/src/logger/MetricsLogger.ts +++ b/src/logger/MetricsLogger.ts @@ -17,8 +17,22 @@ import Configuration from '../config/Configuration'; import { EnvironmentProvider } from '../environment/EnvironmentDetector'; import { IEnvironment } from '../environment/IEnvironment'; import { MetricsContext } from './MetricsContext'; +import { MetricValues } from './MetricValues'; import { Unit } from './Unit'; +type Metrics = { name: string, value: number | number[], unit?: Unit }; +type MetricsWithDimensions = { + metrics: Metrics[], + namespace?: string | undefined, + dimensions?: Array> | undefined, + + /** + * Do not apply default dimensions such as ServiceName and ServiceType. + * The default behavior is to include the default dimensions. + */ + stripDefaultDimensions?: boolean | undefined; +}; + /** * An async metrics logger. * Use this interface to publish logs to CloudWatch Logs @@ -114,6 +128,20 @@ export class MetricsLogger { return this; } + /** + * Add a collection of metrics to be aggregated on a different set of dimensions + * than the default dimension set. + * + * @param metricWithDimensions + */ + public putMetricWithDimensions(metricWithDimensions: MetricsWithDimensions): MetricsLogger { + this.context.putMetricDirective( + new Map(metricWithDimensions.metrics.map(m => [m.name, new MetricValues(m.value, m.unit)])), + metricWithDimensions.dimensions || [], + metricWithDimensions.namespace); + return this; + } + /** * Creates a new logger using the same contextual data as * the previous logger. This allows you to flush the instances diff --git a/src/logger/__tests__/MetricsLogger.test.ts b/src/logger/__tests__/MetricsLogger.test.ts index 984ca79..c0b7549 100644 --- a/src/logger/__tests__/MetricsLogger.test.ts +++ b/src/logger/__tests__/MetricsLogger.test.ts @@ -7,6 +7,7 @@ import { IEnvironment } from '../../environment/IEnvironment'; import { ISink } from '../../sinks/Sink'; import { MetricsContext } from '../MetricsContext'; import { MetricsLogger } from '../MetricsLogger'; +import { Constants } from '../../Constants'; const createSink = () => new TestSink(); const createEnvironment = (sink: ISink) => { @@ -21,6 +22,16 @@ const createEnvironment = (sink: ISink) => { }; const createLogger = (env: EnvironmentProvider) => new MetricsLogger(env); +const DEFAULT_DIMENSIONS = { Foo: 'Bar' }; +const createLoggerWithDefaultDimensions = (): MetricsLogger => { + const context = MetricsContext.empty(); + context.setDefaultDimensions(DEFAULT_DIMENSIONS); + + const sink = createSink(); + const env = createEnvironment(sink); + return new MetricsLogger(() => Promise.resolve(env), context); +} + let sink: TestSink; let environment: IEnvironment; let logger: MetricsLogger; @@ -297,6 +308,164 @@ test('context is preserved across flush() calls', async () => { } }); +test('putMetricWithDimensions metric only', async () => { + // arrange + const logger = createLoggerWithDefaultDimensions(); + + // act + logger.putMetricWithDimensions({ + metrics: [{ name: "MyMetric", value: 100 }] + }); + + await logger.flush(); + + // assert + expect(sink.events).toHaveLength(1); + const evt = sink.events[0]; + expect(evt.metrics.size).toBe(1); + expect(evt.metrics.get("MyMetric")).toBe(100); + // everything else should be defaults + expect(evt.namespace).toBe(Constants.DEFAULT_NAMESPACE); + expect(evt.getDimensions()[0]).toBe(DEFAULT_DIMENSIONS); +}); + +test('putMetricWithDimensions metric and unit', async () => { + // arrange + const logger = createLoggerWithDefaultDimensions(); + + // act + logger.putMetricWithDimensions({ + metrics: [{ name: "MyMetric", value: 100, unit: Unit.Bytes }] + }); + + await logger.flush(); + + // assert + expect(sink.events).toHaveLength(1); + const evt = sink.events[0]; + expect(evt.metrics.size).toBe(1); + const resultMetric = evt.metrics.get("MyMetric"); + expect(resultMetric.values).toBe([100]); + expect(resultMetric.unit).toBe('Bytes'); + // everything else should be defaults + expect(evt.namespace).toBe(Constants.DEFAULT_NAMESPACE); + expect(evt.getDimensions()[0]).toBe(DEFAULT_DIMENSIONS); +}); + +test('putMetricWithDimensions single metric with namespace', async () => { + // arrange + const logger = createLoggerWithDefaultDimensions(); + + // act + logger.putMetricWithDimensions({ + metrics: [{ name: "MyMetric", value: 100 }], + namespace: "My-Namespace" + }); + + // act + await logger.flush(); + + // assert + expect(sink.events).toHaveLength(1); + const evt = sink.events[0]; + expect(evt.metrics.size).toBe(1); + const resultMetric = evt.metrics.get("MyMetric"); + expect(resultMetric.values).toBe([100]); + expect(evt.namespace).toBe("My-Namespace"); + expect(evt.getDimensions()[0]).toBe(DEFAULT_DIMENSIONS); +}); + + +test('putMetricWithDimensions with single dimensions and default namespace', async () => { + // arrange + const logger = createLoggerWithDefaultDimensions(); + const client = 'client'; + + // act + logger.putMetricWithDimensions({ + metrics: [{ name: "MyMetric", value: 100 }], + dimensions: [{ client }] + }); + + await logger.flush(); + + // assert + expect(sink.events).toHaveLength(1); + const evt = sink.events[0]; + expect(evt.metrics.size).toBe(1); + const resultMetric = evt.metrics.get("MyMetric"); + expect(resultMetric.values).toBe([100]); + expect(evt.namespace).toBe(Constants.DEFAULT_NAMESPACE); + expect(evt.getDimensions()).toBe([{ ...DEFAULT_DIMENSIONS, client }]); +}); + +test('putMetricWithDimensions along multiple dimensions', async () => { + // arrange + const logger = createLoggerWithDefaultDimensions(); + const client = 'client'; + const pageType = 'pageType'; + + // act + logger.putMetricWithDimensions({ + metrics: [{ name: "MyMetric", value: 100 }], + namespace: "My Namespace", + dimensions: [ + { client }, + { pageType }, + { client, pageType }, + ] + }); + + await logger.flush(); + + // assert + expect(sink.events).toHaveLength(1); + const evt = sink.events[0]; + expect(evt.metrics.size).toBe(1); + const resultMetric = evt.metrics.get("MyMetric"); + expect(resultMetric.values).toBe([100]); + expect(evt.namespace).toBe("My-Namespace"); + expect(evt.getDimensions()[0]).toBe([ + { ...DEFAULT_DIMENSIONS, client }, + { ...DEFAULT_DIMENSIONS, pageType }, + { ...DEFAULT_DIMENSIONS, client, pageType }, + ]); +}); + +test('putMetricWithDimensions without default dimensions', async () => { + // arrange + const logger = createLoggerWithDefaultDimensions(); + const client = 'client'; + const pageType = 'pageType'; + + // act + logger.putMetricWithDimensions({ + metrics: [{ name: "MyMetric", value: 100 }], + namespace: "My-Namespace", + dimensions: [ + { client }, + { pageType }, + { client, pageType }, + ], + stripDefaultDimensions: true + }); + + await logger.flush(); + + // assert + expect(sink.events).toHaveLength(1); + const evt = sink.events[0]; + expect(evt.metrics.size).toBe(1); + const resultMetric = evt.metrics.get("MyMetric"); + expect(resultMetric.values).toBe([100]); + expect(evt.namespace).toBe("My-Namespace"); + expect(evt.getDimensions()[0]).toBe([ + { client }, + { pageType }, + { client, pageType }, + ]); +}); + const expectDimension = (key: string, value: string) => { expect(sink.events).toHaveLength(1); const dimensionSets = sink.events[0].getDimensions();