diff --git a/packages/opentelemetry-core/src/context/context.ts b/packages/opentelemetry-core/src/context/context.ts index c3a6dc33f5..6edbfa0a43 100644 --- a/packages/opentelemetry-core/src/context/context.ts +++ b/packages/opentelemetry-core/src/context/context.ts @@ -26,6 +26,13 @@ export const ACTIVE_SPAN_KEY = Context.createKey( const EXTRACTED_SPAN_CONTEXT_KEY = Context.createKey( 'OpenTelemetry Context Key EXTRACTED_SPAN_CONTEXT' ); +/** + * Shared key for indicating if instrumentation should be suppressed beyond + * this current scope. + */ +export const SUPPRESS_INSTRUMENTATION_KEY = Context.createKey( + 'OpenTelemetry Context Key SUPPRESS_INSTRUMENTATION' +); /** * Return the active span if one exists @@ -84,3 +91,33 @@ export function getParentSpanContext( ): SpanContext | undefined { return getActiveSpan(context)?.context() || getExtractedSpanContext(context); } + +/** + * Sets value on context to indicate that instrumentation should + * be suppressed beyond this current scope. + * + * @param context context to set the suppress instrumentation value on. + */ +export function suppressInstrumentation(context: Context): Context { + return context.setValue(SUPPRESS_INSTRUMENTATION_KEY, true); +} + +/** + * Sets value on context to indicate that instrumentation should + * no-longer be suppressed beyond this current scope. + * + * @param context context to set the suppress instrumentation value on. + */ +export function unsuppressInstrumentation(context: Context): Context { + return context.setValue(SUPPRESS_INSTRUMENTATION_KEY, false); +} + +/** + * Return current suppress instrumentation value for the given context, + * if it exists. + * + * @param context context check for the suppress instrumentation value. + */ +export function isInstrumentationSuppressed(context: Context): boolean { + return Boolean(context.getValue(SUPPRESS_INSTRUMENTATION_KEY)); +} diff --git a/packages/opentelemetry-core/test/context/context.test.ts b/packages/opentelemetry-core/test/context/context.test.ts new file mode 100644 index 0000000000..1755727d49 --- /dev/null +++ b/packages/opentelemetry-core/test/context/context.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as assert from 'assert'; + +import { + SUPPRESS_INSTRUMENTATION_KEY, + suppressInstrumentation, + unsuppressInstrumentation, + isInstrumentationSuppressed, +} from '../../src/context/context'; +import { Context } from '@opentelemetry/api'; + +describe('Context Helpers', () => { + describe('suppressInstrumentation', () => { + it('should set suppress to true', () => { + const expectedValue = true; + const context = suppressInstrumentation(Context.ROOT_CONTEXT); + + const value = context.getValue(SUPPRESS_INSTRUMENTATION_KEY); + const boolValue = value as boolean; + + assert.equal(boolValue, expectedValue); + }); + }); + + describe('unsuppressInstrumentation', () => { + it('should set suppress to false', () => { + const expectedValue = false; + const context = unsuppressInstrumentation(Context.ROOT_CONTEXT); + + const value = context.getValue(SUPPRESS_INSTRUMENTATION_KEY); + const boolValue = value as boolean; + + assert.equal(boolValue, expectedValue); + }); + }); + + describe('isInstrumentationSuppressed', () => { + it('should get value as bool', () => { + const expectedValue = true; + const context = Context.ROOT_CONTEXT.setValue( + SUPPRESS_INSTRUMENTATION_KEY, + expectedValue + ); + + const value = isInstrumentationSuppressed(context); + + assert.equal(value, expectedValue); + }); + + describe('when suppress instrumentation set to null', () => { + const context = Context.ROOT_CONTEXT.setValue( + SUPPRESS_INSTRUMENTATION_KEY, + null + ); + + it('should return false', () => { + const value = isInstrumentationSuppressed(context); + + assert.equal(value, false); + }); + }); + + describe('when suppress instrumentation set to undefined', () => { + const context = Context.ROOT_CONTEXT.setValue( + SUPPRESS_INSTRUMENTATION_KEY, + undefined + ); + + it('should return false', () => { + const value = isInstrumentationSuppressed(context); + + assert.equal(value, false); + }); + }); + }); +}); diff --git a/packages/opentelemetry-tracing/src/Tracer.ts b/packages/opentelemetry-tracing/src/Tracer.ts index 6f8fcc6f18..b2d21bfe91 100644 --- a/packages/opentelemetry-tracing/src/Tracer.ts +++ b/packages/opentelemetry-tracing/src/Tracer.ts @@ -25,6 +25,7 @@ import { IdGenerator, RandomIdGenerator, setActiveSpan, + isInstrumentationSuppressed, } from '@opentelemetry/core'; import { Resource } from '@opentelemetry/resources'; import { BasicTracerProvider } from './BasicTracerProvider'; @@ -69,6 +70,11 @@ export class Tracer implements api.Tracer { options: api.SpanOptions = {}, context = api.context.active() ): api.Span { + if (isInstrumentationSuppressed(context)) { + this.logger.debug('Instrumentation suppressed, returning Noop Span'); + return api.NOOP_SPAN; + } + const parentContext = getParent(options, context); const spanId = this._idGenerator.generateSpanId(); let traceId; @@ -81,6 +87,7 @@ export class Tracer implements api.Tracer { traceId = parentContext.traceId; traceState = parentContext.traceState; } + const spanKind = options.kind ?? api.SpanKind.INTERNAL; const links = options.links ?? []; const attributes = options.attributes ?? {}; diff --git a/packages/opentelemetry-tracing/src/export/BatchSpanProcessor.ts b/packages/opentelemetry-tracing/src/export/BatchSpanProcessor.ts index a81e747ac8..866e499cbb 100644 --- a/packages/opentelemetry-tracing/src/export/BatchSpanProcessor.ts +++ b/packages/opentelemetry-tracing/src/export/BatchSpanProcessor.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { unrefTimer } from '@opentelemetry/core'; +import { context } from '@opentelemetry/api'; +import { unrefTimer, suppressInstrumentation } from '@opentelemetry/core'; import { SpanProcessor } from '../SpanProcessor'; import { BufferConfig } from '../types'; import { ReadableSpan } from './ReadableSpan'; @@ -88,7 +89,12 @@ export class BatchSpanProcessor implements SpanProcessor { setTimeout(cb, 0); return; } - this._exporter.export(this._finishedSpans, cb); + + // prevent downstream exporter calls from generating spans + context.with(suppressInstrumentation(context.active()), () => { + this._exporter.export(this._finishedSpans, cb); + }); + this._finishedSpans = []; } diff --git a/packages/opentelemetry-tracing/src/export/InMemorySpanExporter.ts b/packages/opentelemetry-tracing/src/export/InMemorySpanExporter.ts index 04eec13318..4a37a0744c 100644 --- a/packages/opentelemetry-tracing/src/export/InMemorySpanExporter.ts +++ b/packages/opentelemetry-tracing/src/export/InMemorySpanExporter.ts @@ -20,12 +20,16 @@ import { ExportResult } from '@opentelemetry/core'; /** * This class can be used for testing purposes. It stores the exported spans - * in a list in memory that can be retrieve using the `getFinishedSpans()` + * in a list in memory that can be retrieved using the `getFinishedSpans()` * method. */ export class InMemorySpanExporter implements SpanExporter { private _finishedSpans: ReadableSpan[] = []; - private _stopped = false; + /** + * Indicates if the exporter has been "shutdown." + * When false, exported spans will not be stored in-memory. + */ + protected _stopped = false; export( spans: ReadableSpan[], @@ -33,7 +37,8 @@ export class InMemorySpanExporter implements SpanExporter { ): void { if (this._stopped) return resultCallback(ExportResult.FAILED_NOT_RETRYABLE); this._finishedSpans.push(...spans); - return resultCallback(ExportResult.SUCCESS); + + setTimeout(() => resultCallback(ExportResult.SUCCESS), 0); } shutdown(): void { diff --git a/packages/opentelemetry-tracing/src/export/SimpleSpanProcessor.ts b/packages/opentelemetry-tracing/src/export/SimpleSpanProcessor.ts index 294b61777a..c63b6fbff4 100644 --- a/packages/opentelemetry-tracing/src/export/SimpleSpanProcessor.ts +++ b/packages/opentelemetry-tracing/src/export/SimpleSpanProcessor.ts @@ -17,6 +17,8 @@ import { SpanProcessor } from '../SpanProcessor'; import { SpanExporter } from './SpanExporter'; import { ReadableSpan } from './ReadableSpan'; +import { context } from '@opentelemetry/api'; +import { suppressInstrumentation } from '@opentelemetry/core'; /** * An implementation of the {@link SpanProcessor} that converts the {@link Span} @@ -40,7 +42,11 @@ export class SimpleSpanProcessor implements SpanProcessor { if (this._isShutdown) { return; } - this._exporter.export([span], () => {}); + + // prevent downstream exporter calls from generating spans + context.with(suppressInstrumentation(context.active()), () => { + this._exporter.export([span], () => {}); + }); } shutdown(cb: () => void = () => {}): void { diff --git a/packages/opentelemetry-tracing/test/Tracer.test.ts b/packages/opentelemetry-tracing/test/Tracer.test.ts index 8bf0481c85..9851aee624 100644 --- a/packages/opentelemetry-tracing/test/Tracer.test.ts +++ b/packages/opentelemetry-tracing/test/Tracer.test.ts @@ -19,6 +19,8 @@ import { NoopSpan, Sampler, SamplingDecision, + Context, + NOOP_SPAN, TraceFlags, } from '@opentelemetry/api'; import { BasicTracerProvider, Tracer, Span } from '../src'; @@ -27,6 +29,7 @@ import { NoopLogger, AlwaysOnSampler, AlwaysOffSampler, + suppressInstrumentation, } from '@opentelemetry/core'; describe('Tracer', () => { @@ -115,6 +118,25 @@ describe('Tracer', () => { assert.strictEqual(lib.version, '0.0.1'); }); + describe('when suppressInstrumentation true', () => { + const context = suppressInstrumentation(Context.ROOT_CONTEXT); + + it('should return cached no-op span ', done => { + const tracer = new Tracer( + { name: 'default', version: '0.0.1' }, + { sampler: new TestSampler() }, + tracerProvider + ); + + const span = tracer.startSpan('span3', undefined, context); + + assert.equal(span, NOOP_SPAN); + span.end(); + + done(); + }); + }); + if (typeof process !== 'undefined' && process.release.name === 'node') { it('should sample a trace when OTEL_SAMPLING_PROBABILITY is invalid', () => { process.env.OTEL_SAMPLING_PROBABILITY = 'invalid value'; diff --git a/packages/opentelemetry-tracing/test/export/BatchSpanProcessor.test.ts b/packages/opentelemetry-tracing/test/export/BatchSpanProcessor.test.ts index 29a9d4117f..56364a1074 100644 --- a/packages/opentelemetry-tracing/test/export/BatchSpanProcessor.test.ts +++ b/packages/opentelemetry-tracing/test/export/BatchSpanProcessor.test.ts @@ -23,6 +23,9 @@ import { InMemorySpanExporter, Span, } from '../../src'; +import { context } from '@opentelemetry/api'; +import { TestTracingSpanExporter } from './TestTracingSpanExporter'; +import { TestStackContextManager } from './TestStackContextManager'; function createSampledSpan(spanName: string): Span { const tracer = new BasicTracerProvider({ @@ -226,5 +229,32 @@ describe('BatchSpanProcessor', () => { }); }); }); + + describe('flushing spans with exporter triggering instrumentation', () => { + beforeEach(() => { + const contextManager = new TestStackContextManager().enable(); + context.setGlobalContextManager(contextManager); + }); + + afterEach(() => { + context.disable(); + }); + + it('should prevent instrumentation prior to export', done => { + const testTracingExporter = new TestTracingSpanExporter(); + const processor = new BatchSpanProcessor(testTracingExporter); + + const span = createSampledSpan('test'); + processor.onStart(span); + processor.onEnd(span); + + processor.forceFlush(() => { + const exporterCreatedSpans = testTracingExporter.getExporterCreatedSpans(); + assert.equal(exporterCreatedSpans.length, 0); + + done(); + }); + }); + }); }); }); diff --git a/packages/opentelemetry-tracing/test/export/SimpleSpanProcessor.test.ts b/packages/opentelemetry-tracing/test/export/SimpleSpanProcessor.test.ts index 8b67013153..584f4b32ec 100644 --- a/packages/opentelemetry-tracing/test/export/SimpleSpanProcessor.test.ts +++ b/packages/opentelemetry-tracing/test/export/SimpleSpanProcessor.test.ts @@ -21,7 +21,9 @@ import { InMemorySpanExporter, SimpleSpanProcessor, } from '../../src'; -import { SpanContext, SpanKind, TraceFlags } from '@opentelemetry/api'; +import { SpanContext, SpanKind, TraceFlags, context } from '@opentelemetry/api'; +import { TestTracingSpanExporter } from './TestTracingSpanExporter'; +import { TestStackContextManager } from './TestStackContextManager'; describe('SimpleSpanProcessor', () => { const provider = new BasicTracerProvider(); @@ -80,16 +82,20 @@ describe('SimpleSpanProcessor', () => { processor.shutdown(); assert.strictEqual(exporter.getFinishedSpans().length, 0); }); + }); - describe('force flush', () => { - it('should call an async callback when flushing is complete', done => { + describe('force flush', () => { + describe('when flushing complete', () => { + it('should call an async callback', done => { const processor = new SimpleSpanProcessor(exporter); processor.forceFlush(() => { done(); }); }); + }); - it('should call an async callback when shutdown is complete', done => { + describe('when shutdown is complete', () => { + it('should call an async callback', done => { const processor = new SimpleSpanProcessor(exporter); processor.shutdown(() => { done(); @@ -97,4 +103,37 @@ describe('SimpleSpanProcessor', () => { }); }); }); + + describe('onEnd', () => { + beforeEach(() => { + const contextManager = new TestStackContextManager().enable(); + context.setGlobalContextManager(contextManager); + }); + + afterEach(() => { + context.disable(); + }); + + it('should prevent instrumentation prior to export', () => { + const testTracingExporter = new TestTracingSpanExporter(); + const processor = new SimpleSpanProcessor(testTracingExporter); + + const spanContext: SpanContext = { + traceId: 'a3cda95b652f4a1592b449d5929fda1b', + spanId: '5e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + const span = new Span( + provider.getTracer('default'), + 'span-name', + spanContext, + SpanKind.CLIENT + ); + + processor.onEnd(span); + + const exporterCreatedSpans = testTracingExporter.getExporterCreatedSpans(); + assert.equal(exporterCreatedSpans.length, 0); + }); + }); }); diff --git a/packages/opentelemetry-tracing/test/export/TestStackContextManager.ts b/packages/opentelemetry-tracing/test/export/TestStackContextManager.ts new file mode 100644 index 0000000000..3062ea1069 --- /dev/null +++ b/packages/opentelemetry-tracing/test/export/TestStackContextManager.ts @@ -0,0 +1,57 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ContextManager, Context } from '@opentelemetry/context-base'; + +/** + * A test-only ContextManager that uses an in-memory stack to keep track of + * the active context. + * + * This is not intended for advanced or asynchronous use cases. + */ +export class TestStackContextManager implements ContextManager { + private _contextStack: Context[] = []; + + active(): Context { + return ( + this._contextStack[this._contextStack.length - 1] ?? Context.ROOT_CONTEXT + ); + } + + with ReturnType>( + context: Context, + fn: T + ): ReturnType { + this._contextStack.push(context); + try { + return fn(); + } finally { + this._contextStack.pop(); + } + } + + bind(target: T, context?: Context): T { + throw new Error('Method not implemented.'); + } + + enable(): this { + return this; + } + + disable(): this { + return this; + } +} diff --git a/packages/opentelemetry-tracing/test/export/TestTracingSpanExporter.ts b/packages/opentelemetry-tracing/test/export/TestTracingSpanExporter.ts new file mode 100644 index 0000000000..0aba00b054 --- /dev/null +++ b/packages/opentelemetry-tracing/test/export/TestTracingSpanExporter.ts @@ -0,0 +1,85 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + BasicTracerProvider, + InMemorySpanExporter, + ReadableSpan, + Tracer, + SpanProcessor, +} from '../../src'; +import { ExportResult, NoopLogger, AlwaysOnSampler } from '@opentelemetry/core'; + +/** + * A test-only span exporter that naively simulates triggering instrumentation + * (creating new spans) during export. + */ +export class TestTracingSpanExporter extends InMemorySpanExporter { + private _exporterCreatedSpans: ReadableSpan[] = []; + private _tracer: Tracer; + + constructor() { + super(); + + const tracerProvider = new BasicTracerProvider({ + logger: new NoopLogger(), + }); + + const spanProcessor: SpanProcessor = { + forceFlush: () => {}, + onStart: () => {}, + shutdown: () => {}, + onEnd: span => { + this._exporterCreatedSpans.push(span); + }, + }; + + tracerProvider.addSpanProcessor(spanProcessor); + + this._tracer = new Tracer( + { name: 'default', version: '0.0.1' }, + { sampler: new AlwaysOnSampler() }, + tracerProvider + ); + } + + export( + spans: ReadableSpan[], + resultCallback: (result: ExportResult) => void + ): void { + if (!this._stopped) { + // Simulates an instrumented exporter by creating a span on the tracer. + const createdSpan = this._tracer.startSpan('exporter-created-span'); + createdSpan.end(); + } + + super.export(spans, resultCallback); + } + + shutdown(): void { + super.shutdown(); + this._exporterCreatedSpans = []; + } + + reset() { + super.reset(); + this._exporterCreatedSpans = []; + } + + getExporterCreatedSpans(): ReadableSpan[] { + return this._exporterCreatedSpans; + } +}