From 71ef1b1f2cc73a48d27310925ce1109fd8d6712b Mon Sep 17 00:00:00 2001 From: Sebastian Poxhofer Date: Tue, 16 Jan 2024 10:25:17 +0100 Subject: [PATCH] feat(SugaredTracer): add draft of sugaredTracer (#3317) * feat(api): add experimental package and SugaredTracer * tests(api/experimental): add tests * fix: packages.json exports * move export into experimental package * add additional tests * fix: do not use catch and finally for compatibility with Node 8 --------- Co-authored-by: Chengzhong Wu --- api/CHANGELOG.md | 4 + api/package.json | 14 ++ api/src/experimental/index.ts | 17 ++ api/src/experimental/trace/SugaredOptions.ts | 29 +++ api/src/experimental/trace/SugaredTracer.ts | 215 ++++++++++++++++++ .../experimental/trace/SugaredTracer.test.ts | 157 +++++++++++++ .../test/common/Tracer.test.ts | 9 +- 7 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 api/src/experimental/index.ts create mode 100644 api/src/experimental/trace/SugaredOptions.ts create mode 100644 api/src/experimental/trace/SugaredTracer.ts create mode 100644 api/test/common/experimental/trace/SugaredTracer.test.ts diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index c46749c01e..748a2d389c 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## Unreleased +* feat(api): add SugaredTracer for functions not defined in the spec + +## Unreleased + ## 1.7.0 ### :rocket: (Enhancement) diff --git a/api/package.json b/api/package.json index 4673acbf9e..0eecea4381 100644 --- a/api/package.json +++ b/api/package.json @@ -12,6 +12,20 @@ "./build/esnext/platform/index.js": "./build/esnext/platform/browser/index.js", "./build/src/platform/index.js": "./build/src/platform/browser/index.js" }, + "exports": { + ".": { + "module": "./build/esm/index.js", + "esnext": "./build/esnext/index.js", + "types": "./build/src/index.d.ts", + "default": "./build/src/index.js" + }, + "./experimental": { + "module": "./build/esm/experimental.js", + "esnext": "./build/esnext/experimental.js", + "types": "./build/src/experimental.d.ts", + "default": "./build/src/experimental.js" + } + }, "repository": "open-telemetry/opentelemetry-js", "scripts": { "clean": "tsc --build --clean tsconfig.json tsconfig.esm.json tsconfig.esnext.json", diff --git a/api/src/experimental/index.ts b/api/src/experimental/index.ts new file mode 100644 index 0000000000..a05c7ba21b --- /dev/null +++ b/api/src/experimental/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ +export { wrapTracer, SugaredTracer } from './trace/SugaredTracer'; +export { SugaredSpanOptions } from './trace/SugaredOptions'; diff --git a/api/src/experimental/trace/SugaredOptions.ts b/api/src/experimental/trace/SugaredOptions.ts new file mode 100644 index 0000000000..3dba14650e --- /dev/null +++ b/api/src/experimental/trace/SugaredOptions.ts @@ -0,0 +1,29 @@ +/* + * 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 { Span, SpanOptions } from '../../'; + +/** + * Options needed for span creation + */ +export interface SugaredSpanOptions extends SpanOptions { + /** + * function to overwrite default exception behavior to record the exception. No exceptions should be thrown in the function. + * @param e Error which triggered this exception + * @param span current span from context + */ + onException?: (e: Error, span: Span) => void; +} diff --git a/api/src/experimental/trace/SugaredTracer.ts b/api/src/experimental/trace/SugaredTracer.ts new file mode 100644 index 0000000000..c4f5cdc642 --- /dev/null +++ b/api/src/experimental/trace/SugaredTracer.ts @@ -0,0 +1,215 @@ +/* + * 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 { SugaredSpanOptions } from './SugaredOptions'; +import { context, Context, Span, SpanStatusCode, Tracer } from '../../'; + +const defaultOnException = (e: Error, span: Span) => { + span.recordException(e); + span.setStatus({ + code: SpanStatusCode.ERROR, + }); +}; + +/** + * return a new SugaredTracer created from the supplied one + * @param tracer + */ +export function wrapTracer(tracer: Tracer): SugaredTracer { + return new SugaredTracer(tracer); +} + +export class SugaredTracer implements Tracer { + private readonly _tracer: Tracer; + + constructor(tracer: Tracer) { + this._tracer = tracer; + this.startSpan = tracer.startSpan.bind(this._tracer); + this.startActiveSpan = tracer.startActiveSpan.bind(this._tracer); + } + + startActiveSpan: Tracer['startActiveSpan']; + startSpan: Tracer['startSpan']; + + /** + * Starts a new {@link Span} and calls the given function passing it the + * created span as first argument. + * Additionally, the new span gets set in context and this context is activated + * for the duration of the function call. + * The span will be closed after the function has executed. + * If an exception occurs, it is recorded, the status is set to ERROR and the exception is rethrown. + * + * @param name The name of the span + * @param [options] SugaredSpanOptions used for span creation + * @param [context] Context to use to extract parent + * @param fn function called in the context of the span and receives the newly created span as an argument + * @returns return value of fn + * @example + * const something = tracer.withActiveSpan('op', span => { + * // do some work + * }); + * @example + * const something = await tracer.withActiveSpan('op', span => { + * // do some async work + * }); + */ + withActiveSpan ReturnType>( + name: string, + fn: F + ): ReturnType; + withActiveSpan ReturnType>( + name: string, + options: SugaredSpanOptions, + fn: F + ): ReturnType; + withActiveSpan ReturnType>( + name: string, + options: SugaredSpanOptions, + context: Context, + fn: F + ): ReturnType; + withActiveSpan ReturnType>( + name: string, + arg2: F | SugaredSpanOptions, + arg3?: F | Context, + arg4?: F + ): ReturnType { + const { opts, ctx, fn } = massageParams(arg2, arg3, arg4); + + return this._tracer.startActiveSpan(name, opts, ctx, (span: Span) => + handleFn(span, opts, fn) + ) as ReturnType; + } + + /** + * Starts a new {@link Span} and ends it after execution of fn without setting it on context. + * The span will be closed after the function has executed. + * If an exception occurs, it is recorded, the status is et to ERROR and rethrown. + * + * This method does NOT modify the current Context. + * + * @param name The name of the span + * @param [options] SugaredSpanOptions used for span creation + * @param [context] Context to use to extract parent + * @param fn function called in the context of the span and receives the newly created span as an argument + * @returns Span The newly created span + * @example + * const something = tracer.withSpan('op', span => { + * // do some work + * }); + * @example + * const something = await tracer.withSpan('op', span => { + * // do some async work + * }); + */ + withSpan ReturnType>( + name: string, + fn: F + ): ReturnType; + withSpan ReturnType>( + name: string, + options: SugaredSpanOptions, + fn: F + ): ReturnType; + withSpan ReturnType>( + name: string, + options: SugaredSpanOptions, + context: Context, + fn: F + ): ReturnType; + withSpan ReturnType>( + name: string, + options: SugaredSpanOptions, + context: Context, + fn: F + ): ReturnType; + withSpan ReturnType>( + name: string, + arg2: SugaredSpanOptions | F, + arg3?: Context | F, + arg4?: F + ): ReturnType { + const { opts, ctx, fn } = massageParams(arg2, arg3, arg4); + + const span = this._tracer.startSpan(name, opts, ctx); + return handleFn(span, opts, fn) as ReturnType; + } +} + +/** + * Massages parameters of withSpan and withActiveSpan to allow signature overwrites + * @param arg + * @param arg2 + * @param arg3 + */ +function massageParams ReturnType>( + arg: F | SugaredSpanOptions, + arg2?: F | Context, + arg3?: F +) { + let opts: SugaredSpanOptions | undefined; + let ctx: Context | undefined; + let fn: F; + + if (!arg2 && !arg3) { + fn = arg as F; + } else if (!arg3) { + opts = arg as SugaredSpanOptions; + fn = arg2 as F; + } else { + opts = arg as SugaredSpanOptions; + ctx = arg2 as Context; + fn = arg3 as F; + } + opts = opts ?? {}; + ctx = ctx ?? context.active(); + + return { opts, ctx, fn }; +} + +/** + * Executes fn, returns results and runs onException in the case of exception to allow overwriting of error handling + * @param span + * @param opts + * @param fn + */ +function handleFn ReturnType>( + span: Span, + opts: SugaredSpanOptions, + fn: F +): ReturnType { + const onException = opts.onException ?? defaultOnException; + const errorHandler = (e: Error) => { + onException(e, span); + span.end(); + throw e; + }; + + try { + const ret = fn(span) as Promise>; + // if fn is an async function, attach a recordException and spanEnd callback to the promise + if (typeof ret?.then === 'function') { + return ret.then(val => { + span.end(); + return val; + }, errorHandler) as ReturnType; + } + span.end(); + return ret as ReturnType; + } catch (e) { + // add throw to signal the compiler that this will throw in the inner scope + throw errorHandler(e); + } +} diff --git a/api/test/common/experimental/trace/SugaredTracer.test.ts b/api/test/common/experimental/trace/SugaredTracer.test.ts new file mode 100644 index 0000000000..aa0d01f3d8 --- /dev/null +++ b/api/test/common/experimental/trace/SugaredTracer.test.ts @@ -0,0 +1,157 @@ +/* + * 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 { wrapTracer } from '../../../../src/experimental'; +import * as assert from 'assert'; +import { NoopTracerProvider } from '../../../../src/trace/NoopTracerProvider'; +import { NoopTracer } from '../../../../src/trace/NoopTracer'; +import { Span } from '../../../../src'; +import { Context, context, SpanOptions } from '../../../../src'; + +describe('SugaredTracer', () => { + class TestTracer extends NoopTracer { + public calls: IArguments[] = []; + + override startActiveSpan ReturnType>( + name: string, + arg2?: SpanOptions, + arg3?: Context, + arg4?: F + ): ReturnType | undefined { + this.calls.push(arguments); + return super.startActiveSpan(name, arg2, arg3, arg4 as F); + } + + override startSpan( + name: string, + options?: SpanOptions, + _context: Context = context.active() + ): Span { + this.calls.push(arguments); + return super.startSpan(name, options, _context); + } + } + + class TestTracerProvider extends NoopTracerProvider { + override getTracer() { + return new TestTracer(); + } + } + + const tracer = new TestTracerProvider().getTracer(); + const sugaredTracer = wrapTracer(tracer); + + afterEach(() => { + tracer.calls = []; + }); + + describe('wrapTracer()', () => { + it('still provides standard tracer functions', () => { + assert.ok( + typeof sugaredTracer.startSpan === 'function', + 'startSpan is missing' + ); + assert.ok( + typeof sugaredTracer.startActiveSpan === 'function', + 'startActiveSpan is missing' + ); + }); + }); + + describe('withActiveSpan()', () => { + it('proxies value with minimal args', () => { + const result = sugaredTracer.withActiveSpan('test', span => { + return 'result'; + }); + + assert.strictEqual(result, 'result', 'Unexpected result'); + + assert.strictEqual(tracer.calls.length, 2); // ensure that startActiveSpan and startSpan is called + }); + + it('proxies value with context', () => { + const result = sugaredTracer.withActiveSpan( + 'test', + { onException: e => e }, + span => { + return 'result'; + } + ); + + assert.strictEqual(result, 'result', 'Unexpected result'); + + assert.strictEqual(tracer.calls.length, 2); // ensure that startActiveSpan and startSpan is called + }); + + it('proxies value with context', () => { + const result = sugaredTracer.withActiveSpan( + 'test', + { onException: e => e }, + context.active(), + span => { + return 'result'; + } + ); + + assert.strictEqual(result, 'result', 'Unexpected result'); + + assert.strictEqual(tracer.calls.length, 2); // ensure that startActiveSpan and startSpan is called + }); + + it('returns promise if wrapped function returns promise', async () => { + const result = sugaredTracer.withActiveSpan('test', span => { + return Promise.resolve('result'); + }); + + assert.ok(typeof result.then == 'function'); + assert.strictEqual(await result, 'result', 'Unexpected result'); + }); + + it('returns void', () => { + const result = sugaredTracer.withActiveSpan('test', (span: Span) => { + return; + }); + + assert.strictEqual(result, undefined); + }); + }); + + describe('withSpan()', () => { + it('proxies value', () => { + const result = sugaredTracer.withSpan('test', span => { + return 'result'; + }); + + assert.strictEqual(result, 'result', 'Unexpected result'); + }); + + it('returns promise if wrapped function returns promise', async () => { + const result = sugaredTracer.withSpan('test', span => { + return Promise.resolve('result'); + }); + + assert.ok(typeof result.then == 'function'); + assert.strictEqual(await result, 'result', 'Unexpected result'); + }); + + it('returns void', () => { + const result = sugaredTracer.withSpan('test', (span: Span) => { + return; + }); + + assert.strictEqual(result, undefined); + }); + }); +}); diff --git a/packages/opentelemetry-sdk-trace-base/test/common/Tracer.test.ts b/packages/opentelemetry-sdk-trace-base/test/common/Tracer.test.ts index 359816874d..9d5d07ea14 100644 --- a/packages/opentelemetry-sdk-trace-base/test/common/Tracer.test.ts +++ b/packages/opentelemetry-sdk-trace-base/test/common/Tracer.test.ts @@ -28,7 +28,6 @@ import { TraceFlags, TraceState, } from '@opentelemetry/api'; -import { getSpan } from '@opentelemetry/api/build/src/trace/context-utils'; import { InstrumentationLibrary, sanitizeAttributes, @@ -303,7 +302,7 @@ describe('Tracer', () => { const samplerContext = shouldSampleSpy.firstCall.args[0]; const processorContext = onStartSpy.firstCall.args[1]; assert.strictEqual(samplerContext, processorContext); - assert.strictEqual(getSpan(samplerContext), undefined); + assert.strictEqual(trace.getSpan(samplerContext), undefined); }); it('should sample a trace when OTEL_TRACES_SAMPLER_ARG is unset', () => { @@ -361,7 +360,7 @@ describe('Tracer', () => { tracer.startActiveSpan('my-span', span => { try { assert(spy.calledWith('my-span')); - assert.strictEqual(getSpan(context.active()), span); + assert.strictEqual(trace.getSpan(context.active()), span); return 1; } finally { span.end(); @@ -387,7 +386,7 @@ describe('Tracer', () => { span => { try { assert(spy.calledWith('my-span', { attributes: { foo: 'bar' } })); - assert.strictEqual(getSpan(context.active()), span); + assert.strictEqual(trace.getSpan(context.active()), span); return 1; } finally { span.end(); @@ -421,7 +420,7 @@ describe('Tracer', () => { assert( spy.calledWith('my-span', { attributes: { foo: 'bar' } }, ctx) ); - assert.strictEqual(getSpan(context.active()), span); + assert.strictEqual(trace.getSpan(context.active()), span); assert.strictEqual(ctx.getValue(ctxKey), 'bar'); return 1; } finally {