Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions api/src/experimental/decorators/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
122 changes: 122 additions & 0 deletions api/src/experimental/decorators/trace.ts
Original file line number Diff line number Diff line change
@@ -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;
};
}
2 changes: 2 additions & 0 deletions api/src/experimental/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@

export { wrapTracer, SugaredTracer } from './trace/SugaredTracer';
export type { SugaredSpanOptions } from './trace/SugaredOptions';

export { decorators } from './decorators/index';
211 changes: 211 additions & 0 deletions api/test/common/experimental/decorators/trace.ts
Original file line number Diff line number Diff line change
@@ -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<F extends (span: Span) => ReturnType<F>>(
name: string,
arg2?: SpanOptions,
arg3?: Context,
arg4?: F
): ReturnType<F> | 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);
});
});
});
Loading