From f40e3fa82a817e710ee6de1eb1df19049d6e2ace Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 21 Mar 2023 14:04:28 +0100 Subject: [PATCH] feat(core): Add trace function --- packages/core/src/tracing/index.ts | 1 + packages/core/src/tracing/trace.ts | 65 +++++++ packages/core/test/lib/tracing/trace.test.ts | 170 +++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 packages/core/src/tracing/trace.ts create mode 100644 packages/core/test/lib/tracing/trace.test.ts diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index fd4949257ceb..1afb556bce4d 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -6,3 +6,4 @@ export { extractTraceparentData, getActiveTransaction, stripUrlQueryAndFragment, // eslint-disable-next-line deprecation/deprecation export { SpanStatus } from './spanstatus'; export type { SpanStatusType } from './span'; +export { trace } from './trace'; diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts new file mode 100644 index 000000000000..19911f7be91f --- /dev/null +++ b/packages/core/src/tracing/trace.ts @@ -0,0 +1,65 @@ +import type { TransactionContext } from '@sentry/types'; +import { isThenable } from '@sentry/utils'; + +import { getCurrentHub } from '../hub'; +import type { Span } from './span'; + +/** + * Wraps a function with a transaction/span and finishes the span after the function is done. + * + * This function is meant to be used internally and may break at any time. Use at your own risk. + * + * @internal + * @private + */ +export function trace( + context: TransactionContext, + callback: (span: Span) => T, + // eslint-disable-next-line @typescript-eslint/no-empty-function + onError: (error: unknown) => void = () => {}, +): T { + const ctx = { ...context }; + // If a name is set and a description is not, set the description to the name. + if (ctx.name !== undefined && ctx.description === undefined) { + ctx.description = ctx.name; + } + + const hub = getCurrentHub(); + const scope = hub.getScope(); + + const parentSpan = scope.getSpan(); + const activeSpan = parentSpan ? parentSpan.startChild(ctx) : hub.startTransaction(ctx); + scope.setSpan(activeSpan); + + function finishAndSetSpan(): void { + activeSpan.finish(); + hub.getScope().setSpan(parentSpan); + } + + let maybePromiseResult: T; + try { + maybePromiseResult = callback(activeSpan); + } catch (e) { + activeSpan.setStatus('internal_error'); + onError(e); + finishAndSetSpan(); + throw e; + } + + if (isThenable(maybePromiseResult)) { + Promise.resolve(maybePromiseResult).then( + () => { + finishAndSetSpan(); + }, + e => { + activeSpan.setStatus('internal_error'); + onError(e); + finishAndSetSpan(); + }, + ); + } else { + finishAndSetSpan(); + } + + return maybePromiseResult; +} diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts new file mode 100644 index 000000000000..8a7aa4e09191 --- /dev/null +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -0,0 +1,170 @@ +import { addTracingExtensions, Hub, makeMain } from '../../../src'; +import { trace } from '../../../src/tracing'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +beforeAll(() => { + addTracingExtensions(); +}); + +const enum Type { + Sync = 'sync', + Async = 'async', +} + +let hub: Hub; +let client: TestClient; + +describe('trace', () => { + beforeEach(() => { + const options = getDefaultTestClientOptions({ tracesSampleRate: 0.0 }); + client = new TestClient(options); + hub = new Hub(client); + makeMain(hub); + }); + + describe.each([ + // isSync, isError, callback, expectedReturnValue + [Type.Async, false, () => Promise.resolve('async good'), 'async good'], + [Type.Sync, false, () => 'sync good', 'sync good'], + [Type.Async, true, () => Promise.reject('async bad'), 'async bad'], + [ + Type.Sync, + true, + () => { + throw 'sync bad'; + }, + 'sync bad', + ], + ])('with %s callback and error %s', (_type, isError, callback, expected) => { + it('should return the same value as the callback', async () => { + try { + const result = await trace({ name: 'GET users/[id]' }, () => { + return callback(); + }); + expect(result).toEqual(expected); + } catch (e) { + expect(e).toEqual(expected); + } + }); + + it('creates a transaction', async () => { + let ref: any = undefined; + client.on('finishTransaction', transaction => { + ref = transaction; + }); + try { + await trace({ name: 'GET users/[id]' }, () => { + return callback(); + }); + } catch (e) { + // + } + expect(ref).toBeDefined(); + + expect(ref.name).toEqual('GET users/[id]'); + expect(ref.status).toEqual(isError ? 'internal_error' : undefined); + }); + + it('allows traceparent information to be overriden', async () => { + let ref: any = undefined; + client.on('finishTransaction', transaction => { + ref = transaction; + }); + try { + await trace( + { + name: 'GET users/[id]', + parentSampled: true, + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + }, + () => { + return callback(); + }, + ); + } catch (e) { + // + } + expect(ref).toBeDefined(); + + expect(ref.sampled).toEqual(true); + expect(ref.traceId).toEqual('12345678901234567890123456789012'); + expect(ref.parentSpanId).toEqual('1234567890123456'); + }); + + it('allows for transaction to be mutated', async () => { + let ref: any = undefined; + client.on('finishTransaction', transaction => { + ref = transaction; + }); + try { + await trace({ name: 'GET users/[id]' }, span => { + span.op = 'http.server'; + return callback(); + }); + } catch (e) { + // + } + + expect(ref.op).toEqual('http.server'); + }); + + it('creates a span with correct description', async () => { + let ref: any = undefined; + client.on('finishTransaction', transaction => { + ref = transaction; + }); + try { + await trace({ name: 'GET users/[id]', parentSampled: true }, () => { + return trace({ name: 'SELECT * from users' }, () => { + return callback(); + }); + }); + } catch (e) { + // + } + + expect(ref.spanRecorder.spans).toHaveLength(2); + expect(ref.spanRecorder.spans[1].description).toEqual('SELECT * from users'); + expect(ref.spanRecorder.spans[1].parentSpanId).toEqual(ref.spanId); + expect(ref.spanRecorder.spans[1].status).toEqual(isError ? 'internal_error' : undefined); + }); + + it('allows for span to be mutated', async () => { + let ref: any = undefined; + client.on('finishTransaction', transaction => { + ref = transaction; + }); + try { + await trace({ name: 'GET users/[id]', parentSampled: true }, () => { + return trace({ name: 'SELECT * from users' }, childSpan => { + childSpan.op = 'db.query'; + return callback(); + }); + }); + } catch (e) { + // + } + + expect(ref.spanRecorder.spans).toHaveLength(2); + expect(ref.spanRecorder.spans[1].op).toEqual('db.query'); + }); + + it('calls `onError` hook', async () => { + const onError = jest.fn(); + try { + await trace( + { name: 'GET users/[id]' }, + () => { + return callback(); + }, + onError, + ); + } catch (e) { + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(e); + } + expect(onError).toHaveBeenCalledTimes(isError ? 1 : 0); + }); + }); +});