diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/src/always-record-sampler.ts b/aws-distro-opentelemetry-node-autoinstrumentation/src/always-record-sampler.ts index 1d3b2ff..1cc14c8 100644 --- a/aws-distro-opentelemetry-node-autoinstrumentation/src/always-record-sampler.ts +++ b/aws-distro-opentelemetry-node-autoinstrumentation/src/always-record-sampler.ts @@ -24,8 +24,8 @@ export class AlwaysRecordSampler implements Sampler { } private constructor(rootSampler: Sampler) { - if (rootSampler === null) { - throw new Error('rootSampler is null. It must be provided'); + if (rootSampler == null) { + throw new Error('rootSampler is null/undefined. It must be provided'); } this.rootSampler = rootSampler; } diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/src/attribute-propagating-span-processor-builder.ts b/aws-distro-opentelemetry-node-autoinstrumentation/src/attribute-propagating-span-processor-builder.ts new file mode 100644 index 0000000..92596f9 --- /dev/null +++ b/aws-distro-opentelemetry-node-autoinstrumentation/src/attribute-propagating-span-processor-builder.ts @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { AttributePropagatingSpanProcessor } from './attribute-propagating-span-processor'; +import { AWS_ATTRIBUTE_KEYS } from './aws-attribute-keys'; +import { AwsSpanProcessingUtil } from './aws-span-processing-util'; + +/** + * AttributePropagatingSpanProcessorBuilder is used to construct a {@link AttributePropagatingSpanProcessor}. + * If {@link setPropagationDataExtractor}, {@link setPropagationDataKey} or {@link setAttributesKeysToPropagate} + * are not invoked, the builder defaults to using specific propagation targets. + */ +export class AttributePropagatingSpanProcessorBuilder { + private propagationDataExtractor: (span: ReadableSpan) => string = AwsSpanProcessingUtil.getIngressOperation; + private propagationDataKey: string = AWS_ATTRIBUTE_KEYS.AWS_LOCAL_OPERATION; + private attributesKeysToPropagate: string[] = [ + AWS_ATTRIBUTE_KEYS.AWS_REMOTE_SERVICE, + AWS_ATTRIBUTE_KEYS.AWS_REMOTE_OPERATION, + ]; + + public static create(): AttributePropagatingSpanProcessorBuilder { + return new AttributePropagatingSpanProcessorBuilder(); + } + + private constructor() {} + + public setPropagationDataExtractor( + propagationDataExtractor: (span: ReadableSpan) => string + ): AttributePropagatingSpanProcessorBuilder { + if (propagationDataExtractor == null) { + throw new Error('propagationDataExtractor must not be null'); + } + this.propagationDataExtractor = propagationDataExtractor; + return this; + } + + public setPropagationDataKey(propagationDataKey: string): AttributePropagatingSpanProcessorBuilder { + if (propagationDataKey == null) { + throw new Error('propagationDataKey must not be null'); + } + this.propagationDataKey = propagationDataKey; + return this; + } + + public setAttributesKeysToPropagate(attributesKeysToPropagate: string[]): AttributePropagatingSpanProcessorBuilder { + if (attributesKeysToPropagate == null) { + throw new Error('attributesKeysToPropagate must not be null'); + } + this.attributesKeysToPropagate = [...attributesKeysToPropagate]; + return this; + } + + public build(): AttributePropagatingSpanProcessor { + return AttributePropagatingSpanProcessor.create( + this.propagationDataExtractor, + this.propagationDataKey, + this.attributesKeysToPropagate + ); + } +} diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/src/attribute-propagating-span-processor.ts b/aws-distro-opentelemetry-node-autoinstrumentation/src/attribute-propagating-span-processor.ts new file mode 100644 index 0000000..f602d7f --- /dev/null +++ b/aws-distro-opentelemetry-node-autoinstrumentation/src/attribute-propagating-span-processor.ts @@ -0,0 +1,129 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Span as APISpan, AttributeValue, Context, SpanKind, trace } from '@opentelemetry/api'; +import { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { AWS_ATTRIBUTE_KEYS } from './aws-attribute-keys'; +import { AwsSpanProcessingUtil } from './aws-span-processing-util'; + +/** + * AttributePropagatingSpanProcessor handles the propagation of attributes from parent spans to + * child spans, specified in {@link attributesKeysToPropagate}. AttributePropagatingSpanProcessor + * also propagates configurable data from parent spans to child spans, as a new attribute specified + * by {@link propagationDataKey}. Propagated data can be configured via the {@link propagationDataExtractor}. + * Span data propagation only starts from local root server/consumer spans, but from there will + * be propagated to any descendant spans. If the span is a CONSUMER PROCESS with the parent also + * a CONSUMER, it will set attribute AWS_CONSUMER_PARENT_SPAN_KIND as CONSUMER to indicate that + * dependency metrics should not be generated for this span. + */ +export class AttributePropagatingSpanProcessor implements SpanProcessor { + private propagationDataExtractor: (span: ReadableSpan) => string; + + private propagationDataKey: string; + private attributesKeysToPropagate: string[]; + + public static create( + propagationDataExtractor: (span: ReadableSpan) => string, + propagationDataKey: string, + attributesKeysToPropagate: string[] + ): AttributePropagatingSpanProcessor { + return new AttributePropagatingSpanProcessor( + propagationDataExtractor, + propagationDataKey, + attributesKeysToPropagate + ); + } + + private constructor( + propagationDataExtractor: (span: ReadableSpan) => string, + propagationDataKey: string, + attributesKeysToPropagate: string[] + ) { + this.propagationDataExtractor = propagationDataExtractor; + this.propagationDataKey = propagationDataKey; + this.attributesKeysToPropagate = attributesKeysToPropagate; + } + + public onStart(span: Span, parentContext: Context): void { + // Divergence from Java/Python + // Workaround implemented in TypeScript. Calculation of isLocalRoot is not possible + // in `AwsSpanProcessingUtil.isLocalRoot` because the parent context is not accessible + // from a span. Therefore we pre-calculate its value here as an attribute. + AwsSpanProcessingUtil.setIsLocalRootInformation(span, parentContext); + + const parentSpan: APISpan | undefined = trace.getSpan(parentContext); + let parentReadableSpan: ReadableSpan | undefined = undefined; + + // `if check` is different than Python and Java. Here we just check if parentSpan is not undefined + // Whereas in Python and Java, the check is if parentSpan is and instance of ReadableSpan, which is + // not possible in TypeScript because the check is not allowed for interfaces (such as ReadableSpan). + if (parentSpan !== undefined) { + parentReadableSpan = parentSpan as Span; + + // Add the AWS_SDK_DESCENDANT attribute to the immediate child spans of AWS SDK span. + // This attribute helps the backend differentiate between SDK spans and their immediate + // children. + // It's assumed that the HTTP spans are immediate children of the AWS SDK span + // TODO: we should have a contract test to check the immediate children are HTTP span + if (AwsSpanProcessingUtil.isAwsSDKSpan(parentReadableSpan)) { + span.setAttribute(AWS_ATTRIBUTE_KEYS.AWS_SDK_DESCENDANT, 'true'); + } + + if (SpanKind.INTERNAL === parentReadableSpan.kind) { + for (const keyToPropagate of this.attributesKeysToPropagate) { + const valueToPropagate: AttributeValue | undefined = parentReadableSpan.attributes[keyToPropagate]; + if (valueToPropagate !== undefined) { + span.setAttribute(keyToPropagate, valueToPropagate); + } + } + } + + // We cannot guarantee that messaging.operation is set onStart, it could be set after the fact. + // To work around this, add the AWS_CONSUMER_PARENT_SPAN_KIND attribute if parent and child are + // both CONSUMER then check later if a metric should be generated. + if (this.isConsumerKind(span) && this.isConsumerKind(parentReadableSpan)) { + span.setAttribute(AWS_ATTRIBUTE_KEYS.AWS_CONSUMER_PARENT_SPAN_KIND, SpanKind[parentReadableSpan.kind]); + } + } + + let propagationData: AttributeValue | undefined = undefined; + if (AwsSpanProcessingUtil.isLocalRoot(span)) { + if (!this.isServerKind(span)) { + propagationData = this.propagationDataExtractor(span); + } + } else if (parentReadableSpan !== undefined && this.isServerKind(parentReadableSpan)) { + // In TypeScript, perform `parentReadableSpan !== undefined` check + // This should be done in Python and Java as well, but is not as of now + // If parentReadableSpan is not defined, the first `if statement` should occur, + // so that is why it is not a problem for Java/Python... + propagationData = this.propagationDataExtractor(parentReadableSpan); + } else { + // In TypeScript, perform `parentReadableSpan?` check (returns undefined if undefined) + // This should be done in Python and Java as well, but is not as of now + propagationData = parentReadableSpan?.attributes[this.propagationDataKey]; + } + + if (propagationData !== undefined) { + span.setAttribute(this.propagationDataKey, propagationData); + } + } + + private isConsumerKind(span: ReadableSpan): boolean { + return SpanKind.CONSUMER === span.kind; + } + + private isServerKind(span: ReadableSpan): boolean { + return SpanKind.SERVER === span.kind; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public onEnd(span: ReadableSpan): void {} + + public shutdown(): Promise { + return this.forceFlush(); + } + + public forceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-metric-attributes-span-exporter-builder.ts b/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-metric-attributes-span-exporter-builder.ts new file mode 100644 index 0000000..bd2bc21 --- /dev/null +++ b/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-metric-attributes-span-exporter-builder.ts @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Resource } from '@opentelemetry/resources'; +import { SpanExporter } from '@opentelemetry/sdk-trace-base'; +import { AwsMetricAttributeGenerator } from './aws-metric-attribute-generator'; +import { AwsMetricAttributesSpanExporter } from './aws-metric-attributes-span-exporter'; +import { MetricAttributeGenerator } from './metric-attribute-generator'; + +export class AwsMetricAttributesSpanExporterBuilder { + // Defaults + private static DEFAULT_GENERATOR: MetricAttributeGenerator = new AwsMetricAttributeGenerator(); + + // Required builder elements + private delegate: SpanExporter; + private resource: Resource; + + // Optional builder elements + private generator: MetricAttributeGenerator = AwsMetricAttributesSpanExporterBuilder.DEFAULT_GENERATOR; + + public static create(delegate: SpanExporter, resource: Resource): AwsMetricAttributesSpanExporterBuilder { + return new AwsMetricAttributesSpanExporterBuilder(delegate, resource); + } + + private constructor(delegate: SpanExporter, resource: Resource) { + this.delegate = delegate; + this.resource = resource; + } + + /** + * Sets the generator used to generate attributes used spancs exported by the exporter. If unset, + * defaults to {@link DEFAULT_GENERATOR}. Must not be null. + */ + public setGenerator(generator: MetricAttributeGenerator): AwsMetricAttributesSpanExporterBuilder { + if (generator == null) { + throw new Error('generator must not be null/undefined'); + } + this.generator = generator; + return this; + } + + public build(): AwsMetricAttributesSpanExporter { + return AwsMetricAttributesSpanExporter.create(this.delegate, this.generator, this.resource); + } +} diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-metric-attributes-span-exporter.ts b/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-metric-attributes-span-exporter.ts new file mode 100644 index 0000000..16329f8 --- /dev/null +++ b/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-metric-attributes-span-exporter.ts @@ -0,0 +1,136 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Attributes } from '@opentelemetry/api'; +import { ExportResult } from '@opentelemetry/core'; +import { Resource } from '@opentelemetry/resources'; +import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; +import { AWS_ATTRIBUTE_KEYS } from './aws-attribute-keys'; +import { AwsSpanProcessingUtil } from './aws-span-processing-util'; +import { + AttributeMap, + DEPENDENCY_METRIC, + MetricAttributeGenerator, + SERVICE_METRIC, +} from './metric-attribute-generator'; + +/** + * This exporter will update a span with metric attributes before exporting. It depends on a + * {@link SpanExporter} being provided on instantiation, which the AwsMetricAttributesSpanExporter will + * delegate export to. Also, a {@link MetricAttributeGenerator} must be provided, which will provide a + * means to determine attributes which should be applied to the span. Finally, a {@link Resource} must + * be provided, which is used to generate metric attributes. + * + *

This exporter should be coupled with the {@link AwsSpanMetricsProcessor} using the same + * {@link MetricAttributeGenerator}. This will result in metrics and spans being produced with + * common attributes. + */ +export class AwsMetricAttributesSpanExporter implements SpanExporter { + private delegate: SpanExporter; + private generator: MetricAttributeGenerator; + private resource: Resource; + + /** Use {@link AwsMetricAttributesSpanExporterBuilder} to construct this exporter. */ + static create( + delegate: SpanExporter, + generator: MetricAttributeGenerator, + resource: Resource + ): AwsMetricAttributesSpanExporter { + return new AwsMetricAttributesSpanExporter(delegate, generator, resource); + } + + private constructor(delegate: SpanExporter, generator: MetricAttributeGenerator, resource: Resource) { + this.delegate = delegate; + this.generator = generator; + this.resource = resource; + } + + public export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void { + const modifiedSpans: ReadableSpan[] = this.addMetricAttributes(spans); + this.delegate.export(modifiedSpans, resultCallback); + } + + public shutdown(): Promise { + return this.delegate.shutdown(); + } + + public forceFlush(): Promise { + if (this.delegate.forceFlush !== undefined) { + return this.delegate.forceFlush(); + } + return Promise.resolve(); + } + + private addMetricAttributes(spans: ReadableSpan[]): ReadableSpan[] { + const modifiedSpans: ReadableSpan[] = []; + + spans.forEach((span: ReadableSpan) => { + // If the map has no items, no modifications are required. If there is one item, it means the + // span either produces Service or Dependency metric attributes, and in either case we want to + // modify the span with them. If there are two items, the span produces both Service and + // Dependency metric attributes indicating the span is a local dependency root. The Service + // Attributes must be a subset of the Dependency, with the exception of AWS_SPAN_KIND. The + // knowledge that the span is a local root is more important that knowing that it is a + // Dependency metric, so we take all the Dependency metrics but replace AWS_SPAN_KIND with + // LOCAL_ROOT. + + const attributeMap: AttributeMap = this.generator.generateMetricAttributeMapFromSpan(span, this.resource); + let attributes: Attributes | undefined = {}; + + const generatesServiceMetrics: boolean = AwsSpanProcessingUtil.shouldGenerateServiceMetricAttributes(span); + const generatesDependencyMetrics: boolean = AwsSpanProcessingUtil.shouldGenerateDependencyMetricAttributes(span); + + if (generatesServiceMetrics && generatesDependencyMetrics) { + attributes = this.copyAttributesWithLocalRoot(attributeMap[DEPENDENCY_METRIC]); + } else if (generatesServiceMetrics) { + attributes = attributeMap[SERVICE_METRIC]; + } else if (generatesDependencyMetrics) { + attributes = attributeMap[DEPENDENCY_METRIC]; + } + + if (attributes !== undefined && Object.keys(attributes).length > 0) { + span = AwsMetricAttributesSpanExporter.wrapSpanWithAttributes(span, attributes); + } + modifiedSpans.push(span); + }); + + return modifiedSpans; + } + + private copyAttributesWithLocalRoot(attributes: Attributes): Attributes { + const updatedAttributes: Attributes = { ...attributes }; + delete updatedAttributes[AWS_ATTRIBUTE_KEYS.AWS_SPAN_KIND]; + updatedAttributes[AWS_ATTRIBUTE_KEYS.AWS_SPAN_KIND] = AwsSpanProcessingUtil.LOCAL_ROOT; + return updatedAttributes; + } + + /** + * {@link export} works with a {@link ReadableSpan}, which does not permit modification. However, we + * need to add derived metric attributes to the span. However, we are still able to modify the + * attributes in the span (the attributes itself is readonly, so it cannot be outright replaced). + * This may be risky. + * + *

See https://github.com/open-telemetry/opentelemetry-specification/issues/1089 for more + * context on this approach. + */ + private static wrapSpanWithAttributes(span: ReadableSpan, attributes: Attributes): ReadableSpan { + const originalAttributes: Attributes = span.attributes; + const updateAttributes: Attributes = {}; + + for (const key in originalAttributes) { + updateAttributes[key] = originalAttributes[key]; + } + for (const key in attributes) { + updateAttributes[key] = attributes[key]; + } + + // Bypass `readonly` restriction of ReadableSpan's attributes. + // Workaround provided from official TypeScript docs: + // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#improved-control-over-mapped-type-modifiers + type Mutable = { -readonly [P in keyof T]: T[P] }; + const mutableSpan: Mutable = span; + mutableSpan.attributes = updateAttributes; + + return span; + } +} diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-span-metrics-processor-builder.ts b/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-span-metrics-processor-builder.ts new file mode 100644 index 0000000..877dff8 --- /dev/null +++ b/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-span-metrics-processor-builder.ts @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Histogram, Meter, MeterProvider, MetricOptions } from '@opentelemetry/api'; +import { Resource } from '@opentelemetry/resources'; +import { AwsMetricAttributeGenerator } from './aws-metric-attribute-generator'; +import { AwsSpanMetricsProcessor } from './aws-span-metrics-processor'; +import { MetricAttributeGenerator } from './metric-attribute-generator'; + +// Metric instrument configuration constants +const ERROR: string = 'Error'; +const FAULT: string = 'Fault'; +const LATENCY: string = 'Latency'; +const LATENCY_UNITS: string = 'Milliseconds'; + +/** A builder for {@link AwsSpanMetricsProcessor} */ +export class AwsSpanMetricsProcessorBuilder { + // Defaults + private static DEFAULT_GENERATOR: MetricAttributeGenerator = new AwsMetricAttributeGenerator(); + private static DEFAULT_SCOPE_NAME: string = 'AwsSpanMetricsProcessor'; + + // Required builder elements + private meterProvider: MeterProvider; + private resource: Resource; + + // Optional builder elements + private generator: MetricAttributeGenerator = AwsSpanMetricsProcessorBuilder.DEFAULT_GENERATOR; + private scopeName: string = AwsSpanMetricsProcessorBuilder.DEFAULT_SCOPE_NAME; + + public static create(meterProvider: MeterProvider, resource: Resource): AwsSpanMetricsProcessorBuilder { + return new AwsSpanMetricsProcessorBuilder(meterProvider, resource); + } + + private constructor(meterProvider: MeterProvider, resource: Resource) { + this.meterProvider = meterProvider; + this.resource = resource; + } + + /** + * Sets the generator used to generate attributes used in metrics produced by span metrics + * processor. If unset, defaults to {@link DEFAULT_GENERATOR}. Must not be null. + */ + public setGenerator(generator: MetricAttributeGenerator): AwsSpanMetricsProcessorBuilder { + if (generator == null) { + throw new Error('generator must not be null/undefined'); + } + this.generator = generator; + return this; + } + + /** + * Sets the scope name used in the creation of metrics by the span metrics processor. If unset, + * defaults to {@link DEFAULT_SCOPE_NAME}. Must not be null. + */ + public setScopeName(scopeName: string): AwsSpanMetricsProcessorBuilder { + this.scopeName = scopeName; + return this; + } + + public build(): AwsSpanMetricsProcessor { + const meter: Meter = this.meterProvider.getMeter(this.scopeName); + const errorHistogram: Histogram = meter.createHistogram(ERROR); + const faultHistogram: Histogram = meter.createHistogram(FAULT); + + const metricOptions: MetricOptions = { + unit: LATENCY_UNITS, + }; + const latencyHistogram: Histogram = meter.createHistogram(LATENCY, metricOptions); + + return AwsSpanMetricsProcessor.create( + errorHistogram, + faultHistogram, + latencyHistogram, + this.generator, + this.resource + ); + } +} diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-span-metrics-processor.ts b/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-span-metrics-processor.ts new file mode 100644 index 0000000..c935b44 --- /dev/null +++ b/aws-distro-opentelemetry-node-autoinstrumentation/src/aws-span-metrics-processor.ts @@ -0,0 +1,140 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AttributeValue, Attributes, Context, Histogram, SpanStatusCode } from '@opentelemetry/api'; +import { Resource } from '@opentelemetry/resources'; +import { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { SEMATTRS_HTTP_STATUS_CODE } from '@opentelemetry/semantic-conventions'; +import { AttributeMap, MetricAttributeGenerator } from './metric-attribute-generator'; + +/** + * This processor will generate metrics based on span data. It depends on a + * {@link MetricAttributeGenerator} being provided on instantiation, which will provide a means to + * determine attributes which should be used to create metrics. A {@link Resource} must also be + * provided, which is used to generate metrics. Finally, three {@link Histogram}'s must be provided, + * which will be used to actually create desired metrics (see below) + * + *

AwsSpanMetricsProcessor produces metrics for errors (e.g. HTTP 4XX status codes), faults (e.g. + * HTTP 5XX status codes), and latency (in Milliseconds). Errors and faults are counted, while + * latency is measured with a histogram. Metrics are emitted with attributes derived from span + * attributes. + * + *

For highest fidelity metrics, this processor should be coupled with the {@link AlwaysRecordSampler}, + * which will result in 100% of spans being sent to the processor. + */ +export class AwsSpanMetricsProcessor implements SpanProcessor { + private NANOS_TO_MILLIS_DIVIDER: number = 1_000_000.0; + private SECONDS_TO_MILLIS_MULTIPLIER: number = 1_000.0; + + // Constants for deriving error and fault metrics + private ERROR_CODE_LOWER_BOUND: number = 400; + private ERROR_CODE_UPPER_BOUND: number = 499; + private FAULT_CODE_LOWER_BOUND: number = 500; + private FAULT_CODE_UPPER_BOUND: number = 599; + + // Metric instruments + private errorHistogram: Histogram; + private faultHistogram: Histogram; + private latencyHistogram: Histogram; + + private generator: MetricAttributeGenerator; + private resource: Resource; + + /** Use {@link AwsSpanMetricsProcessorBuilder} to construct this processor. */ + static create( + errorHistogram: Histogram, + faultHistogram: Histogram, + latencyHistogram: Histogram, + generator: MetricAttributeGenerator, + resource: Resource + ): AwsSpanMetricsProcessor { + return new AwsSpanMetricsProcessor(errorHistogram, faultHistogram, latencyHistogram, generator, resource); + } + + private constructor( + errorHistogram: Histogram, + faultHistogram: Histogram, + latencyHistogram: Histogram, + generator: MetricAttributeGenerator, + resource: Resource + ) { + this.errorHistogram = errorHistogram; + this.faultHistogram = faultHistogram; + this.latencyHistogram = latencyHistogram; + this.generator = generator; + this.resource = resource; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public onStart(span: Span, parentContext: Context): void {} + + public onEnd(span: ReadableSpan): void { + const attributeMap: AttributeMap = this.generator.generateMetricAttributeMapFromSpan(span, this.resource); + + for (const attribute in attributeMap) { + this.recordMetrics(span, attributeMap[attribute]); + } + } + + // The logic to record error and fault should be kept in sync with the aws-xray exporter whenever + // possible except for the throttle + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/exporter/awsxrayexporter/internal/translator/cause.go#L121-L160 + private recordErrorOrFault(spanData: ReadableSpan, attributes: Attributes): void { + let httpStatusCode: AttributeValue | undefined = spanData.attributes[SEMATTRS_HTTP_STATUS_CODE]; + const statusCode: SpanStatusCode = spanData.status.code; + + if (httpStatusCode === undefined) { + httpStatusCode = attributes[SEMATTRS_HTTP_STATUS_CODE]; + } + + if ( + httpStatusCode === undefined || + (httpStatusCode as number) < this.ERROR_CODE_LOWER_BOUND || + (httpStatusCode as number) > this.FAULT_CODE_UPPER_BOUND + ) { + if (SpanStatusCode.ERROR === statusCode) { + this.errorHistogram.record(0, attributes); + this.faultHistogram.record(1, attributes); + } else { + this.errorHistogram.record(0, attributes); + this.faultHistogram.record(0, attributes); + } + } else if ( + (httpStatusCode as number) >= this.ERROR_CODE_LOWER_BOUND && + (httpStatusCode as number) <= this.ERROR_CODE_UPPER_BOUND + ) { + this.errorHistogram.record(1, attributes); + this.faultHistogram.record(0, attributes); + } else if ( + (httpStatusCode as number) >= this.FAULT_CODE_LOWER_BOUND && + (httpStatusCode as number) <= this.FAULT_CODE_UPPER_BOUND + ) { + this.errorHistogram.record(0, attributes); + this.faultHistogram.record(1, attributes); + } + } + + private recordLatency(span: ReadableSpan, attributes: Attributes): void { + const millisFromSeconds: number = span.duration[0] * this.SECONDS_TO_MILLIS_MULTIPLIER; + const millisFromNanos: number = span.duration[1] / this.NANOS_TO_MILLIS_DIVIDER; + + const millis: number = millisFromSeconds + millisFromNanos; + this.latencyHistogram.record(millis, attributes); + } + + private recordMetrics(span: ReadableSpan, attributes: Attributes): void { + // Only record metrics if non-empty attributes are returned. + if (Object.keys(attributes).length > 0) { + this.recordErrorOrFault(span, attributes); + this.recordLatency(span, attributes); + } + } + + public shutdown(): Promise { + return this.forceFlush(); + } + + public forceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/src/sqs-url-parser.ts b/aws-distro-opentelemetry-node-autoinstrumentation/src/sqs-url-parser.ts index ae44666..eba90c0 100644 --- a/aws-distro-opentelemetry-node-autoinstrumentation/src/sqs-url-parser.ts +++ b/aws-distro-opentelemetry-node-autoinstrumentation/src/sqs-url-parser.ts @@ -45,7 +45,7 @@ export class SqsUrlParser { } private static isValidQueueName(input: string): boolean { - if (input === null || input.length === 0 || input.length > 80) { + if (input == null || input.length === 0 || input.length > 80) { return false; } diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/test/attribute-propagating-span-processor-builder.test.ts b/aws-distro-opentelemetry-node-autoinstrumentation/test/attribute-propagating-span-processor-builder.test.ts new file mode 100644 index 0000000..3f56bdb --- /dev/null +++ b/aws-distro-opentelemetry-node-autoinstrumentation/test/attribute-propagating-span-processor-builder.test.ts @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AttributePropagatingSpanProcessor } from '../src/attribute-propagating-span-processor'; +import { AttributePropagatingSpanProcessorBuilder } from '../src/attribute-propagating-span-processor-builder'; +import expect from 'expect'; +import { ReadableSpan, Span } from '@opentelemetry/sdk-trace-base'; +import * as sinon from 'sinon'; + +describe('AttributePropagatingSpanProcessorBuilderTest', () => { + it('BasicTest', () => { + const builder: AttributePropagatingSpanProcessorBuilder = AttributePropagatingSpanProcessorBuilder.create(); + expect(builder.setPropagationDataKey('test')).toBe(builder); + + function mock_extractor(_: ReadableSpan): string { + return 'test'; + } + + expect(builder.setPropagationDataExtractor(mock_extractor)).toBe(builder); + expect(builder.setAttributesKeysToPropagate(['test'])).toBe(builder); + const spanProcessor: AttributePropagatingSpanProcessor = builder.build(); + expect((spanProcessor as any).propagationDataKey).toBe('test'); + expect((spanProcessor as any).propagationDataExtractor(sinon.createStubInstance(Span))).toEqual('test'); + expect((spanProcessor as any).attributesKeysToPropagate).toEqual(['test']); + }); +}); diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/test/attribute-propagating-span-processor.test.ts b/aws-distro-opentelemetry-node-autoinstrumentation/test/attribute-propagating-span-processor.test.ts new file mode 100644 index 0000000..605e32f --- /dev/null +++ b/aws-distro-opentelemetry-node-autoinstrumentation/test/attribute-propagating-span-processor.test.ts @@ -0,0 +1,284 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Span as APISpan, + AttributeValue, + Exception, + Link, + SpanAttributes, + SpanContext, + SpanKind, + SpanStatus, + TimeInput, + TraceFlags, + context, + createTraceState, + trace, +} from '@opentelemetry/api'; +import { ReadableSpan, Tracer } from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { + MESSAGINGOPERATIONVALUES_PROCESS, + SEMATTRS_MESSAGING_OPERATION, + SEMATTRS_RPC_SYSTEM, +} from '@opentelemetry/semantic-conventions'; +import expect from 'expect'; +import { AttributePropagatingSpanProcessor } from '../src/attribute-propagating-span-processor'; +import { AWS_ATTRIBUTE_KEYS } from '../src/aws-attribute-keys'; +import { AwsSpanProcessingUtil } from '../src/aws-span-processing-util'; + +let tracer: Tracer; + +const spanNameExtractor: (span: ReadableSpan) => string = AwsSpanProcessingUtil.getIngressOperation; +const spanNameKey: string = 'spanName'; +const testKey1: string = 'key1'; +const testKey2: string = 'key2'; + +const SPAN_KINDS: SpanKind[] = [ + SpanKind.INTERNAL, + SpanKind.SERVER, + SpanKind.CLIENT, + SpanKind.PRODUCER, + SpanKind.CONSUMER, +]; + +describe('AttributePropagatingSpanProcessorTest', () => { + beforeEach(() => { + const tracerProvider: NodeTracerProvider = new NodeTracerProvider(); + tracerProvider.addSpanProcessor( + AttributePropagatingSpanProcessor.create(spanNameExtractor, spanNameKey, [testKey1, testKey2]) + ); + tracer = tracerProvider.getTracer('awsxray'); + }); + + it('testAttributesPropagationBySpanKind', () => { + SPAN_KINDS.forEach((value: SpanKind) => { + const spanWithAppOnly: APISpan = tracer.startSpan('parent', { + kind: value, + attributes: { [testKey1]: 'testValue1' }, + }); + const spanWithOpOnly: APISpan = tracer.startSpan('parent', { + kind: value, + attributes: { [testKey2]: 'testValue2' }, + }); + const spanWithAppAndOp: APISpan = tracer.startSpan('parent', { + kind: value, + attributes: { + [testKey1]: 'testValue1', + [testKey2]: 'testValue2', + }, + }); + + if (SpanKind.SERVER === value) { + validateSpanAttributesInheritance(spanWithAppOnly, 'parent', undefined, undefined); + validateSpanAttributesInheritance(spanWithOpOnly, 'parent', undefined, undefined); + validateSpanAttributesInheritance(spanWithAppAndOp, 'parent', undefined, undefined); + } else if (SpanKind.INTERNAL === value) { + validateSpanAttributesInheritance(spanWithAppOnly, 'InternalOperation', 'testValue1', undefined); + validateSpanAttributesInheritance(spanWithOpOnly, 'InternalOperation', undefined, 'testValue2'); + validateSpanAttributesInheritance(spanWithAppAndOp, 'InternalOperation', 'testValue1', 'testValue2'); + } else { + validateSpanAttributesInheritance(spanWithOpOnly, 'InternalOperation', undefined, undefined); + validateSpanAttributesInheritance(spanWithAppOnly, 'InternalOperation', undefined, undefined); + validateSpanAttributesInheritance(spanWithAppAndOp, 'InternalOperation', undefined, undefined); + } + }); + }); + + it('testAttributesPropagationWithInternalKinds', () => { + const grandParentSpan: APISpan = tracer.startSpan('grandparent', { + kind: SpanKind.INTERNAL, + attributes: { [testKey1]: 'testValue1' }, + }); + const parentSpan: APISpan = tracer.startSpan( + 'parent', + { kind: SpanKind.INTERNAL, attributes: { [testKey2]: 'testValue2' } }, + trace.setSpan(context.active(), grandParentSpan) + ); + const childSpan: APISpan = tracer.startSpan( + 'child', + { kind: SpanKind.CLIENT }, + trace.setSpan(context.active(), parentSpan) + ); + const grandchildSpan: APISpan = tracer.startSpan( + 'child', + { kind: SpanKind.INTERNAL }, + trace.setSpan(context.active(), childSpan) + ); + + const grandParentReadableSpan: APISpan = grandParentSpan as APISpan; + const parentReadableSpan: APISpan = parentSpan as APISpan; + const childReadableSpan: APISpan = childSpan as APISpan; + const grandchildReadableSpan: APISpan = grandchildSpan as APISpan; + + expect((grandParentReadableSpan as any).attributes[testKey1]).toEqual('testValue1'); + expect((grandParentReadableSpan as any).attributes[testKey2]).toBeUndefined(); + expect((parentReadableSpan as any).attributes[testKey1]).toEqual('testValue1'); + expect((parentReadableSpan as any).attributes[testKey2]).toEqual('testValue2'); + expect((childReadableSpan as any).attributes[testKey1]).toEqual('testValue1'); + expect((childReadableSpan as any).attributes[testKey2]).toEqual('testValue2'); + expect((grandchildReadableSpan as any).attributes[testKey1]).toBeUndefined(); + expect((grandchildReadableSpan as any).attributes[testKey2]).toBeUndefined(); + }); + + it('testOverrideAttributes', () => { + const parentSpan: APISpan = tracer.startSpan('parent', { kind: SpanKind.SERVER }); + + parentSpan.setAttribute(testKey1, 'testValue1'); + parentSpan.setAttribute(testKey2, 'testValue2'); + + const transmitSpans1: APISpan = createNestedSpan(parentSpan, 2); + + const childSpan: APISpan = tracer.startSpan('parent', undefined, trace.setSpan(context.active(), transmitSpans1)); + + childSpan.setAttribute(testKey2, 'testValue3'); + + const transmitSpans2: APISpan = createNestedSpan(childSpan, 2); + + expect((transmitSpans2 as any).attributes[testKey2]).toEqual('testValue3'); + }); + + it('testSpanNamePropagationBySpanKind', () => { + SPAN_KINDS.forEach((value: SpanKind) => { + const span: APISpan = tracer.startSpan('parent', { kind: value }); + + if (value === SpanKind.SERVER) { + validateSpanAttributesInheritance(span, 'parent', undefined, undefined); + } else { + validateSpanAttributesInheritance(span, 'InternalOperation', undefined, undefined); + } + }); + }); + + it('testSpanNamePropagationWithRemoteParentSpan', () => { + const remoteParentContext: SpanContext = { + traceId: '00000000000000000000000000000001', + spanId: '0000000000000002', + traceFlags: TraceFlags.SAMPLED, + traceState: createTraceState(), + isRemote: true, + }; + const remoteParentSpan: APISpan = { + spanContext: () => remoteParentContext, + setAttribute: (key: string, value: AttributeValue) => remoteParentSpan, + setAttributes: (attributes: SpanAttributes) => remoteParentSpan, + addEvent: (name: string, attributesOrStartTime?: SpanAttributes | TimeInput, startTime?: TimeInput) => + remoteParentSpan, + addLink: (link: Link) => remoteParentSpan, + addLinks: (links: Link[]) => remoteParentSpan, + setStatus: (status: SpanStatus) => remoteParentSpan, + updateName: (name: string) => remoteParentSpan, + end: (endTime?: TimeInput) => remoteParentSpan, + isRecording: () => true, + recordException: (exception: Exception, time?: TimeInput) => { + return; + }, + }; + (remoteParentSpan as any).attributes = {}; + + const span: APISpan = tracer.startSpan( + 'parent', + { kind: SpanKind.SERVER }, + trace.setSpan(context.active(), remoteParentSpan) + ); + validateSpanAttributesInheritance(span, 'parent', undefined, undefined); + }); + + it('testAwsSdkDescendantSpan', () => { + const awsSdkSpan: APISpan = tracer.startSpan('parent', { kind: SpanKind.CLIENT }); + + awsSdkSpan.setAttribute(SEMATTRS_RPC_SYSTEM, 'aws-api'); + expect((awsSdkSpan as any).attributes[AWS_ATTRIBUTE_KEYS.AWS_SDK_DESCENDANT]).toBeUndefined(); + + const childSpan: APISpan = createNestedSpan(awsSdkSpan, 1); + expect((childSpan as any).attributes[AWS_ATTRIBUTE_KEYS.AWS_SDK_DESCENDANT]).not.toBeUndefined(); + expect((childSpan as any).attributes[AWS_ATTRIBUTE_KEYS.AWS_SDK_DESCENDANT]).toEqual('true'); + }); + + it('testConsumerParentSpanKindAttributePropagation', () => { + const grandParentSpan: APISpan = tracer.startSpan('grandparent', { kind: SpanKind.CONSUMER }); + const parentSpan: APISpan = tracer.startSpan( + 'parent', + { kind: SpanKind.INTERNAL }, + trace.setSpan(context.active(), grandParentSpan) + ); + + const childSpan: APISpan = tracer.startSpan( + 'child', + { kind: SpanKind.CONSUMER, attributes: { [SEMATTRS_MESSAGING_OPERATION]: MESSAGINGOPERATIONVALUES_PROCESS } }, + trace.setSpan(context.active(), parentSpan) + ); + + expect((parentSpan as any).attributes[AWS_ATTRIBUTE_KEYS.AWS_CONSUMER_PARENT_SPAN_KIND]).toBeUndefined(); + expect((childSpan as any).attributes[AWS_ATTRIBUTE_KEYS.AWS_CONSUMER_PARENT_SPAN_KIND]).toBeUndefined(); + }); + + it('testNoConsumerParentSpanKindAttributeWithConsumerProcess', () => { + const parentSpan: APISpan = tracer.startSpan('parent', { kind: SpanKind.SERVER }); + + const span: APISpan = tracer.startSpan( + 'parent', + { kind: SpanKind.CONSUMER, attributes: { [SEMATTRS_MESSAGING_OPERATION]: MESSAGINGOPERATIONVALUES_PROCESS } }, + trace.setSpan(context.active(), parentSpan) + ); + + expect((span as any).attributes[AWS_ATTRIBUTE_KEYS.AWS_CONSUMER_PARENT_SPAN_KIND]).toBeUndefined(); + }); + + it('testConsumerParentSpanKindAttributeWithConsumerParent', () => { + const parentSpan: APISpan = tracer.startSpan('parent', { kind: SpanKind.CONSUMER }); + + const span: APISpan = tracer.startSpan( + 'parent', + { kind: SpanKind.CONSUMER }, + trace.setSpan(context.active(), parentSpan) + ); + + expect((span as any).attributes[AWS_ATTRIBUTE_KEYS.AWS_CONSUMER_PARENT_SPAN_KIND]).toEqual( + SpanKind[SpanKind.CONSUMER] + ); + }); + + function createNestedSpan(parentSpan: APISpan, depth: number): APISpan { + if (depth === 0) { + return parentSpan; + } + const childSpan: APISpan = tracer.startSpan( + 'child:' + depth, + undefined, + trace.setSpan(context.active(), parentSpan) + ); + try { + return createNestedSpan(childSpan, depth - 1); + } finally { + childSpan.end(); + } + } + + function validateSpanAttributesInheritance( + parentSpan: APISpan, + propagatedName: string, + propagationValue1: string | undefined, + propagatedValue2: string | undefined + ): void { + const leafSpan: APISpan = createNestedSpan(parentSpan, 10) as APISpan; + + expect((leafSpan as any).name).toEqual('child:1'); + if (propagatedName !== undefined) { + expect((leafSpan as any).attributes[spanNameKey]).toEqual(propagatedName); + } else { + expect((leafSpan as any).attributes[spanNameKey]).toBeUndefined(); + } + if (propagationValue1 !== undefined) { + expect((leafSpan as any).attributes[testKey1]).toEqual(propagationValue1); + } else { + expect((leafSpan as any).attributes[testKey1]).toBeUndefined(); + } + if (propagatedValue2 !== undefined) { + expect((leafSpan as any).attributes[testKey2]).toEqual(propagatedValue2); + } else { + expect((leafSpan as any).attributes[testKey2]).toBeUndefined(); + } + } +}); diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-metric-attribute-generator.test.ts b/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-metric-attribute-generator.test.ts index d220545..14e82cf 100644 --- a/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-metric-attribute-generator.test.ts +++ b/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-metric-attribute-generator.test.ts @@ -65,7 +65,7 @@ const GENERATOR: AwsMetricAttributeGenerator = new AwsMetricAttributeGenerator() let attributesMock: Attributes; let spanDataMock: ReadableSpan; -let instrumentationScopeInfoMock: InstrumentationLibrary; +let instrumentationLibraryMock: InstrumentationLibrary; let resource: Resource; /** Unit tests for {@link AwsMetricAttributeGenerator}. */ @@ -73,7 +73,7 @@ describe('AwsMetricAttributeGeneratorTest', () => { // setUpMocks beforeEach(() => { attributesMock = {}; - instrumentationScopeInfoMock = { + instrumentationLibraryMock = { name: 'Scope name', }; spanDataMock = { @@ -97,7 +97,7 @@ describe('AwsMetricAttributeGeneratorTest', () => { duration: [0, 1], ended: true, resource: Resource.default(), - instrumentationLibrary: instrumentationScopeInfoMock, + instrumentationLibrary: instrumentationLibraryMock, droppedAttributesCount: 0, droppedEventsCount: 0, droppedLinksCount: 0, @@ -118,7 +118,7 @@ describe('AwsMetricAttributeGeneratorTest', () => { [AWS_ATTRIBUTE_KEYS.AWS_LOCAL_SERVICE]: UNKNOWN_SERVICE, // This is tested to be UNKNOWN_OPERATION in Java/Python // This is because in other langauges, span name could be null, but - // this is not possibly in OTel JS. + // this is not possible in OTel JS. [AWS_ATTRIBUTE_KEYS.AWS_LOCAL_OPERATION]: 'spanDataMockName', }; validateAttributesProducedForNonLocalRootSpanOfKind(expectedAttributes, SpanKind.SERVER); @@ -141,7 +141,7 @@ describe('AwsMetricAttributeGeneratorTest', () => { [AWS_ATTRIBUTE_KEYS.AWS_LOCAL_SERVICE]: UNKNOWN_SERVICE, // This is tested to be UNKNOWN_OPERATION in Java/Python // This is because in other langauges, span name could be null, but - // this is not possibly in OTel JS. + // this is not possible in OTel JS. [AWS_ATTRIBUTE_KEYS.AWS_LOCAL_OPERATION]: 'spanDataMockName', }; validateAttributesProducedForNonLocalRootSpanOfKind(expectedAttributes, SpanKind.SERVER); diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-metric-attributes-span-exporter-builder.test.ts b/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-metric-attributes-span-exporter-builder.test.ts new file mode 100644 index 0000000..dcdec66 --- /dev/null +++ b/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-metric-attributes-span-exporter-builder.test.ts @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { OTLPTraceExporter as OTLPHttpTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { Resource } from '@opentelemetry/resources'; +import expect from 'expect'; +import * as sinon from 'sinon'; +import { AwsMetricAttributeGenerator } from '../src/aws-metric-attribute-generator'; +import { AwsMetricAttributesSpanExporter } from '../src/aws-metric-attributes-span-exporter'; +import { AwsMetricAttributesSpanExporterBuilder } from '../src/aws-metric-attributes-span-exporter-builder'; + +describe('AwsMetricAttributesSpanExporterBuilderTest', () => { + it('BasicTest', () => { + const generator: AwsMetricAttributeGenerator = sinon.createStubInstance(AwsMetricAttributeGenerator); + (generator as any).testKey = 'test'; + const builder: AwsMetricAttributesSpanExporterBuilder = AwsMetricAttributesSpanExporterBuilder.create( + sinon.createStubInstance(OTLPHttpTraceExporter), + sinon.createStubInstance(Resource) + ); + expect(builder.setGenerator(generator)).toBe(builder); + const exporter: AwsMetricAttributesSpanExporter = builder.build(); + expect((exporter as any).generator.testKey).toBe('test'); + }); +}); diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-metric-attributes-span-exporter.test.ts b/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-metric-attributes-span-exporter.test.ts new file mode 100644 index 0000000..7c3aa41 --- /dev/null +++ b/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-metric-attributes-span-exporter.test.ts @@ -0,0 +1,562 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AttributeValue, Attributes, Link, SpanContext, SpanKind, SpanStatus } from '@opentelemetry/api'; +import { InstrumentationLibrary } from '@opentelemetry/core'; +import { OTLPTraceExporter as OTLPHttpTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { Resource } from '@opentelemetry/resources'; +import { ReadableSpan, SpanExporter, TimedEvent } from '@opentelemetry/sdk-trace-base'; +import { MESSAGINGOPERATIONVALUES_PROCESS, SEMATTRS_MESSAGING_OPERATION } from '@opentelemetry/semantic-conventions'; +import expect from 'expect'; +import * as sinon from 'sinon'; +import { AWS_ATTRIBUTE_KEYS } from '../src/aws-attribute-keys'; +import { AwsMetricAttributeGenerator } from '../src/aws-metric-attribute-generator'; +import { AwsMetricAttributesSpanExporter } from '../src/aws-metric-attributes-span-exporter'; +import { AwsSpanProcessingUtil } from '../src/aws-span-processing-util'; +import { + AttributeMap, + DEPENDENCY_METRIC, + MetricAttributeGenerator, + SERVICE_METRIC, +} from '../src/metric-attribute-generator'; + +describe('AwsMetricAttributesSpanExporterTest', () => { + // Test constants + const CONTAINS_ATTRIBUTES: boolean = true; + const CONTAINS_NO_ATTRIBUTES: boolean = false; + + // Tests can safely rely on an empty resource. + const testResource: Resource = Resource.empty(); + + // Mocks required for tests. + let generatorMock: MetricAttributeGenerator; + let delegateMock: SpanExporter; + + let delegateMockForceFlush: sinon.SinonStub; + let delegateMockShutdown: sinon.SinonStub<[], Promise>; + let delegateMockExport: sinon.SinonStub; + + let awsMetricAttributesSpanExporter: AwsMetricAttributesSpanExporter; + + beforeEach(() => { + generatorMock = new AwsMetricAttributeGenerator(); + delegateMock = new OTLPHttpTraceExporter(); + + delegateMockForceFlush = sinon.stub(delegateMock, 'forceFlush'); + delegateMockShutdown = sinon.stub(delegateMock, 'shutdown'); + delegateMockExport = sinon.stub(delegateMock, 'export'); + + awsMetricAttributesSpanExporter = AwsMetricAttributesSpanExporter.create(delegateMock, generatorMock, testResource); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('testPassthroughDelegations', () => { + awsMetricAttributesSpanExporter.forceFlush(); + awsMetricAttributesSpanExporter.shutdown(); + sinon.assert.calledOnce(delegateMockForceFlush); + sinon.assert.calledOnce(delegateMockShutdown); + }); + + it('testExportDelegationWithoutAttributeOrModification', () => { + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + const spanDataMock: ReadableSpan = buildSpanDataMock(spanAttributes); + const metricAttributes: Attributes = buildMetricAttributes(CONTAINS_NO_ATTRIBUTES); + configureMocksForExport(spanDataMock, metricAttributes); + + awsMetricAttributesSpanExporter.export([spanDataMock], () => {}); + sinon.assert.calledOnce(delegateMockExport); + const exportedSpans: ReadableSpan[] = delegateMockExport.getCall(0).args[0]; + expect(exportedSpans.length).toEqual(1); + + const exportedSpan: ReadableSpan = exportedSpans[0]; + expect(exportedSpan).toEqual(spanDataMock); + }); + + it('testExportDelegationWithAttributeButWithoutModification', () => { + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_ATTRIBUTES); + const spanDataMock: ReadableSpan = buildSpanDataMock(spanAttributes); + const metricAttributes: Attributes = buildMetricAttributes(CONTAINS_NO_ATTRIBUTES); + configureMocksForExport(spanDataMock, metricAttributes); + + awsMetricAttributesSpanExporter.export([spanDataMock], () => {}); + + sinon.assert.calledOnce(delegateMockExport); + const exportedSpans: ReadableSpan[] = delegateMockExport.getCall(0).args[0]; + expect(exportedSpans.length).toEqual(1); + + const exportedSpan: ReadableSpan = exportedSpans[0]; + expect(exportedSpan).toEqual(spanDataMock); + }); + + it('testExportDelegationWithoutAttributeButWithModification', () => { + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + const spanDataMock: ReadableSpan = buildSpanDataMock(spanAttributes); + const metricAttributes: Attributes = buildMetricAttributes(CONTAINS_ATTRIBUTES); + configureMocksForExport(spanDataMock, metricAttributes); + + awsMetricAttributesSpanExporter.export([spanDataMock], () => {}); + sinon.assert.calledOnce(delegateMockExport); + const exportedSpans: ReadableSpan[] = delegateMockExport.getCall(0).args[0]; + expect(exportedSpans.length).toEqual(1); + + const exportedSpan: ReadableSpan = exportedSpans[0]; + const exportedAttributes: Attributes = exportedSpan.attributes; + expect(Object.keys(exportedAttributes).length).toEqual(Object.keys(metricAttributes).length); + for (const k in metricAttributes) { + expect(exportedAttributes[k]).toEqual(metricAttributes[k]); + } + }); + + it('testExportDelegationWithAttributeAndModification', () => { + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_ATTRIBUTES); + const spanDataMock: ReadableSpan = buildSpanDataMock(spanAttributes); + const metricAttributes: Attributes = buildMetricAttributes(CONTAINS_ATTRIBUTES); + configureMocksForExport(spanDataMock, metricAttributes); + + awsMetricAttributesSpanExporter.export([spanDataMock], () => {}); + sinon.assert.calledOnce(delegateMockExport); + const exportedSpans: ReadableSpan[] = delegateMockExport.getCall(0).args[0]; + expect(exportedSpans.length).toEqual(1); + + const exportedSpan: ReadableSpan = exportedSpans[0]; + const expectedAttributeCount: number = Object.keys(metricAttributes).length + Object.keys(spanAttributes).length; + const exportedAttributes: Attributes = exportedSpan.attributes; + expect(Object.keys(exportedAttributes).length).toEqual(expectedAttributeCount); + for (const k in spanAttributes) { + expect(exportedAttributes[k]).toEqual(spanAttributes[k]); + } + for (const k in metricAttributes) { + expect(exportedAttributes[k]).toEqual(metricAttributes[k]); + } + }); + + it('testExportDelegationWithMultipleSpans', () => { + const spanAttributes1: Attributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + const spanDataMock1: ReadableSpan = buildSpanDataMock(spanAttributes1); + const metricAttributes1: Attributes = buildMetricAttributes(CONTAINS_NO_ATTRIBUTES); + configureMocksForExport(spanDataMock1, metricAttributes1); + + const spanAttributes2: Attributes = buildSpanAttributes(CONTAINS_ATTRIBUTES); + const spanDataMock2: ReadableSpan = buildSpanDataMock(spanAttributes2); + const metricAttributes2: Attributes = buildMetricAttributes(CONTAINS_ATTRIBUTES); + configureMocksForExport(spanDataMock2, metricAttributes2); + + const spanAttributes3: Attributes = buildSpanAttributes(CONTAINS_ATTRIBUTES); + const spanDataMock3: ReadableSpan = buildSpanDataMock({ ...spanAttributes3 }); + const metricAttributes3: Attributes = buildMetricAttributes(CONTAINS_NO_ATTRIBUTES); + configureMocksForExport(spanDataMock3, metricAttributes3); + + configureMocksForExportWithMultipleSideEffect( + [spanDataMock1, spanDataMock2, spanDataMock3], + [metricAttributes1, metricAttributes2, metricAttributes3] + ); + + awsMetricAttributesSpanExporter.export([spanDataMock1, spanDataMock2, spanDataMock3], () => {}); + + sinon.assert.calledOnce(delegateMockExport); + const exportedSpans: ReadableSpan[] = delegateMockExport.getCall(0).args[0]; + expect(exportedSpans.length).toEqual(3); + + const exportedSpan1: ReadableSpan = exportedSpans[0]; + const exportedSpan2: ReadableSpan = exportedSpans[1]; + const exportedSpan3: ReadableSpan = exportedSpans[2]; + + expect(exportedSpan1).toEqual(spanDataMock1); + expect(exportedSpan3).toEqual(spanDataMock3); + + const expectedAttributeCount: number = Object.keys(metricAttributes2).length + Object.keys(spanAttributes2).length; + const exportedAttributes: Attributes = exportedSpan2.attributes; + expect(Object.keys(exportedAttributes).length).toEqual(expectedAttributeCount); + for (const k in spanAttributes2) { + expect(exportedAttributes[k]).toEqual(spanAttributes2[k]); + } + for (const k in metricAttributes2) { + expect(exportedAttributes[k]).toEqual(metricAttributes2[k]); + } + }); + + it('testOverridenAttributes', () => { + const spanAttributes: Attributes = { + key1: 'old value1', + key2: 'old value2', + }; + const spanDataMock: ReadableSpan = buildSpanDataMock(spanAttributes); + const metricAttributes: Attributes = { + key1: 'new value1', + key3: 'new value3', + }; + configureMocksForExport(spanDataMock, metricAttributes); + + awsMetricAttributesSpanExporter.export([spanDataMock], () => {}); + sinon.assert.calledOnce(delegateMockExport); + const exportedSpans: ReadableSpan[] = delegateMockExport.getCall(0).args[0]; + expect(exportedSpans.length).toEqual(1); + + const exportedSpan: ReadableSpan = exportedSpans[0]; + expect(Object.keys(exportedSpan.attributes).length).toEqual(3); + const exportedAttributes: Attributes = exportedSpan.attributes; + expect(Object.keys(exportedAttributes).length).toEqual(3); + expect(exportedAttributes['key1']).toEqual('new value1'); + expect(exportedAttributes['key2']).toEqual('old value2'); + expect(exportedAttributes['key3']).toEqual('new value3'); + }); + + it('testExportDelegatingSpanDataBehaviour', () => { + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_ATTRIBUTES); + const spanDataMock: ReadableSpan = buildSpanDataMock(spanAttributes); + const metricAttributes: Attributes = buildMetricAttributes(CONTAINS_ATTRIBUTES); + configureMocksForExport(spanDataMock, metricAttributes); + + awsMetricAttributesSpanExporter.export([spanDataMock], () => {}); + + sinon.assert.calledOnce(delegateMockExport); + const exportedSpans: ReadableSpan[] = delegateMockExport.getCall(0).args[0]; + expect(exportedSpans.length).toEqual(1); + + const exportedSpan: ReadableSpan = exportedSpans[0]; + + const spanContextMock: SpanContext = createMockSpanContext(); + (spanDataMock as any).spanContext = spanContextMock; + expect(exportedSpan.spanContext).toEqual(spanContextMock); + + (spanDataMock as any).parentSpanId = '0000000000000003'; + expect(exportedSpan.parentSpanId).toEqual(spanDataMock.parentSpanId); + + (spanDataMock as any).resource = testResource; + expect(exportedSpan.resource).toEqual(testResource); + + const testInstrumentationLibrary: InstrumentationLibrary = { name: 'mockedLibrary' }; + (spanDataMock as any).instrumentationLibrary = testInstrumentationLibrary; + expect(exportedSpan.instrumentationLibrary).toEqual(testInstrumentationLibrary); + + const testName: string = 'name'; + (spanDataMock as any).name = testName; + expect(exportedSpan.name).toEqual(testName); + + const kindMock: SpanKind = SpanKind.SERVER; + (spanDataMock as any).kind = kindMock; + expect(exportedSpan.kind).toEqual(kindMock); + + const testStartEpochNanos: number = 1; + spanDataMock.startTime[1] = testStartEpochNanos; + expect(exportedSpan.startTime[1]).toEqual(testStartEpochNanos); + + const eventsMock: TimedEvent[] = [{ time: [0, 1], name: 'event0' }]; + (spanDataMock as any).events = eventsMock; + expect(exportedSpan.events).toEqual(eventsMock); + + const linksMock: Link[] = [{ context: createMockSpanContext() }]; + (spanDataMock as any).links = linksMock; + expect(exportedSpan.links).toEqual(linksMock); + + const statusMock: SpanStatus = { code: 0 }; + (spanDataMock as any).status = statusMock; + expect(exportedSpan.status).toEqual(statusMock); + + const testEndEpochNanosMock: number = 2; + spanDataMock.endTime[1] = testEndEpochNanosMock; + expect(exportedSpan.endTime[1]).toEqual(testEndEpochNanosMock); + + (spanDataMock as any).ended = true; + expect(exportedSpan.ended).toEqual(true); + + const testTotalRecordedEventsMock: number = 3; + (spanDataMock as any).events = [ + { time: [0, 1], name: 'event0' }, + { time: [0, 2], name: 'event1' }, + { time: [0, 3], name: 'event2' }, + ]; + expect(exportedSpan.events.length).toEqual(testTotalRecordedEventsMock); + + const testTotalRecordedLinksMock: number = 4; + (spanDataMock as any).links = [ + createMockSpanContext(), + createMockSpanContext(), + createMockSpanContext(), + createMockSpanContext(), + ]; + expect(exportedSpan.links.length).toEqual(testTotalRecordedLinksMock); + }); + + it('testExportDelegationWithTwoMetrics', () => { + // Original Span Attribute + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_ATTRIBUTES); + + // Create new span data mock + const spanDataMock: ReadableSpan = createReadableSpanMock(); + (spanDataMock as any).attributes = { ...spanAttributes }; + (spanDataMock as any).kind = SpanKind.PRODUCER; + (spanDataMock as any).parentSpanId = undefined; + + // Create mock for the generateMetricAttributeMapFromSpan. Returns both dependency and service + // metric + const attributeMap: AttributeMap = {}; + const serviceMtricAttributes: Attributes = { 'new service key': 'new service value' }; + attributeMap[SERVICE_METRIC] = serviceMtricAttributes; + + const dependencyMetricAttributes: Attributes = { + 'new dependency key': 'new dependency value', + [AWS_ATTRIBUTE_KEYS.AWS_SPAN_KIND]: SpanKind[SpanKind.PRODUCER], + }; + attributeMap[DEPENDENCY_METRIC] = dependencyMetricAttributes; + + generatorMock.generateMetricAttributeMapFromSpan = (span: ReadableSpan, resource: Resource) => { + if (spanDataMock === span && testResource === resource) { + return attributeMap; + } + return {}; + }; + + awsMetricAttributesSpanExporter.export([spanDataMock], () => {}); + sinon.assert.calledOnce(delegateMockExport); + const exportedSpans: ReadableSpan[] = delegateMockExport.getCall(0).args[0]; + expect(exportedSpans.length).toEqual(1); + + // Retrieve the returned span + const exportedSpan: ReadableSpan = exportedSpans[0]; + + // Check the number of attributes + const expectedAttributeCount: number = + Object.keys(dependencyMetricAttributes).length + Object.keys(spanAttributes).length; + const exportedAttributes: Attributes = exportedSpan.attributes; + expect(Object.keys(exportedAttributes).length).toEqual(expectedAttributeCount); + + // Check that all expected attributes are present + for (const k in spanAttributes) { + const v: AttributeValue | undefined = spanAttributes[k]; + expect(exportedAttributes[k]).toEqual(v); + } + + for (const k in dependencyMetricAttributes) { + const v: AttributeValue | undefined = dependencyMetricAttributes[k]; + if (k === AWS_ATTRIBUTE_KEYS.AWS_SPAN_KIND) { + expect(exportedAttributes[k]).not.toEqual(v); + } else { + expect(exportedAttributes[k]).toEqual(v); + } + } + + expect(exportedAttributes[AWS_ATTRIBUTE_KEYS.AWS_SPAN_KIND]).toEqual(AwsSpanProcessingUtil.LOCAL_ROOT); + }); + + it('testConsumerProcessSpanHasEmptyAttribute', () => { + const attributesMock: Attributes = {}; + const spanDataMock: ReadableSpan = createReadableSpanMock(); + const parentSpanContextMock: SpanContext = createMockSpanContext(); + + attributesMock[AWS_ATTRIBUTE_KEYS.AWS_CONSUMER_PARENT_SPAN_KIND] = SpanKind[SpanKind.CONSUMER]; + attributesMock[SEMATTRS_MESSAGING_OPERATION] = MESSAGINGOPERATIONVALUES_PROCESS; + // set AWS_IS_LOCAL_ROOT as false because parentSpanContext is valid and not remote in this test + attributesMock[AWS_ATTRIBUTE_KEYS.AWS_IS_LOCAL_ROOT] = false; + (spanDataMock as any).kind = SpanKind.CONSUMER; + (spanDataMock as any).attributes = attributesMock; + parentSpanContextMock.isRemote = false; + + // The dependencyAttributesMock will only be used if + // AwsSpanProcessingUtil.shouldGenerateDependencyMetricAttributes(span) is true. + // It shouldn't have any interaction since the spanData is a consumer process with parent span + // of consumer + const attributeMap: AttributeMap = {}; + const dependencyAttributesMock: Attributes = {}; + attributeMap[DEPENDENCY_METRIC] = dependencyAttributesMock; + // Configure generated attributes + generatorMock.generateMetricAttributeMapFromSpan = (span: ReadableSpan, resource: Resource) => { + if (spanDataMock === span && testResource === resource) { + return attributeMap; + } + return {}; + }; + + awsMetricAttributesSpanExporter.export([spanDataMock], () => {}); + + sinon.assert.calledOnce(delegateMockExport); + const exportedSpans: ReadableSpan[] = delegateMockExport.getCall(0).args[0]; + expect(exportedSpans.length).toEqual(1); + + expect(dependencyAttributesMock).toEqual({}); + + const exportedSpan: ReadableSpan = exportedSpans[0]; + expect(exportedSpan).toEqual(spanDataMock); + }); + + it('testExportDelegationWithDependencyMetrics', () => { + // Original Span Attribute + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_ATTRIBUTES); + + // Create new span data mock + const spanDataMock: ReadableSpan = createReadableSpanMock(); + // set AWS_IS_LOCAL_ROOT as false because parentSpanContext is valid and not remote in this test + spanAttributes[AWS_ATTRIBUTE_KEYS.AWS_IS_LOCAL_ROOT] = false; + + (spanDataMock as any).attributes = spanAttributes; + (spanDataMock as any).kind = SpanKind.PRODUCER; + + // Create mock for the generateMetricAttributeMapFromSpan. Returns dependency metric + const metricAttributes: Attributes = { 'new service key': 'new dependency value' }; + const attributeMap: AttributeMap = { + [DEPENDENCY_METRIC]: metricAttributes, + }; + + generatorMock.generateMetricAttributeMapFromSpan = (span: ReadableSpan, resource: Resource) => { + if (spanDataMock === span && testResource === resource) { + return attributeMap; + } + return {}; + }; + + awsMetricAttributesSpanExporter.export([spanDataMock], () => {}); + + sinon.assert.calledOnce(delegateMockExport); + const exportedSpans: ReadableSpan[] = delegateMockExport.getCall(0).args[0]; + expect(exportedSpans.length).toEqual(1); + + // Retrieve the returned span + const exportedSpan: ReadableSpan = exportedSpans[0]; + + // Check the number of attributes + const expectedAttributeCount: number = Object.keys(metricAttributes).length + Object.keys(spanAttributes).length; + const exportedAttributes: Attributes = exportedSpan.attributes; + expect(Object.keys(exportedAttributes).length).toEqual(expectedAttributeCount); + + // Check that all expected attributes are present + for (const k in spanAttributes) { + expect(exportedAttributes[k]).toEqual(spanAttributes[k]); + } + for (const k in metricAttributes) { + expect(exportedAttributes[k]).toEqual(metricAttributes[k]); + } + }); + + function buildSpanAttributes(containsAttribute: boolean): Attributes { + if (containsAttribute) { + return { 'original key': 'original value' }; + } else { + return {}; + } + } + + function buildMetricAttributes(containsAttribute: boolean): Attributes { + if (containsAttribute) { + return { 'new key': 'new value' }; + } else { + return {}; + } + } + + function buildSpanDataMock(spanAttributes: Attributes): ReadableSpan { + // Configure spanData + const mockSpanData: ReadableSpan = { + name: 'spanName', + kind: SpanKind.SERVER, + spanContext: () => { + const spanContext: SpanContext = { + traceId: '00000000000000000000000000000008', + spanId: '0000000000000009', + traceFlags: 0, + }; + return spanContext; + }, + startTime: [0, 0], + endTime: [0, 1], + status: { code: 0 }, + attributes: {}, + links: [], + events: [], + duration: [0, 1], + ended: true, + resource: new Resource({}), + instrumentationLibrary: { name: 'mockedLibrary' }, + droppedAttributesCount: 0, + droppedEventsCount: 0, + droppedLinksCount: 0, + }; + (mockSpanData as any).attributes = spanAttributes; + (mockSpanData as any).kind = SpanKind.SERVER; + return mockSpanData; + } + + function configureMocksForExport(spanDataMock: ReadableSpan, metricAttributes: Attributes): void { + const attributeMap: AttributeMap = {}; + if (AwsSpanProcessingUtil.shouldGenerateServiceMetricAttributes(spanDataMock)) { + attributeMap[SERVICE_METRIC] = metricAttributes; + } + + if (AwsSpanProcessingUtil.shouldGenerateDependencyMetricAttributes(spanDataMock)) { + attributeMap[DEPENDENCY_METRIC] = metricAttributes; + } + + // Configure generated attributes + generatorMock.generateMetricAttributeMapFromSpan = (span: ReadableSpan, resource: Resource) => { + if (span === spanDataMock && resource === testResource) { + return attributeMap; + } + return {}; + }; + } + + function configureMocksForExportWithMultipleSideEffect( + spanDataMocks: ReadableSpan[], + metricAttributesList: Attributes[] + ): void { + const attributeMapList: AttributeMap[] = []; + spanDataMocks.forEach((spanDataMock, i) => { + const attributeMap: AttributeMap = {}; + if (AwsSpanProcessingUtil.shouldGenerateServiceMetricAttributes(spanDataMock)) { + attributeMap[SERVICE_METRIC] = { ...metricAttributesList[i] }; + } + + if (AwsSpanProcessingUtil.shouldGenerateDependencyMetricAttributes(spanDataMock)) { + attributeMap[DEPENDENCY_METRIC] = { ...metricAttributesList[i] }; + } + attributeMapList.push(attributeMap); + }); + function sideEffect(span: ReadableSpan, resource: Resource) { + const index: number = spanDataMocks.indexOf(span); + if (index > -1 && resource === testResource) { + return attributeMapList[index]; + } + return {}; + } + generatorMock.generateMetricAttributeMapFromSpan = sideEffect; + } + + function createReadableSpanMock(): ReadableSpan { + const mockSpanData: ReadableSpan = { + name: 'spanName', + kind: SpanKind.SERVER, + spanContext: () => { + const spanContext: SpanContext = { + traceId: '00000000000000000000000000000008', + spanId: '0000000000000009', + traceFlags: 0, + }; + return spanContext; + }, + startTime: [0, 0], + endTime: [0, 1], + status: { code: 0 }, + attributes: {}, + links: [], + events: [], + duration: [0, 1], + ended: true, + resource: new Resource({}), + instrumentationLibrary: { name: 'mockedLibrary' }, + droppedAttributesCount: 0, + droppedEventsCount: 0, + droppedLinksCount: 0, + }; + return mockSpanData; + } + + function createMockSpanContext(): SpanContext { + return { + traceId: '00000000000000000000000000000008', + spanId: '0000000000000009', + traceFlags: 0, + }; + } +}); diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-span-metrics-processor-builder.test.ts b/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-span-metrics-processor-builder.test.ts new file mode 100644 index 0000000..144663a --- /dev/null +++ b/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-span-metrics-processor-builder.test.ts @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Resource } from '@opentelemetry/resources'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; +import expect from 'expect'; +import * as sinon from 'sinon'; +import { AwsMetricAttributeGenerator } from '../src/aws-metric-attribute-generator'; +import { AwsSpanMetricsProcessor } from '../src/aws-span-metrics-processor'; +import { AwsSpanMetricsProcessorBuilder } from '../src/aws-span-metrics-processor-builder'; +import { MetricAttributeGenerator } from '../src/metric-attribute-generator'; + +describe('AwsSpanMetricsProcessorBuilderTest', () => { + it('TestAllMethods', () => { + // Basic functionality tests for constructor, setters, and build(). Mostly these tests exist to validate the + // code can be run, as the implementation is fairly trivial and does not require robust unit tests. + const meterProvider: MeterProvider = new MeterProvider({}); + const builder: AwsSpanMetricsProcessorBuilder = AwsSpanMetricsProcessorBuilder.create( + meterProvider, + sinon.createStubInstance(Resource) + ); + const generatorMock: MetricAttributeGenerator = sinon.createStubInstance(AwsMetricAttributeGenerator); + expect(builder.setGenerator(generatorMock)).toBe(builder); + expect(builder.setScopeName('test')).toBe(builder); + const metricProcessor: AwsSpanMetricsProcessor = builder.build(); + expect(metricProcessor).not.toBeUndefined(); + }); +}); diff --git a/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-span-metrics-processor.test.ts b/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-span-metrics-processor.test.ts new file mode 100644 index 0000000..1aba323 --- /dev/null +++ b/aws-distro-opentelemetry-node-autoinstrumentation/test/aws-span-metrics-processor.test.ts @@ -0,0 +1,676 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Attributes, + Context, + Histogram, + HrTime, + Meter, + SpanContext, + SpanKind, + SpanStatus, + SpanStatusCode, + TraceFlags, + isSpanContextValid, +} from '@opentelemetry/api'; +import { InstrumentationLibrary, hrTimeDuration } from '@opentelemetry/core'; +import { Resource } from '@opentelemetry/resources'; +import { MeterProvider } from '@opentelemetry/sdk-metrics'; +import { ReadableSpan, Span } from '@opentelemetry/sdk-trace-base'; +import { SEMATTRS_HTTP_STATUS_CODE } from '@opentelemetry/semantic-conventions'; +import expect from 'expect'; +import * as sinon from 'sinon'; +import { AWS_ATTRIBUTE_KEYS } from '../src/aws-attribute-keys'; +import { AwsMetricAttributeGenerator } from '../src/aws-metric-attribute-generator'; +import { AwsSpanMetricsProcessor } from '../src/aws-span-metrics-processor'; +import { AwsSpanProcessingUtil } from '../src/aws-span-processing-util'; +import { + AttributeMap, + DEPENDENCY_METRIC, + MetricAttributeGenerator, + SERVICE_METRIC, +} from '../src/metric-attribute-generator'; + +const INVALID_SPAN_CONTEXT: SpanContext = { + traceId: 'INVALID_TRACE_ID', + spanId: 'INVALID_SPAN_ID', + traceFlags: TraceFlags.NONE, +}; + +describe('AwsSpanMetricsProcessorTest', () => { + // Test constants + const CONTAINS_ATTRIBUTES: boolean = true; + const CONTAINS_NO_ATTRIBUTES: boolean = false; + const TEST_LATENCY_MILLIS: number = 150.0; + const TEST_LATENCY_NANOS: number = 150_000_000; + + // Resource is not mockable, but tests can safely rely on an empty resource. + const testResource: Resource = Resource.empty(); + + // Useful enum for indicating expected HTTP status code-related metrics + enum ExpectedStatusMetric { + ERROR, + FAULT, + NEITHER, + } + + // Mocks required for tests. + let errorHistogramMock: Histogram; + let faultHistogramMock: Histogram; + let latencyHistogramMock: Histogram; + + let errorHistogramMockRecord: sinon.SinonStub< + [value: number, attributes?: Attributes | undefined, context?: Context | undefined], + void + >; + let faultHistogramMockRecord: sinon.SinonStub< + [value: number, attributes?: Attributes | undefined, context?: Context | undefined], + void + >; + let latencyHistogramMockRecord: sinon.SinonStub< + [value: number, attributes?: Attributes | undefined, context?: Context | undefined], + void + >; + + let generatorMock: MetricAttributeGenerator; + let awsSpanMetricsProcessor: AwsSpanMetricsProcessor; + + beforeEach(() => { + const meterProvider: Meter = new MeterProvider({}).getMeter('testMeter'); + + errorHistogramMock = meterProvider.createHistogram('Error'); + faultHistogramMock = meterProvider.createHistogram('Fault'); + latencyHistogramMock = meterProvider.createHistogram('Latency'); + errorHistogramMockRecord = sinon.stub(errorHistogramMock, 'record'); + faultHistogramMockRecord = sinon.stub(faultHistogramMock, 'record'); + latencyHistogramMockRecord = sinon.stub(latencyHistogramMock, 'record'); + + generatorMock = new AwsMetricAttributeGenerator(); + + awsSpanMetricsProcessor = AwsSpanMetricsProcessor.create( + errorHistogramMock, + faultHistogramMock, + latencyHistogramMock, + generatorMock, + testResource + ); + }); + + it('testStartDoesNothingToSpan', () => { + const parentContextMock: Context = { + getValue: (key: symbol) => 'unknown', + setValue: (key: symbol, value: unknown) => parentContextMock, + deleteValue: (key: symbol) => parentContextMock, + }; + const parentContextMockGetValue: sinon.SinonStub<[key: symbol], unknown> = sinon.stub( + parentContextMock, + 'getValue' + ); + const parentContextMockSetValue: sinon.SinonStub<[key: symbol, value: unknown], Context> = sinon.stub( + parentContextMock, + 'setValue' + ); + const parentContextMockDeleteValue: sinon.SinonStub<[key: symbol], Context> = sinon.stub( + parentContextMock, + 'deleteValue' + ); + const spanMock: Span = sinon.createStubInstance(Span); + + awsSpanMetricsProcessor.onStart(spanMock, parentContextMock); + sinon.assert.notCalled(parentContextMockGetValue); + sinon.assert.notCalled(parentContextMockSetValue); + sinon.assert.notCalled(parentContextMockDeleteValue); + }); + + it('testTearDown', async () => { + expect(awsSpanMetricsProcessor.shutdown()).resolves.not.toThrow(); + expect(awsSpanMetricsProcessor.forceFlush()).resolves.not.toThrow(); + }); + + /** + * Tests starting with testOnEndMetricsGeneration are testing the logic in + * AwsSpanMetricsProcessor's onEnd method pertaining to metrics generation. + */ + it('testOnEndMetricsGenerationWithoutSpanAttributes', () => { + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + const readableSpanMock: ReadableSpan = buildReadableSpanMock(spanAttributes); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + verifyHistogramRecords(metricAttributesMap, 1, 0); + }); + + it('testOnEndMetricsGenerationWithoutMetricAttributes', () => { + const spanAttributes: Attributes = { [SEMATTRS_HTTP_STATUS_CODE]: 500 }; + const readableSpanMock: ReadableSpan = buildReadableSpanMock(spanAttributes); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_NO_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + sinon.assert.notCalled(errorHistogramMockRecord); + sinon.assert.notCalled(faultHistogramMockRecord); + sinon.assert.notCalled(latencyHistogramMockRecord); + }); + + it('testsOnEndMetricsGenerationLocalRootServerSpan', () => { + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + const readableSpanMock: ReadableSpan = buildReadableSpanMock( + spanAttributes, + SpanKind.SERVER, + INVALID_SPAN_CONTEXT, + { code: SpanStatusCode.UNSET } + ); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + verifyHistogramRecords(metricAttributesMap, 1, 0); + }); + + it('testsOnEndMetricsGenerationLocalRootConsumerSpan', () => { + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + const readableSpanMock: ReadableSpan = buildReadableSpanMock( + spanAttributes, + SpanKind.CONSUMER, + INVALID_SPAN_CONTEXT, + { code: SpanStatusCode.UNSET } + ); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + verifyHistogramRecords(metricAttributesMap, 1, 1); + }); + + it('testsOnEndMetricsGenerationLocalRootClientSpan', () => { + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + const readableSpanMock: ReadableSpan = buildReadableSpanMock( + spanAttributes, + SpanKind.CLIENT, + INVALID_SPAN_CONTEXT, + { code: SpanStatusCode.UNSET } + ); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + verifyHistogramRecords(metricAttributesMap, 1, 1); + }); + + it('testsOnEndMetricsGenerationLocalRootProducerSpan', () => { + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + const readableSpanMock: ReadableSpan = buildReadableSpanMock( + spanAttributes, + SpanKind.PRODUCER, + INVALID_SPAN_CONTEXT, + { code: SpanStatusCode.UNSET } + ); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + verifyHistogramRecords(metricAttributesMap, 1, 1); + }); + + it('testsOnEndMetricsGenerationLocalRootInternalSpan', () => { + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + const readableSpanMock: ReadableSpan = buildReadableSpanMock( + spanAttributes, + SpanKind.INTERNAL, + INVALID_SPAN_CONTEXT, + { code: SpanStatusCode.UNSET } + ); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + verifyHistogramRecords(metricAttributesMap, 1, 0); + }); + + it('testsOnEndMetricsGenerationLocalRootProducerSpanWithoutMetricAttributes', () => { + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + const readableSpanMock: ReadableSpan = buildReadableSpanMock( + spanAttributes, + SpanKind.PRODUCER, + INVALID_SPAN_CONTEXT, + { code: SpanStatusCode.UNSET } + ); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_NO_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + awsSpanMetricsProcessor.onEnd(readableSpanMock); + sinon.assert.notCalled(errorHistogramMockRecord); + sinon.assert.notCalled(faultHistogramMockRecord); + sinon.assert.notCalled(latencyHistogramMockRecord); + }); + + it('testsOnEndMetricsGenerationClientSpan', () => { + const mockSpanContext: SpanContext = createMockValidSpanContext(); + mockSpanContext.isRemote = false; + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + const readableSpanMock: ReadableSpan = buildReadableSpanMock(spanAttributes, SpanKind.CLIENT, mockSpanContext, { + code: SpanStatusCode.UNSET, + }); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + verifyHistogramRecords(metricAttributesMap, 0, 1); + }); + + it('testsOnEndMetricsGenerationProducerSpan', () => { + const mockSpanContext: SpanContext = createMockValidSpanContext(); + mockSpanContext.isRemote = false; + const spanAttributes: Attributes = buildSpanAttributes(CONTAINS_NO_ATTRIBUTES); + const readableSpanMock: ReadableSpan = buildReadableSpanMock(spanAttributes, SpanKind.PRODUCER, mockSpanContext, { + code: SpanStatusCode.UNSET, + }); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + verifyHistogramRecords(metricAttributesMap, 0, 1); + }); + + it('testOnEndMetricsGenerationWithoutEndRequired', () => { + const spanAttributes: Attributes = { [SEMATTRS_HTTP_STATUS_CODE]: 500 }; + const readableSpanMock: ReadableSpan = buildReadableSpanMock(spanAttributes); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + sinon.assert.calledOnceWithExactly(errorHistogramMockRecord, 0, metricAttributesMap[SERVICE_METRIC]); + sinon.assert.calledOnceWithExactly(faultHistogramMockRecord, 1, metricAttributesMap[SERVICE_METRIC]); + sinon.assert.calledOnceWithExactly( + latencyHistogramMockRecord, + TEST_LATENCY_MILLIS, + metricAttributesMap[SERVICE_METRIC] + ); + + let wantedCalls: sinon.SinonSpyCall< + [value: number, attributes?: Attributes | undefined, context?: Context | undefined], + void + >[]; + wantedCalls = errorHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(0, metricAttributesMap[DEPENDENCY_METRIC])); + expect(wantedCalls.length).toEqual(0); + wantedCalls = faultHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(0, metricAttributesMap[DEPENDENCY_METRIC])); + expect(wantedCalls.length).toEqual(0); + wantedCalls = latencyHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(TEST_LATENCY_MILLIS, metricAttributesMap[DEPENDENCY_METRIC])); + expect(wantedCalls.length).toEqual(0); + }); + + it('testOnEndMetricsGenerationWithLatency', () => { + const spanAttributes: Attributes = { [SEMATTRS_HTTP_STATUS_CODE]: 200 }; + const readableSpanMock: ReadableSpan = buildReadableSpanMock(spanAttributes); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + readableSpanMock.endTime[1] = 5_500_000; + (readableSpanMock as any).duration = hrTimeDuration(readableSpanMock.startTime, readableSpanMock.endTime); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + sinon.assert.calledOnceWithExactly(errorHistogramMockRecord, 0, metricAttributesMap[SERVICE_METRIC]); + sinon.assert.calledOnceWithExactly(faultHistogramMockRecord, 0, metricAttributesMap[SERVICE_METRIC]); + sinon.assert.calledOnceWithExactly(latencyHistogramMockRecord, 5.5, metricAttributesMap[SERVICE_METRIC]); + + let wantedCalls: sinon.SinonSpyCall< + [value: number, attributes?: Attributes | undefined, context?: Context | undefined], + void + >[]; + wantedCalls = errorHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(0, metricAttributesMap[DEPENDENCY_METRIC])); + expect(wantedCalls.length).toEqual(0); + wantedCalls = faultHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(0, metricAttributesMap[DEPENDENCY_METRIC])); + expect(wantedCalls.length).toEqual(0); + wantedCalls = latencyHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(5.5, metricAttributesMap[DEPENDENCY_METRIC])); + expect(wantedCalls.length).toEqual(0); + }); + + it('testOnEndMetricsGenerationWithAwsStatusCodes', () => { + // Invalid HTTP status codes + validateMetricsGeneratedForAttributeStatusCode(undefined, ExpectedStatusMetric.NEITHER); + + // Valid HTTP status codes + validateMetricsGeneratedForAttributeStatusCode(399, ExpectedStatusMetric.NEITHER); + validateMetricsGeneratedForAttributeStatusCode(400, ExpectedStatusMetric.ERROR); + validateMetricsGeneratedForAttributeStatusCode(499, ExpectedStatusMetric.ERROR); + validateMetricsGeneratedForAttributeStatusCode(500, ExpectedStatusMetric.FAULT); + validateMetricsGeneratedForAttributeStatusCode(599, ExpectedStatusMetric.FAULT); + validateMetricsGeneratedForAttributeStatusCode(600, ExpectedStatusMetric.NEITHER); + }); + + it('testOnEndMetricsGenerationWithStatusCodes', () => { + // Invalid HTTP status codes + validateMetricsGeneratedForHttpStatusCode(undefined, ExpectedStatusMetric.NEITHER); + + // Valid HTTP status codes + validateMetricsGeneratedForHttpStatusCode(200, ExpectedStatusMetric.NEITHER); + validateMetricsGeneratedForHttpStatusCode(399, ExpectedStatusMetric.NEITHER); + validateMetricsGeneratedForHttpStatusCode(400, ExpectedStatusMetric.ERROR); + validateMetricsGeneratedForHttpStatusCode(499, ExpectedStatusMetric.ERROR); + validateMetricsGeneratedForHttpStatusCode(500, ExpectedStatusMetric.FAULT); + validateMetricsGeneratedForHttpStatusCode(599, ExpectedStatusMetric.FAULT); + validateMetricsGeneratedForHttpStatusCode(600, ExpectedStatusMetric.NEITHER); + }); + + it('testOnEndMetricsGenerationWithStatusDataError', () => { + // Empty Status and HTTP with Error Status + validateMetricsGeneratedForStatusDataError(undefined, ExpectedStatusMetric.FAULT); + + // Valid HTTP with Error Status + validateMetricsGeneratedForStatusDataError(200, ExpectedStatusMetric.FAULT); + validateMetricsGeneratedForStatusDataError(399, ExpectedStatusMetric.FAULT); + validateMetricsGeneratedForStatusDataError(400, ExpectedStatusMetric.ERROR); + validateMetricsGeneratedForStatusDataError(499, ExpectedStatusMetric.ERROR); + validateMetricsGeneratedForStatusDataError(500, ExpectedStatusMetric.FAULT); + validateMetricsGeneratedForStatusDataError(599, ExpectedStatusMetric.FAULT); + validateMetricsGeneratedForStatusDataError(600, ExpectedStatusMetric.FAULT); + }); + + it('testOnEndMetricsGenerationWithStatusDataOk', () => { + // Empty Status and HTTP with Ok Status + validateMetricsGeneratedForStatusDataOk(undefined, ExpectedStatusMetric.NEITHER); + + // Valid HTTP with Ok Status + validateMetricsGeneratedForStatusDataOk(200, ExpectedStatusMetric.NEITHER); + validateMetricsGeneratedForStatusDataOk(399, ExpectedStatusMetric.NEITHER); + validateMetricsGeneratedForStatusDataOk(400, ExpectedStatusMetric.ERROR); + validateMetricsGeneratedForStatusDataOk(499, ExpectedStatusMetric.ERROR); + validateMetricsGeneratedForStatusDataOk(500, ExpectedStatusMetric.FAULT); + validateMetricsGeneratedForStatusDataOk(599, ExpectedStatusMetric.FAULT); + validateMetricsGeneratedForStatusDataOk(600, ExpectedStatusMetric.NEITHER); + }); + + function buildSpanAttributes(containsAttribute: boolean): Attributes { + if (containsAttribute) { + return { 'original key': 'original value' }; + } else { + return {}; + } + } + + function buildMetricAttributes(containsAttribute: boolean, span: ReadableSpan): AttributeMap { + const attributesMap: AttributeMap = {}; + if (containsAttribute) { + let attributes: Attributes; + if (AwsSpanProcessingUtil.shouldGenerateServiceMetricAttributes(span)) { + const attributes: Attributes = { 'new service key': 'new service value' }; + attributesMap[SERVICE_METRIC] = attributes; + } + if (AwsSpanProcessingUtil.shouldGenerateDependencyMetricAttributes(span)) { + attributes = { 'new dependency key': 'new dependency value' }; + attributesMap[DEPENDENCY_METRIC] = attributes; + } + } + return attributesMap; + } + + function buildReadableSpanMock( + spanAttributes: Attributes, + spanKind: SpanKind = SpanKind.SERVER, + parentSpanContext: SpanContext | undefined = undefined, + statusData: SpanStatus = { code: SpanStatusCode.UNSET } + ): ReadableSpan { + const awsSdkInstrumentationLibrary: InstrumentationLibrary = { + name: '@opentelemetry/instrumentation-aws-sdk', + }; + + const startTime: HrTime = [0, 0]; + const endTime: HrTime = [0, TEST_LATENCY_NANOS]; + const duration: HrTime = hrTimeDuration(startTime, endTime); + + // Configure spanData + const mockSpanData: ReadableSpan = { + name: 'spanName', + // Configure Span Kind + kind: spanKind, + spanContext: () => { + const spanContext: SpanContext = { + traceId: '00000000000000000000000000000008', + spanId: '0000000000000009', + traceFlags: 0, + }; + return spanContext; + }, + startTime: startTime, + // Configure latency + endTime: endTime, + // Configure Span Status + status: statusData, + // Configure attributes + attributes: spanAttributes, + links: [], + events: [], + duration: duration, + ended: true, + resource: new Resource({}), + // Configure Instrumentation Library + instrumentationLibrary: awsSdkInstrumentationLibrary, + droppedAttributesCount: 0, + droppedEventsCount: 0, + droppedLinksCount: 0, + }; + + if (parentSpanContext === undefined) { + parentSpanContext = INVALID_SPAN_CONTEXT; + } else { + (mockSpanData as any).parentSpanId = parentSpanContext.spanId; + } + const isParentSpanContextValid: boolean = parentSpanContext !== undefined && isSpanContextValid(parentSpanContext); + const isParentSpanRemote: boolean = parentSpanContext !== undefined && parentSpanContext.isRemote === true; + const isLocalRoot: boolean = + mockSpanData.parentSpanId === undefined || !isParentSpanContextValid || isParentSpanRemote; + mockSpanData.attributes[AWS_ATTRIBUTE_KEYS.AWS_IS_LOCAL_ROOT] = isLocalRoot; + + return mockSpanData; + } + + function configureMocksForOnEnd(readableSpanMock: ReadableSpan, metricAttributesMap: AttributeMap): void { + // Configure generated attributes + generatorMock.generateMetricAttributeMapFromSpan = (span: ReadableSpan, resource: Resource) => { + if (readableSpanMock === span && testResource === resource) { + return metricAttributesMap; + } + return {}; + }; + } + + function validateMetricsGeneratedForHttpStatusCode( + httpStatusCode: number | undefined, + expectedStatusMetric: ExpectedStatusMetric + ): void { + const spanAttributes: Attributes = { [SEMATTRS_HTTP_STATUS_CODE]: httpStatusCode }; + const readableSpanMock: ReadableSpan = buildReadableSpanMock(spanAttributes, SpanKind.PRODUCER, undefined, { + code: SpanStatusCode.UNSET, + }); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + validateMetrics(metricAttributesMap, expectedStatusMetric); + } + + function validateMetricsGeneratedForAttributeStatusCode( + awsStatusCode: number | undefined, + expectedStatusMetric: ExpectedStatusMetric + ): void { + // Testing Dependency Metric + const attributes: Attributes = { 'new key': 'new value' }; + const readableSpanMock: ReadableSpan = buildReadableSpanMock(attributes, SpanKind.PRODUCER, undefined, { + code: SpanStatusCode.UNSET, + }); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + if (awsStatusCode !== undefined) { + metricAttributesMap[SERVICE_METRIC] = { + 'new service key': 'new service value', + [SEMATTRS_HTTP_STATUS_CODE]: awsStatusCode, + }; + metricAttributesMap[DEPENDENCY_METRIC] = { + 'new dependency key': 'new dependency value', + [SEMATTRS_HTTP_STATUS_CODE]: awsStatusCode, + }; + } + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + awsSpanMetricsProcessor.onEnd(readableSpanMock); + validateMetrics(metricAttributesMap, expectedStatusMetric); + } + + function validateMetricsGeneratedForStatusDataError( + httpStatusCode: number | undefined, + expectedStatusMetric: ExpectedStatusMetric + ): void { + const spanAttributes: Attributes = { [SEMATTRS_HTTP_STATUS_CODE]: httpStatusCode }; + const readableSpanMock: ReadableSpan = buildReadableSpanMock(spanAttributes, SpanKind.PRODUCER, undefined, { + code: SpanStatusCode.ERROR, + }); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + validateMetrics(metricAttributesMap, expectedStatusMetric); + } + + function validateMetricsGeneratedForStatusDataOk( + httpStatusCode: number | undefined, + expectedStatusMetric: ExpectedStatusMetric + ): void { + const spanAttributes: Attributes = { [SEMATTRS_HTTP_STATUS_CODE]: httpStatusCode }; + + const readableSpanMock: ReadableSpan = buildReadableSpanMock(spanAttributes, SpanKind.PRODUCER, undefined, { + code: SpanStatusCode.OK, + }); + const metricAttributesMap: AttributeMap = buildMetricAttributes(CONTAINS_ATTRIBUTES, readableSpanMock); + configureMocksForOnEnd(readableSpanMock, metricAttributesMap); + + awsSpanMetricsProcessor.onEnd(readableSpanMock); + validateMetrics(metricAttributesMap, expectedStatusMetric); + } + + function validateMetrics(metricAttributesMap: AttributeMap, expectedStatusMetric: ExpectedStatusMetric): void { + const serviceAttributes: Attributes = metricAttributesMap[SERVICE_METRIC]; + const dependencyAttributes: Attributes = metricAttributesMap[DEPENDENCY_METRIC]; + let wantedCalls: sinon.SinonSpyCall< + [value: number, attributes?: Attributes | undefined, context?: Context | undefined], + void + >[]; + switch (expectedStatusMetric) { + case ExpectedStatusMetric.ERROR: + wantedCalls = errorHistogramMockRecord.getCalls().filter(call => call.calledWithExactly(1, serviceAttributes)); + expect(wantedCalls.length).toEqual(1); + wantedCalls = faultHistogramMockRecord.getCalls().filter(call => call.calledWithExactly(0, serviceAttributes)); + expect(wantedCalls.length).toEqual(1); + wantedCalls = errorHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(1, dependencyAttributes)); + expect(wantedCalls.length).toEqual(1); + wantedCalls = faultHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(0, dependencyAttributes)); + expect(wantedCalls.length).toEqual(1); + break; + case ExpectedStatusMetric.FAULT: + wantedCalls = errorHistogramMockRecord.getCalls().filter(call => call.calledWithExactly(0, serviceAttributes)); + expect(wantedCalls.length).toEqual(1); + wantedCalls = faultHistogramMockRecord.getCalls().filter(call => call.calledWithExactly(1, serviceAttributes)); + expect(wantedCalls.length).toEqual(1); + wantedCalls = errorHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(0, dependencyAttributes)); + expect(wantedCalls.length).toEqual(1); + wantedCalls = faultHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(1, dependencyAttributes)); + expect(wantedCalls.length).toEqual(1); + break; + case ExpectedStatusMetric.NEITHER: + wantedCalls = errorHistogramMockRecord.getCalls().filter(call => call.calledWithExactly(0, serviceAttributes)); + expect(wantedCalls.length).toEqual(1); + wantedCalls = faultHistogramMockRecord.getCalls().filter(call => call.calledWithExactly(0, serviceAttributes)); + expect(wantedCalls.length).toEqual(1); + wantedCalls = errorHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(0, dependencyAttributes)); + expect(wantedCalls.length).toEqual(1); + wantedCalls = faultHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(0, dependencyAttributes)); + expect(wantedCalls.length).toEqual(1); + break; + } + + wantedCalls = latencyHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(TEST_LATENCY_MILLIS, serviceAttributes)); + expect(wantedCalls.length).toEqual(1); + + wantedCalls = latencyHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(TEST_LATENCY_MILLIS, dependencyAttributes)); + expect(wantedCalls.length).toEqual(1); + + // Clear invocations so this method can be called multiple times in one test. + errorHistogramMockRecord.reset(); + faultHistogramMockRecord.reset(); + latencyHistogramMockRecord.reset(); + } + + function verifyHistogramRecords( + metricAttributesMap: AttributeMap, + wantedServiceMetricInvocation: number, + wantedDependencyMetricInvocation: number + ): void { + let wantedCalls: sinon.SinonSpyCall< + [value: number, attributes?: Attributes | undefined, context?: Context | undefined], + void + >[]; + wantedCalls = errorHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(0, metricAttributesMap[SERVICE_METRIC])); + expect(wantedCalls.length).toEqual(wantedServiceMetricInvocation); + + wantedCalls = faultHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(0, metricAttributesMap[SERVICE_METRIC])); + expect(wantedCalls.length).toEqual(wantedServiceMetricInvocation); + + wantedCalls = latencyHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(TEST_LATENCY_MILLIS, metricAttributesMap[SERVICE_METRIC])); + expect(wantedCalls.length).toEqual(wantedServiceMetricInvocation); + + wantedCalls = errorHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(0, metricAttributesMap[DEPENDENCY_METRIC])); + expect(wantedCalls.length).toEqual(wantedDependencyMetricInvocation); + + wantedCalls = faultHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(0, metricAttributesMap[DEPENDENCY_METRIC])); + expect(wantedCalls.length).toEqual(wantedDependencyMetricInvocation); + + wantedCalls = latencyHistogramMockRecord + .getCalls() + .filter(call => call.calledWithExactly(TEST_LATENCY_MILLIS, metricAttributesMap[DEPENDENCY_METRIC])); + expect(wantedCalls.length).toEqual(wantedDependencyMetricInvocation); + } + + function createMockValidSpanContext(): SpanContext { + return { + traceId: '00000000000000000000000000000004', + spanId: '0000000000000005', + traceFlags: 0, + }; + } +});