diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ac4aa25416..e9a3c187b05f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- [react] feat: Export `createReduxEnhancer` to log redux actions as breadcrumbs, and attach state as an extra. (#2717) +- [tracing] feat: `Add @sentry/tracing` (#2719) ## 5.19.2 @@ -17,7 +19,6 @@ - [tracing] fix: APM CDN bundle expose startTransaction (#2726) - [browser] fix: Correctly remove all event listeners (#2725) - [tracing] fix: Add manual `DOMStringList` typing (#2718) -- [react] feat: Export `createReduxEnhancer` to log redux actions as breadcrumbs, and attach state as an extra. (#2717) ## 5.19.0 diff --git a/package.json b/package.json index 79b315b23bca..823adaea3f95 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "packages/minimal", "packages/node", "packages/react", + "packages/tracing", "packages/types", "packages/typescript", "packages/utils" diff --git a/packages/integrations/src/vue.ts b/packages/integrations/src/vue.ts index 6d62d811ca5e..74f84bf9bfd8 100644 --- a/packages/integrations/src/vue.ts +++ b/packages/integrations/src/vue.ts @@ -1,14 +1,22 @@ -import { EventProcessor, Hub, Integration, IntegrationClass, Span } from '@sentry/types'; +import { EventProcessor, Hub, Integration, IntegrationClass, Scope, Span, Transaction } from '@sentry/types'; import { basename, getGlobalObject, logger, timestampWithMs } from '@sentry/utils'; /** * Used to extract Tracing integration from the current client, * without the need to import `Tracing` itself from the @sentry/apm package. + * @deprecated as @sentry/tracing should be used over @sentry/apm. */ const TRACING_GETTER = ({ id: 'Tracing', } as any) as IntegrationClass; +/** + * Used to extract BrowserTracing integration from @sentry/tracing + */ +const BROWSER_TRACING_GETTER = ({ + id: 'BrowserTracing', +} as any) as IntegrationClass; + /** Global Vue object limited to the methods/attributes we require */ interface VueInstance { config: { @@ -229,6 +237,7 @@ export class Vue implements Integration { // We do this whole dance with `TRACING_GETTER` to prevent `@sentry/apm` from becoming a peerDependency. // We also need to ask for the `.constructor`, as `pushActivity` and `popActivity` are static, not instance methods. + // tslint:disable-next-line: deprecation const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER); if (tracingIntegration) { // tslint:disable-next-line:no-unsafe-any @@ -242,6 +251,15 @@ export class Vue implements Integration { op: 'Vue', }); } + // Use functionality from @sentry/tracing + } else { + const activeTransaction = getActiveTransaction(getCurrentHub()); + if (activeTransaction) { + this._rootSpan = activeTransaction.startChild({ + description: 'Application Render', + op: 'Vue', + }); + } } }); } @@ -315,15 +333,18 @@ export class Vue implements Integration { if (this._tracingActivity) { // We do this whole dance with `TRACING_GETTER` to prevent `@sentry/apm` from becoming a peerDependency. // We also need to ask for the `.constructor`, as `pushActivity` and `popActivity` are static, not instance methods. + // tslint:disable-next-line: deprecation const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER); if (tracingIntegration) { // tslint:disable-next-line:no-unsafe-any (tracingIntegration as any).constructor.popActivity(this._tracingActivity); - if (this._rootSpan) { - this._rootSpan.finish(timestamp); - } } } + + // We should always finish the span, only should pop activity if using @sentry/apm + if (this._rootSpan) { + this._rootSpan.finish(timestamp); + } }, this._options.tracingOptions.timeout); } @@ -333,7 +354,8 @@ export class Vue implements Integration { this._options.Vue.mixin({ beforeCreate(this: ViewModel): void { - if (getCurrentHub().getIntegration(TRACING_GETTER)) { + // tslint:disable-next-line: deprecation + if (getCurrentHub().getIntegration(TRACING_GETTER) || getCurrentHub().getIntegration(BROWSER_TRACING_GETTER)) { // `this` points to currently rendered component applyTracingHooks(this, getCurrentHub); } else { @@ -405,3 +427,21 @@ export class Vue implements Integration { } } } + +// tslint:disable-next-line: completed-docs +interface HubType extends Hub { + // tslint:disable-next-line: completed-docs + getScope?(): Scope | undefined; +} + +/** Grabs active transaction off scope */ +export function getActiveTransaction(hub: HubType): T | undefined { + if (hub && hub.getScope) { + const scope = hub.getScope() as Scope; + if (scope) { + return scope.getTransaction() as T | undefined; + } + } + + return undefined; +} diff --git a/packages/react/src/profiler.tsx b/packages/react/src/profiler.tsx index 2af9dbaab86d..8cff4389ea83 100644 --- a/packages/react/src/profiler.tsx +++ b/packages/react/src/profiler.tsx @@ -1,6 +1,6 @@ -import { getCurrentHub } from '@sentry/browser'; -import { Integration, IntegrationClass, Span } from '@sentry/types'; -import { logger, timestampWithMs } from '@sentry/utils'; +import { getCurrentHub, Hub } from '@sentry/browser'; +import { Integration, IntegrationClass, Span, Transaction } from '@sentry/types'; +import { timestampWithMs } from '@sentry/utils'; import * as hoistNonReactStatic from 'hoist-non-react-statics'; import * as React from 'react'; @@ -11,6 +11,7 @@ const TRACING_GETTER = ({ } as any) as IntegrationClass; let globalTracingIntegration: Integration | null = null; +/** @deprecated remove when @sentry/apm no longer used */ const getTracingIntegration = () => { if (globalTracingIntegration) { return globalTracingIntegration; @@ -20,21 +21,11 @@ const getTracingIntegration = () => { return globalTracingIntegration; }; -/** - * Warn if tracing integration not configured. Will only warn once. - */ -function warnAboutTracing(name: string): void { - if (globalTracingIntegration === null) { - logger.warn( - `Unable to profile component ${name} due to invalid Tracing Integration. Please make sure the Tracing integration is setup properly.`, - ); - } -} - /** * pushActivity creates an new react activity. * Is a no-op if Tracing integration is not valid * @param name displayName of component that started activity + * @deprecated remove when @sentry/apm no longer used */ function pushActivity(name: string, op: string): number | null { if (globalTracingIntegration === null) { @@ -52,6 +43,7 @@ function pushActivity(name: string, op: string): number | null { * popActivity removes a React activity. * Is a no-op if Tracing integration is not valid. * @param activity id of activity that is being popped + * @deprecated remove when @sentry/apm no longer used */ function popActivity(activity: number | null): void { if (activity === null || globalTracingIntegration === null) { @@ -66,6 +58,7 @@ function popActivity(activity: number | null): void { * Obtain a span given an activity id. * Is a no-op if Tracing integration is not valid. * @param activity activity id associated with obtained span + * @deprecated remove when @sentry/apm no longer used */ function getActivitySpan(activity: number | null): Span | undefined { if (activity === null || globalTracingIntegration === null) { @@ -96,11 +89,9 @@ export type ProfilerProps = { */ class Profiler extends React.Component { // The activity representing how long it takes to mount a component. - public mountActivity: number | null = null; + private _mountActivity: number | null = null; // The span of the mount activity - public mountSpan: Span | undefined = undefined; - // The span of the render - public renderSpan: Span | undefined = undefined; + private _mountSpan: Span | undefined = undefined; public static defaultProps: Partial = { disabled: false, @@ -116,25 +107,40 @@ class Profiler extends React.Component { return; } + // If they are using @sentry/apm, we need to push/pop activities + // tslint:disable-next-line: deprecation if (getTracingIntegration()) { - this.mountActivity = pushActivity(name, 'mount'); + // tslint:disable-next-line: deprecation + this._mountActivity = pushActivity(name, 'mount'); } else { - warnAboutTracing(name); + const activeTransaction = getActiveTransaction(); + if (activeTransaction) { + this._mountSpan = activeTransaction.startChild({ + description: `<${name}>`, + op: 'react.mount', + }); + } } } // If a component mounted, we can finish the mount activity. public componentDidMount(): void { - this.mountSpan = getActivitySpan(this.mountActivity); - popActivity(this.mountActivity); - this.mountActivity = null; + if (this._mountSpan) { + this._mountSpan.finish(); + } else { + // tslint:disable-next-line: deprecation + this._mountSpan = getActivitySpan(this._mountActivity); + // tslint:disable-next-line: deprecation + popActivity(this._mountActivity); + this._mountActivity = null; + } } public componentDidUpdate({ updateProps, includeUpdates = true }: ProfilerProps): void { // Only generate an update span if hasUpdateSpan is true, if there is a valid mountSpan, // and if the updateProps have changed. It is ok to not do a deep equality check here as it is expensive. // We are just trying to give baseline clues for further investigation. - if (includeUpdates && this.mountSpan && updateProps !== this.props.updateProps) { + if (includeUpdates && this._mountSpan && updateProps !== this.props.updateProps) { // See what props haved changed between the previous props, and the current props. This is // set as data on the span. We just store the prop keys as the values could be potenially very large. const changedProps = Object.keys(updateProps).filter(k => updateProps[k] !== this.props.updateProps[k]); @@ -142,7 +148,7 @@ class Profiler extends React.Component { // The update span is a point in time span with 0 duration, just signifying that the component // has been updated. const now = timestampWithMs(); - this.mountSpan.startChild({ + this._mountSpan.startChild({ data: { changedProps, }, @@ -160,14 +166,14 @@ class Profiler extends React.Component { public componentWillUnmount(): void { const { name, includeRender = true } = this.props; - if (this.mountSpan && includeRender) { + if (this._mountSpan && includeRender) { // If we were able to obtain the spanId of the mount activity, we should set the // next activity as a child to the component mount activity. - this.mountSpan.startChild({ + this._mountSpan.startChild({ description: `<${name}>`, endTimestamp: timestampWithMs(), op: `react.render`, - startTimestamp: this.mountSpan.endTimestamp, + startTimestamp: this._mountSpan.endTimestamp, }); } } @@ -221,22 +227,26 @@ function useProfiler( hasRenderSpan: true, }, ): void { - const [mountActivity] = React.useState(() => { + const [mountSpan] = React.useState(() => { if (options && options.disabled) { - return null; + return undefined; } - if (getTracingIntegration()) { - return pushActivity(name, 'mount'); + const activeTransaction = getActiveTransaction(); + if (activeTransaction) { + return activeTransaction.startChild({ + description: `<${name}>`, + op: 'react.mount', + }); } - warnAboutTracing(name); - return null; + return undefined; }); React.useEffect(() => { - const mountSpan = getActivitySpan(mountActivity); - popActivity(mountActivity); + if (mountSpan) { + mountSpan.finish(); + } return () => { if (mountSpan && options.hasRenderSpan) { @@ -252,3 +262,15 @@ function useProfiler( } export { withProfiler, Profiler, useProfiler }; + +/** Grabs active transaction off scope */ +export function getActiveTransaction(hub: Hub = getCurrentHub()): T | undefined { + if (hub) { + const scope = hub.getScope(); + if (scope) { + return scope.getTransaction() as T | undefined; + } + } + + return undefined; +} diff --git a/packages/react/test/profiler.test.tsx b/packages/react/test/profiler.test.tsx index 60c2fd312349..faaed0a960c6 100644 --- a/packages/react/test/profiler.test.tsx +++ b/packages/react/test/profiler.test.tsx @@ -5,51 +5,37 @@ import * as React from 'react'; import { UNKNOWN_COMPONENT, useProfiler, withProfiler } from '../src/profiler'; -const TEST_SPAN_ID = '518999beeceb49af'; -const TEST_TIMESTAMP = '123456'; - const mockStartChild = jest.fn((spanArgs: SpanContext) => ({ ...spanArgs })); -const mockPushActivity = jest.fn().mockReturnValue(1); -const mockPopActivity = jest.fn(); -const mockLoggerWarn = jest.fn(); -const mockGetActivitySpan = jest.fn().mockReturnValue({ - spanId: TEST_SPAN_ID, - startChild: mockStartChild, -}); +const mockFinish = jest.fn(); -jest.mock('@sentry/utils', () => ({ - logger: { - warn: (message: string) => { - mockLoggerWarn(message); - }, - }, - timestampWithMs: () => TEST_TIMESTAMP, -})); +// @sent +class MockSpan { + public constructor(public readonly ctx: SpanContext) {} + + public startChild(ctx: SpanContext): MockSpan { + mockStartChild(ctx); + return new MockSpan(ctx); + } + + public finish(): void { + mockFinish(); + } +} + +let activeTransaction: Record; jest.mock('@sentry/browser', () => ({ getCurrentHub: () => ({ - getIntegration: (_: string) => { - class MockIntegration { - public constructor(name: string) { - this.name = name; - } - public name: string; - public setupOnce: () => void = jest.fn(); - public static pushActivity: () => void = mockPushActivity; - public static popActivity: () => void = mockPopActivity; - public static getActivitySpan: () => void = mockGetActivitySpan; - } - return new MockIntegration('test'); - }, + getIntegration: () => undefined, + getScope: () => ({ + getTransaction: () => activeTransaction, + }), }), })); beforeEach(() => { - mockPushActivity.mockClear(); - mockPopActivity.mockClear(); - mockLoggerWarn.mockClear(); - mockGetActivitySpan.mockClear(); mockStartChild.mockClear(); + activeTransaction = new MockSpan({ op: 'pageload' }); }); describe('withProfiler', () => { @@ -75,30 +61,23 @@ describe('withProfiler', () => { describe('mount span', () => { it('does not get created if Profiler is disabled', () => { const ProfiledComponent = withProfiler(() =>

Testing

, { disabled: true }); - expect(mockPushActivity).toHaveBeenCalledTimes(0); + expect(mockStartChild).toHaveBeenCalledTimes(0); render(); - expect(mockPushActivity).toHaveBeenCalledTimes(0); + expect(mockStartChild).toHaveBeenCalledTimes(0); }); it('is created when a component is mounted', () => { const ProfiledComponent = withProfiler(() =>

Testing

); - expect(mockPushActivity).toHaveBeenCalledTimes(0); - expect(mockGetActivitySpan).toHaveBeenCalledTimes(0); - expect(mockPopActivity).toHaveBeenCalledTimes(0); + expect(mockStartChild).toHaveBeenCalledTimes(0); render(); - expect(mockPushActivity).toHaveBeenCalledTimes(1); - expect(mockPushActivity).toHaveBeenLastCalledWith(UNKNOWN_COMPONENT, { + expect(mockStartChild).toHaveBeenCalledTimes(1); + expect(mockStartChild).toHaveBeenLastCalledWith({ description: `<${UNKNOWN_COMPONENT}>`, op: 'react.mount', }); - expect(mockGetActivitySpan).toHaveBeenCalledTimes(1); - expect(mockGetActivitySpan).toHaveBeenLastCalledWith(1); - - expect(mockPopActivity).toHaveBeenCalledTimes(1); - expect(mockPopActivity).toHaveBeenLastCalledWith(1); }); }); @@ -110,13 +89,13 @@ describe('withProfiler', () => { const component = render(); component.unmount(); - expect(mockStartChild).toHaveBeenCalledTimes(1); - expect(mockStartChild).toHaveBeenLastCalledWith( - expect.objectContaining({ - description: `<${UNKNOWN_COMPONENT}>`, - op: 'react.render', - }), - ); + expect(mockStartChild).toHaveBeenCalledTimes(2); + expect(mockStartChild).toHaveBeenLastCalledWith({ + description: `<${UNKNOWN_COMPONENT}>`, + endTimestamp: expect.any(Number), + op: 'react.render', + startTimestamp: undefined, + }); }); it('is not created if hasRenderSpan is false', () => { @@ -126,7 +105,7 @@ describe('withProfiler', () => { const component = render(); component.unmount(); - expect(mockStartChild).toHaveBeenCalledTimes(0); + expect(mockStartChild).toHaveBeenCalledTimes(1); }); }); @@ -134,33 +113,33 @@ describe('withProfiler', () => { it('is created when component is updated', () => { const ProfiledComponent = withProfiler((props: { num: number }) =>
{props.num}
); const { rerender } = render(); - expect(mockStartChild).toHaveBeenCalledTimes(0); + expect(mockStartChild).toHaveBeenCalledTimes(1); // Dispatch new props rerender(); - expect(mockStartChild).toHaveBeenCalledTimes(1); + expect(mockStartChild).toHaveBeenCalledTimes(2); expect(mockStartChild).toHaveBeenLastCalledWith({ data: { changedProps: ['num'] }, description: `<${UNKNOWN_COMPONENT}>`, - endTimestamp: TEST_TIMESTAMP, + endTimestamp: expect.any(Number), op: 'react.update', - startTimestamp: TEST_TIMESTAMP, + startTimestamp: expect.any(Number), }); // New props yet again rerender(); - expect(mockStartChild).toHaveBeenCalledTimes(2); + expect(mockStartChild).toHaveBeenCalledTimes(3); expect(mockStartChild).toHaveBeenLastCalledWith({ data: { changedProps: ['num'] }, description: `<${UNKNOWN_COMPONENT}>`, - endTimestamp: TEST_TIMESTAMP, + endTimestamp: expect.any(Number), op: 'react.update', - startTimestamp: TEST_TIMESTAMP, + startTimestamp: expect.any(Number), }); // Should not create spans if props haven't changed rerender(); - expect(mockStartChild).toHaveBeenCalledTimes(2); + expect(mockStartChild).toHaveBeenCalledTimes(3); }); it('does not get created if hasUpdateSpan is false', () => { @@ -168,11 +147,11 @@ describe('withProfiler', () => { includeUpdates: false, }); const { rerender } = render(); - expect(mockStartChild).toHaveBeenCalledTimes(0); + expect(mockStartChild).toHaveBeenCalledTimes(1); // Dispatch new props rerender(); - expect(mockStartChild).toHaveBeenCalledTimes(0); + expect(mockStartChild).toHaveBeenCalledTimes(1); }); }); }); @@ -182,23 +161,18 @@ describe('useProfiler()', () => { it('does not get created if Profiler is disabled', () => { // tslint:disable-next-line: no-void-expression renderHook(() => useProfiler('Example', { disabled: true })); - expect(mockPushActivity).toHaveBeenCalledTimes(0); + expect(mockStartChild).toHaveBeenCalledTimes(0); }); it('is created when a component is mounted', () => { // tslint:disable-next-line: no-void-expression renderHook(() => useProfiler('Example')); - expect(mockPushActivity).toHaveBeenCalledTimes(1); - expect(mockPushActivity).toHaveBeenLastCalledWith('Example', { + expect(mockStartChild).toHaveBeenCalledTimes(1); + expect(mockStartChild).toHaveBeenLastCalledWith({ description: '', op: 'react.mount', }); - expect(mockGetActivitySpan).toHaveBeenCalledTimes(1); - expect(mockGetActivitySpan).toHaveBeenLastCalledWith(1); - - expect(mockPopActivity).toHaveBeenCalledTimes(1); - expect(mockPopActivity).toHaveBeenLastCalledWith(1); }); }); @@ -206,18 +180,18 @@ describe('useProfiler()', () => { it('does not get created when hasRenderSpan is false', () => { // tslint:disable-next-line: no-void-expression const component = renderHook(() => useProfiler('Example', { hasRenderSpan: false })); - expect(mockStartChild).toHaveBeenCalledTimes(0); + expect(mockStartChild).toHaveBeenCalledTimes(1); component.unmount(); - expect(mockStartChild).toHaveBeenCalledTimes(0); + expect(mockStartChild).toHaveBeenCalledTimes(1); }); it('is created by default', () => { // tslint:disable-next-line: no-void-expression const component = renderHook(() => useProfiler('Example')); - expect(mockStartChild).toHaveBeenCalledTimes(0); - component.unmount(); expect(mockStartChild).toHaveBeenCalledTimes(1); + component.unmount(); + expect(mockStartChild).toHaveBeenCalledTimes(2); expect(mockStartChild).toHaveBeenLastCalledWith( expect.objectContaining({ description: '', diff --git a/packages/tracing/.npmignore b/packages/tracing/.npmignore new file mode 100644 index 000000000000..14e80551ae7c --- /dev/null +++ b/packages/tracing/.npmignore @@ -0,0 +1,4 @@ +* +!/dist/**/* +!/esm/**/* +*.tsbuildinfo diff --git a/packages/tracing/LICENSE b/packages/tracing/LICENSE new file mode 100644 index 000000000000..22fef4436de0 --- /dev/null +++ b/packages/tracing/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Sentry (https://sentry.io/) and individual contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/tracing/README.md b/packages/tracing/README.md new file mode 100644 index 000000000000..ce3719a23852 --- /dev/null +++ b/packages/tracing/README.md @@ -0,0 +1,74 @@ +

+ + + +
+

+ +# Sentry Tracing Extensions + +[![npm version](https://img.shields.io/npm/v/@sentry/tracing.svg)](https://www.npmjs.com/package/@sentry/tracing) +[![npm dm](https://img.shields.io/npm/dm/@sentry/tracing.svg)](https://www.npmjs.com/package/@sentry/tracing) +[![npm dt](https://img.shields.io/npm/dt/@sentry/tracing.svg)](https://www.npmjs.com/package/@sentry/tracing) +[![typedoc](https://img.shields.io/badge/docs-typedoc-blue.svg)](http://getsentry.github.io/sentry-javascript/) + +## Links + +- [Official SDK Docs](https://docs.sentry.io/quickstart/) +- [TypeDoc](http://getsentry.github.io/sentry-javascript/) + +## General + +This package contains extensions to the `@sentry/hub` to enable Sentry AM related functionality. It also provides integrations for Browser and Node that provide a good experience out of the box. + +## Migrating from @sentry/apm to @sentry/tracing + +The `@sentry/tracing` package is the replacement to the `@sentry/apm` package. No functionality has changed between +the packages, but there are some steps required for upgrade. + +First, you must update your imports from the `Tracing` integration to the `BrowserTracing` integration. + +```ts +import * as Sentry from "@sentry/browser"; +import { Integrations } from "@sentry/tracing"; + +Sentry.init({ + integrations: [ + new Integrations.BrowserTracing({}), + ] +}) +``` + +Next, if you were using the `beforeNavigate` option, the API has changed to this type: + +```ts +/** + * beforeNavigate is called before a pageload/navigation transaction is created and allows for users + * to set a custom transaction context. + * + * If undefined is returned, a pageload/navigation transaction will not be created. + */ +beforeNavigate(context: TransactionContext): TransactionContext | undefined; +``` + +We removed the location argument, in favour of being able to see what the transaction context is on creation. You will +have to access `window.location` yourself if you want to replicate that. In addition, if you return undefined in +`beforeNavigate`, the transaction will not be created. + +```ts +import * as Sentry from "@sentry/browser"; +import { Integrations } from "@sentry/tracing"; + +Sentry.init({ + integrations: [ + new Integrations.BrowserTracing({ + beforeNavigate: (ctx) => { + return { + ...ctx, + name: getTransactionName(ctx.name, window.location) + } + } + }), + ] +}) +``` diff --git a/packages/tracing/package.json b/packages/tracing/package.json new file mode 100644 index 000000000000..e51b95685127 --- /dev/null +++ b/packages/tracing/package.json @@ -0,0 +1,86 @@ +{ + "name": "@sentry/tracing", + "version": "5.19.2", + "description": "Extensions for Sentry AM", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tracing", + "author": "Sentry", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "main": "dist/index.js", + "module": "esm/index.js", + "types": "dist/index.d.ts", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@sentry/hub": "5.19.2", + "@sentry/minimal": "5.19.2", + "@sentry/types": "5.19.2", + "@sentry/utils": "5.19.2", + "tslib": "^1.9.3" + }, + "devDependencies": { + "@sentry/browser": "5.19.2", + "@types/express": "^4.17.1", + "@types/jsdom": "^16.2.3", + "jest": "^24.7.1", + "jsdom": "^16.2.2", + "npm-run-all": "^4.1.2", + "prettier": "^1.17.0", + "prettier-check": "^2.0.0", + "rimraf": "^2.6.3", + "rollup": "^1.10.1", + "rollup-plugin-commonjs": "^9.3.4", + "rollup-plugin-license": "^0.8.1", + "rollup-plugin-node-resolve": "^4.2.3", + "rollup-plugin-terser": "^4.0.4", + "rollup-plugin-typescript2": "^0.21.0", + "tslint": "^5.16.0", + "typescript": "^3.4.5" + }, + "scripts": { + "build": "run-p build:es5 build:esm build:bundle", + "build:bundle": "rollup --config", + "build:bundle:watch": "rollup --config --watch", + "build:es5": "tsc -p tsconfig.build.json", + "build:esm": "tsc -p tsconfig.esm.json", + "build:watch": "run-p build:watch:es5 build:watch:esm", + "build:watch:es5": "tsc -p tsconfig.build.json -w --preserveWatchOutput", + "build:watch:esm": "tsc -p tsconfig.esm.json -w --preserveWatchOutput", + "clean": "rimraf dist coverage build esm", + "link:yarn": "yarn link", + "lint": "run-s lint:prettier lint:tslint", + "lint:prettier": "prettier-check \"{src,test}/**/*.ts\"", + "lint:tslint": "tslint -t stylish -p .", + "lint:tslint:json": "tslint --format json -p . | tee lint-results.json", + "fix": "run-s fix:tslint fix:prettier", + "fix:prettier": "prettier --write \"{src,test}/**/*.ts\"", + "fix:tslint": "tslint --fix -t stylish -p .", + "test": "jest", + "test:watch": "jest --watch" + }, + "jest": { + "collectCoverage": true, + "transform": { + "^.+\\.ts$": "ts-jest" + }, + "moduleFileExtensions": [ + "js", + "ts" + ], + "testEnvironment": "node", + "testMatch": [ + "**/*.test.ts" + ], + "globals": { + "ts-jest": { + "tsConfig": "./tsconfig.json", + "diagnostics": false + } + } + }, + "sideEffects": true +} diff --git a/packages/tracing/rollup.config.js b/packages/tracing/rollup.config.js new file mode 100644 index 000000000000..018af07360ce --- /dev/null +++ b/packages/tracing/rollup.config.js @@ -0,0 +1,94 @@ +import { terser } from 'rollup-plugin-terser'; +import typescript from 'rollup-plugin-typescript2'; +import license from 'rollup-plugin-license'; +import resolve from 'rollup-plugin-node-resolve'; +import commonjs from 'rollup-plugin-commonjs'; + +const commitHash = require('child_process') + .execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }) + .trim(); + +const terserInstance = terser({ + mangle: { + // captureExceptions and captureMessage are public API methods and they don't need to be listed here + // as mangler doesn't touch user-facing thing, however sentryWrapped is not, and it would be mangled into a minified version. + // We need those full names to correctly detect our internal frames for stripping. + // I listed all of them here just for the clarity sake, as they are all used in the frames manipulation process. + reserved: ['captureException', 'captureMessage', 'sentryWrapped'], + properties: { + regex: /^_[^_]/, + }, + }, +}); + +const paths = { + '@sentry/utils': ['../utils/src'], + '@sentry/core': ['../core/src'], + '@sentry/hub': ['../hub/src'], + '@sentry/types': ['../types/src'], + '@sentry/minimal': ['../minimal/src'], + '@sentry/browser': ['../browser/src'], +}; + +const plugins = [ + typescript({ + tsconfig: 'tsconfig.build.json', + tsconfigOverride: { + compilerOptions: { + declaration: false, + declarationMap: false, + module: 'ES2015', + paths, + }, + }, + include: ['*.ts+(|x)', '**/*.ts+(|x)', '../**/*.ts+(|x)'], + }), + resolve({ + mainFields: ['module'], + }), + commonjs(), +]; + +const bundleConfig = { + input: 'src/index.ts', + output: { + format: 'iife', + name: 'Sentry', + sourcemap: true, + strict: false, + }, + context: 'window', + plugins: [ + ...plugins, + license({ + sourcemap: true, + banner: `/*! @sentry/tracing & @sentry/browser <%= pkg.version %> (${commitHash}) | https://github.com/getsentry/sentry-javascript */`, + }), + ], +}; + +export default [ + // ES5 Browser Tracing Bundle + { + ...bundleConfig, + input: 'src/index.bundle.ts', + output: { + ...bundleConfig.output, + file: 'build/bundle.tracing.js', + }, + plugins: bundleConfig.plugins, + }, + { + ...bundleConfig, + input: 'src/index.bundle.ts', + output: { + ...bundleConfig.output, + file: 'build/bundle.tracing.min.js', + }, + // Uglify has to be at the end of compilation, BUT before the license banner + plugins: bundleConfig.plugins + .slice(0, -1) + .concat(terserInstance) + .concat(bundleConfig.plugins.slice(-1)), + }, +]; diff --git a/packages/tracing/src/browser/backgroundtab.ts b/packages/tracing/src/browser/backgroundtab.ts new file mode 100644 index 000000000000..9deea870841b --- /dev/null +++ b/packages/tracing/src/browser/backgroundtab.ts @@ -0,0 +1,32 @@ +import { getGlobalObject, logger } from '@sentry/utils'; + +import { IdleTransaction } from '../idletransaction'; +import { SpanStatus } from '../spanstatus'; + +import { getActiveTransaction } from './utils'; + +const global = getGlobalObject(); + +/** + * Add a listener that cancels and finishes a transaction when the global + * document is hidden. + */ +export function registerBackgroundTabDetection(): void { + if (global && global.document) { + global.document.addEventListener('visibilitychange', () => { + const activeTransaction = getActiveTransaction() as IdleTransaction; + if (global.document.hidden && activeTransaction) { + logger.log( + `[Tracing] Transaction: ${SpanStatus.Cancelled} -> since tab moved to the background, op: ${ + activeTransaction.op + }`, + ); + activeTransaction.setStatus(SpanStatus.Cancelled); + activeTransaction.setTag('visibilitychange', 'document.hidden'); + activeTransaction.finish(); + } + }); + } else { + logger.warn('[Tracing] Could not set up background tab detection due to lack of global document'); + } +} diff --git a/packages/tracing/src/browser/browsertracing.ts b/packages/tracing/src/browser/browsertracing.ts new file mode 100644 index 000000000000..4c3ef17319b2 --- /dev/null +++ b/packages/tracing/src/browser/browsertracing.ts @@ -0,0 +1,250 @@ +import { Hub } from '@sentry/hub'; +import { EventProcessor, Integration, Transaction as TransactionType, TransactionContext } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +import { startIdleTransaction } from '../hubextensions'; +import { DEFAULT_IDLE_TIMEOUT, IdleTransaction } from '../idletransaction'; +import { Span } from '../span'; +import { SpanStatus } from '../spanstatus'; + +import { registerBackgroundTabDetection } from './backgroundtab'; +import { registerErrorInstrumentation } from './errors'; +import { MetricsInstrumentation } from './metrics'; +import { + defaultRequestInstrumentionOptions, + registerRequestInstrumentation, + RequestInstrumentationOptions, +} from './request'; +import { defaultBeforeNavigate, defaultRoutingInstrumentation } from './router'; +import { secToMs } from './utils'; + +export const DEFAULT_MAX_TRANSACTION_DURATION_SECONDS = 600; + +/** Options for Browser Tracing integration */ +export interface BrowserTracingOptions extends RequestInstrumentationOptions { + /** + * The time to wait in ms until the transaction will be finished. The transaction will use the end timestamp of + * the last finished span as the endtime for the transaction. + * Time is in ms. + * + * Default: 1000 + */ + idleTimeout: number; + + /** + * Flag to enable/disable creation of `navigation` transaction on history changes. + * + * Default: true + */ + startTransactionOnLocationChange: boolean; + + /** + * Flag to enable/disable creation of `pageload` transaction on first pageload. + * + * Default: true + */ + startTransactionOnPageLoad: boolean; + + /** + * beforeNavigate is called before a pageload/navigation transaction is created and allows for users + * to set custom transaction context. Defaults behaviour is to return `window.location.pathname`. + * + * If undefined is returned, a pageload/navigation transaction will not be created. + */ + beforeNavigate(context: TransactionContext): TransactionContext | undefined; + + /** + * Instrumentation that creates routing change transactions. By default creates + * pageload and navigation transactions. + */ + routingInstrumentation( + startTransaction: (context: TransactionContext) => T | undefined, + startTransactionOnPageLoad?: boolean, + startTransactionOnLocationChange?: boolean, + ): void; + + /** + * The maximum duration of a transaction before it will be marked as "deadline_exceeded". + * If you never want to mark a transaction set it to 0. + * Time is in seconds. + * + * Default: 600 + */ + maxTransactionDuration: number; + + /** + * Flag Transactions where tabs moved to background with "cancelled". Browser background tab timing is + * not suited towards doing precise measurements of operations. By default, we recommend that this option + * be enabled as background transactions can mess up your statistics in nondeterministic ways. + * + * Default: true + */ + markBackgroundTransactions: boolean; +} + +/** + * The Browser Tracing integration automatically instruments browser pageload/navigation + * actions as transactions, and captures requests, metrics and errors as spans. + * + * The integration can be configured with a variety of options, and can be extended to use + * any routing library. This integration uses {@see IdleTransaction} to create transactions. + */ +export class BrowserTracing implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'BrowserTracing'; + + /** Browser Tracing integration options */ + public options: BrowserTracingOptions = { + beforeNavigate: defaultBeforeNavigate, + idleTimeout: DEFAULT_IDLE_TIMEOUT, + markBackgroundTransactions: true, + maxTransactionDuration: DEFAULT_MAX_TRANSACTION_DURATION_SECONDS, + routingInstrumentation: defaultRoutingInstrumentation, + startTransactionOnLocationChange: true, + startTransactionOnPageLoad: true, + ...defaultRequestInstrumentionOptions, + }; + + /** + * @inheritDoc + */ + public name: string = BrowserTracing.id; + + private _getCurrentHub?: () => Hub; + + private readonly _metrics: MetricsInstrumentation = new MetricsInstrumentation(); + + private readonly _emitOptionsWarning: boolean = false; + + public constructor(_options?: Partial) { + let tracingOrigins = defaultRequestInstrumentionOptions.tracingOrigins; + // NOTE: Logger doesn't work in constructors, as it's initialized after integrations instances + if ( + _options && + _options.tracingOrigins && + Array.isArray(_options.tracingOrigins) && + _options.tracingOrigins.length !== 0 + ) { + tracingOrigins = _options.tracingOrigins; + } else { + this._emitOptionsWarning = true; + } + + this.options = { + ...this.options, + ..._options, + tracingOrigins, + }; + } + + /** + * @inheritDoc + */ + public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + this._getCurrentHub = getCurrentHub; + + if (this._emitOptionsWarning) { + logger.warn( + '[Tracing] You need to define `tracingOrigins` in the options. Set an array of urls or patterns to trace.', + ); + logger.warn( + `[Tracing] We added a reasonable default for you: ${defaultRequestInstrumentionOptions.tracingOrigins}`, + ); + } + + const { + routingInstrumentation, + startTransactionOnLocationChange, + startTransactionOnPageLoad, + markBackgroundTransactions, + traceFetch, + traceXHR, + tracingOrigins, + shouldCreateSpanForRequest, + } = this.options; + + routingInstrumentation( + (context: TransactionContext) => this._createRouteTransaction(context), + startTransactionOnPageLoad, + startTransactionOnLocationChange, + ); + + // TODO: Should this be default behaviour? + registerErrorInstrumentation(); + + if (markBackgroundTransactions) { + registerBackgroundTabDetection(); + } + + registerRequestInstrumentation({ traceFetch, traceXHR, tracingOrigins, shouldCreateSpanForRequest }); + } + + /** Create routing idle transaction. */ + private _createRouteTransaction(context: TransactionContext): TransactionType | undefined { + if (!this._getCurrentHub) { + logger.warn(`[Tracing] Did not create ${context.op} idleTransaction due to invalid _getCurrentHub`); + return undefined; + } + + const { beforeNavigate, idleTimeout, maxTransactionDuration } = this.options; + + // if beforeNavigate returns undefined, we should not start a transaction. + const ctx = beforeNavigate({ + ...context, + ...getHeaderContext(), + trimEnd: true, + }); + + if (ctx === undefined) { + logger.log(`[Tracing] Did not create ${context.op} idleTransaction due to beforeNavigate`); + return undefined; + } + + const hub = this._getCurrentHub(); + logger.log(`[Tracing] starting ${ctx.op} idleTransaction on scope`); + const idleTransaction = startIdleTransaction(hub, ctx, idleTimeout, true); + idleTransaction.registerBeforeFinishCallback((transaction, endTimestamp) => { + this._metrics.addPerformanceEntires(transaction); + adjustTransactionDuration(secToMs(maxTransactionDuration), transaction, endTimestamp); + }); + + return idleTransaction as TransactionType; + } +} + +/** + * Gets transaction context from a sentry-trace meta. + */ +function getHeaderContext(): Partial { + const header = getMetaContent('sentry-trace'); + if (header) { + const span = Span.fromTraceparent(header); + if (span) { + return { + parentSpanId: span.parentSpanId, + sampled: span.sampled, + traceId: span.traceId, + }; + } + } + + return {}; +} + +/** Returns the value of a meta tag */ +export function getMetaContent(metaName: string): string | null { + const el = document.querySelector(`meta[name=${metaName}]`); + return el ? el.getAttribute('content') : null; +} + +/** Adjusts transaction value based on max transaction duration */ +function adjustTransactionDuration(maxDuration: number, transaction: IdleTransaction, endTimestamp: number): void { + const diff = endTimestamp - transaction.startTimestamp; + const isOutdatedTransaction = endTimestamp && (diff > maxDuration || diff < 0); + if (isOutdatedTransaction) { + transaction.setStatus(SpanStatus.DeadlineExceeded); + transaction.setTag('maxTransactionDurationExceeded', 'true'); + } +} diff --git a/packages/tracing/src/browser/errors.ts b/packages/tracing/src/browser/errors.ts new file mode 100644 index 000000000000..5a8a97910647 --- /dev/null +++ b/packages/tracing/src/browser/errors.ts @@ -0,0 +1,30 @@ +import { addInstrumentationHandler, logger } from '@sentry/utils'; + +import { SpanStatus } from '../spanstatus'; + +import { getActiveTransaction } from './utils'; + +/** + * Configures global error listeners + */ +export function registerErrorInstrumentation(): void { + addInstrumentationHandler({ + callback: errorCallback, + type: 'error', + }); + addInstrumentationHandler({ + callback: errorCallback, + type: 'unhandledrejection', + }); +} + +/** + * If an error or unhandled promise occurs, we mark the active transaction as failed + */ +function errorCallback(): void { + const activeTransaction = getActiveTransaction(); + if (activeTransaction) { + logger.log(`[Tracing] Transaction: ${SpanStatus.InternalError} -> Global error occured`); + activeTransaction.setStatus(SpanStatus.InternalError); + } +} diff --git a/packages/tracing/src/browser/index.ts b/packages/tracing/src/browser/index.ts new file mode 100644 index 000000000000..b06f3288bd7c --- /dev/null +++ b/packages/tracing/src/browser/index.ts @@ -0,0 +1 @@ +export { BrowserTracing } from './browsertracing'; diff --git a/packages/tracing/src/browser/metrics.ts b/packages/tracing/src/browser/metrics.ts new file mode 100644 index 000000000000..606e703bfacb --- /dev/null +++ b/packages/tracing/src/browser/metrics.ts @@ -0,0 +1,280 @@ +import { getGlobalObject, logger } from '@sentry/utils'; + +import { Span } from '../span'; +import { Transaction } from '../transaction'; + +import { msToSec } from './utils'; + +const global = getGlobalObject(); + +/** Class tracking metrics */ +export class MetricsInstrumentation { + private _lcp: Record = {}; + + private _performanceCursor: number = 0; + + private _forceLCP = () => { + /* No-op, replaced later if LCP API is available. */ + return; + }; + + /** Starts tracking the Largest Contentful Paint on the current page. */ + private _trackLCP(): void { + // Based on reference implementation from https://web.dev/lcp/#measure-lcp-in-javascript. + // Use a try/catch instead of feature detecting `largest-contentful-paint` + // support, since some browsers throw when using the new `type` option. + // https://bugs.webkit.org/show_bug.cgi?id=209216 + try { + // Keep track of whether (and when) the page was first hidden, see: + // https://github.com/w3c/page-visibility/issues/29 + // NOTE: ideally this check would be performed in the document + // to avoid cases where the visibility state changes before this code runs. + let firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity; + document.addEventListener( + 'visibilitychange', + event => { + firstHiddenTime = Math.min(firstHiddenTime, event.timeStamp); + }, + { once: true }, + ); + + const updateLCP = (entry: PerformanceEntry) => { + // Only include an LCP entry if the page wasn't hidden prior to + // the entry being dispatched. This typically happens when a page is + // loaded in a background tab. + if (entry.startTime < firstHiddenTime) { + // NOTE: the `startTime` value is a getter that returns the entry's + // `renderTime` value, if available, or its `loadTime` value otherwise. + // The `renderTime` value may not be available if the element is an image + // that's loaded cross-origin without the `Timing-Allow-Origin` header. + this._lcp = { + // @ts-ignore + ...(entry.id && { elementId: entry.id }), + // @ts-ignore + ...(entry.size && { elementSize: entry.size }), + value: entry.startTime, + }; + } + }; + + // Create a PerformanceObserver that calls `updateLCP` for each entry. + const po = new PerformanceObserver(entryList => { + entryList.getEntries().forEach(updateLCP); + }); + + // Observe entries of type `largest-contentful-paint`, including buffered entries, + // i.e. entries that occurred before calling `observe()` below. + po.observe({ + buffered: true, + // @ts-ignore + type: 'largest-contentful-paint', + }); + + this._forceLCP = () => { + po.takeRecords().forEach(updateLCP); + }; + } catch (e) { + // Do nothing if the browser doesn't support this API. + } + } + + public constructor() { + if (global && global.performance) { + if (global.performance.mark) { + global.performance.mark('sentry-tracing-init'); + } + + this._trackLCP(); + } + } + + /** Add performance related spans to a transaction */ + public addPerformanceEntires(transaction: Transaction): void { + if (!global || !global.performance || !global.performance.getEntries) { + // Gatekeeper if performance API not available + return; + } + + logger.log('[Tracing] Adding & adjusting spans using Performance API'); + + // TODO(fixme): depending on the 'op' directly is brittle. + if (transaction.op === 'pageload') { + // Force any pending records to be dispatched. + this._forceLCP(); + if (this._lcp) { + // Set the last observed LCP score. + transaction.setData('_sentry_web_vitals', { LCP: this._lcp }); + } + } + + const timeOrigin = msToSec(performance.timeOrigin); + let entryScriptSrc: string | undefined; + + if (global.document) { + // tslint:disable-next-line: prefer-for-of + for (let i = 0; i < document.scripts.length; i++) { + // We go through all scripts on the page and look for 'data-entry' + // We remember the name and measure the time between this script finished loading and + // our mark 'sentry-tracing-init' + if (document.scripts[i].dataset.entry === 'true') { + entryScriptSrc = document.scripts[i].src; + break; + } + } + } + + let entryScriptStartTimestamp: number | undefined; + let tracingInitMarkStartTime: number | undefined; + + global.performance + .getEntries() + .slice(this._performanceCursor) + .forEach((entry: Record) => { + const startTime = msToSec(entry.startTime as number); + const duration = msToSec(entry.duration as number); + + if (transaction.op === 'navigation' && timeOrigin + startTime < transaction.startTimestamp) { + return; + } + + switch (entry.entryType) { + case 'navigation': + addNavigationSpans(transaction, entry, timeOrigin); + break; + case 'mark': + case 'paint': + case 'measure': + const startTimestamp = addMeasureSpans(transaction, entry, startTime, duration, timeOrigin); + if (tracingInitMarkStartTime === undefined && entry.name === 'sentry-tracing-init') { + tracingInitMarkStartTime = startTimestamp; + } + break; + case 'resource': + const resourceName = (entry.name as string).replace(window.location.origin, ''); + const endTimestamp = addResourceSpans(transaction, entry, resourceName, startTime, duration, timeOrigin); + // We remember the entry script end time to calculate the difference to the first init mark + if (entryScriptStartTimestamp === undefined && (entryScriptSrc || '').indexOf(resourceName) > -1) { + entryScriptStartTimestamp = endTimestamp; + } + break; + default: + // Ignore other entry types. + } + }); + + if (entryScriptStartTimestamp !== undefined && tracingInitMarkStartTime !== undefined) { + transaction.startChild({ + description: 'evaluation', + endTimestamp: tracingInitMarkStartTime, + op: 'script', + startTimestamp: entryScriptStartTimestamp, + }); + } + + this._performanceCursor = Math.max(performance.getEntries().length - 1, 0); + } +} + +/** Instrument navigation entries */ +function addNavigationSpans(transaction: Transaction, entry: Record, timeOrigin: number): void { + addPerformanceNavigationTiming(transaction, entry, 'unloadEvent', timeOrigin); + addPerformanceNavigationTiming(transaction, entry, 'domContentLoadedEvent', timeOrigin); + addPerformanceNavigationTiming(transaction, entry, 'loadEvent', timeOrigin); + addPerformanceNavigationTiming(transaction, entry, 'connect', timeOrigin); + addPerformanceNavigationTiming(transaction, entry, 'domainLookup', timeOrigin); + addRequest(transaction, entry, timeOrigin); +} + +/** Create measure related spans */ +function addMeasureSpans( + transaction: Transaction, + entry: Record, + startTime: number, + duration: number, + timeOrigin: number, +): number { + const measureStartTimestamp = timeOrigin + startTime; + const measureEndTimestamp = measureStartTimestamp + duration; + + transaction.startChild({ + description: entry.name as string, + endTimestamp: measureEndTimestamp, + op: entry.entryType as string, + startTimestamp: measureStartTimestamp, + }); + + return measureStartTimestamp; +} + +/** Create resource related spans */ +function addResourceSpans( + transaction: Transaction, + entry: Record, + resourceName: string, + startTime: number, + duration: number, + timeOrigin: number, +): number | undefined { + if (entry.initiatorType === 'xmlhttprequest' || entry.initiatorType === 'fetch') { + // We need to update existing spans with new timing info + if (transaction.spanRecorder) { + transaction.spanRecorder.spans.map((finishedSpan: Span) => { + if (finishedSpan.description && finishedSpan.description.indexOf(resourceName) !== -1) { + finishedSpan.startTimestamp = timeOrigin + startTime; + finishedSpan.endTimestamp = finishedSpan.startTimestamp + duration; + } + }); + } + } else { + const startTimestamp = timeOrigin + startTime; + const endTimestamp = startTimestamp + duration; + + transaction.startChild({ + description: `${entry.initiatorType} ${resourceName}`, + endTimestamp, + op: 'resource', + startTimestamp, + }); + + return endTimestamp; + } + + return undefined; +} + +/** Create performance navigation related spans */ +function addPerformanceNavigationTiming( + transaction: Transaction, + entry: Record, + event: string, + timeOrigin: number, +): void { + const end = entry[`${event}End`] as number | undefined; + const start = entry[`${event}Start`] as number | undefined; + if (!start || !end) { + return; + } + transaction.startChild({ + description: event, + endTimestamp: timeOrigin + msToSec(end), + op: 'browser', + startTimestamp: timeOrigin + msToSec(start), + }); +} + +/** Create request and response related spans */ +function addRequest(transaction: Transaction, entry: Record, timeOrigin: number): void { + transaction.startChild({ + description: 'request', + endTimestamp: timeOrigin + msToSec(entry.responseEnd as number), + op: 'browser', + startTimestamp: timeOrigin + msToSec(entry.requestStart as number), + }); + + transaction.startChild({ + description: 'response', + endTimestamp: timeOrigin + msToSec(entry.responseEnd as number), + op: 'browser', + startTimestamp: timeOrigin + msToSec(entry.responseStart as number), + }); +} diff --git a/packages/tracing/src/browser/request.ts b/packages/tracing/src/browser/request.ts new file mode 100644 index 000000000000..e66dc97606d2 --- /dev/null +++ b/packages/tracing/src/browser/request.ts @@ -0,0 +1,240 @@ +import { addInstrumentationHandler, isInstanceOf, isMatchingPattern } from '@sentry/utils'; + +import { Span } from '../span'; + +import { getActiveTransaction } from './utils'; + +export const DEFAULT_TRACING_ORIGINS = ['localhost', /^\//]; + +/** Options for Request Instrumentation */ +export interface RequestInstrumentationOptions { + /** + * List of strings / regex where the integration should create Spans out of. Additionally this will be used + * to define which outgoing requests the `sentry-trace` header will be attached to. + * + * Default: ['localhost', /^\//] {@see DEFAULT_TRACING_ORIGINS} + */ + tracingOrigins: Array; + + /** + * Flag to disable patching all together for fetch requests. + * + * Default: true + */ + traceFetch: boolean; + + /** + * Flag to disable patching all together for xhr requests. + * + * Default: true + */ + traceXHR: boolean; + + /** + * This function will be called before creating a span for a request with the given url. + * Return false if you don't want a span for the given url. + * + * By default it uses the `tracingOrigins` options as a url match. + */ + shouldCreateSpanForRequest?(url: string): boolean; +} + +/** Data returned from fetch callback */ +interface FetchData { + args: any[]; + fetchData: { + method: string; + url: string; + // span_id + __span?: string; + }; + startTimestamp: number; + endTimestamp?: number; +} + +/** Data returned from XHR request */ +interface XHRData { + xhr?: { + __sentry_xhr__?: { + method: string; + url: string; + status_code: number; + data: Record; + }; + __sentry_xhr_span_id__?: string; + __sentry_own_request__: boolean; + setRequestHeader?: Function; + }; + startTimestamp: number; + endTimestamp?: number; +} + +export const defaultRequestInstrumentionOptions: RequestInstrumentationOptions = { + traceFetch: true, + traceXHR: true, + tracingOrigins: DEFAULT_TRACING_ORIGINS, +}; + +/** Registers span creators for xhr and fetch requests */ +export function registerRequestInstrumentation(_options?: Partial): void { + const { traceFetch, traceXHR, tracingOrigins, shouldCreateSpanForRequest } = { + ...defaultRequestInstrumentionOptions, + ..._options, + }; + + // We should cache url -> decision so that we don't have to compute + // regexp everytime we create a request. + const urlMap: Record = {}; + + const defaultShouldCreateSpan = (url: string): boolean => { + if (urlMap[url]) { + return urlMap[url]; + } + const origins = tracingOrigins; + urlMap[url] = + origins.some((origin: string | RegExp) => isMatchingPattern(url, origin)) && + !isMatchingPattern(url, 'sentry_key'); + return urlMap[url]; + }; + + const shouldCreateSpan = shouldCreateSpanForRequest || defaultShouldCreateSpan; + + const spans: Record = {}; + + if (traceFetch) { + addInstrumentationHandler({ + callback: (handlerData: FetchData) => { + fetchCallback(handlerData, shouldCreateSpan, spans); + }, + type: 'fetch', + }); + } + + if (traceXHR) { + addInstrumentationHandler({ + callback: (handlerData: XHRData) => { + xhrCallback(handlerData, shouldCreateSpan, spans); + }, + type: 'xhr', + }); + } +} + +/** + * Create and track fetch request spans + */ +function fetchCallback( + handlerData: FetchData, + shouldCreateSpan: (url: string) => boolean, + spans: Record, +): void { + if (!shouldCreateSpan(handlerData.fetchData.url) || !handlerData.fetchData) { + return; + } + + if (handlerData.endTimestamp && handlerData.fetchData.__span) { + const span = spans[handlerData.fetchData.__span]; + if (span) { + span.finish(); + + // tslint:disable-next-line: no-dynamic-delete + delete spans[handlerData.fetchData.__span]; + } + return; + } + + const activeTransaction = getActiveTransaction(); + if (activeTransaction) { + const span = activeTransaction.startChild({ + data: { + ...handlerData.fetchData, + type: 'fetch', + }, + description: `${handlerData.fetchData.method} ${handlerData.fetchData.url}`, + op: 'http', + }); + + spans[span.spanId] = span; + + const request = (handlerData.args[0] = handlerData.args[0] as string | Request); + const options = (handlerData.args[1] = (handlerData.args[1] as { [key: string]: any }) || {}); + let headers = options.headers; + if (isInstanceOf(request, Request)) { + headers = (request as Request).headers; + } + if (headers) { + // tslint:disable-next-line: no-unsafe-any + if (typeof headers.append === 'function') { + // tslint:disable-next-line: no-unsafe-any + headers.append('sentry-trace', span.toTraceparent()); + } else if (Array.isArray(headers)) { + headers = [...headers, ['sentry-trace', span.toTraceparent()]]; + } else { + headers = { ...headers, 'sentry-trace': span.toTraceparent() }; + } + } else { + headers = { 'sentry-trace': span.toTraceparent() }; + } + options.headers = headers; + } +} + +/** + * Create and track xhr request spans + */ +function xhrCallback( + handlerData: XHRData, + shouldCreateSpan: (url: string) => boolean, + spans: Record, +): void { + if (!handlerData || !handlerData.xhr || !handlerData.xhr.__sentry_xhr__) { + return; + } + + const xhr = handlerData.xhr.__sentry_xhr__; + if (!shouldCreateSpan(xhr.url)) { + return; + } + + // We only capture complete, non-sentry requests + if (handlerData.xhr.__sentry_own_request__) { + return; + } + + if (handlerData.endTimestamp && handlerData.xhr.__sentry_xhr_span_id__) { + const span = spans[handlerData.xhr.__sentry_xhr_span_id__]; + if (span) { + span.setData('url', xhr.url); + span.setData('method', xhr.method); + span.setHttpStatus(xhr.status_code); + span.finish(); + + // tslint:disable-next-line: no-dynamic-delete + delete spans[handlerData.xhr.__sentry_xhr_span_id__]; + } + return; + } + + const activeTransaction = getActiveTransaction(); + if (activeTransaction) { + const span = activeTransaction.startChild({ + data: { + ...xhr.data, + type: 'xhr', + }, + description: `${xhr.method} ${xhr.url}`, + op: 'http', + }); + + handlerData.xhr.__sentry_xhr_span_id__ = span.spanId; + spans[handlerData.xhr.__sentry_xhr_span_id__] = span; + + if (handlerData.xhr.setRequestHeader) { + try { + handlerData.xhr.setRequestHeader('sentry-trace', span.toTraceparent()); + } catch (_) { + // Error: InvalidStateError: Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED. + } + } + } +} diff --git a/packages/tracing/src/browser/router.ts b/packages/tracing/src/browser/router.ts new file mode 100644 index 000000000000..b7d0565ecad6 --- /dev/null +++ b/packages/tracing/src/browser/router.ts @@ -0,0 +1,62 @@ +import { Transaction as TransactionType, TransactionContext } from '@sentry/types'; +import { addInstrumentationHandler, getGlobalObject, logger } from '@sentry/utils'; + +// type StartTransaction +const global = getGlobalObject(); + +/** + * Creates a default router based on + */ +export function defaultRoutingInstrumentation( + startTransaction: (context: TransactionContext) => T | undefined, + startTransactionOnPageLoad: boolean = true, + startTransactionOnLocationChange: boolean = true, +): void { + if (!global || !global.location) { + logger.warn('Could not initialize routing instrumentation due to invalid location'); + return; + } + + let startingUrl: string | undefined = global.location.href; + + let activeTransaction: T | undefined; + if (startTransactionOnPageLoad) { + activeTransaction = startTransaction({ name: global.location.pathname, op: 'pageload' }); + } + + if (startTransactionOnLocationChange) { + addInstrumentationHandler({ + callback: ({ to, from }: { to: string; from?: string }) => { + /** + * This early return is there to account for some cases where navigation transaction + * starts right after long running pageload. We make sure that if `from` is undefined + * and that a valid `startingURL` exists, we don't uncessarily create a navigation transaction. + * + * This was hard to duplicate, but this behaviour stopped as soon as this fix + * was applied. This issue might also only be caused in certain development environments + * where the usage of a hot module reloader is causing errors. + */ + if (from === undefined && startingUrl && startingUrl.indexOf(to) !== -1) { + startingUrl = undefined; + return; + } + if (from !== to) { + startingUrl = undefined; + if (activeTransaction) { + logger.log(`[Tracing] finishing current idleTransaction with op: ${activeTransaction.op}`); + // We want to finish all current ongoing idle transactions as we + // are navigating to a new page. + activeTransaction.finish(); + } + activeTransaction = startTransaction({ name: global.location.pathname, op: 'navigation' }); + } + }, + type: 'history', + }); + } +} + +/** default implementation of Browser Tracing before navigate */ +export function defaultBeforeNavigate(context: TransactionContext): TransactionContext | undefined { + return context; +} diff --git a/packages/tracing/src/browser/utils.ts b/packages/tracing/src/browser/utils.ts new file mode 100644 index 000000000000..bda91de29c82 --- /dev/null +++ b/packages/tracing/src/browser/utils.ts @@ -0,0 +1,30 @@ +import { getCurrentHub, Hub } from '@sentry/hub'; +import { Transaction } from '@sentry/types'; + +/** Grabs active transaction off scope */ +export function getActiveTransaction(hub: Hub = getCurrentHub()): T | undefined { + if (hub) { + const scope = hub.getScope(); + if (scope) { + return scope.getTransaction() as T | undefined; + } + } + + return undefined; +} + +/** + * Converts from milliseconds to seconds + * @param time time in ms + */ +export function msToSec(time: number): number { + return time / 1000; +} + +/** + * Converts from seconds to milliseconds + * @param time time in seconds + */ +export function secToMs(time: number): number { + return time * 1000; +} diff --git a/packages/tracing/src/hubextensions.ts b/packages/tracing/src/hubextensions.ts new file mode 100644 index 000000000000..dfc5ebe5e4a5 --- /dev/null +++ b/packages/tracing/src/hubextensions.ts @@ -0,0 +1,79 @@ +import { getMainCarrier, Hub } from '@sentry/hub'; +import { TransactionContext } from '@sentry/types'; + +import { IdleTransaction } from './idletransaction'; +import { Transaction } from './transaction'; + +/** Returns all trace headers that are currently on the top scope. */ +function traceHeaders(this: Hub): { [key: string]: string } { + const scope = this.getScope(); + if (scope) { + const span = scope.getSpan(); + if (span) { + return { + 'sentry-trace': span.toTraceparent(), + }; + } + } + return {}; +} + +/** + * Use RNG to generate sampling decision, which all child spans inherit. + */ +function sample(hub: Hub, transaction: T): T { + const client = hub.getClient(); + if (transaction.sampled === undefined) { + const sampleRate = (client && client.getOptions().tracesSampleRate) || 0; + // if true = we want to have the transaction + // if false = we don't want to have it + // Math.random (inclusive of 0, but not 1) + transaction.sampled = Math.random() < sampleRate; + } + + // We only want to create a span list if we sampled the transaction + // If sampled == false, we will discard the span anyway, so we can save memory by not storing child spans + if (transaction.sampled) { + const experimentsOptions = (client && client.getOptions()._experiments) || {}; + transaction.initSpanRecorder(experimentsOptions.maxSpans as number); + } + + return transaction; +} + +/** + * {@see Hub.startTransaction} + */ +function startTransaction(this: Hub, context: TransactionContext): Transaction { + const transaction = new Transaction(context, this); + return sample(this, transaction); +} + +/** + * Create new idle transaction. + */ +export function startIdleTransaction( + hub: Hub, + context: TransactionContext, + idleTimeout?: number, + onScope?: boolean, +): IdleTransaction { + const transaction = new IdleTransaction(context, hub, idleTimeout, onScope); + return sample(hub, transaction); +} + +/** + * This patches the global object and injects the Tracing extensions methods + */ +export function addExtensionMethods(): void { + const carrier = getMainCarrier(); + if (carrier.__SENTRY__) { + carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {}; + if (!carrier.__SENTRY__.extensions.startTransaction) { + carrier.__SENTRY__.extensions.startTransaction = startTransaction; + } + if (!carrier.__SENTRY__.extensions.traceHeaders) { + carrier.__SENTRY__.extensions.traceHeaders = traceHeaders; + } + } +} diff --git a/packages/tracing/src/idletransaction.ts b/packages/tracing/src/idletransaction.ts new file mode 100644 index 000000000000..fc212ff46ef1 --- /dev/null +++ b/packages/tracing/src/idletransaction.ts @@ -0,0 +1,269 @@ +// tslint:disable: max-classes-per-file +import { Hub } from '@sentry/hub'; +import { TransactionContext } from '@sentry/types'; +import { logger, timestampWithMs } from '@sentry/utils'; + +import { Span, SpanRecorder } from './span'; +import { SpanStatus } from './spanstatus'; +import { Transaction } from './transaction'; + +export const DEFAULT_IDLE_TIMEOUT = 1000; + +/** + * @inheritDoc + */ +export class IdleTransactionSpanRecorder extends SpanRecorder { + public constructor( + private readonly _pushActivity: (id: string) => void, + private readonly _popActivity: (id: string) => void, + public transactionSpanId: string = '', + maxlen?: number, + ) { + super(maxlen); + } + + /** + * @inheritDoc + */ + public add(span: Span): void { + // We should make sure we do not push and pop activities for + // the transaction that this span recorder belongs to. + if (span.spanId !== this.transactionSpanId) { + // We patch span.finish() to pop an activity after setting an endTimestamp. + span.finish = (endTimestamp?: number) => { + span.endTimestamp = typeof endTimestamp === 'number' ? endTimestamp : timestampWithMs(); + this._popActivity(span.spanId); + }; + + // We should only push new activities if the span does not have an end timestamp. + if (span.endTimestamp === undefined) { + this._pushActivity(span.spanId); + } + } + + super.add(span); + } +} + +export type BeforeFinishCallback = (transactionSpan: IdleTransaction, endTimestamp: number) => void; + +/** + * An IdleTransaction is a transaction that automatically finishes. It does this by tracking child spans as activities. + * You can have multiple IdleTransactions active, but if the `onScope` option is specified, the idle transaction will + * put itself on the scope on creation. + */ +export class IdleTransaction extends Transaction { + // Activities store a list of active spans + public activities: Record = {}; + + // Stores reference to the timeout that calls _beat(). + private _heartbeatTimer: number = 0; + + // Track state of activities in previous heartbeat + private _prevHeartbeatString: string | undefined; + + // Amount of times heartbeat has counted. Will cause transaction to finish after 3 beats. + private _heartbeatCounter: number = 1; + + // We should not use heartbeat if we finished a transaction + private _finished: boolean = false; + + private readonly _beforeFinishCallbacks: BeforeFinishCallback[] = []; + + public constructor( + transactionContext: TransactionContext, + private readonly _idleHub?: Hub, + // The time to wait in ms until the idle transaction will be finished. Default: 1000 + private readonly _idleTimeout: number = DEFAULT_IDLE_TIMEOUT, + // If an idle transaction should be put itself on and off the scope automatically. + private readonly _onScope: boolean = false, + ) { + super(transactionContext, _idleHub); + + if (_idleHub && _onScope) { + // There should only be one active transaction on the scope + clearActiveTransaction(_idleHub); + + // We set the transaction here on the scope so error events pick up the trace + // context and attach it to the error. + logger.log(`Setting idle transaction on scope. Span ID: ${this.spanId}`); + _idleHub.configureScope(scope => scope.setSpan(this)); + } + } + + /** + * Checks when entries of this.activities are not changing for 3 beats. + * If this occurs we finish the transaction. + */ + private _beat(): void { + clearTimeout(this._heartbeatTimer); + // We should not be running heartbeat if the idle transaction is finished. + if (this._finished) { + return; + } + + const keys = Object.keys(this.activities); + const heartbeatString = keys.length ? keys.reduce((prev: string, current: string) => prev + current) : ''; + + if (heartbeatString === this._prevHeartbeatString) { + this._heartbeatCounter++; + } else { + this._heartbeatCounter = 1; + } + + this._prevHeartbeatString = heartbeatString; + + if (this._heartbeatCounter >= 3) { + logger.log( + `[Tracing] Transaction: ${ + SpanStatus.Cancelled + } -> Heartbeat safeguard kicked in since content hasn't changed for 3 beats`, + ); + this.setStatus(SpanStatus.DeadlineExceeded); + this.setTag('heartbeat', 'failed'); + this.finish(); + } else { + this._pingHeartbeat(); + } + } + + /** + * Pings the heartbeat + */ + private _pingHeartbeat(): void { + logger.log(`pinging Heartbeat -> current counter: ${this._heartbeatCounter}`); + this._heartbeatTimer = (setTimeout(() => { + this._beat(); + }, 5000) as any) as number; + } + + /** {@inheritDoc} */ + public finish(endTimestamp: number = timestampWithMs()): string | undefined { + this._finished = true; + this.activities = {}; + + if (this.spanRecorder) { + logger.log('[Tracing] finishing IdleTransaction', new Date(endTimestamp * 1000).toISOString(), this.op); + + for (const callback of this._beforeFinishCallbacks) { + callback(this, endTimestamp); + } + + this.spanRecorder.spans = this.spanRecorder.spans.filter((span: Span) => { + // If we are dealing with the transaction itself, we just return it + if (span.spanId === this.spanId) { + return true; + } + + // We cancel all pending spans with status "cancelled" to indicate the idle transaction was finished early + if (!span.endTimestamp) { + span.endTimestamp = endTimestamp; + span.setStatus(SpanStatus.Cancelled); + logger.log('[Tracing] cancelling span since transaction ended early', JSON.stringify(span, undefined, 2)); + } + + const keepSpan = span.startTimestamp < endTimestamp; + if (!keepSpan) { + logger.log( + '[Tracing] discarding Span since it happened after Transaction was finished', + JSON.stringify(span, undefined, 2), + ); + } + return keepSpan; + }); + + // this._onScope is true if the transaction was previously on the scope. + if (this._onScope) { + clearActiveTransaction(this._idleHub); + } + + logger.log('[Tracing] flushing IdleTransaction'); + } else { + logger.log('[Tracing] No active IdleTransaction'); + } + + return super.finish(endTimestamp); + } + + /** + * Start tracking a specific activity. + * @param spanId The span id that represents the activity + */ + private _pushActivity(spanId: string): void { + logger.log(`[Tracing] pushActivity: ${spanId}`); + this.activities[spanId] = true; + logger.log('[Tracing] new activities count', Object.keys(this.activities).length); + } + + /** + * Remove an activity from usage + * @param spanId The span id that represents the activity + */ + private _popActivity(spanId: string): void { + if (this.activities[spanId]) { + logger.log(`[Tracing] popActivity ${spanId}`); + // tslint:disable-next-line: no-dynamic-delete + delete this.activities[spanId]; + logger.log('[Tracing] new activities count', Object.keys(this.activities).length); + } + + if (Object.keys(this.activities).length === 0) { + const timeout = this._idleTimeout; + // We need to add the timeout here to have the real endtimestamp of the transaction + // Remember timestampWithMs is in seconds, timeout is in ms + const end = timestampWithMs() + timeout / 1000; + + setTimeout(() => { + if (!this._finished) { + this.finish(end); + } + }, timeout); + } + } + + /** + * Register a callback function that gets excecuted before the transaction finishes. + * Useful for cleanup or if you want to add any additional spans based on current context. + * + * This is exposed because users have no other way of running something before an idle transaction + * finishes. + */ + public registerBeforeFinishCallback(callback: BeforeFinishCallback): void { + this._beforeFinishCallbacks.push(callback); + } + + /** + * @inheritDoc + */ + public initSpanRecorder(maxlen?: number): void { + if (!this.spanRecorder) { + const pushActivity = (id: string) => { + this._pushActivity(id); + }; + const popActivity = (id: string) => { + this._popActivity(id); + }; + this.spanRecorder = new IdleTransactionSpanRecorder(pushActivity, popActivity, this.spanId, maxlen); + + // Start heartbeat so that transactions do not run forever. + logger.log('Starting heartbeat'); + this._pingHeartbeat(); + } + this.spanRecorder.add(this); + } +} + +/** + * Reset active transaction on scope + */ +function clearActiveTransaction(hub?: Hub): void { + if (hub) { + const scope = hub.getScope(); + if (scope) { + const transaction = scope.getTransaction(); + if (transaction) { + scope.setSpan(undefined); + } + } + } +} diff --git a/packages/tracing/src/index.bundle.ts b/packages/tracing/src/index.bundle.ts new file mode 100644 index 000000000000..f04df5dbd1ae --- /dev/null +++ b/packages/tracing/src/index.bundle.ts @@ -0,0 +1,80 @@ +// tslint:disable: no-implicit-dependencies +export { + Breadcrumb, + Request, + SdkInfo, + Event, + Exception, + Response, + Severity, + StackFrame, + Stacktrace, + Status, + Thread, + User, +} from '@sentry/types'; + +export { + addGlobalEventProcessor, + addBreadcrumb, + captureException, + captureEvent, + captureMessage, + configureScope, + getHubFromCarrier, + getCurrentHub, + Hub, + Scope, + setContext, + setExtra, + setExtras, + setTag, + setTags, + setUser, + Transports, + withScope, +} from '@sentry/browser'; + +export { BrowserOptions } from '@sentry/browser'; +export { BrowserClient, ReportDialogOptions } from '@sentry/browser'; +export { + defaultIntegrations, + forceLoad, + init, + lastEventId, + onLoad, + showReportDialog, + flush, + close, + wrap, +} from '@sentry/browser'; +export { SDK_NAME, SDK_VERSION } from '@sentry/browser'; + +import { Integrations as BrowserIntegrations } from '@sentry/browser'; +import { getGlobalObject } from '@sentry/utils'; + +import { BrowserTracing } from './browser'; +import { addExtensionMethods } from './hubextensions'; + +export { Span, TRACEPARENT_REGEXP } from './span'; + +let windowIntegrations = {}; + +// This block is needed to add compatibility with the integrations packages when used with a CDN +// tslint:disable: no-unsafe-any +const _window = getGlobalObject(); +if (_window.Sentry && _window.Sentry.Integrations) { + windowIntegrations = _window.Sentry.Integrations; +} +// tslint:enable: no-unsafe-any + +const INTEGRATIONS = { + ...windowIntegrations, + ...BrowserIntegrations, + ...BrowserTracing, +}; + +export { INTEGRATIONS as Integrations }; + +// We are patching the global object with our hub extension methods +addExtensionMethods(); diff --git a/packages/tracing/src/index.ts b/packages/tracing/src/index.ts new file mode 100644 index 000000000000..9a976e53bcac --- /dev/null +++ b/packages/tracing/src/index.ts @@ -0,0 +1,15 @@ +import { BrowserTracing } from './browser'; +import { addExtensionMethods } from './hubextensions'; +import * as ApmIntegrations from './integrations'; + +// tslint:disable-next-line: variable-name +const Integrations = { ...ApmIntegrations, BrowserTracing }; + +export { Integrations }; +export { Span, TRACEPARENT_REGEXP } from './span'; +export { Transaction } from './transaction'; + +export { SpanStatus } from './spanstatus'; + +// We are patching the global object with our hub extension methods +addExtensionMethods(); diff --git a/packages/tracing/src/integrations/express.ts b/packages/tracing/src/integrations/express.ts new file mode 100644 index 000000000000..113d41423dcd --- /dev/null +++ b/packages/tracing/src/integrations/express.ts @@ -0,0 +1,175 @@ +import { Integration, Transaction } from '@sentry/types'; +import { logger } from '@sentry/utils'; +// tslint:disable-next-line:no-implicit-dependencies +import { Application, ErrorRequestHandler, NextFunction, Request, RequestHandler, Response } from 'express'; + +/** + * Internal helper for `__sentry_transaction` + * @hidden + */ +interface SentryTracingResponse { + __sentry_transaction?: Transaction; +} + +/** + * Express integration + * + * Provides an request and error handler for Express framework + * as well as tracing capabilities + */ +export class Express implements Integration { + /** + * @inheritDoc + */ + public name: string = Express.id; + + /** + * @inheritDoc + */ + public static id: string = 'Express'; + + /** + * Express App instance + */ + private readonly _app?: Application; + + /** + * @inheritDoc + */ + public constructor(options: { app?: Application } = {}) { + this._app = options.app; + } + + /** + * @inheritDoc + */ + public setupOnce(): void { + if (!this._app) { + logger.error('ExpressIntegration is missing an Express instance'); + return; + } + instrumentMiddlewares(this._app); + } +} + +/** + * Wraps original middleware function in a tracing call, which stores the info about the call as a span, + * and finishes it once the middleware is done invoking. + * + * Express middlewares have 3 various forms, thus we have to take care of all of them: + * // sync + * app.use(function (req, res) { ... }) + * // async + * app.use(function (req, res, next) { ... }) + * // error handler + * app.use(function (err, req, res, next) { ... }) + */ +function wrap(fn: Function): RequestHandler | ErrorRequestHandler { + const arrity = fn.length; + + switch (arrity) { + case 2: { + return function(this: NodeJS.Global, _req: Request, res: Response & SentryTracingResponse): any { + const transaction = res.__sentry_transaction; + if (transaction) { + const span = transaction.startChild({ + description: fn.name, + op: 'middleware', + }); + res.once('finish', () => { + span.finish(); + }); + } + return fn.apply(this, arguments); + }; + } + case 3: { + return function( + this: NodeJS.Global, + req: Request, + res: Response & SentryTracingResponse, + next: NextFunction, + ): any { + const transaction = res.__sentry_transaction; + const span = + transaction && + transaction.startChild({ + description: fn.name, + op: 'middleware', + }); + fn.call(this, req, res, function(this: NodeJS.Global): any { + if (span) { + span.finish(); + } + return next.apply(this, arguments); + }); + }; + } + case 4: { + return function( + this: NodeJS.Global, + err: any, + req: Request, + res: Response & SentryTracingResponse, + next: NextFunction, + ): any { + const transaction = res.__sentry_transaction; + const span = + transaction && + transaction.startChild({ + description: fn.name, + op: 'middleware', + }); + fn.call(this, err, req, res, function(this: NodeJS.Global): any { + if (span) { + span.finish(); + } + return next.apply(this, arguments); + }); + }; + } + default: { + throw new Error(`Express middleware takes 2-4 arguments. Got: ${arrity}`); + } + } +} + +/** + * Takes all the function arguments passed to the original `app.use` call + * and wraps every function, as well as array of functions with a call to our `wrap` method. + * We have to take care of the arrays as well as iterate over all of the arguments, + * as `app.use` can accept middlewares in few various forms. + * + * app.use([], ) + * app.use([], , ...) + * app.use([], ...[]) + */ +function wrapUseArgs(args: IArguments): unknown[] { + return Array.from(args).map((arg: unknown) => { + if (typeof arg === 'function') { + return wrap(arg); + } + + if (Array.isArray(arg)) { + return arg.map((a: unknown) => { + if (typeof a === 'function') { + return wrap(a); + } + return a; + }); + } + + return arg; + }); +} + +/** + * Patches original app.use to utilize our tracing functionality + */ +function instrumentMiddlewares(app: Application): Application { + const originalAppUse = app.use; + app.use = function(): any { + return originalAppUse.apply(this, wrapUseArgs(arguments)); + }; + return app; +} diff --git a/packages/tracing/src/integrations/index.ts b/packages/tracing/src/integrations/index.ts new file mode 100644 index 000000000000..abe1faf43c27 --- /dev/null +++ b/packages/tracing/src/integrations/index.ts @@ -0,0 +1 @@ +export { Express } from './express'; diff --git a/packages/tracing/src/span.ts b/packages/tracing/src/span.ts new file mode 100644 index 000000000000..bad9dc1fbc60 --- /dev/null +++ b/packages/tracing/src/span.ts @@ -0,0 +1,325 @@ +// tslint:disable:max-classes-per-file +import { Span as SpanInterface, SpanContext } from '@sentry/types'; +import { dropUndefinedKeys, timestampWithMs, uuid4 } from '@sentry/utils'; + +import { SpanStatus } from './spanstatus'; + +export const TRACEPARENT_REGEXP = new RegExp( + '^[ \\t]*' + // whitespace + '([0-9a-f]{32})?' + // trace_id + '-?([0-9a-f]{16})?' + // span_id + '-?([01])?' + // sampled + '[ \\t]*$', // whitespace +); + +/** + * Keeps track of finished spans for a given transaction + * @internal + * @hideconstructor + * @hidden + */ +export class SpanRecorder { + private readonly _maxlen: number; + public spans: Span[] = []; + + public constructor(maxlen: number = 1000) { + this._maxlen = maxlen; + } + + /** + * This is just so that we don't run out of memory while recording a lot + * of spans. At some point we just stop and flush out the start of the + * trace tree (i.e.the first n spans with the smallest + * start_timestamp). + */ + public add(span: Span): void { + if (this.spans.length > this._maxlen) { + span.spanRecorder = undefined; + } else { + this.spans.push(span); + } + } +} + +/** + * Span contains all data about a span + */ +export class Span implements SpanInterface, SpanContext { + /** + * @inheritDoc + */ + public traceId: string = uuid4(); + + /** + * @inheritDoc + */ + public spanId: string = uuid4().substring(16); + + /** + * @inheritDoc + */ + public parentSpanId?: string; + + /** + * Internal keeper of the status + */ + public status?: SpanStatus | string; + + /** + * @inheritDoc + */ + public sampled?: boolean; + + /** + * Timestamp in seconds when the span was created. + */ + public startTimestamp: number = timestampWithMs(); + + /** + * Timestamp in seconds when the span ended. + */ + public endTimestamp?: number; + + /** + * @inheritDoc + */ + public op?: string; + + /** + * @inheritDoc + */ + public description?: string; + + /** + * @inheritDoc + */ + public tags: { [key: string]: string } = {}; + + /** + * @inheritDoc + */ + public data: { [key: string]: any } = {}; + + /** + * List of spans that were finalized + */ + public spanRecorder?: SpanRecorder; + + /** + * You should never call the constructor manually, always use `hub.startSpan()`. + * @internal + * @hideconstructor + * @hidden + */ + public constructor(spanContext?: SpanContext) { + if (!spanContext) { + return this; + } + if (spanContext.traceId) { + this.traceId = spanContext.traceId; + } + if (spanContext.spanId) { + this.spanId = spanContext.spanId; + } + if (spanContext.parentSpanId) { + this.parentSpanId = spanContext.parentSpanId; + } + // We want to include booleans as well here + if ('sampled' in spanContext) { + this.sampled = spanContext.sampled; + } + if (spanContext.op) { + this.op = spanContext.op; + } + if (spanContext.description) { + this.description = spanContext.description; + } + if (spanContext.data) { + this.data = spanContext.data; + } + if (spanContext.tags) { + this.tags = spanContext.tags; + } + if (spanContext.status) { + this.status = spanContext.status; + } + if (spanContext.startTimestamp) { + this.startTimestamp = spanContext.startTimestamp; + } + if (spanContext.endTimestamp) { + this.endTimestamp = spanContext.endTimestamp; + } + } + + /** + * @inheritDoc + * @deprecated + */ + public child( + spanContext?: Pick>, + ): Span { + return this.startChild(spanContext); + } + + /** + * @inheritDoc + */ + public startChild( + spanContext?: Pick>, + ): Span { + const span = new Span({ + ...spanContext, + parentSpanId: this.spanId, + sampled: this.sampled, + traceId: this.traceId, + }); + + span.spanRecorder = this.spanRecorder; + if (span.spanRecorder) { + span.spanRecorder.add(span); + } + + return span; + } + + /** + * Continues a trace from a string (usually the header). + * @param traceparent Traceparent string + */ + public static fromTraceparent( + traceparent: string, + spanContext?: Pick>, + ): Span | undefined { + const matches = traceparent.match(TRACEPARENT_REGEXP); + if (matches) { + let sampled: boolean | undefined; + if (matches[3] === '1') { + sampled = true; + } else if (matches[3] === '0') { + sampled = false; + } + return new Span({ + ...spanContext, + parentSpanId: matches[2], + sampled, + traceId: matches[1], + }); + } + return undefined; + } + + /** + * @inheritDoc + */ + public setTag(key: string, value: string): this { + this.tags = { ...this.tags, [key]: value }; + return this; + } + + /** + * @inheritDoc + */ + public setData(key: string, value: any): this { + this.data = { ...this.data, [key]: value }; + return this; + } + + /** + * @inheritDoc + */ + public setStatus(value: SpanStatus): this { + this.status = value; + return this; + } + + /** + * @inheritDoc + */ + public setHttpStatus(httpStatus: number): this { + this.setTag('http.status_code', String(httpStatus)); + const spanStatus = SpanStatus.fromHttpCode(httpStatus); + if (spanStatus !== SpanStatus.UnknownError) { + this.setStatus(spanStatus); + } + return this; + } + + /** + * @inheritDoc + */ + public isSuccess(): boolean { + return this.status === SpanStatus.Ok; + } + + /** + * @inheritDoc + */ + public finish(endTimestamp?: number): void { + this.endTimestamp = typeof endTimestamp === 'number' ? endTimestamp : timestampWithMs(); + } + + /** + * @inheritDoc + */ + public toTraceparent(): string { + let sampledString = ''; + if (this.sampled !== undefined) { + sampledString = this.sampled ? '-1' : '-0'; + } + return `${this.traceId}-${this.spanId}${sampledString}`; + } + + /** + * @inheritDoc + */ + public getTraceContext(): { + data?: { [key: string]: any }; + description?: string; + op?: string; + parent_span_id?: string; + span_id: string; + status?: string; + tags?: { [key: string]: string }; + trace_id: string; + } { + return dropUndefinedKeys({ + data: Object.keys(this.data).length > 0 ? this.data : undefined, + description: this.description, + op: this.op, + parent_span_id: this.parentSpanId, + span_id: this.spanId, + status: this.status, + tags: Object.keys(this.tags).length > 0 ? this.tags : undefined, + trace_id: this.traceId, + }); + } + + /** + * @inheritDoc + */ + public toJSON(): { + data?: { [key: string]: any }; + description?: string; + op?: string; + parent_span_id?: string; + sampled?: boolean; + span_id: string; + start_timestamp: number; + tags?: { [key: string]: string }; + timestamp?: number; + trace_id: string; + } { + return dropUndefinedKeys({ + data: Object.keys(this.data).length > 0 ? this.data : undefined, + description: this.description, + op: this.op, + parent_span_id: this.parentSpanId, + span_id: this.spanId, + start_timestamp: this.startTimestamp, + status: this.status, + tags: Object.keys(this.tags).length > 0 ? this.tags : undefined, + timestamp: this.endTimestamp, + trace_id: this.traceId, + }); + } +} diff --git a/packages/tracing/src/spanstatus.ts b/packages/tracing/src/spanstatus.ts new file mode 100644 index 000000000000..6aa87a4dc833 --- /dev/null +++ b/packages/tracing/src/spanstatus.ts @@ -0,0 +1,87 @@ +/** The status of an Span. */ +export enum SpanStatus { + /** The operation completed successfully. */ + Ok = 'ok', + /** Deadline expired before operation could complete. */ + DeadlineExceeded = 'deadline_exceeded', + /** 401 Unauthorized (actually does mean unauthenticated according to RFC 7235) */ + Unauthenticated = 'unauthenticated', + /** 403 Forbidden */ + PermissionDenied = 'permission_denied', + /** 404 Not Found. Some requested entity (file or directory) was not found. */ + NotFound = 'not_found', + /** 429 Too Many Requests */ + ResourceExhausted = 'resource_exhausted', + /** Client specified an invalid argument. 4xx. */ + InvalidArgument = 'invalid_argument', + /** 501 Not Implemented */ + Unimplemented = 'unimplemented', + /** 503 Service Unavailable */ + Unavailable = 'unavailable', + /** Other/generic 5xx. */ + InternalError = 'internal_error', + /** Unknown. Any non-standard HTTP status code. */ + UnknownError = 'unknown_error', + /** The operation was cancelled (typically by the user). */ + Cancelled = 'cancelled', + /** Already exists (409) */ + AlreadyExists = 'already_exists', + /** Operation was rejected because the system is not in a state required for the operation's */ + FailedPrecondition = 'failed_precondition', + /** The operation was aborted, typically due to a concurrency issue. */ + Aborted = 'aborted', + /** Operation was attempted past the valid range. */ + OutOfRange = 'out_of_range', + /** Unrecoverable data loss or corruption */ + DataLoss = 'data_loss', +} + +// tslint:disable:no-unnecessary-qualifier no-namespace +export namespace SpanStatus { + /** + * Converts a HTTP status code into a {@link SpanStatus}. + * + * @param httpStatus The HTTP response status code. + * @returns The span status or {@link SpanStatus.UnknownError}. + */ + // tslint:disable-next-line:completed-docs + export function fromHttpCode(httpStatus: number): SpanStatus { + if (httpStatus < 400) { + return SpanStatus.Ok; + } + + if (httpStatus >= 400 && httpStatus < 500) { + switch (httpStatus) { + case 401: + return SpanStatus.Unauthenticated; + case 403: + return SpanStatus.PermissionDenied; + case 404: + return SpanStatus.NotFound; + case 409: + return SpanStatus.AlreadyExists; + case 413: + return SpanStatus.FailedPrecondition; + case 429: + return SpanStatus.ResourceExhausted; + default: + return SpanStatus.InvalidArgument; + } + } + + if (httpStatus >= 500 && httpStatus < 600) { + switch (httpStatus) { + case 501: + return SpanStatus.Unimplemented; + case 503: + return SpanStatus.Unavailable; + case 504: + return SpanStatus.DeadlineExceeded; + default: + return SpanStatus.InternalError; + } + } + + return SpanStatus.UnknownError; + } +} diff --git a/packages/tracing/src/transaction.ts b/packages/tracing/src/transaction.ts new file mode 100644 index 000000000000..869724e59f2b --- /dev/null +++ b/packages/tracing/src/transaction.ts @@ -0,0 +1,102 @@ +import { getCurrentHub, Hub } from '@sentry/hub'; +import { TransactionContext } from '@sentry/types'; +import { isInstanceOf, logger } from '@sentry/utils'; + +import { Span as SpanClass, SpanRecorder } from './span'; + +/** JSDoc */ +export class Transaction extends SpanClass { + /** + * The reference to the current hub. + */ + private readonly _hub: Hub = (getCurrentHub() as unknown) as Hub; + + public name?: string; + + private readonly _trimEnd?: boolean; + + /** + * This constructor should never be called manually. Those instrumenting tracing should use + * `Sentry.startTransaction()`, and internal methods should use `hub.startTransaction()`. + * @internal + * @hideconstructor + * @hidden + */ + public constructor(transactionContext: TransactionContext, hub?: Hub) { + super(transactionContext); + + if (isInstanceOf(hub, Hub)) { + this._hub = hub as Hub; + } + + if (transactionContext.name) { + this.name = transactionContext.name; + } + + this._trimEnd = transactionContext.trimEnd; + } + + /** + * JSDoc + */ + public setName(name: string): void { + this.name = name; + } + + /** + * Attaches SpanRecorder to the span itself + * @param maxlen maximum number of spans that can be recorded + */ + public initSpanRecorder(maxlen: number = 1000): void { + if (!this.spanRecorder) { + this.spanRecorder = new SpanRecorder(maxlen); + } + this.spanRecorder.add(this); + } + + /** + * @inheritDoc + */ + public finish(endTimestamp?: number): string | undefined { + // This transaction is already finished, so we should not flush it again. + if (this.endTimestamp !== undefined) { + return undefined; + } + + if (!this.name) { + logger.warn('Transaction has no name, falling back to ``.'); + this.name = ''; + } + + super.finish(endTimestamp); + + if (this.sampled !== true) { + // At this point if `sampled !== true` we want to discard the transaction. + logger.warn('Discarding transaction because it was not chosen to be sampled.'); + return undefined; + } + + const finishedSpans = this.spanRecorder ? this.spanRecorder.spans.filter(s => s !== this && s.endTimestamp) : []; + + if (this._trimEnd && finishedSpans.length > 0) { + this.endTimestamp = finishedSpans.reduce((prev: SpanClass, current: SpanClass) => { + if (prev.endTimestamp && current.endTimestamp) { + return prev.endTimestamp > current.endTimestamp ? prev : current; + } + return prev; + }).endTimestamp; + } + + return this._hub.captureEvent({ + contexts: { + trace: this.getTraceContext(), + }, + spans: finishedSpans, + start_timestamp: this.startTimestamp, + tags: this.tags, + timestamp: this.endTimestamp, + transaction: this.name, + type: 'transaction', + }); + } +} diff --git a/packages/tracing/test/browser/backgroundtab.test.ts b/packages/tracing/test/browser/backgroundtab.test.ts new file mode 100644 index 000000000000..a85a93434bc2 --- /dev/null +++ b/packages/tracing/test/browser/backgroundtab.test.ts @@ -0,0 +1,57 @@ +import { BrowserClient } from '@sentry/browser'; +import { Hub, makeMain } from '@sentry/hub'; +// tslint:disable-next-line: no-implicit-dependencies +import { JSDOM } from 'jsdom'; + +import { SpanStatus } from '../../src'; +import { registerBackgroundTabDetection } from '../../src/browser/backgroundtab'; + +describe('registerBackgroundTabDetection', () => { + let events: Record = {}; + let hub: Hub; + beforeEach(() => { + const dom = new JSDOM(); + // @ts-ignore + global.document = dom.window.document; + + hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); + makeMain(hub); + + // @ts-ignore + global.document.addEventListener = jest.fn((event, callback) => { + events[event] = callback; + }); + }); + + afterEach(() => { + events = {}; + hub.configureScope(scope => scope.setSpan(undefined)); + }); + + it('does not creates an event listener if global document is undefined', () => { + // @ts-ignore; + global.document = undefined; + registerBackgroundTabDetection(); + expect(events).toMatchObject({}); + }); + + it('creates an event listener', () => { + registerBackgroundTabDetection(); + expect(events).toMatchObject({ visibilitychange: expect.any(Function) }); + }); + + it('finishes a transaction on visibility change', () => { + registerBackgroundTabDetection(); + const transaction = hub.startTransaction({ name: 'test' }); + hub.configureScope(scope => scope.setSpan(transaction)); + + // Simulate document visibility hidden event + // @ts-ignore + global.document.hidden = true; + events.visibilitychange(); + + expect(transaction.status).toBe(SpanStatus.Cancelled); + expect(transaction.tags.visibilitychange).toBe('document.hidden'); + expect(transaction.endTimestamp).toBeDefined(); + }); +}); diff --git a/packages/tracing/test/browser/browsertracing.test.ts b/packages/tracing/test/browser/browsertracing.test.ts new file mode 100644 index 000000000000..5e7f695aed86 --- /dev/null +++ b/packages/tracing/test/browser/browsertracing.test.ts @@ -0,0 +1,364 @@ +import { BrowserClient } from '@sentry/browser'; +import { Hub, makeMain } from '@sentry/hub'; +// tslint:disable-next-line: no-implicit-dependencies +import { JSDOM } from 'jsdom'; + +import { SpanStatus } from '../../src'; +import { + BrowserTracing, + BrowserTracingOptions, + DEFAULT_MAX_TRANSACTION_DURATION_SECONDS, + getMetaContent, +} from '../../src/browser/browsertracing'; +import { defaultRequestInstrumentionOptions } from '../../src/browser/request'; +import { defaultRoutingInstrumentation } from '../../src/browser/router'; +import { getActiveTransaction, secToMs } from '../../src/browser/utils'; +import { DEFAULT_IDLE_TIMEOUT, IdleTransaction } from '../../src/idletransaction'; + +let mockChangeHistory: ({ to, from }: { to: string; from?: string }) => void = () => undefined; + +jest.mock('@sentry/utils', () => { + const actual = jest.requireActual('@sentry/utils'); + return { + ...actual, + addInstrumentationHandler: ({ callback, type }: any): void => { + if (type === 'history') { + mockChangeHistory = callback; + } + }, + }; +}); + +const { logger } = jest.requireActual('@sentry/utils'); +const warnSpy = jest.spyOn(logger, 'warn'); + +beforeAll(() => { + const dom = new JSDOM(); + // @ts-ignore + global.document = dom.window.document; + // @ts-ignore + global.window = dom.window; + // @ts-ignore + global.location = dom.window.location; +}); + +describe('BrowserTracing', () => { + let hub: Hub; + beforeEach(() => { + jest.useFakeTimers(); + hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); + makeMain(hub); + document.head.innerHTML = ''; + + warnSpy.mockClear(); + }); + + afterEach(() => { + const activeTransaction = getActiveTransaction(); + if (activeTransaction) { + // Should unset off of scope. + activeTransaction.finish(); + } + }); + + // tslint:disable-next-line: completed-docs + function createBrowserTracing(setup?: boolean, _options?: Partial): BrowserTracing { + const inst = new BrowserTracing(_options); + if (setup) { + const processor = () => undefined; + inst.setupOnce(processor, () => hub); + } + + return inst; + } + + // These are important enough to check with a test as incorrect defaults could + // break a lot of users configurations. + it('is created with default settings', () => { + const browserTracing = createBrowserTracing(); + + expect(browserTracing.options).toEqual({ + beforeNavigate: expect.any(Function), + idleTimeout: DEFAULT_IDLE_TIMEOUT, + markBackgroundTransactions: true, + maxTransactionDuration: DEFAULT_MAX_TRANSACTION_DURATION_SECONDS, + routingInstrumentation: defaultRoutingInstrumentation, + startTransactionOnLocationChange: true, + startTransactionOnPageLoad: true, + ...defaultRequestInstrumentionOptions, + }); + }); + + /** + * All of these tests under `describe('route transaction')` are tested with + * `browserTracing.options = { routingInstrumentation: customRoutingInstrumentation }`, + * so that we can show this functionality works independent of the default routing integration. + */ + describe('route transaction', () => { + const customRoutingInstrumentation = (startTransaction: Function) => { + startTransaction({ name: 'a/path', op: 'pageload' }); + }; + + it('calls custom routing instrumenation', () => { + createBrowserTracing(true, { + routingInstrumentation: customRoutingInstrumentation, + }); + + const transaction = getActiveTransaction(hub) as IdleTransaction; + expect(transaction).toBeDefined(); + expect(transaction.name).toBe('a/path'); + expect(transaction.op).toBe('pageload'); + }); + + it('trims all transactions', () => { + createBrowserTracing(true, { + routingInstrumentation: customRoutingInstrumentation, + }); + + const transaction = getActiveTransaction(hub) as IdleTransaction; + const span = transaction.startChild(); + span.finish(); + + if (span.endTimestamp) { + transaction.finish(span.endTimestamp + 12345); + } + expect(transaction.endTimestamp).toBe(span.endTimestamp); + }); + + describe('tracingOrigins', () => { + it('warns and uses default tracing origins if non are provided', () => { + const inst = createBrowserTracing(true, { + routingInstrumentation: customRoutingInstrumentation, + }); + + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(inst.options.tracingOrigins).toEqual(defaultRequestInstrumentionOptions.tracingOrigins); + }); + + it('warns and uses default tracing origins if array not given', () => { + const inst = createBrowserTracing(true, { + routingInstrumentation: customRoutingInstrumentation, + tracingOrigins: [], + }); + + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(inst.options.tracingOrigins).toEqual(defaultRequestInstrumentionOptions.tracingOrigins); + }); + + it('warns and uses default tracing origins if tracing origins are not defined', () => { + const inst = createBrowserTracing(true, { + routingInstrumentation: customRoutingInstrumentation, + tracingOrigins: undefined, + }); + + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(inst.options.tracingOrigins).toEqual(defaultRequestInstrumentionOptions.tracingOrigins); + }); + + it('sets tracing origins if provided and does not warn', () => { + const inst = createBrowserTracing(true, { + routingInstrumentation: customRoutingInstrumentation, + tracingOrigins: ['something'], + }); + + expect(warnSpy).toHaveBeenCalledTimes(0); + expect(inst.options.tracingOrigins).toEqual(['something']); + }); + }); + + describe('beforeNavigate', () => { + it('is called on transaction creation', () => { + const mockBeforeNavigation = jest.fn().mockReturnValue({ name: 'here/is/my/path' }); + createBrowserTracing(true, { + beforeNavigate: mockBeforeNavigation, + routingInstrumentation: customRoutingInstrumentation, + }); + const transaction = getActiveTransaction(hub) as IdleTransaction; + expect(transaction).toBeDefined(); + + expect(mockBeforeNavigation).toHaveBeenCalledTimes(1); + }); + + it('does not create a transaction if it returns undefined', () => { + const mockBeforeNavigation = jest.fn().mockReturnValue(undefined); + createBrowserTracing(true, { + beforeNavigate: mockBeforeNavigation, + routingInstrumentation: customRoutingInstrumentation, + }); + const transaction = getActiveTransaction(hub) as IdleTransaction; + expect(transaction).not.toBeDefined(); + + expect(mockBeforeNavigation).toHaveBeenCalledTimes(1); + }); + + it('can be a custom value', () => { + const mockBeforeNavigation = jest.fn(ctx => ({ + ...ctx, + op: 'something-else', + })); + createBrowserTracing(true, { + beforeNavigate: mockBeforeNavigation, + routingInstrumentation: customRoutingInstrumentation, + }); + const transaction = getActiveTransaction(hub) as IdleTransaction; + expect(transaction).toBeDefined(); + expect(transaction.op).toBe('something-else'); + + expect(mockBeforeNavigation).toHaveBeenCalledTimes(1); + }); + }); + + it('sets transaction context from sentry-trace header', () => { + const name = 'sentry-trace'; + const content = '126de09502ae4e0fb26c6967190756a4-b6e54397b12a2a0f-1'; + document.head.innerHTML = ``; + createBrowserTracing(true, { routingInstrumentation: customRoutingInstrumentation }); + const transaction = getActiveTransaction(hub) as IdleTransaction; + + expect(transaction.traceId).toBe('126de09502ae4e0fb26c6967190756a4'); + expect(transaction.parentSpanId).toBe('b6e54397b12a2a0f'); + expect(transaction.sampled).toBe(true); + }); + + describe('idleTimeout', () => { + it('is created by default', () => { + createBrowserTracing(true, { routingInstrumentation: customRoutingInstrumentation }); + const mockFinish = jest.fn(); + const transaction = getActiveTransaction(hub) as IdleTransaction; + transaction.finish = mockFinish; + + const span = transaction.startChild(); // activities = 1 + span.finish(); // activities = 0 + + expect(mockFinish).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(DEFAULT_IDLE_TIMEOUT); + expect(mockFinish).toHaveBeenCalledTimes(1); + }); + + it('can be a custom value', () => { + createBrowserTracing(true, { idleTimeout: 2000, routingInstrumentation: customRoutingInstrumentation }); + const mockFinish = jest.fn(); + const transaction = getActiveTransaction(hub) as IdleTransaction; + transaction.finish = mockFinish; + + const span = transaction.startChild(); // activities = 1 + span.finish(); // activities = 0 + + expect(mockFinish).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(2000); + expect(mockFinish).toHaveBeenCalledTimes(1); + }); + }); + + describe('maxTransactionDuration', () => { + it('cancels a transaction if exceeded', () => { + createBrowserTracing(true, { routingInstrumentation: customRoutingInstrumentation }); + const transaction = getActiveTransaction(hub) as IdleTransaction; + transaction.finish(transaction.startTimestamp + secToMs(DEFAULT_MAX_TRANSACTION_DURATION_SECONDS) + 1); + + expect(transaction.status).toBe(SpanStatus.DeadlineExceeded); + expect(transaction.tags.maxTransactionDurationExceeded).toBeDefined(); + }); + + it('does not cancel a transaction if not exceeded', () => { + createBrowserTracing(true, { routingInstrumentation: customRoutingInstrumentation }); + const transaction = getActiveTransaction(hub) as IdleTransaction; + transaction.finish(transaction.startTimestamp + secToMs(DEFAULT_MAX_TRANSACTION_DURATION_SECONDS)); + + expect(transaction.status).toBe(undefined); + expect(transaction.tags.maxTransactionDurationExceeded).not.toBeDefined(); + }); + + it('can have a custom value', () => { + const customMaxTransactionDuration = 700; + // Test to make sure default duration is less than tested custom value. + expect(DEFAULT_MAX_TRANSACTION_DURATION_SECONDS < customMaxTransactionDuration).toBe(true); + createBrowserTracing(true, { + maxTransactionDuration: customMaxTransactionDuration, + routingInstrumentation: customRoutingInstrumentation, + }); + const transaction = getActiveTransaction(hub) as IdleTransaction; + + transaction.finish(transaction.startTimestamp + secToMs(customMaxTransactionDuration)); + + expect(transaction.status).toBe(undefined); + expect(transaction.tags.maxTransactionDurationExceeded).not.toBeDefined(); + }); + }); + }); + + // Integration tests for the default routing instrumentation + describe('default routing instrumentation', () => { + describe('pageload transaction', () => { + it('is created on setup on scope', () => { + createBrowserTracing(true); + const transaction = getActiveTransaction(hub) as IdleTransaction; + expect(transaction).toBeDefined(); + + expect(transaction.op).toBe('pageload'); + }); + + it('is not created if the option is false', () => { + createBrowserTracing(true, { startTransactionOnPageLoad: false }); + const transaction = getActiveTransaction(hub) as IdleTransaction; + expect(transaction).not.toBeDefined(); + }); + }); + + describe('navigation transaction', () => { + beforeEach(() => { + mockChangeHistory = () => undefined; + }); + + it('it is not created automatically', () => { + createBrowserTracing(true); + jest.runAllTimers(); + + const transaction = getActiveTransaction(hub) as IdleTransaction; + expect(transaction).not.toBeDefined(); + }); + + it('is created on location change', () => { + createBrowserTracing(true); + const transaction1 = getActiveTransaction(hub) as IdleTransaction; + expect(transaction1.op).toBe('pageload'); + expect(transaction1.endTimestamp).not.toBeDefined(); + + mockChangeHistory({ to: 'here', from: 'there' }); + const transaction2 = getActiveTransaction(hub) as IdleTransaction; + expect(transaction2.op).toBe('navigation'); + + expect(transaction1.endTimestamp).toBeDefined(); + }); + + it('is not created if startTransactionOnLocationChange is false', () => { + createBrowserTracing(true, { startTransactionOnLocationChange: false }); + const transaction1 = getActiveTransaction(hub) as IdleTransaction; + expect(transaction1.op).toBe('pageload'); + expect(transaction1.endTimestamp).not.toBeDefined(); + + mockChangeHistory({ to: 'here', from: 'there' }); + const transaction2 = getActiveTransaction(hub) as IdleTransaction; + expect(transaction2.op).toBe('pageload'); + }); + }); + }); +}); + +describe('getMeta', () => { + it('returns a found meta tag contents', () => { + const name = 'sentry-trace'; + const content = '126de09502ae4e0fb26c6967190756a4-b6e54397b12a2a0f-1'; + document.head.innerHTML = ``; + + const meta = getMetaContent(name); + expect(meta).toBe(content); + }); + + it('only returns meta tags queried for', () => { + document.head.innerHTML = ``; + + const meta = getMetaContent('test'); + expect(meta).toBe(null); + }); +}); diff --git a/packages/tracing/test/browser/errors.test.ts b/packages/tracing/test/browser/errors.test.ts new file mode 100644 index 000000000000..07b43ad6252f --- /dev/null +++ b/packages/tracing/test/browser/errors.test.ts @@ -0,0 +1,86 @@ +import { BrowserClient } from '@sentry/browser'; +import { Hub, makeMain } from '@sentry/hub'; + +import { SpanStatus } from '../../src'; +import { registerErrorInstrumentation } from '../../src/browser/errors'; +import { addExtensionMethods } from '../../src/hubextensions'; + +const mockAddInstrumentationHandler = jest.fn(); +let mockErrorCallback: () => void = () => undefined; +let mockUnhandledRejectionCallback: () => void = () => undefined; +jest.mock('@sentry/utils', () => { + const actual = jest.requireActual('@sentry/utils'); + return { + ...actual, + addInstrumentationHandler: ({ callback, type }: any) => { + if (type === 'error') { + mockErrorCallback = callback; + } + if (type === 'unhandledrejection') { + mockUnhandledRejectionCallback = callback; + } + return mockAddInstrumentationHandler({ callback, type }); + }, + }; +}); + +beforeAll(() => { + addExtensionMethods(); +}); + +describe('registerErrorHandlers()', () => { + let hub: Hub; + beforeEach(() => { + mockAddInstrumentationHandler.mockClear(); + hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); + makeMain(hub); + }); + + afterEach(() => { + hub.configureScope(scope => scope.setSpan(undefined)); + }); + + it('registers error instrumentation', () => { + registerErrorInstrumentation(); + expect(mockAddInstrumentationHandler).toHaveBeenCalledTimes(2); + expect(mockAddInstrumentationHandler).toHaveBeenNthCalledWith(1, { callback: expect.any(Function), type: 'error' }); + expect(mockAddInstrumentationHandler).toHaveBeenNthCalledWith(2, { + callback: expect.any(Function), + type: 'unhandledrejection', + }); + }); + + it('does not set status if transaction is not on scope', () => { + registerErrorInstrumentation(); + const transaction = hub.startTransaction({ name: 'test' }); + expect(transaction.status).toBe(undefined); + + mockErrorCallback(); + expect(transaction.status).toBe(undefined); + + mockUnhandledRejectionCallback(); + expect(transaction.status).toBe(undefined); + transaction.finish(); + }); + + it('sets status for transaction on scope on error', () => { + registerErrorInstrumentation(); + const transaction = hub.startTransaction({ name: 'test' }); + hub.configureScope(scope => scope.setSpan(transaction)); + + mockErrorCallback(); + expect(transaction.status).toBe(SpanStatus.InternalError); + + transaction.finish(); + }); + + it('sets status for transaction on scope on unhandledrejection', () => { + registerErrorInstrumentation(); + const transaction = hub.startTransaction({ name: 'test' }); + hub.configureScope(scope => scope.setSpan(transaction)); + + mockUnhandledRejectionCallback(); + expect(transaction.status).toBe(SpanStatus.InternalError); + transaction.finish(); + }); +}); diff --git a/packages/tracing/test/browser/request.test.ts b/packages/tracing/test/browser/request.test.ts new file mode 100644 index 000000000000..5e22c6985db7 --- /dev/null +++ b/packages/tracing/test/browser/request.test.ts @@ -0,0 +1,49 @@ +import { registerRequestInstrumentation } from '../../src/browser/request'; + +const mockAddInstrumentationHandler = jest.fn(); +let mockFetchCallback = jest.fn(); +let mockXHRCallback = jest.fn(); +jest.mock('@sentry/utils', () => { + const actual = jest.requireActual('@sentry/utils'); + return { + ...actual, + addInstrumentationHandler: ({ callback, type }: any) => { + if (type === 'fetch') { + mockFetchCallback = jest.fn(callback); + } + if (type === 'xhr') { + mockXHRCallback = jest.fn(callback); + } + return mockAddInstrumentationHandler({ callback, type }); + }, + }; +}); + +describe('registerRequestInstrumentation', () => { + beforeEach(() => { + mockFetchCallback.mockReset(); + mockXHRCallback.mockReset(); + mockAddInstrumentationHandler.mockReset(); + }); + + it('tracks fetch and xhr requests', () => { + registerRequestInstrumentation(); + expect(mockAddInstrumentationHandler).toHaveBeenCalledTimes(2); + // fetch + expect(mockAddInstrumentationHandler).toHaveBeenNthCalledWith(1, { callback: expect.any(Function), type: 'fetch' }); + // xhr + expect(mockAddInstrumentationHandler).toHaveBeenNthCalledWith(2, { callback: expect.any(Function), type: 'xhr' }); + }); + + it('does not add fetch requests spans if traceFetch is false', () => { + registerRequestInstrumentation({ traceFetch: false }); + expect(mockAddInstrumentationHandler).toHaveBeenCalledTimes(1); + expect(mockFetchCallback()).toBe(undefined); + }); + + it('does not add xhr requests spans if traceXHR is false', () => { + registerRequestInstrumentation({ traceXHR: false }); + expect(mockAddInstrumentationHandler).toHaveBeenCalledTimes(1); + expect(mockXHRCallback()).toBe(undefined); + }); +}); diff --git a/packages/tracing/test/browser/router.test.ts b/packages/tracing/test/browser/router.test.ts new file mode 100644 index 000000000000..ab83366c9f86 --- /dev/null +++ b/packages/tracing/test/browser/router.test.ts @@ -0,0 +1,117 @@ +// tslint:disable-next-line: no-implicit-dependencies +import { JSDOM } from 'jsdom'; + +import { defaultBeforeNavigate, defaultRoutingInstrumentation } from '../../src/browser/router'; + +let mockChangeHistory: ({ to, from }: { to: string; from?: string }) => void = () => undefined; +let addInstrumentationHandlerType: string = ''; +jest.mock('@sentry/utils', () => { + const actual = jest.requireActual('@sentry/utils'); + return { + ...actual, + addInstrumentationHandler: ({ callback, type }: any): void => { + addInstrumentationHandlerType = type; + mockChangeHistory = callback; + }, + }; +}); + +describe('defaultBeforeNavigate()', () => { + it('returns a context', () => { + const ctx = { name: 'testing', status: 'ok' }; + expect(defaultBeforeNavigate(ctx)).toBe(ctx); + }); +}); + +describe('defaultRoutingInstrumentation', () => { + const mockFinish = jest.fn(); + const startTransaction = jest.fn().mockReturnValue({ finish: mockFinish }); + beforeEach(() => { + const dom = new JSDOM(); + // @ts-ignore + global.document = dom.window.document; + // @ts-ignore + global.window = dom.window; + // @ts-ignore + global.location = dom.window.location; + + startTransaction.mockClear(); + mockFinish.mockClear(); + }); + + it('does not start transactions if global location is undefined', () => { + // @ts-ignore + global.location = undefined; + defaultRoutingInstrumentation(startTransaction); + expect(startTransaction).toHaveBeenCalledTimes(0); + }); + + it('starts a pageload transaction', () => { + defaultRoutingInstrumentation(startTransaction); + expect(startTransaction).toHaveBeenCalledTimes(1); + expect(startTransaction).toHaveBeenLastCalledWith({ name: 'blank', op: 'pageload' }); + }); + + it('does not start a pageload transaction if startTransactionOnPageLoad is false', () => { + defaultRoutingInstrumentation(startTransaction, false); + expect(startTransaction).toHaveBeenCalledTimes(0); + }); + + describe('navigation transaction', () => { + beforeEach(() => { + mockChangeHistory = () => undefined; + addInstrumentationHandlerType = ''; + }); + + it('it is not created automatically', () => { + defaultRoutingInstrumentation(startTransaction); + expect(startTransaction).not.toHaveBeenLastCalledWith({ name: 'blank', op: 'navigation' }); + }); + + it('is created on location change', () => { + defaultRoutingInstrumentation(startTransaction); + mockChangeHistory({ to: 'here', from: 'there' }); + expect(addInstrumentationHandlerType).toBe('history'); + + expect(startTransaction).toHaveBeenCalledTimes(2); + expect(startTransaction).toHaveBeenLastCalledWith({ name: 'blank', op: 'navigation' }); + }); + + it('is not created if startTransactionOnLocationChange is false', () => { + defaultRoutingInstrumentation(startTransaction, true, false); + mockChangeHistory({ to: 'here', from: 'there' }); + expect(addInstrumentationHandlerType).toBe(''); + + expect(startTransaction).toHaveBeenCalledTimes(1); + }); + + it('finishes the last active transaction', () => { + defaultRoutingInstrumentation(startTransaction); + + expect(mockFinish).toHaveBeenCalledTimes(0); + mockChangeHistory({ to: 'here', from: 'there' }); + expect(mockFinish).toHaveBeenCalledTimes(1); + }); + + it('will finish active transaction multiple times', () => { + defaultRoutingInstrumentation(startTransaction); + + expect(mockFinish).toHaveBeenCalledTimes(0); + mockChangeHistory({ to: 'here', from: 'there' }); + expect(mockFinish).toHaveBeenCalledTimes(1); + mockChangeHistory({ to: 'over/there', from: 'here' }); + expect(mockFinish).toHaveBeenCalledTimes(2); + mockChangeHistory({ to: 'nowhere', from: 'over/there' }); + expect(mockFinish).toHaveBeenCalledTimes(3); + }); + + it('not created if `from` is equal to `to`', () => { + defaultRoutingInstrumentation(startTransaction); + mockChangeHistory({ to: 'first/path', from: 'first/path' }); + expect(addInstrumentationHandlerType).toBe('history'); + + expect(startTransaction).toHaveBeenCalledTimes(1); + expect(startTransaction).not.toHaveBeenLastCalledWith('navigation'); + }); + }); +}); diff --git a/packages/tracing/test/hub.test.ts b/packages/tracing/test/hub.test.ts new file mode 100644 index 000000000000..017be5a5b898 --- /dev/null +++ b/packages/tracing/test/hub.test.ts @@ -0,0 +1,56 @@ +import { BrowserClient } from '@sentry/browser'; +import { Hub } from '@sentry/hub'; + +import { addExtensionMethods } from '../src/hubextensions'; + +addExtensionMethods(); + +describe('Hub', () => { + afterEach(() => { + jest.resetAllMocks(); + jest.useRealTimers(); + }); + + describe('getTransaction', () => { + test('simple invoke', () => { + const hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); + const transaction = hub.startTransaction({ name: 'foo' }); + hub.configureScope(scope => { + scope.setSpan(transaction); + }); + hub.configureScope(s => { + expect(s.getTransaction()).toBe(transaction); + }); + }); + + test('not invoke', () => { + const hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); + const transaction = hub.startTransaction({ name: 'foo' }); + hub.configureScope(s => { + expect(s.getTransaction()).toBeUndefined(); + }); + transaction.finish(); + }); + }); + + describe('spans', () => { + describe('sampling', () => { + test('set tracesSampleRate 0 on transaction', () => { + const hub = new Hub(new BrowserClient({ tracesSampleRate: 0 })); + const transaction = hub.startTransaction({ name: 'foo' }); + expect(transaction.sampled).toBe(false); + }); + test('set tracesSampleRate 1 on transaction', () => { + const hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); + const transaction = hub.startTransaction({ name: 'foo' }); + expect(transaction.sampled).toBeTruthy(); + }); + test('set tracesSampleRate should be propergated to children', () => { + const hub = new Hub(new BrowserClient({ tracesSampleRate: 0 })); + const transaction = hub.startTransaction({ name: 'foo' }); + const child = transaction.startChild({ op: 'test' }); + expect(child.sampled).toBeFalsy(); + }); + }); + }); +}); diff --git a/packages/tracing/test/idletransaction.test.ts b/packages/tracing/test/idletransaction.test.ts new file mode 100644 index 000000000000..44c00b8e3945 --- /dev/null +++ b/packages/tracing/test/idletransaction.test.ts @@ -0,0 +1,286 @@ +import { BrowserClient } from '@sentry/browser'; +import { Hub } from '@sentry/hub'; + +import { IdleTransaction, IdleTransactionSpanRecorder } from '../src/idletransaction'; +import { Span } from '../src/span'; +import { SpanStatus } from '../src/spanstatus'; + +let hub: Hub; +beforeEach(() => { + hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); +}); + +describe('IdleTransaction', () => { + describe('onScope', () => { + it('sets the transaction on the scope on creation if onScope is true', () => { + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000, true); + transaction.initSpanRecorder(10); + + hub.configureScope(s => { + expect(s.getTransaction()).toBe(transaction); + }); + }); + + it('does not set the transaction on the scope on creation if onScope is falsey', () => { + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + transaction.initSpanRecorder(10); + + hub.configureScope(s => { + expect(s.getTransaction()).toBe(undefined); + }); + }); + + it('removes transaction from scope on finish if onScope is true', () => { + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000, true); + transaction.initSpanRecorder(10); + + transaction.finish(); + jest.runAllTimers(); + + hub.configureScope(s => { + expect(s.getTransaction()).toBe(undefined); + }); + }); + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + it('push and pops activities', () => { + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + const mockFinish = jest.spyOn(transaction, 'finish'); + transaction.initSpanRecorder(10); + expect(transaction.activities).toMatchObject({}); + + const span = transaction.startChild(); + expect(transaction.activities).toMatchObject({ [span.spanId]: true }); + + expect(mockFinish).toHaveBeenCalledTimes(0); + + span.finish(); + expect(transaction.activities).toMatchObject({}); + + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(1); + }); + + it('does not push activities if a span already has an end timestamp', () => { + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + transaction.initSpanRecorder(10); + expect(transaction.activities).toMatchObject({}); + + transaction.startChild({ startTimestamp: 1234, endTimestamp: 5678 }); + expect(transaction.activities).toMatchObject({}); + }); + + it('does not finish if there are still active activities', () => { + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + const mockFinish = jest.spyOn(transaction, 'finish'); + transaction.initSpanRecorder(10); + expect(transaction.activities).toMatchObject({}); + + const span = transaction.startChild(); + const childSpan = span.startChild(); + + expect(transaction.activities).toMatchObject({ [span.spanId]: true, [childSpan.spanId]: true }); + span.finish(); + jest.runOnlyPendingTimers(); + + expect(mockFinish).toHaveBeenCalledTimes(0); + expect(transaction.activities).toMatchObject({ [childSpan.spanId]: true }); + }); + + it('calls beforeFinish callback before finishing', () => { + const mockCallback1 = jest.fn(); + const mockCallback2 = jest.fn(); + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + transaction.initSpanRecorder(10); + transaction.registerBeforeFinishCallback(mockCallback1); + transaction.registerBeforeFinishCallback(mockCallback2); + + expect(mockCallback1).toHaveBeenCalledTimes(0); + expect(mockCallback2).toHaveBeenCalledTimes(0); + + const span = transaction.startChild(); + span.finish(); + + jest.runOnlyPendingTimers(); + expect(mockCallback1).toHaveBeenCalledTimes(1); + expect(mockCallback1).toHaveBeenLastCalledWith(transaction, expect.any(Number)); + expect(mockCallback2).toHaveBeenCalledTimes(1); + expect(mockCallback2).toHaveBeenLastCalledWith(transaction, expect.any(Number)); + }); + + it('filters spans on finish', () => { + const transaction = new IdleTransaction({ name: 'foo', startTimestamp: 1234 }, hub, 1000); + transaction.initSpanRecorder(10); + + // regular child - should be kept + const regularSpan = transaction.startChild({ startTimestamp: transaction.startTimestamp + 2 }); + + // discardedSpan - startTimestamp is too large + transaction.startChild({ startTimestamp: 645345234 }); + + // Should be cancelled - will not finish + const cancelledSpan = transaction.startChild({ startTimestamp: transaction.startTimestamp + 4 }); + + regularSpan.finish(regularSpan.startTimestamp + 4); + transaction.finish(transaction.startTimestamp + 10); + + expect(transaction.spanRecorder).toBeDefined(); + if (transaction.spanRecorder) { + const spans = transaction.spanRecorder.spans; + expect(spans).toHaveLength(3); + expect(spans[0].spanId).toBe(transaction.spanId); + + // Regular Span - should not modified + expect(spans[1].spanId).toBe(regularSpan.spanId); + expect(spans[1].endTimestamp).not.toBe(transaction.endTimestamp); + + // Cancelled Span - has endtimestamp of transaction + expect(spans[2].spanId).toBe(cancelledSpan.spanId); + expect(spans[2].status).toBe(SpanStatus.Cancelled); + expect(spans[2].endTimestamp).toBe(transaction.endTimestamp); + } + }); + + describe('heartbeat', () => { + it('does not start heartbeat if there is no span recorder', () => { + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + const mockFinish = jest.spyOn(transaction, 'finish'); + + expect(mockFinish).toHaveBeenCalledTimes(0); + + // Beat 1 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(0); + + // Beat 2 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(0); + + // Beat 3 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(0); + }); + it('finishes a transaction after 3 beats', () => { + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + const mockFinish = jest.spyOn(transaction, 'finish'); + transaction.initSpanRecorder(10); + + expect(mockFinish).toHaveBeenCalledTimes(0); + + // Beat 1 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(0); + + // Beat 2 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(0); + + // Beat 3 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(1); + }); + + it('resets after new activities are added', () => { + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + const mockFinish = jest.spyOn(transaction, 'finish'); + transaction.initSpanRecorder(10); + + expect(mockFinish).toHaveBeenCalledTimes(0); + + // Beat 1 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(0); + + const span = transaction.startChild(); // push activity + + // Beat 1 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(0); + + // Beat 2 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(0); + + transaction.startChild(); // push activity + transaction.startChild(); // push activity + + // Beat 1 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(0); + + // Beat 2 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(0); + + span.finish(); // pop activity + + // Beat 1 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(0); + + // Beat 2 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(0); + + // Beat 3 + jest.runOnlyPendingTimers(); + expect(mockFinish).toHaveBeenCalledTimes(1); + + // Heartbeat does not keep going after finish has been called + jest.runAllTimers(); + expect(mockFinish).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('IdleTransactionSpanRecorder', () => { + it('pushes and pops activities', () => { + const mockPushActivity = jest.fn(); + const mockPopActivity = jest.fn(); + const spanRecorder = new IdleTransactionSpanRecorder(mockPushActivity, mockPopActivity, undefined, 10); + expect(mockPushActivity).toHaveBeenCalledTimes(0); + expect(mockPopActivity).toHaveBeenCalledTimes(0); + + const span = new Span({ sampled: true }); + + expect(spanRecorder.spans).toHaveLength(0); + spanRecorder.add(span); + expect(spanRecorder.spans).toHaveLength(1); + + expect(mockPushActivity).toHaveBeenCalledTimes(1); + expect(mockPushActivity).toHaveBeenLastCalledWith(span.spanId); + expect(mockPopActivity).toHaveBeenCalledTimes(0); + + span.finish(); + expect(mockPushActivity).toHaveBeenCalledTimes(1); + expect(mockPopActivity).toHaveBeenCalledTimes(1); + expect(mockPushActivity).toHaveBeenLastCalledWith(span.spanId); + }); + + it('does not push activities if a span has a timestamp', () => { + const mockPushActivity = jest.fn(); + const mockPopActivity = jest.fn(); + const spanRecorder = new IdleTransactionSpanRecorder(mockPushActivity, mockPopActivity, undefined, 10); + + const span = new Span({ sampled: true, startTimestamp: 765, endTimestamp: 345 }); + spanRecorder.add(span); + + expect(mockPushActivity).toHaveBeenCalledTimes(0); + }); + + it('does not push or pop transaction spans', () => { + const mockPushActivity = jest.fn(); + const mockPopActivity = jest.fn(); + + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + const spanRecorder = new IdleTransactionSpanRecorder(mockPushActivity, mockPopActivity, transaction.spanId, 10); + + spanRecorder.add(transaction); + expect(mockPushActivity).toHaveBeenCalledTimes(0); + expect(mockPopActivity).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/tracing/test/span.test.ts b/packages/tracing/test/span.test.ts new file mode 100644 index 000000000000..5fed6cfbf9d4 --- /dev/null +++ b/packages/tracing/test/span.test.ts @@ -0,0 +1,355 @@ +import { BrowserClient } from '@sentry/browser'; +import { Hub, Scope } from '@sentry/hub'; + +import { Span, SpanStatus, TRACEPARENT_REGEXP, Transaction } from '../src'; + +describe('Span', () => { + let hub: Hub; + + beforeEach(() => { + const myScope = new Scope(); + hub = new Hub(new BrowserClient({ tracesSampleRate: 1 }), myScope); + }); + + describe('new Span', () => { + test('simple', () => { + const span = new Span({ sampled: true }); + const span2 = span.startChild(); + expect((span2 as any).parentSpanId).toBe((span as any).spanId); + expect((span2 as any).traceId).toBe((span as any).traceId); + expect((span2 as any).sampled).toBe((span as any).sampled); + }); + }); + + describe('new Transaction', () => { + test('simple', () => { + const transaction = new Transaction({ name: 'test', sampled: true }); + const span2 = transaction.startChild(); + expect((span2 as any).parentSpanId).toBe((transaction as any).spanId); + expect((span2 as any).traceId).toBe((transaction as any).traceId); + expect((span2 as any).sampled).toBe((transaction as any).sampled); + }); + + test('gets currentHub', () => { + const transaction = new Transaction({ name: 'test' }); + expect((transaction as any)._hub).toBeInstanceOf(Hub); + }); + + test('inherit span list', () => { + const transaction = new Transaction({ name: 'test', sampled: true }); + const span2 = transaction.startChild(); + const span3 = span2.startChild(); + span3.finish(); + expect(transaction.spanRecorder).toBe(span2.spanRecorder); + expect(transaction.spanRecorder).toBe(span3.spanRecorder); + }); + }); + + describe('setters', () => { + test('setTag', () => { + const span = new Span({}); + expect(span.tags.foo).toBeUndefined(); + span.setTag('foo', 'bar'); + expect(span.tags.foo).toBe('bar'); + span.setTag('foo', 'baz'); + expect(span.tags.foo).toBe('baz'); + }); + + test('setData', () => { + const span = new Span({}); + expect(span.data.foo).toBeUndefined(); + span.setData('foo', null); + expect(span.data.foo).toBe(null); + span.setData('foo', 2); + expect(span.data.foo).toBe(2); + span.setData('foo', true); + expect(span.data.foo).toBe(true); + }); + }); + + describe('status', () => { + test('setStatus', () => { + const span = new Span({}); + span.setStatus(SpanStatus.PermissionDenied); + expect((span.getTraceContext() as any).status).toBe('permission_denied'); + }); + + test('setHttpStatus', () => { + const span = new Span({}); + span.setHttpStatus(404); + expect((span.getTraceContext() as any).status).toBe('not_found'); + expect(span.tags['http.status_code']).toBe('404'); + }); + + test('isSuccess', () => { + const span = new Span({}); + expect(span.isSuccess()).toBe(false); + span.setHttpStatus(200); + expect(span.isSuccess()).toBe(true); + span.setStatus(SpanStatus.PermissionDenied); + expect(span.isSuccess()).toBe(false); + }); + }); + + describe('toTraceparent', () => { + test('simple', () => { + expect(new Span().toTraceparent()).toMatch(TRACEPARENT_REGEXP); + }); + test('with sample', () => { + expect(new Span({ sampled: true }).toTraceparent()).toMatch(TRACEPARENT_REGEXP); + }); + }); + + describe('fromTraceparent', () => { + test('no sample', () => { + const from = Span.fromTraceparent('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb') as any; + + expect(from.parentSpanId).toEqual('bbbbbbbbbbbbbbbb'); + expect(from.traceId).toEqual('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + expect(from.spanId).not.toEqual('bbbbbbbbbbbbbbbb'); + expect(from.sampled).toBeUndefined(); + }); + test('sample true', () => { + const from = Span.fromTraceparent('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-1') as any; + expect(from.sampled).toBeTruthy(); + }); + + test('sample false', () => { + const from = Span.fromTraceparent('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-0') as any; + expect(from.sampled).toBeFalsy(); + }); + + test('just sample rate', () => { + const from = Span.fromTraceparent('0') as any; + expect(from.traceId).toHaveLength(32); + expect(from.spanId).toHaveLength(16); + expect(from.sampled).toBeFalsy(); + + const from2 = Span.fromTraceparent('1') as any; + expect(from2.traceId).toHaveLength(32); + expect(from2.spanId).toHaveLength(16); + expect(from2.sampled).toBeTruthy(); + }); + + test('invalid', () => { + expect(Span.fromTraceparent('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-x')).toBeUndefined(); + }); + }); + + describe('toJSON', () => { + test('simple', () => { + const span = JSON.parse( + JSON.stringify(new Span({ traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', spanId: 'bbbbbbbbbbbbbbbb' })), + ); + expect(span).toHaveProperty('span_id', 'bbbbbbbbbbbbbbbb'); + expect(span).toHaveProperty('trace_id', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + }); + + test('with parent', () => { + const spanA = new Span({ traceId: 'a', spanId: 'b' }) as any; + const spanB = new Span({ traceId: 'c', spanId: 'd', sampled: false, parentSpanId: spanA.spanId }); + const serialized = JSON.parse(JSON.stringify(spanB)); + expect(serialized).toHaveProperty('parent_span_id', 'b'); + expect(serialized).toHaveProperty('span_id', 'd'); + expect(serialized).toHaveProperty('trace_id', 'c'); + }); + + test('should drop all `undefined` values', () => { + const spanA = new Span({ traceId: 'a', spanId: 'b' }) as any; + const spanB = new Span({ + parentSpanId: spanA.spanId, + spanId: 'd', + traceId: 'c', + }); + const serialized = spanB.toJSON(); + expect(serialized).toHaveProperty('start_timestamp'); + delete (serialized as { start_timestamp: number }).start_timestamp; + expect(serialized).toStrictEqual({ + parent_span_id: 'b', + span_id: 'd', + trace_id: 'c', + }); + }); + }); + + describe('finish', () => { + test('simple', () => { + const span = new Span({}); + expect(span.endTimestamp).toBeUndefined(); + span.finish(); + expect(span.endTimestamp).toBeGreaterThan(1); + }); + + describe('hub.startTransaction', () => { + test('finish a transaction', () => { + const spy = jest.spyOn(hub as any, 'captureEvent') as any; + const transaction = hub.startTransaction({ name: 'test' }); + transaction.finish(); + expect(spy).toHaveBeenCalled(); + expect(spy.mock.calls[0][0].spans).toHaveLength(0); + expect(spy.mock.calls[0][0].timestamp).toBeTruthy(); + expect(spy.mock.calls[0][0].start_timestamp).toBeTruthy(); + expect(spy.mock.calls[0][0].contexts.trace).toEqual(transaction.getTraceContext()); + }); + + test('finish a transaction + child span', () => { + const spy = jest.spyOn(hub as any, 'captureEvent') as any; + const transaction = hub.startTransaction({ name: 'test' }); + const childSpan = transaction.startChild(); + childSpan.finish(); + transaction.finish(); + expect(spy).toHaveBeenCalled(); + expect(spy.mock.calls[0][0].spans).toHaveLength(1); + expect(spy.mock.calls[0][0].contexts.trace).toEqual(transaction.getTraceContext()); + }); + + test("finish a child span shouldn't trigger captureEvent", () => { + const spy = jest.spyOn(hub as any, 'captureEvent') as any; + const transaction = hub.startTransaction({ name: 'test' }); + const childSpan = transaction.startChild(); + childSpan.finish(); + expect(spy).not.toHaveBeenCalled(); + }); + + test("finish a span with another one on the scope shouldn't override contexts.trace", () => { + const spy = jest.spyOn(hub as any, 'captureEvent') as any; + const transaction = hub.startTransaction({ name: 'test' }); + const childSpanOne = transaction.startChild(); + childSpanOne.finish(); + + hub.configureScope(scope => { + scope.setSpan(childSpanOne); + }); + + const spanTwo = transaction.startChild(); + spanTwo.finish(); + transaction.finish(); + + expect(spy).toHaveBeenCalled(); + expect(spy.mock.calls[0][0].spans).toHaveLength(2); + expect(spy.mock.calls[0][0].contexts.trace).toEqual(transaction.getTraceContext()); + }); + + test('span child limit', () => { + const _hub = new Hub( + new BrowserClient({ + _experiments: { maxSpans: 3 }, + tracesSampleRate: 1, + }), + ); + const spy = jest.spyOn(_hub as any, 'captureEvent') as any; + const transaction = _hub.startTransaction({ name: 'test' }); + for (let i = 0; i < 10; i++) { + const child = transaction.startChild(); + child.finish(); + } + transaction.finish(); + expect(spy.mock.calls[0][0].spans).toHaveLength(3); + }); + + test('if we sampled the transaction we do not want any children', () => { + const _hub = new Hub( + new BrowserClient({ + tracesSampleRate: 0, + }), + ); + const spy = jest.spyOn(_hub as any, 'captureEvent') as any; + const transaction = _hub.startTransaction({ name: 'test' }); + for (let i = 0; i < 10; i++) { + const child = transaction.startChild(); + child.finish(); + } + transaction.finish(); + expect((transaction as any).spanRecorder).toBeUndefined(); + expect(spy).not.toHaveBeenCalled(); + }); + + test('mixing hub.startSpan(transaction) + span.startChild + maxSpans', () => { + const _hub = new Hub( + new BrowserClient({ + _experiments: { maxSpans: 2 }, + tracesSampleRate: 1, + }), + ); + const spy = jest.spyOn(_hub as any, 'captureEvent') as any; + + const transaction = _hub.startTransaction({ name: 'test' }); + const childSpanOne = transaction.startChild({ op: '1' }); + childSpanOne.finish(); + + _hub.configureScope(scope => { + scope.setSpan(transaction); + }); + + const spanTwo = transaction.startChild({ op: '2' }); + spanTwo.finish(); + + const spanThree = transaction.startChild({ op: '3' }); + spanThree.finish(); + + transaction.finish(); + + expect(spy).toHaveBeenCalled(); + expect(spy.mock.calls[0][0].spans).toHaveLength(2); + }); + + test('tree structure of spans should be correct when mixing it with span on scope', () => { + const spy = jest.spyOn(hub as any, 'captureEvent') as any; + + const transaction = hub.startTransaction({ name: 'test' }); + const childSpanOne = transaction.startChild(); + + const childSpanTwo = childSpanOne.startChild(); + childSpanTwo.finish(); + + childSpanOne.finish(); + + hub.configureScope(scope => { + scope.setSpan(transaction); + }); + + const spanTwo = transaction.startChild({}); + spanTwo.finish(); + transaction.finish(); + + expect(spy).toHaveBeenCalled(); + expect(spy.mock.calls[0][0].spans).toHaveLength(3); + expect(spy.mock.calls[0][0].contexts.trace).toEqual(transaction.getTraceContext()); + expect(childSpanOne.toJSON().parent_span_id).toEqual(transaction.toJSON().span_id); + expect(childSpanTwo.toJSON().parent_span_id).toEqual(childSpanOne.toJSON().span_id); + expect(spanTwo.toJSON().parent_span_id).toEqual(transaction.toJSON().span_id); + }); + }); + }); + + describe('getTraceContext', () => { + test('should have status attribute undefined if no status tag is available', () => { + const span = new Span({}); + const context = span.getTraceContext(); + expect((context as any).status).toBeUndefined(); + }); + + test('should have success status extracted from tags', () => { + const span = new Span({}); + span.setStatus(SpanStatus.Ok); + const context = span.getTraceContext(); + expect((context as any).status).toBe('ok'); + }); + + test('should have failure status extracted from tags', () => { + const span = new Span({}); + span.setStatus(SpanStatus.ResourceExhausted); + const context = span.getTraceContext(); + expect((context as any).status).toBe('resource_exhausted'); + }); + + test('should drop all `undefined` values', () => { + const spanB = new Span({ spanId: 'd', traceId: 'c' }); + const context = spanB.getTraceContext(); + expect(context).toStrictEqual({ + span_id: 'd', + trace_id: 'c', + }); + }); + }); +}); diff --git a/packages/tracing/test/tslint.json b/packages/tracing/test/tslint.json new file mode 100644 index 000000000000..0827b5c40259 --- /dev/null +++ b/packages/tracing/test/tslint.json @@ -0,0 +1,6 @@ +{ + "extends": ["../tslint.json"], + "rules": { + "no-unsafe-any": false + } +} diff --git a/packages/tracing/tsconfig.build.json b/packages/tracing/tsconfig.build.json new file mode 100644 index 000000000000..a263a085c70a --- /dev/null +++ b/packages/tracing/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/tracing/tsconfig.esm.json b/packages/tracing/tsconfig.esm.json new file mode 100644 index 000000000000..33a3842217d4 --- /dev/null +++ b/packages/tracing/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.esm.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "esm" + }, + "include": ["src/**/*"] +} diff --git a/packages/tracing/tsconfig.json b/packages/tracing/tsconfig.json new file mode 100644 index 000000000000..55b38e135ae2 --- /dev/null +++ b/packages/tracing/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.build.json", + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["dist"], + "compilerOptions": { + "rootDir": ".", + "types": ["node", "jest"] + } +} diff --git a/packages/tracing/tslint.json b/packages/tracing/tslint.json new file mode 100644 index 000000000000..3016a27a85cc --- /dev/null +++ b/packages/tracing/tslint.json @@ -0,0 +1,3 @@ +{ + "extends": "@sentry/typescript/tslint" +} diff --git a/scripts/pack-and-upload.sh b/scripts/pack-and-upload.sh index e6b3f3ec6b57..03b4ac61b645 100755 --- a/scripts/pack-and-upload.sh +++ b/scripts/pack-and-upload.sh @@ -19,4 +19,5 @@ zeus upload -t "application/javascript" ./packages/browser/build/bundle* zeus upload -t "application/javascript" ./packages/integrations/build/* # Upload "apm" bundles zeus upload -t "application/javascript" ./packages/apm/build/* - +# Upload "tracing" bundles +zeus upload -t "application/javascript" ./packages/tracing/build/* diff --git a/scripts/test.sh b/scripts/test.sh index 9752244cdbb1..3eefeef6809f 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -8,7 +8,11 @@ if [[ "$(cut -d. -f1 <<< "$TRAVIS_NODE_VERSION")" -le 6 ]]; then yarn install --ignore-engines yarn build nvm use 6 - yarn test --ignore="@sentry/browser" --ignore="@sentry/integrations" --ignore="@sentry/react" # latest version of karma doesn't run on node 6 + yarn test --ignore="@sentry/browser" --ignore="@sentry/integrations" --ignore="@sentry/react" --ignore="@sentry/tracing" # latest version of karma doesn't run on node 6 +elif [[ "$(cut -d. -f1 <<< "$TRAVIS_NODE_VERSION")" -le 8 ]]; then + yarn install + yarn build + yarn test --ignore="@sentry/tracing" else yarn install yarn build diff --git a/yarn.lock b/yarn.lock index b638089c590c..a09c5d8d6573 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1397,6 +1397,15 @@ dependencies: "@types/jest-diff" "*" +"@types/jsdom@^16.2.3": + version "16.2.3" + resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-16.2.3.tgz#c6feadfe0836389b27f9c911cde82cd32e91c537" + integrity sha512-BREatezSn74rmLIDksuqGNFUTi9HNAWWQXYpFBFLK9U6wlMCO4M0QCa8CMpDsZQuqxSO9XifVLT5Q1P0vgKLqw== + dependencies: + "@types/node" "*" + "@types/parse5" "*" + "@types/tough-cookie" "*" + "@types/lodash@^4.14.110": version "4.14.121" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.121.tgz#9327e20d49b95fc2bf983fc2f045b2c6effc80b9" @@ -1444,6 +1453,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-11.13.7.tgz#85dbb71c510442d00c0631f99dae957ce44fd104" integrity sha512-suFHr6hcA9mp8vFrZTgrmqW2ZU3mbWsryQtQlY/QvwTISCw7nw/j+bCQPPohqmskhmqa5wLNuMHTTsc+xf1MQg== +"@types/parse5@*": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.3.tgz#e7b5aebbac150f8b5fdd4a46e7f0bd8e65e19109" + integrity sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw== + "@types/prop-types@*": version "15.7.3" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" @@ -1833,32 +1847,11 @@ after@0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f" -agent-base@4, agent-base@^4.3.0: - version "4.3.0" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" - integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== - dependencies: - es6-promisify "^5.0.0" - -agent-base@5: +agent-base@4, agent-base@5, agent-base@6, agent-base@^4.3.0, agent-base@~4.2.0: version "5.1.1" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-5.1.1.tgz#e8fb3f242959db44d63be665db7a8e739537a32c" integrity sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g== -agent-base@6: - version "6.0.0" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.0.tgz#5d0101f19bbfaed39980b22ae866de153b93f09a" - integrity sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw== - dependencies: - debug "4" - -agent-base@~4.2.0: - version "4.2.1" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9" - integrity sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg== - dependencies: - es6-promisify "^5.0.0" - agentkeepalive@^3.4.1: version "3.5.2" resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-3.5.2.tgz#a113924dd3fa24a0bc3b78108c450c2abee00f67" @@ -4619,18 +4612,6 @@ es-to-primitive@^1.1.1, es-to-primitive@^1.2.0: is-date-object "^1.0.1" is-symbol "^1.0.2" -es6-promise@^4.0.3: - version "4.2.8" - resolved "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" - integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== - -es6-promisify@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" - integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM= - dependencies: - es6-promise "^4.0.3" - escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"