diff --git a/api/src/experimental/decorators/index.ts b/api/src/experimental/decorators/index.ts new file mode 100644 index 00000000000..2c1c8dcb497 --- /dev/null +++ b/api/src/experimental/decorators/index.ts @@ -0,0 +1,22 @@ +/* + * 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 { TraceDecoratorAPI } from './trace'; + +export const trace = TraceDecoratorAPI.getInstance(); +export const decorators = { + trace, +}; diff --git a/api/src/experimental/decorators/trace.ts b/api/src/experimental/decorators/trace.ts new file mode 100644 index 00000000000..27908728d28 --- /dev/null +++ b/api/src/experimental/decorators/trace.ts @@ -0,0 +1,122 @@ +/* + * 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 { + Context, + Span, + SpanStatusCode, + Tracer, + Exception, + SpanOptions, +} from '../../'; + +/** + * Cast an arbitrary exception value to {@link Exception} type. + */ +function asException(e: unknown): Exception { + if (typeof e === 'object' && e !== null && 'message' in e) { + return e as Exception; + } + if (typeof e === 'string') { + return e; + } + return `${e}`; +} + +function onException(e: Exception, span: Span) { + span.recordException(e); + span.setStatus({ + code: SpanStatusCode.ERROR, + }); +} + +export class TraceDecoratorAPI { + private static _instance?: TraceDecoratorAPI; + + /** Empty private constructor prevents end users from constructing a new instance of the API */ + private constructor() {} + + /** Get the singleton instance of the TraceDecoratorAPI API */ + public static getInstance(): TraceDecoratorAPI { + if (!this._instance) { + this._instance = new TraceDecoratorAPI(); + } + + return this._instance; + } + + public startActiveSpan = startActiveSpan; +} + +/** + * Decorator to trace a class method with {@link Tracer.startActiveSpan}. + */ +function startActiveSpan( + tracer: Tracer, + name?: string, + options?: SpanOptions, + context?: Context +) { + return ( + originalMethod: Function, // eslint-disable-line @typescript-eslint/no-unsafe-function-type + decContext: ClassMethodDecoratorContext + ) => { + const methodName = String(decContext.name); + let spanName = name; + if (name === undefined) { + spanName = methodName; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + decContext.addInitializer(function init(this: any) { + spanName = `${this.constructor.name}.${methodName}`; + }); + } + + function replacementMethod(this: unknown, ...args: unknown[]) { + // Force positional arguments on `startActiveSpan`. + return tracer.startActiveSpan(spanName!, options!, context!, span => { + try { + const ret = originalMethod.apply(this, args); + if (typeof ret?.then === 'function') { + // If the originalMethod is an async function, attach span handler to + // the returned promise. + return ret.then( + (val: unknown) => { + span.end(); + return val; + }, + (e: unknown) => { + onException(asException(e), span); + span.end(); + throw e; + } + ); + } else { + // Only end the span if the originalMethod is not an async function. + span.end(); + } + return ret; + } catch (e) { + onException(asException(e), span); + span.end(); + throw e; + } + }); + } + Object.defineProperty(replacementMethod, 'name', { value: methodName }); + + return replacementMethod; + }; +} diff --git a/api/src/experimental/index.ts b/api/src/experimental/index.ts index f034ae6c1d7..072709724d6 100644 --- a/api/src/experimental/index.ts +++ b/api/src/experimental/index.ts @@ -21,3 +21,5 @@ export { wrapTracer, SugaredTracer } from './trace/SugaredTracer'; export type { SugaredSpanOptions } from './trace/SugaredOptions'; + +export { decorators } from './decorators/index'; diff --git a/api/test/common/experimental/decorators/trace.ts b/api/test/common/experimental/decorators/trace.ts new file mode 100644 index 00000000000..919770f395c --- /dev/null +++ b/api/test/common/experimental/decorators/trace.ts @@ -0,0 +1,211 @@ +/* + * 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 * as sinon from 'sinon'; +import { NoopTracerProvider } from '../../../../src/trace/NoopTracerProvider'; +import { NoopTracer } from '../../../../src/trace/NoopTracer'; +import { Span } from '../../../../src'; +import { Context, SpanOptions } from '../../../../src'; +import { NonRecordingSpan } from '../../../../src/trace/NonRecordingSpan'; +import { decorators } from '../../../../src/experimental'; + +describe('decorators/trace', function () { + class TestTracer extends NoopTracer { + public calls: IArguments[] = []; + public span = new NonRecordingSpan(); + + override startActiveSpan ReturnType>( + name: string, + arg2?: SpanOptions, + arg3?: Context, + arg4?: F + ): ReturnType | undefined { + this.calls.push(arguments); + return super.startActiveSpan(name, arg2, arg3, () => { + return arg4?.(this.span); + }); + } + } + + class TestTracerProvider extends NoopTracerProvider { + override getTracer() { + return new TestTracer(); + } + } + + const tracer = new TestTracerProvider().getTracer(); + + afterEach(() => { + tracer.calls = []; + tracer.span = new NonRecordingSpan(); + }); + + const error = new Error('test error'); + class TestClass { + @decorators.trace.startActiveSpan(tracer) + foo(throws = false) { + if (throws) throw error; + return 'foo'; + } + + @decorators.trace.startActiveSpan(tracer) + async asyncFoo(throws = false) { + if (throws) throw error; + return 'asyncFoo'; + } + + @decorators.trace.startActiveSpan(tracer) + #privateFoo(throws = false) { + if (throws) throw error; + return 'privateFoo'; + } + + @decorators.trace.startActiveSpan(tracer, 'customSpanName', { + attributes: { 'custom-attribute': 'value' }, + }) + customMethodName() { + return; + } + + callPrivateFoo(throws = false) { + return this.#privateFoo(throws); + } + } + + describe('startActiveSpan', () => { + it('should trace sync method', function () { + const endStub = sinon.stub(tracer.span, 'end'); + const test = new TestClass(); + + assert.strictEqual(test.foo.name, 'foo'); + const ret = test.foo(); + assert.strictEqual(ret, 'foo'); + + assert.strictEqual(tracer.calls.length, 1); + assert.strictEqual(tracer.calls[0][0], 'TestClass.foo'); + + assert.strictEqual(endStub.callCount, 1); + }); + + it('should trace method with custom options', function () { + const endStub = sinon.stub(tracer.span, 'end'); + const test = new TestClass(); + + assert.strictEqual(test.customMethodName.name, 'customMethodName'); + test.customMethodName(); + + assert.strictEqual(tracer.calls.length, 1); + assert.strictEqual(tracer.calls[0][0], 'customSpanName'); + assert.strictEqual( + tracer.calls[0][1].attributes['custom-attribute'], + 'value' + ); + + assert.strictEqual(endStub.callCount, 1); + }); + + it('should trace async method', async function () { + const endStub = sinon.stub(tracer.span, 'end'); + const test = new TestClass(); + + assert.strictEqual(test.asyncFoo.name, 'asyncFoo'); + const ret = test.asyncFoo(); + assert.strictEqual(typeof ret.then, 'function'); + + assert.strictEqual(tracer.calls.length, 1); + assert.strictEqual(tracer.calls[0][0], 'TestClass.asyncFoo'); + + // The promise is not resolved yet. + assert.strictEqual(endStub.callCount, 0); + + assert.strictEqual(await ret, 'asyncFoo'); + // After the promise is resolved, span should be ended. + assert.strictEqual(endStub.callCount, 1); + }); + + it('should trace private method', async function () { + const endStub = sinon.stub(tracer.span, 'end'); + const test = new TestClass(); + + const ret = test.callPrivateFoo(); + assert.strictEqual(ret, 'privateFoo'); + + assert.strictEqual(tracer.calls.length, 1); + assert.strictEqual(tracer.calls[0][0], 'TestClass.#privateFoo'); + + assert.strictEqual(endStub.callCount, 1); + }); + }); + + describe('startActiveSpan() with exceptions', function () { + it('should trace sync method', function () { + const endStub = sinon.stub(tracer.span, 'end'); + const recordExceptionStub = sinon.stub(tracer.span, 'recordException'); + const test = new TestClass(); + + try { + test.foo(true); + } catch { + /* ignore */ + } + + assert.strictEqual(tracer.calls.length, 1); + assert.strictEqual(tracer.calls[0][0], 'TestClass.foo'); + + assert.strictEqual(endStub.callCount, 1); + assert.strictEqual(recordExceptionStub.args[0][0], error); + }); + + it('should trace async method', async function () { + const endStub = sinon.stub(tracer.span, 'end'); + const recordExceptionStub = sinon.stub(tracer.span, 'recordException'); + const test = new TestClass(); + + const ret = test.asyncFoo(true).catch(() => {}); + + assert.strictEqual(tracer.calls.length, 1); + assert.strictEqual(tracer.calls[0][0], 'TestClass.asyncFoo'); + + // The promise is not settled yet. + assert.strictEqual(endStub.callCount, 0); + + await ret; + // After the promise is settled, span should be ended. + assert.strictEqual(endStub.callCount, 1); + // Promise rejection should be recorded. + assert.strictEqual(recordExceptionStub.args[0][0], error); + }); + + it('should trace private method', async function () { + const endStub = sinon.stub(tracer.span, 'end'); + const recordExceptionStub = sinon.stub(tracer.span, 'recordException'); + const test = new TestClass(); + + try { + test.callPrivateFoo(true); + } catch { + /* ignore */ + } + + assert.strictEqual(tracer.calls.length, 1); + assert.strictEqual(tracer.calls[0][0], 'TestClass.#privateFoo'); + + assert.strictEqual(endStub.callCount, 1); + assert.strictEqual(recordExceptionStub.args[0][0], error); + }); + }); +});