From ffabb2647190762c19b97ef48a3a03e0b4ef9a8c Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 3 Jul 2020 13:00:46 -0400 Subject: [PATCH 01/18] feat: Add @sentry/tracing --- packages/tracing/apm/.npmignore | 4 + packages/tracing/apm/README.md | 22 + packages/tracing/apm/package.json | 84 ++ packages/tracing/apm/rollup.config.js | 93 ++ packages/tracing/apm/src/hubextensions.ts | 103 ++ packages/tracing/apm/src/index.bundle.ts | 79 ++ packages/tracing/apm/src/index.ts | 11 + .../tracing/apm/src/integrations/express.ts | 175 +++ .../tracing/apm/src/integrations/index.ts | 2 + .../tracing/apm/src/integrations/tracing.ts | 1058 +++++++++++++++++ .../tracing/apm/src/integrations/types.ts | 109 ++ packages/tracing/apm/src/span.ts | 296 +++++ packages/tracing/apm/src/spanstatus.ts | 87 ++ packages/tracing/apm/src/transaction.ts | 132 ++ packages/tracing/apm/test/hub.test.ts | 107 ++ packages/tracing/apm/test/span.test.ts | 399 +++++++ packages/tracing/apm/test/tslint.json | 6 + packages/tracing/apm/tsconfig.build.json | 8 + packages/tracing/apm/tsconfig.esm.json | 8 + packages/tracing/apm/tsconfig.json | 9 + packages/tracing/apm/tslint.json | 3 + 21 files changed, 2795 insertions(+) create mode 100644 packages/tracing/apm/.npmignore create mode 100644 packages/tracing/apm/README.md create mode 100644 packages/tracing/apm/package.json create mode 100644 packages/tracing/apm/rollup.config.js create mode 100644 packages/tracing/apm/src/hubextensions.ts create mode 100644 packages/tracing/apm/src/index.bundle.ts create mode 100644 packages/tracing/apm/src/index.ts create mode 100644 packages/tracing/apm/src/integrations/express.ts create mode 100644 packages/tracing/apm/src/integrations/index.ts create mode 100644 packages/tracing/apm/src/integrations/tracing.ts create mode 100644 packages/tracing/apm/src/integrations/types.ts create mode 100644 packages/tracing/apm/src/span.ts create mode 100644 packages/tracing/apm/src/spanstatus.ts create mode 100644 packages/tracing/apm/src/transaction.ts create mode 100644 packages/tracing/apm/test/hub.test.ts create mode 100644 packages/tracing/apm/test/span.test.ts create mode 100644 packages/tracing/apm/test/tslint.json create mode 100644 packages/tracing/apm/tsconfig.build.json create mode 100644 packages/tracing/apm/tsconfig.esm.json create mode 100644 packages/tracing/apm/tsconfig.json create mode 100644 packages/tracing/apm/tslint.json diff --git a/packages/tracing/apm/.npmignore b/packages/tracing/apm/.npmignore new file mode 100644 index 000000000000..14e80551ae7c --- /dev/null +++ b/packages/tracing/apm/.npmignore @@ -0,0 +1,4 @@ +* +!/dist/**/* +!/esm/**/* +*.tsbuildinfo diff --git a/packages/tracing/apm/README.md b/packages/tracing/apm/README.md new file mode 100644 index 000000000000..05b45f2195f4 --- /dev/null +++ b/packages/tracing/apm/README.md @@ -0,0 +1,22 @@ +

+ + + +
+

+ +# 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. diff --git a/packages/tracing/apm/package.json b/packages/tracing/apm/package.json new file mode 100644 index 000000000000..821d237b0a7f --- /dev/null +++ b/packages/tracing/apm/package.json @@ -0,0 +1,84 @@ +{ + "name": "@sentry/tracing", + "version": "5.19.0", + "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": "BSD-3-Clause", + "engines": { + "node": ">=6" + }, + "main": "dist/index.js", + "module": "esm/index.js", + "types": "dist/index.d.ts", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@sentry/browser": "5.19.0", + "@sentry/hub": "5.19.0", + "@sentry/minimal": "5.19.0", + "@sentry/types": "5.19.0", + "@sentry/utils": "5.19.0", + "tslib": "^1.9.3" + }, + "devDependencies": { + "@types/express": "^4.17.1", + "jest": "^24.7.1", + "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": false +} diff --git a/packages/tracing/apm/rollup.config.js b/packages/tracing/apm/rollup.config.js new file mode 100644 index 000000000000..ca17a2f999bd --- /dev/null +++ b/packages/tracing/apm/rollup.config.js @@ -0,0 +1,93 @@ +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'], +}; + +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/apm/src/hubextensions.ts b/packages/tracing/apm/src/hubextensions.ts new file mode 100644 index 000000000000..8480ffbf1bde --- /dev/null +++ b/packages/tracing/apm/src/hubextensions.ts @@ -0,0 +1,103 @@ +import { getMainCarrier, Hub } from '@sentry/hub'; +import { SpanContext, TransactionContext } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +import { Span } from './span'; +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 {}; +} + +/** + * {@see Hub.startTransaction} + */ +function startTransaction(this: Hub, context: TransactionContext): Transaction { + const transaction = new Transaction(context, this); + + const client = this.getClient(); + // Roll the dice for sampling transaction, all child spans inherit the sampling decision. + 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.startSpan} + */ +function startSpan(this: Hub, context: SpanContext): Transaction | Span { + /** + * @deprecated + * TODO: consider removing this in a future release. + * + * This is for backwards compatibility with releases before startTransaction + * existed, to allow for a smoother transition. + */ + { + // The `TransactionContext.name` field used to be called `transaction`. + const transactionContext = context as Partial; + if (transactionContext.transaction !== undefined) { + transactionContext.name = transactionContext.transaction; + } + // Check for not undefined since we defined it's ok to start a transaction + // with an empty name. + if (transactionContext.name !== undefined) { + logger.warn('Deprecated: Use startTransaction to start transactions and Transaction.startChild to start spans.'); + return this.startTransaction(transactionContext as TransactionContext); + } + } + + const scope = this.getScope(); + if (scope) { + // If there is a Span on the Scope we start a child and return that instead + const parentSpan = scope.getSpan(); + if (parentSpan) { + return parentSpan.startChild(context); + } + } + + // Otherwise we return a new Span + return new Span(context); +} + +/** + * 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.startSpan) { + carrier.__SENTRY__.extensions.startSpan = startSpan; + } + if (!carrier.__SENTRY__.extensions.traceHeaders) { + carrier.__SENTRY__.extensions.traceHeaders = traceHeaders; + } + } +} diff --git a/packages/tracing/apm/src/index.bundle.ts b/packages/tracing/apm/src/index.bundle.ts new file mode 100644 index 000000000000..0d39253e7980 --- /dev/null +++ b/packages/tracing/apm/src/index.bundle.ts @@ -0,0 +1,79 @@ +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 { addExtensionMethods } from './hubextensions'; +import * as ApmIntegrations from './integrations'; + +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, + Tracing: ApmIntegrations.Tracing, +}; + +export { INTEGRATIONS as Integrations }; + +// We are patching the global object with our hub extension methods +addExtensionMethods(); diff --git a/packages/tracing/apm/src/index.ts b/packages/tracing/apm/src/index.ts new file mode 100644 index 000000000000..9f59e3515f40 --- /dev/null +++ b/packages/tracing/apm/src/index.ts @@ -0,0 +1,11 @@ +import { addExtensionMethods } from './hubextensions'; +import * as ApmIntegrations from './integrations'; + +export { ApmIntegrations as 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/apm/src/integrations/express.ts b/packages/tracing/apm/src/integrations/express.ts new file mode 100644 index 000000000000..113d41423dcd --- /dev/null +++ b/packages/tracing/apm/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/apm/src/integrations/index.ts b/packages/tracing/apm/src/integrations/index.ts new file mode 100644 index 000000000000..8833c8cd301c --- /dev/null +++ b/packages/tracing/apm/src/integrations/index.ts @@ -0,0 +1,2 @@ +export { Express } from './express'; +export { Tracing } from './tracing'; diff --git a/packages/tracing/apm/src/integrations/tracing.ts b/packages/tracing/apm/src/integrations/tracing.ts new file mode 100644 index 000000000000..59451647569f --- /dev/null +++ b/packages/tracing/apm/src/integrations/tracing.ts @@ -0,0 +1,1058 @@ +// tslint:disable: max-file-line-count +import { Hub } from '@sentry/hub'; +import { Event, EventProcessor, Integration, Severity, Span, SpanContext, TransactionContext } from '@sentry/types'; +import { + addInstrumentationHandler, + getGlobalObject, + isInstanceOf, + isMatchingPattern, + logger, + safeJoin, + supportsNativeFetch, + timestampWithMs, +} from '@sentry/utils'; + +import { Span as SpanClass } from '../span'; +import { SpanStatus } from '../spanstatus'; +import { Transaction } from '../transaction'; + +import { Location } from './types'; + +/** + * Options for Tracing integration + */ +export interface TracingOptions { + /** + * 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', /^\//] + */ + 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; + /** + * 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: 500 + */ + idleTimeout: number; + + /** + * Flag to enable/disable creation of `navigation` transaction on history changes. Useful for react applications with + * a router. + * + * Default: true + */ + startTransactionOnLocationChange: boolean; + + /** + * Flag to enable/disable creation of `pageload` transaction on first pageload. + * + * Default: true + */ + startTransactionOnPageLoad: boolean; + + /** + * 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. Background transaction can mess up your + * statistics in non deterministic ways that's why we by default recommend leaving this opition enabled. + * + * Default: true + */ + markBackgroundTransactions: boolean; + + /** + * This is only if you want to debug in prod. + * writeAsBreadcrumbs: Instead of having console.log statements we log messages to breadcrumbs + * so you can investigate whats happening in production with your users to figure why things might not appear the + * way you expect them to. + * + * spanDebugTimingInfo: Add timing info to spans at the point where we create them to figure out browser timing + * issues. + * + * You shouldn't care about this. + * + * Default: { + * writeAsBreadcrumbs: false; + * spanDebugTimingInfo: false; + * } + */ + debug: { + writeAsBreadcrumbs: boolean; + spanDebugTimingInfo: boolean; + }; + + /** + * beforeNavigate is called before a pageload/navigation transaction is created and allows for users + * to set a custom navigation transaction name based on the current `window.location`. Defaults to returning + * `window.location.pathname`. + * + * @param location the current location before navigation span is created + */ + beforeNavigate(location: Location): string; +} + +/** JSDoc */ +interface Activity { + name: string; + span?: Span; +} + +const global = getGlobalObject(); +const defaultTracingOrigins = ['localhost', /^\//]; + +/** + * Tracing Integration + */ +export class Tracing implements Integration { + /** + * @inheritDoc + */ + public name: string = Tracing.id; + + /** + * @inheritDoc + */ + public static id: string = 'Tracing'; + + /** JSDoc */ + public static options: TracingOptions; + + /** + * Returns current hub. + */ + private static _getCurrentHub?: () => Hub; + + private static _activeTransaction?: Transaction; + + private static _currentIndex: number = 1; + + public static _activities: { [key: number]: Activity } = {}; + + private readonly _emitOptionsWarning: boolean = false; + + private static _performanceCursor: number = 0; + + private static _heartbeatTimer: number = 0; + + private static _prevHeartbeatString: string | undefined; + + private static _heartbeatCounter: number = 0; + + /** Holds the latest LargestContentfulPaint value (it changes during page load). */ + private static _lcp?: { [key: string]: any }; + + /** Force any pending LargestContentfulPaint records to be dispatched. */ + private static _forceLCP = () => { + /* No-op, replaced later if LCP API is available. */ + }; + + /** + * Constructor for Tracing + * + * @param _options TracingOptions + */ + public constructor(_options?: Partial) { + if (global.performance) { + if (global.performance.mark) { + global.performance.mark('sentry-tracing-init'); + } + Tracing._trackLCP(); + } + const defaults = { + beforeNavigate(location: Location): string { + return location.pathname; + }, + debug: { + spanDebugTimingInfo: false, + writeAsBreadcrumbs: false, + }, + idleTimeout: 500, + markBackgroundTransactions: true, + maxTransactionDuration: 600, + shouldCreateSpanForRequest(url: string): boolean { + const origins = (_options && _options.tracingOrigins) || defaultTracingOrigins; + return ( + origins.some((origin: string | RegExp) => isMatchingPattern(url, origin)) && + !isMatchingPattern(url, 'sentry_key') + ); + }, + startTransactionOnLocationChange: true, + startTransactionOnPageLoad: true, + traceFetch: true, + traceXHR: true, + tracingOrigins: defaultTracingOrigins, + }; + // NOTE: Logger doesn't work in contructors, as it's initialized after integrations instances + if (!_options || !Array.isArray(_options.tracingOrigins) || _options.tracingOrigins.length === 0) { + this._emitOptionsWarning = true; + } + Tracing.options = { + ...defaults, + ..._options, + }; + } + + /** + * @inheritDoc + */ + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + Tracing._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: ${defaultTracingOrigins}`); + } + + // Starting pageload transaction + if (global.location && Tracing.options && Tracing.options.startTransactionOnPageLoad) { + Tracing.startIdleTransaction({ + name: Tracing.options.beforeNavigate(window.location), + op: 'pageload', + }); + } + + this._setupXHRTracing(); + + this._setupFetchTracing(); + + this._setupHistory(); + + this._setupErrorHandling(); + + this._setupBackgroundTabDetection(); + + Tracing._pingHeartbeat(); + + // This EventProcessor makes sure that the transaction is not longer than maxTransactionDuration + addGlobalEventProcessor((event: Event) => { + const self = getCurrentHub().getIntegration(Tracing); + if (!self) { + return event; + } + + const isOutdatedTransaction = + event.timestamp && + event.start_timestamp && + (event.timestamp - event.start_timestamp > Tracing.options.maxTransactionDuration || + event.timestamp - event.start_timestamp < 0); + + if (Tracing.options.maxTransactionDuration !== 0 && event.type === 'transaction' && isOutdatedTransaction) { + Tracing._log(`[Tracing] Transaction: ${SpanStatus.Cancelled} since it maxed out maxTransactionDuration`); + if (event.contexts && event.contexts.trace) { + event.contexts.trace = { + ...event.contexts.trace, + status: SpanStatus.DeadlineExceeded, + }; + event.tags = { + ...event.tags, + maxTransactionDurationExceeded: 'true', + }; + } + } + + return event; + }); + } + + /** + * Returns a new Transaction either continued from sentry-trace meta or a new one + */ + private static _getNewTransaction(hub: Hub, transactionContext: TransactionContext): Transaction { + let traceId; + let parentSpanId; + let sampled; + + const header = Tracing._getMeta('sentry-trace'); + if (header) { + const span = SpanClass.fromTraceparent(header); + if (span) { + traceId = span.traceId; + parentSpanId = span.parentSpanId; + sampled = span.sampled; + Tracing._log( + `[Tracing] found 'sentry-meta' '' continuing trace with: trace_id: ${traceId} span_id: ${parentSpanId}`, + ); + } + } + + return hub.startTransaction({ + parentSpanId, + sampled, + traceId, + trimEnd: true, + ...transactionContext, + }) as Transaction; + } + + /** + * Returns the value of a meta tag + */ + private static _getMeta(metaName: string): string | null { + const el = document.querySelector(`meta[name=${metaName}]`); + return el ? el.getAttribute('content') : null; + } + + /** + * Pings the heartbeat + */ + private static _pingHeartbeat(): void { + Tracing._heartbeatTimer = (setTimeout(() => { + Tracing._beat(); + }, 5000) as any) as number; + } + + /** + * Checks when entries of Tracing._activities are not changing for 3 beats. If this occurs we finish the transaction + * + */ + private static _beat(): void { + clearTimeout(Tracing._heartbeatTimer); + const keys = Object.keys(Tracing._activities); + if (keys.length) { + const heartbeatString = keys.reduce((prev: string, current: string) => prev + current); + if (heartbeatString === Tracing._prevHeartbeatString) { + Tracing._heartbeatCounter++; + } else { + Tracing._heartbeatCounter = 0; + } + if (Tracing._heartbeatCounter >= 3) { + if (Tracing._activeTransaction) { + Tracing._log( + `[Tracing] Transaction: ${ + SpanStatus.Cancelled + } -> Heartbeat safeguard kicked in since content hasn't changed for 3 beats`, + ); + Tracing._activeTransaction.setStatus(SpanStatus.DeadlineExceeded); + Tracing._activeTransaction.setTag('heartbeat', 'failed'); + Tracing.finishIdleTransaction(timestampWithMs()); + } + } + Tracing._prevHeartbeatString = heartbeatString; + } + Tracing._pingHeartbeat(); + } + + /** + * Discards active transactions if tab moves to background + */ + private _setupBackgroundTabDetection(): void { + if (Tracing.options && Tracing.options.markBackgroundTransactions && global.document) { + document.addEventListener('visibilitychange', () => { + if (document.hidden && Tracing._activeTransaction) { + Tracing._log(`[Tracing] Transaction: ${SpanStatus.Cancelled} -> since tab moved to the background`); + Tracing._activeTransaction.setStatus(SpanStatus.Cancelled); + Tracing._activeTransaction.setTag('visibilitychange', 'document.hidden'); + Tracing.finishIdleTransaction(timestampWithMs()); + } + }); + } + } + + /** + * Unsets the current active transaction + activities + */ + private static _resetActiveTransaction(): void { + // We want to clean up after ourselves + // If there is still the active transaction on the scope we remove it + const _getCurrentHub = Tracing._getCurrentHub; + if (_getCurrentHub) { + const hub = _getCurrentHub(); + const scope = hub.getScope(); + if (scope) { + if (scope.getSpan() === Tracing._activeTransaction) { + scope.setSpan(undefined); + } + } + } + // ------------------------------------------------------------------ + Tracing._activeTransaction = undefined; + Tracing._activities = {}; + } + + /** + * Registers to History API to detect navigation changes + */ + private _setupHistory(): void { + if (Tracing.options.startTransactionOnLocationChange) { + addInstrumentationHandler({ + callback: historyCallback, + type: 'history', + }); + } + } + + /** + * Attaches to fetch to add sentry-trace header + creating spans + */ + private _setupFetchTracing(): void { + if (Tracing.options.traceFetch && supportsNativeFetch()) { + addInstrumentationHandler({ + callback: fetchCallback, + type: 'fetch', + }); + } + } + + /** + * Attaches to XHR to add sentry-trace header + creating spans + */ + private _setupXHRTracing(): void { + if (Tracing.options.traceXHR) { + addInstrumentationHandler({ + callback: xhrCallback, + type: 'xhr', + }); + } + } + + /** + * Configures global error listeners + */ + private _setupErrorHandling(): void { + // tslint:disable-next-line: completed-docs + function errorCallback(): void { + if (Tracing._activeTransaction) { + /** + * If an error or unhandled promise occurs, we mark the active transaction as failed + */ + Tracing._log(`[Tracing] Transaction: ${SpanStatus.InternalError} -> Global error occured`); + Tracing._activeTransaction.setStatus(SpanStatus.InternalError); + } + } + addInstrumentationHandler({ + callback: errorCallback, + type: 'error', + }); + addInstrumentationHandler({ + callback: errorCallback, + type: 'unhandledrejection', + }); + } + + /** + * Uses logger.log to log things in the SDK or as breadcrumbs if defined in options + */ + private static _log(...args: any[]): void { + if (Tracing.options && Tracing.options.debug && Tracing.options.debug.writeAsBreadcrumbs) { + const _getCurrentHub = Tracing._getCurrentHub; + if (_getCurrentHub) { + _getCurrentHub().addBreadcrumb({ + category: 'tracing', + level: Severity.Debug, + message: safeJoin(args, ' '), + type: 'debug', + }); + } + } + logger.log(...args); + } + + /** + * Starts a Transaction waiting for activity idle to finish + */ + public static startIdleTransaction(transactionContext: TransactionContext): Transaction | undefined { + Tracing._log('[Tracing] startIdleTransaction'); + + const _getCurrentHub = Tracing._getCurrentHub; + if (!_getCurrentHub) { + return undefined; + } + + const hub = _getCurrentHub(); + if (!hub) { + return undefined; + } + + Tracing._activeTransaction = Tracing._getNewTransaction(hub, transactionContext); + + // We set the transaction here on the scope so error events pick up the trace context and attach it to the error + hub.configureScope(scope => scope.setSpan(Tracing._activeTransaction)); + + // The reason we do this here is because of cached responses + // If we start and transaction without an activity it would never finish since there is no activity + const id = Tracing.pushActivity('idleTransactionStarted'); + setTimeout(() => { + Tracing.popActivity(id); + }, (Tracing.options && Tracing.options.idleTimeout) || 100); + + return Tracing._activeTransaction; + } + + /** + * Finishes the current active transaction + */ + public static finishIdleTransaction(endTimestamp: number): void { + const active = Tracing._activeTransaction; + if (active) { + Tracing._log('[Tracing] finishing IdleTransaction', new Date(endTimestamp * 1000).toISOString()); + Tracing._addPerformanceEntries(active); + + if (active.spanRecorder) { + active.spanRecorder.spans = active.spanRecorder.spans.filter((span: Span) => { + // If we are dealing with the transaction itself, we just return it + if (span.spanId === active.spanId) { + return span; + } + + // 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); + Tracing._log('[Tracing] cancelling span since transaction ended early', JSON.stringify(span, undefined, 2)); + } + + // We remove all spans that happend after the end of the transaction + // This is here to prevent super long transactions and timing issues + const keepSpan = span.startTimestamp < endTimestamp; + if (!keepSpan) { + Tracing._log( + '[Tracing] discarding Span since it happened after Transaction was finished', + JSON.stringify(span, undefined, 2), + ); + } + return keepSpan; + }); + } + + Tracing._log('[Tracing] flushing IdleTransaction'); + active.finish(); + Tracing._resetActiveTransaction(); + } else { + Tracing._log('[Tracing] No active IdleTransaction'); + } + } + + /** + * This uses `performance.getEntries()` to add additional spans to the active transaction. + * Also, we update our timings since we consider the timings in this API to be more correct than our manual + * measurements. + * + * @param transactionSpan The transaction span + */ + private static _addPerformanceEntries(transactionSpan: SpanClass): void { + if (!global.performance || !global.performance.getEntries) { + // Gatekeeper if performance API not available + return; + } + + Tracing._log('[Tracing] Adding & adjusting spans using Performance API'); + + // FIXME: depending on the 'op' directly is brittle. + if (transactionSpan.op === 'pageload') { + // Force any pending records to be dispatched. + Tracing._forceLCP(); + if (Tracing._lcp) { + // Set the last observed LCP score. + transactionSpan.setData('_sentry_web_vitals', { LCP: Tracing._lcp }); + } + } + + const timeOrigin = Tracing._msToSec(performance.timeOrigin); + + // tslint:disable-next-line: completed-docs + function addPerformanceNavigationTiming(parent: Span, entry: { [key: string]: number }, event: string): void { + parent.startChild({ + description: event, + endTimestamp: timeOrigin + Tracing._msToSec(entry[`${event}End`]), + op: 'browser', + startTimestamp: timeOrigin + Tracing._msToSec(entry[`${event}Start`]), + }); + } + + // tslint:disable-next-line: completed-docs + function addRequest(parent: Span, entry: { [key: string]: number }): void { + parent.startChild({ + description: 'request', + endTimestamp: timeOrigin + Tracing._msToSec(entry.responseEnd), + op: 'browser', + startTimestamp: timeOrigin + Tracing._msToSec(entry.requestStart), + }); + + parent.startChild({ + description: 'response', + endTimestamp: timeOrigin + Tracing._msToSec(entry.responseEnd), + op: 'browser', + startTimestamp: timeOrigin + Tracing._msToSec(entry.responseStart), + }); + } + + 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 entryScriptStartEndTime: number | undefined; + let tracingInitMarkStartTime: number | undefined; + + // tslint:disable: no-unsafe-any + performance + .getEntries() + .slice(Tracing._performanceCursor) + .forEach((entry: any) => { + const startTime = Tracing._msToSec(entry.startTime as number); + const duration = Tracing._msToSec(entry.duration as number); + + if (transactionSpan.op === 'navigation' && timeOrigin + startTime < transactionSpan.startTimestamp) { + return; + } + + switch (entry.entryType) { + case 'navigation': + addPerformanceNavigationTiming(transactionSpan, entry, 'unloadEvent'); + addPerformanceNavigationTiming(transactionSpan, entry, 'domContentLoadedEvent'); + addPerformanceNavigationTiming(transactionSpan, entry, 'loadEvent'); + addPerformanceNavigationTiming(transactionSpan, entry, 'connect'); + addPerformanceNavigationTiming(transactionSpan, entry, 'domainLookup'); + addRequest(transactionSpan, entry); + break; + case 'mark': + case 'paint': + case 'measure': + const mark = transactionSpan.startChild({ + description: entry.name, + op: entry.entryType, + }); + mark.startTimestamp = timeOrigin + startTime; + mark.endTimestamp = mark.startTimestamp + duration; + if (tracingInitMarkStartTime === undefined && entry.name === 'sentry-tracing-init') { + tracingInitMarkStartTime = mark.startTimestamp; + } + break; + case 'resource': + const resourceName = entry.name.replace(window.location.origin, ''); + if (entry.initiatorType === 'xmlhttprequest' || entry.initiatorType === 'fetch') { + // We need to update existing spans with new timing info + if (transactionSpan.spanRecorder) { + transactionSpan.spanRecorder.spans.map((finishedSpan: Span) => { + if (finishedSpan.description && finishedSpan.description.indexOf(resourceName) !== -1) { + finishedSpan.startTimestamp = timeOrigin + startTime; + finishedSpan.endTimestamp = finishedSpan.startTimestamp + duration; + } + }); + } + } else { + const resource = transactionSpan.startChild({ + description: `${entry.initiatorType} ${resourceName}`, + op: `resource`, + }); + resource.startTimestamp = timeOrigin + startTime; + resource.endTimestamp = resource.startTimestamp + duration; + // We remember the entry script end time to calculate the difference to the first init mark + if (entryScriptStartEndTime === undefined && (entryScriptSrc || '').indexOf(resourceName) > -1) { + entryScriptStartEndTime = resource.endTimestamp; + } + } + break; + default: + // Ignore other entry types. + } + }); + + if (entryScriptStartEndTime !== undefined && tracingInitMarkStartTime !== undefined) { + transactionSpan.startChild({ + description: 'evaluation', + endTimestamp: tracingInitMarkStartTime, + op: `script`, + startTimestamp: entryScriptStartEndTime, + }); + } + + Tracing._performanceCursor = Math.max(performance.getEntries().length - 1, 0); + // tslint:enable: no-unsafe-any + } + + /** + * Starts tracking the Largest Contentful Paint on the current page. + */ + private static _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. + Tracing._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', + }); + + Tracing._forceLCP = () => { + po.takeRecords().forEach(updateLCP); + }; + } catch (e) { + // Do nothing if the browser doesn't support this API. + } + } + + /** + * Sets the status of the current active transaction (if there is one) + */ + public static setTransactionStatus(status: SpanStatus): void { + const active = Tracing._activeTransaction; + if (active) { + Tracing._log('[Tracing] setTransactionStatus', status); + active.setStatus(status); + } + } + + /** + * Returns the current active idle transaction if there is one + */ + public static getTransaction(): Transaction | undefined { + return Tracing._activeTransaction; + } + + /** + * Converts from milliseconds to seconds + * @param time time in ms + */ + private static _msToSec(time: number): number { + return time / 1000; + } + + /** + * Adds debug data to the span + */ + private static _addSpanDebugInfo(span: Span): void { + // tslint:disable: no-unsafe-any + const debugData: any = {}; + if (global.performance) { + debugData.performance = true; + debugData['performance.timeOrigin'] = global.performance.timeOrigin; + debugData['performance.now'] = global.performance.now(); + // tslint:disable-next-line: deprecation + if (global.performance.timing) { + // tslint:disable-next-line: deprecation + debugData['performance.timing.navigationStart'] = performance.timing.navigationStart; + } + } else { + debugData.performance = false; + } + debugData['Date.now()'] = Date.now(); + span.setData('sentry_debug', debugData); + // tslint:enable: no-unsafe-any + } + + /** + * Starts tracking for a specifc activity + * + * @param name Name of the activity, can be any string (Only used internally to identify the activity) + * @param spanContext If provided a Span with the SpanContext will be created. + * @param options _autoPopAfter_ | Time in ms, if provided the activity will be popped automatically after this timeout. This can be helpful in cases where you cannot gurantee your application knows the state and calls `popActivity` for sure. + */ + public static pushActivity( + name: string, + spanContext?: SpanContext, + options?: { + autoPopAfter?: number; + }, + ): number { + const activeTransaction = Tracing._activeTransaction; + + if (!activeTransaction) { + Tracing._log(`[Tracing] Not pushing activity ${name} since there is no active transaction`); + return 0; + } + + const _getCurrentHub = Tracing._getCurrentHub; + if (spanContext && _getCurrentHub) { + const hub = _getCurrentHub(); + if (hub) { + const span = activeTransaction.startChild(spanContext); + Tracing._activities[Tracing._currentIndex] = { + name, + span, + }; + } + } else { + Tracing._activities[Tracing._currentIndex] = { + name, + }; + } + + Tracing._log(`[Tracing] pushActivity: ${name}#${Tracing._currentIndex}`); + Tracing._log('[Tracing] activies count', Object.keys(Tracing._activities).length); + if (options && typeof options.autoPopAfter === 'number') { + Tracing._log(`[Tracing] auto pop of: ${name}#${Tracing._currentIndex} in ${options.autoPopAfter}ms`); + const index = Tracing._currentIndex; + setTimeout(() => { + Tracing.popActivity(index, { + autoPop: true, + status: SpanStatus.DeadlineExceeded, + }); + }, options.autoPopAfter); + } + return Tracing._currentIndex++; + } + + /** + * Removes activity and finishes the span in case there is one + * @param id the id of the activity being removed + * @param spanData span data that can be updated + * + */ + public static popActivity(id: number, spanData?: { [key: string]: any }): void { + // The !id is on purpose to also fail with 0 + // Since 0 is returned by push activity in case there is no active transaction + if (!id) { + return; + } + + const activity = Tracing._activities[id]; + + if (activity) { + Tracing._log(`[Tracing] popActivity ${activity.name}#${id}`); + const span = activity.span; + if (span) { + if (spanData) { + Object.keys(spanData).forEach((key: string) => { + span.setData(key, spanData[key]); + if (key === 'status_code') { + span.setHttpStatus(spanData[key] as number); + } + if (key === 'status') { + span.setStatus(spanData[key] as SpanStatus); + } + }); + } + if (Tracing.options && Tracing.options.debug && Tracing.options.debug.spanDebugTimingInfo) { + Tracing._addSpanDebugInfo(span); + } + span.finish(); + } + // tslint:disable-next-line: no-dynamic-delete + delete Tracing._activities[id]; + } + + const count = Object.keys(Tracing._activities).length; + + Tracing._log('[Tracing] activies count', count); + + if (count === 0 && Tracing._activeTransaction) { + const timeout = Tracing.options && Tracing.options.idleTimeout; + Tracing._log(`[Tracing] Flushing Transaction in ${timeout}ms`); + // We need to add the timeout here to have the real endtimestamp of the transaction + // Remeber timestampWithMs is in seconds, timeout is in ms + const end = timestampWithMs() + timeout / 1000; + setTimeout(() => { + Tracing.finishIdleTransaction(end); + }, timeout); + } + } + + /** + * Get span based on activity id + */ + public static getActivitySpan(id: number): Span | undefined { + if (!id) { + return undefined; + } + const activity = Tracing._activities[id]; + if (activity) { + return activity.span; + } + return undefined; + } +} + +/** + * Creates breadcrumbs from XHR API calls + */ +function xhrCallback(handlerData: { [key: string]: any }): void { + if (!Tracing.options.traceXHR) { + return; + } + + // tslint:disable-next-line: no-unsafe-any + if (!handlerData || !handlerData.xhr || !handlerData.xhr.__sentry_xhr__) { + return; + } + + // tslint:disable: no-unsafe-any + const xhr = handlerData.xhr.__sentry_xhr__; + + if (!Tracing.options.shouldCreateSpanForRequest(xhr.url)) { + return; + } + + // We only capture complete, non-sentry requests + if (handlerData.xhr.__sentry_own_request__) { + return; + } + + if (handlerData.endTimestamp && handlerData.xhr.__sentry_xhr_activity_id__) { + Tracing.popActivity(handlerData.xhr.__sentry_xhr_activity_id__, handlerData.xhr.__sentry_xhr__); + return; + } + + handlerData.xhr.__sentry_xhr_activity_id__ = Tracing.pushActivity('xhr', { + data: { + ...xhr.data, + type: 'xhr', + }, + description: `${xhr.method} ${xhr.url}`, + op: 'http', + }); + + // Adding the trace header to the span + const activity = Tracing._activities[handlerData.xhr.__sentry_xhr_activity_id__]; + if (activity) { + const span = activity.span; + if (span && 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. + } + } + } + // tslint:enable: no-unsafe-any +} + +/** + * Creates breadcrumbs from fetch API calls + */ +function fetchCallback(handlerData: { [key: string]: any }): void { + // tslint:disable: no-unsafe-any + if (!Tracing.options.traceFetch) { + return; + } + + if (!Tracing.options.shouldCreateSpanForRequest(handlerData.fetchData.url)) { + return; + } + + if (handlerData.endTimestamp && handlerData.fetchData.__activity) { + Tracing.popActivity(handlerData.fetchData.__activity, handlerData.fetchData); + } else { + handlerData.fetchData.__activity = Tracing.pushActivity('fetch', { + data: { + ...handlerData.fetchData, + type: 'fetch', + }, + description: `${handlerData.fetchData.method} ${handlerData.fetchData.url}`, + op: 'http', + }); + + const activity = Tracing._activities[handlerData.fetchData.__activity]; + if (activity) { + const span = activity.span; + if (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) { + if (typeof headers.append === 'function') { + 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; + } + } + } + // tslint:enable: no-unsafe-any +} + +/** + * Creates transaction from navigation changes + */ +function historyCallback(_: { [key: string]: any }): void { + if (Tracing.options.startTransactionOnLocationChange && global && global.location) { + Tracing.finishIdleTransaction(timestampWithMs()); + Tracing.startIdleTransaction({ + name: Tracing.options.beforeNavigate(window.location), + op: 'navigation', + }); + } +} diff --git a/packages/tracing/apm/src/integrations/types.ts b/packages/tracing/apm/src/integrations/types.ts new file mode 100644 index 000000000000..331cbee7ba57 --- /dev/null +++ b/packages/tracing/apm/src/integrations/types.ts @@ -0,0 +1,109 @@ +/** + * A type returned by some APIs which contains a list of DOMString (strings). + * + * Copy DOMStringList interface so that user's dont have to include dom typings with Tracing integration + * Based on https://github.com/microsoft/TypeScript/blob/4cf0afe2662980ebcd8d444dbd13d8f47d06fcd5/lib/lib.dom.d.ts#L4051 + */ +interface DOMStringList { + /** + * Returns the number of strings in strings. + */ + readonly length: number; + /** + * Returns true if strings contains string, and false otherwise. + */ + contains(str: string): boolean; + /** + * Returns the string with index index from strings. + */ + item(index: number): string | null; + [index: number]: string; +} + +declare var DOMStringList: { + prototype: DOMStringList; + new (): DOMStringList; +}; + +/** + * The location (URL) of the object it is linked to. Changes done on it are reflected on the object it relates to. + * Both the Document and Window interface have such a linked Location, accessible via Document.location and Window.location respectively. + * + * Copy Location interface so that user's dont have to include dom typings with Tracing integration + * Based on https://github.com/microsoft/TypeScript/blob/4cf0afe2662980ebcd8d444dbd13d8f47d06fcd5/lib/lib.dom.d.ts#L9691 + */ +export interface Location { + /** + * Returns a DOMStringList object listing the origins of the ancestor browsing contexts, from the parent browsing context to the top-level browsing context. + */ + readonly ancestorOrigins: DOMStringList; + /** + * Returns the Location object's URL's fragment (includes leading "#" if non-empty). + * + * Can be set, to navigate to the same URL with a changed fragment (ignores leading "#"). + */ + hash: string; + /** + * Returns the Location object's URL's host and port (if different from the default port for the scheme). + * + * Can be set, to navigate to the same URL with a changed host and port. + */ + host: string; + /** + * Returns the Location object's URL's host. + * + * Can be set, to navigate to the same URL with a changed host. + */ + hostname: string; + /** + * Returns the Location object's URL. + * + * Can be set, to navigate to the given URL. + */ + href: string; + // tslint:disable-next-line: completed-docs + toString(): string; + /** + * Returns the Location object's URL's origin. + */ + readonly origin: string; + /** + * Returns the Location object's URL's path. + * + * Can be set, to navigate to the same URL with a changed path. + */ + pathname: string; + /** + * Returns the Location object's URL's port. + * + * Can be set, to navigate to the same URL with a changed port. + */ + port: string; + /** + * Returns the Location object's URL's scheme. + * + * Can be set, to navigate to the same URL with a changed scheme. + */ + protocol: string; + /** + * Returns the Location object's URL's query (includes leading "?" if non-empty). + * + * Can be set, to navigate to the same URL with a changed query (ignores leading "?"). + */ + search: string; + /** + * Navigates to the given URL. + */ + assign(url: string): void; + /** + * Reloads the current page. + */ + reload(): void; + /** @deprecated */ + // tslint:disable-next-line: unified-signatures completed-docs + reload(forcedReload: boolean): void; + /** + * Removes the current page from the session history and navigates to the given URL. + */ + replace(url: string): void; +} diff --git a/packages/tracing/apm/src/span.ts b/packages/tracing/apm/src/span.ts new file mode 100644 index 000000000000..eb6c49d42bab --- /dev/null +++ b/packages/tracing/apm/src/span.ts @@ -0,0 +1,296 @@ +import { Span as SpanInterface, SpanContext } from '@sentry/types'; +import { dropUndefinedKeys, timestampWithMs, uuid4 } from '@sentry/utils'; + +import { SpanStatus } from './spanstatus'; +import { SpanRecorder } from './transaction'; + +export const TRACEPARENT_REGEXP = new RegExp( + '^[ \\t]*' + // whitespace + '([0-9a-f]{32})?' + // trace_id + '-?([0-9a-f]{16})?' + // span_id + '-?([01])?' + // sampled + '[ \\t]*$', // whitespace +); + +/** + * 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/apm/src/spanstatus.ts b/packages/tracing/apm/src/spanstatus.ts new file mode 100644 index 000000000000..6aa87a4dc833 --- /dev/null +++ b/packages/tracing/apm/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/apm/src/transaction.ts b/packages/tracing/apm/src/transaction.ts new file mode 100644 index 000000000000..9366fca34613 --- /dev/null +++ b/packages/tracing/apm/src/transaction.ts @@ -0,0 +1,132 @@ +// tslint:disable:max-classes-per-file +import { getCurrentHub, Hub } from '@sentry/hub'; +import { TransactionContext } from '@sentry/types'; +import { isInstanceOf, logger } from '@sentry/utils'; + +import { Span as SpanClass } from './span'; + +/** + * Keeps track of finished spans for a given transaction + * @internal + * @hideconstructor + * @hidden + */ +export class SpanRecorder { + private readonly _maxlen: number; + public spans: SpanClass[] = []; + + 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: SpanClass): void { + if (this.spans.length > this._maxlen) { + span.spanRecorder = undefined; + } else { + this.spans.push(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/apm/test/hub.test.ts b/packages/tracing/apm/test/hub.test.ts new file mode 100644 index 000000000000..e24f1fc6638d --- /dev/null +++ b/packages/tracing/apm/test/hub.test.ts @@ -0,0 +1,107 @@ +import { BrowserClient } from '@sentry/browser'; +import { Hub, Scope } 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 span', () => { + const hub = new Hub(new BrowserClient({ tracesSampleRate: 0 })); + const span = hub.startSpan({}) as any; + expect(span.sampled).toBeUndefined(); + }); + 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(); + }); + }); + + describe('startSpan', () => { + test('simple standalone Span', () => { + const hub = new Hub(new BrowserClient()); + const span = hub.startSpan({}) as any; + expect(span.spanId).toBeTruthy(); + }); + + test('simple standalone Transaction', () => { + const hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); + const transaction = hub.startTransaction({ name: 'transaction' }); + expect(transaction.spanId).toBeTruthy(); + // tslint:disable-next-line: no-unbound-method + expect(transaction.setName).toBeTruthy(); + }); + + test('Transaction inherits trace_id from span on scope', () => { + const myScope = new Scope(); + const hub = new Hub(new BrowserClient(), myScope); + const parentSpan = hub.startSpan({}) as any; + hub.configureScope(scope => { + scope.setSpan(parentSpan); + }); + // @ts-ignore + const span = hub.startSpan({ name: 'test' }) as any; + expect(span.trace_id).toEqual(parentSpan.trace_id); + }); + + test('create a child if there is a Span already on the scope', () => { + const myScope = new Scope(); + const hub = new Hub(new BrowserClient({ tracesSampleRate: 1 }), myScope); + const transaction = hub.startTransaction({ name: 'transaction' }); + hub.configureScope(scope => { + scope.setSpan(transaction); + }); + const span = hub.startSpan({}); + expect(span.traceId).toEqual(transaction.traceId); + expect(span.parentSpanId).toEqual(transaction.spanId); + hub.configureScope(scope => { + scope.setSpan(span); + }); + const span2 = hub.startSpan({}); + expect(span2.traceId).toEqual(span.traceId); + expect(span2.parentSpanId).toEqual(span.spanId); + }); + }); + }); +}); diff --git a/packages/tracing/apm/test/span.test.ts b/packages/tracing/apm/test/span.test.ts new file mode 100644 index 000000000000..0fb373174259 --- /dev/null +++ b/packages/tracing/apm/test/span.test.ts @@ -0,0 +1,399 @@ +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('hub.startSpan', () => { + test('finish a transaction', () => { + const spy = jest.spyOn(hub as any, 'captureEvent') as any; + // @ts-ignore + const transaction = hub.startSpan({ 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 (deprecated way)', () => { + const spy = jest.spyOn(hub as any, 'captureEvent') as any; + // @ts-ignore + const transaction = hub.startSpan({ transaction: '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('startSpan with Span on the Scope should be a child', () => { + const spy = jest.spyOn(hub as any, 'captureEvent') as any; + const transaction = hub.startTransaction({ name: 'test' }); + const child1 = transaction.startChild(); + hub.configureScope(scope => { + scope.setSpan(child1); + }); + + const child2 = hub.startSpan({}); + child1.finish(); + child2.finish(); + transaction.finish(); + + expect(spy).toHaveBeenCalled(); + expect(spy.mock.calls[0][0].spans).toHaveLength(2); + expect(child2.parentSpanId).toEqual(child1.spanId); + }); + }); + }); + + 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/apm/test/tslint.json b/packages/tracing/apm/test/tslint.json new file mode 100644 index 000000000000..0827b5c40259 --- /dev/null +++ b/packages/tracing/apm/test/tslint.json @@ -0,0 +1,6 @@ +{ + "extends": ["../tslint.json"], + "rules": { + "no-unsafe-any": false + } +} diff --git a/packages/tracing/apm/tsconfig.build.json b/packages/tracing/apm/tsconfig.build.json new file mode 100644 index 000000000000..a263a085c70a --- /dev/null +++ b/packages/tracing/apm/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/tracing/apm/tsconfig.esm.json b/packages/tracing/apm/tsconfig.esm.json new file mode 100644 index 000000000000..33a3842217d4 --- /dev/null +++ b/packages/tracing/apm/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.esm.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "esm" + }, + "include": ["src/**/*"] +} diff --git a/packages/tracing/apm/tsconfig.json b/packages/tracing/apm/tsconfig.json new file mode 100644 index 000000000000..55b38e135ae2 --- /dev/null +++ b/packages/tracing/apm/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/apm/tslint.json b/packages/tracing/apm/tslint.json new file mode 100644 index 000000000000..3016a27a85cc --- /dev/null +++ b/packages/tracing/apm/tslint.json @@ -0,0 +1,3 @@ +{ + "extends": "@sentry/typescript/tslint" +} From 716666272d75d8d4726b7042c24c8c76a2534143 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 3 Jul 2020 13:01:29 -0400 Subject: [PATCH 02/18] CHANGELOG --- CHANGELOG.md | 1 + packages/tracing/{apm => }/.npmignore | 0 packages/tracing/{apm => }/README.md | 0 packages/tracing/{apm => }/package.json | 0 packages/tracing/{apm => }/rollup.config.js | 0 packages/tracing/{apm => }/src/hubextensions.ts | 0 packages/tracing/{apm => }/src/index.bundle.ts | 0 packages/tracing/{apm => }/src/index.ts | 0 packages/tracing/{apm => }/src/integrations/express.ts | 0 packages/tracing/{apm => }/src/integrations/index.ts | 0 packages/tracing/{apm => }/src/integrations/tracing.ts | 0 packages/tracing/{apm => }/src/integrations/types.ts | 0 packages/tracing/{apm => }/src/span.ts | 0 packages/tracing/{apm => }/src/spanstatus.ts | 0 packages/tracing/{apm => }/src/transaction.ts | 0 packages/tracing/{apm => }/test/hub.test.ts | 0 packages/tracing/{apm => }/test/span.test.ts | 0 packages/tracing/{apm => }/test/tslint.json | 0 packages/tracing/{apm => }/tsconfig.build.json | 0 packages/tracing/{apm => }/tsconfig.esm.json | 0 packages/tracing/{apm => }/tsconfig.json | 0 packages/tracing/{apm => }/tslint.json | 0 22 files changed, 1 insertion(+) rename packages/tracing/{apm => }/.npmignore (100%) rename packages/tracing/{apm => }/README.md (100%) rename packages/tracing/{apm => }/package.json (100%) rename packages/tracing/{apm => }/rollup.config.js (100%) rename packages/tracing/{apm => }/src/hubextensions.ts (100%) rename packages/tracing/{apm => }/src/index.bundle.ts (100%) rename packages/tracing/{apm => }/src/index.ts (100%) rename packages/tracing/{apm => }/src/integrations/express.ts (100%) rename packages/tracing/{apm => }/src/integrations/index.ts (100%) rename packages/tracing/{apm => }/src/integrations/tracing.ts (100%) rename packages/tracing/{apm => }/src/integrations/types.ts (100%) rename packages/tracing/{apm => }/src/span.ts (100%) rename packages/tracing/{apm => }/src/spanstatus.ts (100%) rename packages/tracing/{apm => }/src/transaction.ts (100%) rename packages/tracing/{apm => }/test/hub.test.ts (100%) rename packages/tracing/{apm => }/test/span.test.ts (100%) rename packages/tracing/{apm => }/test/tslint.json (100%) rename packages/tracing/{apm => }/tsconfig.build.json (100%) rename packages/tracing/{apm => }/tsconfig.esm.json (100%) rename packages/tracing/{apm => }/tsconfig.json (100%) rename packages/tracing/{apm => }/tslint.json (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ac4aa25416..3779f0672e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - [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) +- [tracing] feat: `Add @sentry/tracing` (#2719) ## 5.19.0 diff --git a/packages/tracing/apm/.npmignore b/packages/tracing/.npmignore similarity index 100% rename from packages/tracing/apm/.npmignore rename to packages/tracing/.npmignore diff --git a/packages/tracing/apm/README.md b/packages/tracing/README.md similarity index 100% rename from packages/tracing/apm/README.md rename to packages/tracing/README.md diff --git a/packages/tracing/apm/package.json b/packages/tracing/package.json similarity index 100% rename from packages/tracing/apm/package.json rename to packages/tracing/package.json diff --git a/packages/tracing/apm/rollup.config.js b/packages/tracing/rollup.config.js similarity index 100% rename from packages/tracing/apm/rollup.config.js rename to packages/tracing/rollup.config.js diff --git a/packages/tracing/apm/src/hubextensions.ts b/packages/tracing/src/hubextensions.ts similarity index 100% rename from packages/tracing/apm/src/hubextensions.ts rename to packages/tracing/src/hubextensions.ts diff --git a/packages/tracing/apm/src/index.bundle.ts b/packages/tracing/src/index.bundle.ts similarity index 100% rename from packages/tracing/apm/src/index.bundle.ts rename to packages/tracing/src/index.bundle.ts diff --git a/packages/tracing/apm/src/index.ts b/packages/tracing/src/index.ts similarity index 100% rename from packages/tracing/apm/src/index.ts rename to packages/tracing/src/index.ts diff --git a/packages/tracing/apm/src/integrations/express.ts b/packages/tracing/src/integrations/express.ts similarity index 100% rename from packages/tracing/apm/src/integrations/express.ts rename to packages/tracing/src/integrations/express.ts diff --git a/packages/tracing/apm/src/integrations/index.ts b/packages/tracing/src/integrations/index.ts similarity index 100% rename from packages/tracing/apm/src/integrations/index.ts rename to packages/tracing/src/integrations/index.ts diff --git a/packages/tracing/apm/src/integrations/tracing.ts b/packages/tracing/src/integrations/tracing.ts similarity index 100% rename from packages/tracing/apm/src/integrations/tracing.ts rename to packages/tracing/src/integrations/tracing.ts diff --git a/packages/tracing/apm/src/integrations/types.ts b/packages/tracing/src/integrations/types.ts similarity index 100% rename from packages/tracing/apm/src/integrations/types.ts rename to packages/tracing/src/integrations/types.ts diff --git a/packages/tracing/apm/src/span.ts b/packages/tracing/src/span.ts similarity index 100% rename from packages/tracing/apm/src/span.ts rename to packages/tracing/src/span.ts diff --git a/packages/tracing/apm/src/spanstatus.ts b/packages/tracing/src/spanstatus.ts similarity index 100% rename from packages/tracing/apm/src/spanstatus.ts rename to packages/tracing/src/spanstatus.ts diff --git a/packages/tracing/apm/src/transaction.ts b/packages/tracing/src/transaction.ts similarity index 100% rename from packages/tracing/apm/src/transaction.ts rename to packages/tracing/src/transaction.ts diff --git a/packages/tracing/apm/test/hub.test.ts b/packages/tracing/test/hub.test.ts similarity index 100% rename from packages/tracing/apm/test/hub.test.ts rename to packages/tracing/test/hub.test.ts diff --git a/packages/tracing/apm/test/span.test.ts b/packages/tracing/test/span.test.ts similarity index 100% rename from packages/tracing/apm/test/span.test.ts rename to packages/tracing/test/span.test.ts diff --git a/packages/tracing/apm/test/tslint.json b/packages/tracing/test/tslint.json similarity index 100% rename from packages/tracing/apm/test/tslint.json rename to packages/tracing/test/tslint.json diff --git a/packages/tracing/apm/tsconfig.build.json b/packages/tracing/tsconfig.build.json similarity index 100% rename from packages/tracing/apm/tsconfig.build.json rename to packages/tracing/tsconfig.build.json diff --git a/packages/tracing/apm/tsconfig.esm.json b/packages/tracing/tsconfig.esm.json similarity index 100% rename from packages/tracing/apm/tsconfig.esm.json rename to packages/tracing/tsconfig.esm.json diff --git a/packages/tracing/apm/tsconfig.json b/packages/tracing/tsconfig.json similarity index 100% rename from packages/tracing/apm/tsconfig.json rename to packages/tracing/tsconfig.json diff --git a/packages/tracing/apm/tslint.json b/packages/tracing/tslint.json similarity index 100% rename from packages/tracing/apm/tslint.json rename to packages/tracing/tslint.json From 8baeea597ff70716d87bb6ed905dd5b41dff8c99 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 3 Jul 2020 15:55:13 -0400 Subject: [PATCH 03/18] Add to package.json --- package.json | 1 + 1 file changed, 1 insertion(+) 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" From f0f288db405688b008f051ff52b29a2f702cbcd7 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 6 Jul 2020 08:16:47 -0400 Subject: [PATCH 04/18] feat: Create IdleTransaction class (#2720) * feat: Adjust hub for idle transaction * feat: Add IdleTransaction class * test: IdleTransaction * ref: Some uneeded code * ref: Declare class variables in constructor * chore: Cleanup set() comments --- packages/tracing/src/hubextensions.ts | 66 ++-- packages/tracing/src/idletransaction.ts | 265 ++++++++++++++++ packages/tracing/test/idletransaction.test.ts | 287 ++++++++++++++++++ 3 files changed, 573 insertions(+), 45 deletions(-) create mode 100644 packages/tracing/src/idletransaction.ts create mode 100644 packages/tracing/test/idletransaction.test.ts diff --git a/packages/tracing/src/hubextensions.ts b/packages/tracing/src/hubextensions.ts index 8480ffbf1bde..313ee8c81119 100644 --- a/packages/tracing/src/hubextensions.ts +++ b/packages/tracing/src/hubextensions.ts @@ -1,8 +1,7 @@ import { getMainCarrier, Hub } from '@sentry/hub'; -import { SpanContext, TransactionContext } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import { TransactionContext } from '@sentry/types'; -import { Span } from './span'; +import { IdleTransaction } from './idletransaction'; import { Transaction } from './transaction'; /** Returns all trace headers that are currently on the top scope. */ @@ -20,13 +19,10 @@ function traceHeaders(this: Hub): { [key: string]: string } { } /** - * {@see Hub.startTransaction} + * Use RNG to generate sampling decision, which all child spans inherit. */ -function startTransaction(this: Hub, context: TransactionContext): Transaction { - const transaction = new Transaction(context, this); - - const client = this.getClient(); - // Roll the dice for sampling transaction, all child spans inherit the sampling decision. +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 @@ -46,41 +42,24 @@ function startTransaction(this: Hub, context: TransactionContext): Transaction { } /** - * {@see Hub.startSpan} + * {@see Hub.startTransaction} */ -function startSpan(this: Hub, context: SpanContext): Transaction | Span { - /** - * @deprecated - * TODO: consider removing this in a future release. - * - * This is for backwards compatibility with releases before startTransaction - * existed, to allow for a smoother transition. - */ - { - // The `TransactionContext.name` field used to be called `transaction`. - const transactionContext = context as Partial; - if (transactionContext.transaction !== undefined) { - transactionContext.name = transactionContext.transaction; - } - // Check for not undefined since we defined it's ok to start a transaction - // with an empty name. - if (transactionContext.name !== undefined) { - logger.warn('Deprecated: Use startTransaction to start transactions and Transaction.startChild to start spans.'); - return this.startTransaction(transactionContext as TransactionContext); - } - } - - const scope = this.getScope(); - if (scope) { - // If there is a Span on the Scope we start a child and return that instead - const parentSpan = scope.getSpan(); - if (parentSpan) { - return parentSpan.startChild(context); - } - } +function startTransaction(this: Hub, context: TransactionContext): Transaction { + const transaction = new Transaction(context, this); + return sample(this, transaction); +} - // Otherwise we return a new Span - return new Span(context); +/** + * Create new idle transaction. + */ +export function startIdleTransaction( + this: Hub, + context: TransactionContext, + idleTimeout?: number, + onScope?: boolean, +): IdleTransaction { + const transaction = new IdleTransaction(context, this, idleTimeout, onScope); + return sample(this, transaction); } /** @@ -93,9 +72,6 @@ export function addExtensionMethods(): void { if (!carrier.__SENTRY__.extensions.startTransaction) { carrier.__SENTRY__.extensions.startTransaction = startTransaction; } - if (!carrier.__SENTRY__.extensions.startSpan) { - carrier.__SENTRY__.extensions.startSpan = startSpan; - } 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..5ba230f5a12d --- /dev/null +++ b/packages/tracing/src/idletransaction.ts @@ -0,0 +1,265 @@ +// tslint:disable: max-classes-per-file +import { Hub } from '@sentry/hub'; +import { TransactionContext } from '@sentry/types'; +import { logger, timestampWithMs } from '@sentry/utils'; + +import { Span } from './span'; +import { SpanStatus } from './spanstatus'; +import { SpanRecorder, Transaction } from './transaction'; + +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); + } +} + +/** + * 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 _finishCallback?: (transactionSpan: IdleTransaction) => void; + + 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.finishIdleTransaction(timestampWithMs()); + } 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; + } + + /** + * Finish the current active idle transaction + */ + public finishIdleTransaction(endTimestamp: number): void { + if (this.spanRecorder) { + logger.log('[Tracing] finishing IdleTransaction', new Date(endTimestamp * 1000).toISOString(), this.op); + + if (this._finishCallback) { + this._finishCallback(this); + } + + 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._finished = true; + this.activities = {}; + // this._onScope is true if the transaction was previously on the scope. + if (this._onScope) { + clearActiveTransaction(this._idleHub); + } + + logger.log('[Tracing] flushing IdleTransaction'); + this.finish(endTimestamp); + } else { + logger.log('[Tracing] No active IdleTransaction'); + } + } + + /** + * 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(() => { + this.finishIdleTransaction(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 beforeFinish(callback: (transactionSpan: IdleTransaction) => void): void { + this._finishCallback = 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/test/idletransaction.test.ts b/packages/tracing/test/idletransaction.test.ts new file mode 100644 index 000000000000..72b2418b7f29 --- /dev/null +++ b/packages/tracing/test/idletransaction.test.ts @@ -0,0 +1,287 @@ +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 mockFinish = jest.fn(); + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + transaction.finishIdleTransaction = mockFinish; + 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 mockFinish = jest.fn(); + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + + transaction.finish = mockFinish; + 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 mockCallback = jest.fn(); + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + transaction.initSpanRecorder(10); + transaction.beforeFinish(mockCallback); + + expect(mockCallback).toHaveBeenCalledTimes(0); + + const span = transaction.startChild(); + span.finish(); + + jest.runOnlyPendingTimers(); + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenLastCalledWith(transaction); + }); + + 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.finishIdleTransaction(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 mockFinish = jest.fn(); + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + transaction.finish = mockFinish; + + 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 mockFinish = jest.fn(); + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + transaction.finish = mockFinish; + 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 mockFinish = jest.fn(); + const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); + transaction.finish = mockFinish; + 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); + }); +}); From 9587853020869b525cff682a176f867d54004233 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 9 Jul 2020 08:57:01 -0400 Subject: [PATCH 05/18] feat: Add BrowserTracing integration (#2723) * test: remove hub.startSpan test * feat(tracing): Add BrowserTracing integration and tests * fix: defaultRoutingInstrumentation * ref: Remove static methods * multiple before finishes * ref: Routing Instrumentation * remove tracing --- packages/tracing/package.json | 2 + .../tracing/src/browser/browsertracing.ts | 157 +++ packages/tracing/src/browser/index.ts | 1 + packages/tracing/src/browser/router.ts | 61 + packages/tracing/src/hubextensions.ts | 6 +- packages/tracing/src/idletransaction.ts | 27 +- packages/tracing/src/index.bundle.ts | 4 +- packages/tracing/src/index.ts | 6 +- packages/tracing/src/integrations/index.ts | 1 - packages/tracing/src/integrations/tracing.ts | 1058 ----------------- packages/tracing/src/integrations/types.ts | 109 -- .../test/browser/browsertracing.test.ts | 252 ++++ packages/tracing/test/browser/router.test.ts | 117 ++ packages/tracing/test/hub.test.ts | 53 +- packages/tracing/test/idletransaction.test.ts | 33 +- packages/tracing/test/span.test.ts | 44 - yarn.lock | 101 +- 17 files changed, 698 insertions(+), 1334 deletions(-) create mode 100644 packages/tracing/src/browser/browsertracing.ts create mode 100644 packages/tracing/src/browser/index.ts create mode 100644 packages/tracing/src/browser/router.ts delete mode 100644 packages/tracing/src/integrations/tracing.ts delete mode 100644 packages/tracing/src/integrations/types.ts create mode 100644 packages/tracing/test/browser/browsertracing.test.ts create mode 100644 packages/tracing/test/browser/router.test.ts diff --git a/packages/tracing/package.json b/packages/tracing/package.json index 821d237b0a7f..e0f5355edb74 100644 --- a/packages/tracing/package.json +++ b/packages/tracing/package.json @@ -25,7 +25,9 @@ }, "devDependencies": { "@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", diff --git a/packages/tracing/src/browser/browsertracing.ts b/packages/tracing/src/browser/browsertracing.ts new file mode 100644 index 000000000000..1b07ebac3206 --- /dev/null +++ b/packages/tracing/src/browser/browsertracing.ts @@ -0,0 +1,157 @@ +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 } from '../idletransaction'; +import { Span } from '../span'; + +import { defaultBeforeNavigate, defaultRoutingInstrumentation } from './router'; + +/** Options for Browser Tracing integration */ +export interface BrowserTracingOptions { + /** + * 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 a custom navigation transaction name. 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 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, + routingInstrumentation: defaultRoutingInstrumentation, + startTransactionOnLocationChange: true, + startTransactionOnPageLoad: true, + }; + + /** + * @inheritDoc + */ + public name: string = BrowserTracing.id; + + private _getCurrentHub?: () => Hub; + + // navigationTransactionInvoker() -> Uses history API NavigationTransaction[] + + public constructor(_options?: Partial) { + this.options = { + ...this.options, + ..._options, + }; + } + + /** + * @inheritDoc + */ + public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + this._getCurrentHub = getCurrentHub; + + const { routingInstrumentation, startTransactionOnLocationChange, startTransactionOnPageLoad } = this.options; + + routingInstrumentation( + (context: TransactionContext) => this._createRouteTransaction(context), + startTransactionOnPageLoad, + startTransactionOnLocationChange, + ); + } + + /** Create routing idle transaction. */ + private _createRouteTransaction(context: TransactionContext): TransactionType | undefined { + if (!this._getCurrentHub) { + logger.warn(`[Tracing] Did not creeate ${context.op} idleTransaction due to invalid _getCurrentHub`); + return undefined; + } + + const { beforeNavigate, idleTimeout } = this.options; + + // if beforeNavigate returns undefined, we should not start a transaction. + const ctx = beforeNavigate({ + ...context, + ...getHeaderContext(), + }); + + 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 with context:`, ctx); + return startIdleTransaction(hub, ctx, idleTimeout, true) 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; +} 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/router.ts b/packages/tracing/src/browser/router.ts new file mode 100644 index 000000000000..6ce36eef5bd5 --- /dev/null +++ b/packages/tracing/src/browser/router.ts @@ -0,0 +1,61 @@ +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) { + // 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/hubextensions.ts b/packages/tracing/src/hubextensions.ts index 313ee8c81119..dfc5ebe5e4a5 100644 --- a/packages/tracing/src/hubextensions.ts +++ b/packages/tracing/src/hubextensions.ts @@ -53,13 +53,13 @@ function startTransaction(this: Hub, context: TransactionContext): Transaction { * Create new idle transaction. */ export function startIdleTransaction( - this: Hub, + hub: Hub, context: TransactionContext, idleTimeout?: number, onScope?: boolean, ): IdleTransaction { - const transaction = new IdleTransaction(context, this, idleTimeout, onScope); - return sample(this, transaction); + const transaction = new IdleTransaction(context, hub, idleTimeout, onScope); + return sample(hub, transaction); } /** diff --git a/packages/tracing/src/idletransaction.ts b/packages/tracing/src/idletransaction.ts index 5ba230f5a12d..2b24549ddefe 100644 --- a/packages/tracing/src/idletransaction.ts +++ b/packages/tracing/src/idletransaction.ts @@ -7,7 +7,7 @@ import { Span } from './span'; import { SpanStatus } from './spanstatus'; import { SpanRecorder, Transaction } from './transaction'; -const DEFAULT_IDLE_TIMEOUT = 1000; +export const DEFAULT_IDLE_TIMEOUT = 1000; /** * @inheritDoc @@ -45,6 +45,8 @@ export class IdleTransactionSpanRecorder extends SpanRecorder { } } +export type BeforeFinishCallback = (transactionSpan: IdleTransaction) => 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 @@ -66,7 +68,7 @@ export class IdleTransaction extends Transaction { // We should not use heartbeat if we finished a transaction private _finished: boolean = false; - private _finishCallback?: (transactionSpan: IdleTransaction) => void; + private readonly _beforeFinishCallbacks: BeforeFinishCallback[] = []; public constructor( transactionContext: TransactionContext, @@ -119,7 +121,7 @@ export class IdleTransaction extends Transaction { ); this.setStatus(SpanStatus.DeadlineExceeded); this.setTag('heartbeat', 'failed'); - this.finishIdleTransaction(timestampWithMs()); + this.finish(); } else { this._pingHeartbeat(); } @@ -135,15 +137,13 @@ export class IdleTransaction extends Transaction { }, 5000) as any) as number; } - /** - * Finish the current active idle transaction - */ - public finishIdleTransaction(endTimestamp: number): void { + /** {@inheritDoc} */ + public finish(endTimestamp: number = timestampWithMs()): string | undefined { if (this.spanRecorder) { logger.log('[Tracing] finishing IdleTransaction', new Date(endTimestamp * 1000).toISOString(), this.op); - if (this._finishCallback) { - this._finishCallback(this); + for (const callback of this._beforeFinishCallbacks) { + callback(this); } this.spanRecorder.spans = this.spanRecorder.spans.filter((span: Span) => { @@ -177,10 +177,11 @@ export class IdleTransaction extends Transaction { } logger.log('[Tracing] flushing IdleTransaction'); - this.finish(endTimestamp); } else { logger.log('[Tracing] No active IdleTransaction'); } + + return super.finish(endTimestamp); } /** @@ -212,7 +213,7 @@ export class IdleTransaction extends Transaction { const end = timestampWithMs() + timeout / 1000; setTimeout(() => { - this.finishIdleTransaction(end); + this.finish(end); }, timeout); } } @@ -224,8 +225,8 @@ export class IdleTransaction extends Transaction { * This is exposed because users have no other way of running something before an idle transaction * finishes. */ - public beforeFinish(callback: (transactionSpan: IdleTransaction) => void): void { - this._finishCallback = callback; + public registerBeforeFinishCallback(callback: BeforeFinishCallback): void { + this._beforeFinishCallbacks.push(callback); } /** diff --git a/packages/tracing/src/index.bundle.ts b/packages/tracing/src/index.bundle.ts index 0d39253e7980..e509bbee51c9 100644 --- a/packages/tracing/src/index.bundle.ts +++ b/packages/tracing/src/index.bundle.ts @@ -52,8 +52,8 @@ 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'; -import * as ApmIntegrations from './integrations'; export { Span, TRACEPARENT_REGEXP } from './span'; @@ -70,7 +70,7 @@ if (_window.Sentry && _window.Sentry.Integrations) { const INTEGRATIONS = { ...windowIntegrations, ...BrowserIntegrations, - Tracing: ApmIntegrations.Tracing, + ...BrowserTracing, }; export { INTEGRATIONS as Integrations }; diff --git a/packages/tracing/src/index.ts b/packages/tracing/src/index.ts index 9f59e3515f40..9a976e53bcac 100644 --- a/packages/tracing/src/index.ts +++ b/packages/tracing/src/index.ts @@ -1,7 +1,11 @@ +import { BrowserTracing } from './browser'; import { addExtensionMethods } from './hubextensions'; import * as ApmIntegrations from './integrations'; -export { ApmIntegrations as Integrations }; +// tslint:disable-next-line: variable-name +const Integrations = { ...ApmIntegrations, BrowserTracing }; + +export { Integrations }; export { Span, TRACEPARENT_REGEXP } from './span'; export { Transaction } from './transaction'; diff --git a/packages/tracing/src/integrations/index.ts b/packages/tracing/src/integrations/index.ts index 8833c8cd301c..abe1faf43c27 100644 --- a/packages/tracing/src/integrations/index.ts +++ b/packages/tracing/src/integrations/index.ts @@ -1,2 +1 @@ export { Express } from './express'; -export { Tracing } from './tracing'; diff --git a/packages/tracing/src/integrations/tracing.ts b/packages/tracing/src/integrations/tracing.ts deleted file mode 100644 index 59451647569f..000000000000 --- a/packages/tracing/src/integrations/tracing.ts +++ /dev/null @@ -1,1058 +0,0 @@ -// tslint:disable: max-file-line-count -import { Hub } from '@sentry/hub'; -import { Event, EventProcessor, Integration, Severity, Span, SpanContext, TransactionContext } from '@sentry/types'; -import { - addInstrumentationHandler, - getGlobalObject, - isInstanceOf, - isMatchingPattern, - logger, - safeJoin, - supportsNativeFetch, - timestampWithMs, -} from '@sentry/utils'; - -import { Span as SpanClass } from '../span'; -import { SpanStatus } from '../spanstatus'; -import { Transaction } from '../transaction'; - -import { Location } from './types'; - -/** - * Options for Tracing integration - */ -export interface TracingOptions { - /** - * 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', /^\//] - */ - 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; - /** - * 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: 500 - */ - idleTimeout: number; - - /** - * Flag to enable/disable creation of `navigation` transaction on history changes. Useful for react applications with - * a router. - * - * Default: true - */ - startTransactionOnLocationChange: boolean; - - /** - * Flag to enable/disable creation of `pageload` transaction on first pageload. - * - * Default: true - */ - startTransactionOnPageLoad: boolean; - - /** - * 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. Background transaction can mess up your - * statistics in non deterministic ways that's why we by default recommend leaving this opition enabled. - * - * Default: true - */ - markBackgroundTransactions: boolean; - - /** - * This is only if you want to debug in prod. - * writeAsBreadcrumbs: Instead of having console.log statements we log messages to breadcrumbs - * so you can investigate whats happening in production with your users to figure why things might not appear the - * way you expect them to. - * - * spanDebugTimingInfo: Add timing info to spans at the point where we create them to figure out browser timing - * issues. - * - * You shouldn't care about this. - * - * Default: { - * writeAsBreadcrumbs: false; - * spanDebugTimingInfo: false; - * } - */ - debug: { - writeAsBreadcrumbs: boolean; - spanDebugTimingInfo: boolean; - }; - - /** - * beforeNavigate is called before a pageload/navigation transaction is created and allows for users - * to set a custom navigation transaction name based on the current `window.location`. Defaults to returning - * `window.location.pathname`. - * - * @param location the current location before navigation span is created - */ - beforeNavigate(location: Location): string; -} - -/** JSDoc */ -interface Activity { - name: string; - span?: Span; -} - -const global = getGlobalObject(); -const defaultTracingOrigins = ['localhost', /^\//]; - -/** - * Tracing Integration - */ -export class Tracing implements Integration { - /** - * @inheritDoc - */ - public name: string = Tracing.id; - - /** - * @inheritDoc - */ - public static id: string = 'Tracing'; - - /** JSDoc */ - public static options: TracingOptions; - - /** - * Returns current hub. - */ - private static _getCurrentHub?: () => Hub; - - private static _activeTransaction?: Transaction; - - private static _currentIndex: number = 1; - - public static _activities: { [key: number]: Activity } = {}; - - private readonly _emitOptionsWarning: boolean = false; - - private static _performanceCursor: number = 0; - - private static _heartbeatTimer: number = 0; - - private static _prevHeartbeatString: string | undefined; - - private static _heartbeatCounter: number = 0; - - /** Holds the latest LargestContentfulPaint value (it changes during page load). */ - private static _lcp?: { [key: string]: any }; - - /** Force any pending LargestContentfulPaint records to be dispatched. */ - private static _forceLCP = () => { - /* No-op, replaced later if LCP API is available. */ - }; - - /** - * Constructor for Tracing - * - * @param _options TracingOptions - */ - public constructor(_options?: Partial) { - if (global.performance) { - if (global.performance.mark) { - global.performance.mark('sentry-tracing-init'); - } - Tracing._trackLCP(); - } - const defaults = { - beforeNavigate(location: Location): string { - return location.pathname; - }, - debug: { - spanDebugTimingInfo: false, - writeAsBreadcrumbs: false, - }, - idleTimeout: 500, - markBackgroundTransactions: true, - maxTransactionDuration: 600, - shouldCreateSpanForRequest(url: string): boolean { - const origins = (_options && _options.tracingOrigins) || defaultTracingOrigins; - return ( - origins.some((origin: string | RegExp) => isMatchingPattern(url, origin)) && - !isMatchingPattern(url, 'sentry_key') - ); - }, - startTransactionOnLocationChange: true, - startTransactionOnPageLoad: true, - traceFetch: true, - traceXHR: true, - tracingOrigins: defaultTracingOrigins, - }; - // NOTE: Logger doesn't work in contructors, as it's initialized after integrations instances - if (!_options || !Array.isArray(_options.tracingOrigins) || _options.tracingOrigins.length === 0) { - this._emitOptionsWarning = true; - } - Tracing.options = { - ...defaults, - ..._options, - }; - } - - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - Tracing._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: ${defaultTracingOrigins}`); - } - - // Starting pageload transaction - if (global.location && Tracing.options && Tracing.options.startTransactionOnPageLoad) { - Tracing.startIdleTransaction({ - name: Tracing.options.beforeNavigate(window.location), - op: 'pageload', - }); - } - - this._setupXHRTracing(); - - this._setupFetchTracing(); - - this._setupHistory(); - - this._setupErrorHandling(); - - this._setupBackgroundTabDetection(); - - Tracing._pingHeartbeat(); - - // This EventProcessor makes sure that the transaction is not longer than maxTransactionDuration - addGlobalEventProcessor((event: Event) => { - const self = getCurrentHub().getIntegration(Tracing); - if (!self) { - return event; - } - - const isOutdatedTransaction = - event.timestamp && - event.start_timestamp && - (event.timestamp - event.start_timestamp > Tracing.options.maxTransactionDuration || - event.timestamp - event.start_timestamp < 0); - - if (Tracing.options.maxTransactionDuration !== 0 && event.type === 'transaction' && isOutdatedTransaction) { - Tracing._log(`[Tracing] Transaction: ${SpanStatus.Cancelled} since it maxed out maxTransactionDuration`); - if (event.contexts && event.contexts.trace) { - event.contexts.trace = { - ...event.contexts.trace, - status: SpanStatus.DeadlineExceeded, - }; - event.tags = { - ...event.tags, - maxTransactionDurationExceeded: 'true', - }; - } - } - - return event; - }); - } - - /** - * Returns a new Transaction either continued from sentry-trace meta or a new one - */ - private static _getNewTransaction(hub: Hub, transactionContext: TransactionContext): Transaction { - let traceId; - let parentSpanId; - let sampled; - - const header = Tracing._getMeta('sentry-trace'); - if (header) { - const span = SpanClass.fromTraceparent(header); - if (span) { - traceId = span.traceId; - parentSpanId = span.parentSpanId; - sampled = span.sampled; - Tracing._log( - `[Tracing] found 'sentry-meta' '' continuing trace with: trace_id: ${traceId} span_id: ${parentSpanId}`, - ); - } - } - - return hub.startTransaction({ - parentSpanId, - sampled, - traceId, - trimEnd: true, - ...transactionContext, - }) as Transaction; - } - - /** - * Returns the value of a meta tag - */ - private static _getMeta(metaName: string): string | null { - const el = document.querySelector(`meta[name=${metaName}]`); - return el ? el.getAttribute('content') : null; - } - - /** - * Pings the heartbeat - */ - private static _pingHeartbeat(): void { - Tracing._heartbeatTimer = (setTimeout(() => { - Tracing._beat(); - }, 5000) as any) as number; - } - - /** - * Checks when entries of Tracing._activities are not changing for 3 beats. If this occurs we finish the transaction - * - */ - private static _beat(): void { - clearTimeout(Tracing._heartbeatTimer); - const keys = Object.keys(Tracing._activities); - if (keys.length) { - const heartbeatString = keys.reduce((prev: string, current: string) => prev + current); - if (heartbeatString === Tracing._prevHeartbeatString) { - Tracing._heartbeatCounter++; - } else { - Tracing._heartbeatCounter = 0; - } - if (Tracing._heartbeatCounter >= 3) { - if (Tracing._activeTransaction) { - Tracing._log( - `[Tracing] Transaction: ${ - SpanStatus.Cancelled - } -> Heartbeat safeguard kicked in since content hasn't changed for 3 beats`, - ); - Tracing._activeTransaction.setStatus(SpanStatus.DeadlineExceeded); - Tracing._activeTransaction.setTag('heartbeat', 'failed'); - Tracing.finishIdleTransaction(timestampWithMs()); - } - } - Tracing._prevHeartbeatString = heartbeatString; - } - Tracing._pingHeartbeat(); - } - - /** - * Discards active transactions if tab moves to background - */ - private _setupBackgroundTabDetection(): void { - if (Tracing.options && Tracing.options.markBackgroundTransactions && global.document) { - document.addEventListener('visibilitychange', () => { - if (document.hidden && Tracing._activeTransaction) { - Tracing._log(`[Tracing] Transaction: ${SpanStatus.Cancelled} -> since tab moved to the background`); - Tracing._activeTransaction.setStatus(SpanStatus.Cancelled); - Tracing._activeTransaction.setTag('visibilitychange', 'document.hidden'); - Tracing.finishIdleTransaction(timestampWithMs()); - } - }); - } - } - - /** - * Unsets the current active transaction + activities - */ - private static _resetActiveTransaction(): void { - // We want to clean up after ourselves - // If there is still the active transaction on the scope we remove it - const _getCurrentHub = Tracing._getCurrentHub; - if (_getCurrentHub) { - const hub = _getCurrentHub(); - const scope = hub.getScope(); - if (scope) { - if (scope.getSpan() === Tracing._activeTransaction) { - scope.setSpan(undefined); - } - } - } - // ------------------------------------------------------------------ - Tracing._activeTransaction = undefined; - Tracing._activities = {}; - } - - /** - * Registers to History API to detect navigation changes - */ - private _setupHistory(): void { - if (Tracing.options.startTransactionOnLocationChange) { - addInstrumentationHandler({ - callback: historyCallback, - type: 'history', - }); - } - } - - /** - * Attaches to fetch to add sentry-trace header + creating spans - */ - private _setupFetchTracing(): void { - if (Tracing.options.traceFetch && supportsNativeFetch()) { - addInstrumentationHandler({ - callback: fetchCallback, - type: 'fetch', - }); - } - } - - /** - * Attaches to XHR to add sentry-trace header + creating spans - */ - private _setupXHRTracing(): void { - if (Tracing.options.traceXHR) { - addInstrumentationHandler({ - callback: xhrCallback, - type: 'xhr', - }); - } - } - - /** - * Configures global error listeners - */ - private _setupErrorHandling(): void { - // tslint:disable-next-line: completed-docs - function errorCallback(): void { - if (Tracing._activeTransaction) { - /** - * If an error or unhandled promise occurs, we mark the active transaction as failed - */ - Tracing._log(`[Tracing] Transaction: ${SpanStatus.InternalError} -> Global error occured`); - Tracing._activeTransaction.setStatus(SpanStatus.InternalError); - } - } - addInstrumentationHandler({ - callback: errorCallback, - type: 'error', - }); - addInstrumentationHandler({ - callback: errorCallback, - type: 'unhandledrejection', - }); - } - - /** - * Uses logger.log to log things in the SDK or as breadcrumbs if defined in options - */ - private static _log(...args: any[]): void { - if (Tracing.options && Tracing.options.debug && Tracing.options.debug.writeAsBreadcrumbs) { - const _getCurrentHub = Tracing._getCurrentHub; - if (_getCurrentHub) { - _getCurrentHub().addBreadcrumb({ - category: 'tracing', - level: Severity.Debug, - message: safeJoin(args, ' '), - type: 'debug', - }); - } - } - logger.log(...args); - } - - /** - * Starts a Transaction waiting for activity idle to finish - */ - public static startIdleTransaction(transactionContext: TransactionContext): Transaction | undefined { - Tracing._log('[Tracing] startIdleTransaction'); - - const _getCurrentHub = Tracing._getCurrentHub; - if (!_getCurrentHub) { - return undefined; - } - - const hub = _getCurrentHub(); - if (!hub) { - return undefined; - } - - Tracing._activeTransaction = Tracing._getNewTransaction(hub, transactionContext); - - // We set the transaction here on the scope so error events pick up the trace context and attach it to the error - hub.configureScope(scope => scope.setSpan(Tracing._activeTransaction)); - - // The reason we do this here is because of cached responses - // If we start and transaction without an activity it would never finish since there is no activity - const id = Tracing.pushActivity('idleTransactionStarted'); - setTimeout(() => { - Tracing.popActivity(id); - }, (Tracing.options && Tracing.options.idleTimeout) || 100); - - return Tracing._activeTransaction; - } - - /** - * Finishes the current active transaction - */ - public static finishIdleTransaction(endTimestamp: number): void { - const active = Tracing._activeTransaction; - if (active) { - Tracing._log('[Tracing] finishing IdleTransaction', new Date(endTimestamp * 1000).toISOString()); - Tracing._addPerformanceEntries(active); - - if (active.spanRecorder) { - active.spanRecorder.spans = active.spanRecorder.spans.filter((span: Span) => { - // If we are dealing with the transaction itself, we just return it - if (span.spanId === active.spanId) { - return span; - } - - // 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); - Tracing._log('[Tracing] cancelling span since transaction ended early', JSON.stringify(span, undefined, 2)); - } - - // We remove all spans that happend after the end of the transaction - // This is here to prevent super long transactions and timing issues - const keepSpan = span.startTimestamp < endTimestamp; - if (!keepSpan) { - Tracing._log( - '[Tracing] discarding Span since it happened after Transaction was finished', - JSON.stringify(span, undefined, 2), - ); - } - return keepSpan; - }); - } - - Tracing._log('[Tracing] flushing IdleTransaction'); - active.finish(); - Tracing._resetActiveTransaction(); - } else { - Tracing._log('[Tracing] No active IdleTransaction'); - } - } - - /** - * This uses `performance.getEntries()` to add additional spans to the active transaction. - * Also, we update our timings since we consider the timings in this API to be more correct than our manual - * measurements. - * - * @param transactionSpan The transaction span - */ - private static _addPerformanceEntries(transactionSpan: SpanClass): void { - if (!global.performance || !global.performance.getEntries) { - // Gatekeeper if performance API not available - return; - } - - Tracing._log('[Tracing] Adding & adjusting spans using Performance API'); - - // FIXME: depending on the 'op' directly is brittle. - if (transactionSpan.op === 'pageload') { - // Force any pending records to be dispatched. - Tracing._forceLCP(); - if (Tracing._lcp) { - // Set the last observed LCP score. - transactionSpan.setData('_sentry_web_vitals', { LCP: Tracing._lcp }); - } - } - - const timeOrigin = Tracing._msToSec(performance.timeOrigin); - - // tslint:disable-next-line: completed-docs - function addPerformanceNavigationTiming(parent: Span, entry: { [key: string]: number }, event: string): void { - parent.startChild({ - description: event, - endTimestamp: timeOrigin + Tracing._msToSec(entry[`${event}End`]), - op: 'browser', - startTimestamp: timeOrigin + Tracing._msToSec(entry[`${event}Start`]), - }); - } - - // tslint:disable-next-line: completed-docs - function addRequest(parent: Span, entry: { [key: string]: number }): void { - parent.startChild({ - description: 'request', - endTimestamp: timeOrigin + Tracing._msToSec(entry.responseEnd), - op: 'browser', - startTimestamp: timeOrigin + Tracing._msToSec(entry.requestStart), - }); - - parent.startChild({ - description: 'response', - endTimestamp: timeOrigin + Tracing._msToSec(entry.responseEnd), - op: 'browser', - startTimestamp: timeOrigin + Tracing._msToSec(entry.responseStart), - }); - } - - 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 entryScriptStartEndTime: number | undefined; - let tracingInitMarkStartTime: number | undefined; - - // tslint:disable: no-unsafe-any - performance - .getEntries() - .slice(Tracing._performanceCursor) - .forEach((entry: any) => { - const startTime = Tracing._msToSec(entry.startTime as number); - const duration = Tracing._msToSec(entry.duration as number); - - if (transactionSpan.op === 'navigation' && timeOrigin + startTime < transactionSpan.startTimestamp) { - return; - } - - switch (entry.entryType) { - case 'navigation': - addPerformanceNavigationTiming(transactionSpan, entry, 'unloadEvent'); - addPerformanceNavigationTiming(transactionSpan, entry, 'domContentLoadedEvent'); - addPerformanceNavigationTiming(transactionSpan, entry, 'loadEvent'); - addPerformanceNavigationTiming(transactionSpan, entry, 'connect'); - addPerformanceNavigationTiming(transactionSpan, entry, 'domainLookup'); - addRequest(transactionSpan, entry); - break; - case 'mark': - case 'paint': - case 'measure': - const mark = transactionSpan.startChild({ - description: entry.name, - op: entry.entryType, - }); - mark.startTimestamp = timeOrigin + startTime; - mark.endTimestamp = mark.startTimestamp + duration; - if (tracingInitMarkStartTime === undefined && entry.name === 'sentry-tracing-init') { - tracingInitMarkStartTime = mark.startTimestamp; - } - break; - case 'resource': - const resourceName = entry.name.replace(window.location.origin, ''); - if (entry.initiatorType === 'xmlhttprequest' || entry.initiatorType === 'fetch') { - // We need to update existing spans with new timing info - if (transactionSpan.spanRecorder) { - transactionSpan.spanRecorder.spans.map((finishedSpan: Span) => { - if (finishedSpan.description && finishedSpan.description.indexOf(resourceName) !== -1) { - finishedSpan.startTimestamp = timeOrigin + startTime; - finishedSpan.endTimestamp = finishedSpan.startTimestamp + duration; - } - }); - } - } else { - const resource = transactionSpan.startChild({ - description: `${entry.initiatorType} ${resourceName}`, - op: `resource`, - }); - resource.startTimestamp = timeOrigin + startTime; - resource.endTimestamp = resource.startTimestamp + duration; - // We remember the entry script end time to calculate the difference to the first init mark - if (entryScriptStartEndTime === undefined && (entryScriptSrc || '').indexOf(resourceName) > -1) { - entryScriptStartEndTime = resource.endTimestamp; - } - } - break; - default: - // Ignore other entry types. - } - }); - - if (entryScriptStartEndTime !== undefined && tracingInitMarkStartTime !== undefined) { - transactionSpan.startChild({ - description: 'evaluation', - endTimestamp: tracingInitMarkStartTime, - op: `script`, - startTimestamp: entryScriptStartEndTime, - }); - } - - Tracing._performanceCursor = Math.max(performance.getEntries().length - 1, 0); - // tslint:enable: no-unsafe-any - } - - /** - * Starts tracking the Largest Contentful Paint on the current page. - */ - private static _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. - Tracing._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', - }); - - Tracing._forceLCP = () => { - po.takeRecords().forEach(updateLCP); - }; - } catch (e) { - // Do nothing if the browser doesn't support this API. - } - } - - /** - * Sets the status of the current active transaction (if there is one) - */ - public static setTransactionStatus(status: SpanStatus): void { - const active = Tracing._activeTransaction; - if (active) { - Tracing._log('[Tracing] setTransactionStatus', status); - active.setStatus(status); - } - } - - /** - * Returns the current active idle transaction if there is one - */ - public static getTransaction(): Transaction | undefined { - return Tracing._activeTransaction; - } - - /** - * Converts from milliseconds to seconds - * @param time time in ms - */ - private static _msToSec(time: number): number { - return time / 1000; - } - - /** - * Adds debug data to the span - */ - private static _addSpanDebugInfo(span: Span): void { - // tslint:disable: no-unsafe-any - const debugData: any = {}; - if (global.performance) { - debugData.performance = true; - debugData['performance.timeOrigin'] = global.performance.timeOrigin; - debugData['performance.now'] = global.performance.now(); - // tslint:disable-next-line: deprecation - if (global.performance.timing) { - // tslint:disable-next-line: deprecation - debugData['performance.timing.navigationStart'] = performance.timing.navigationStart; - } - } else { - debugData.performance = false; - } - debugData['Date.now()'] = Date.now(); - span.setData('sentry_debug', debugData); - // tslint:enable: no-unsafe-any - } - - /** - * Starts tracking for a specifc activity - * - * @param name Name of the activity, can be any string (Only used internally to identify the activity) - * @param spanContext If provided a Span with the SpanContext will be created. - * @param options _autoPopAfter_ | Time in ms, if provided the activity will be popped automatically after this timeout. This can be helpful in cases where you cannot gurantee your application knows the state and calls `popActivity` for sure. - */ - public static pushActivity( - name: string, - spanContext?: SpanContext, - options?: { - autoPopAfter?: number; - }, - ): number { - const activeTransaction = Tracing._activeTransaction; - - if (!activeTransaction) { - Tracing._log(`[Tracing] Not pushing activity ${name} since there is no active transaction`); - return 0; - } - - const _getCurrentHub = Tracing._getCurrentHub; - if (spanContext && _getCurrentHub) { - const hub = _getCurrentHub(); - if (hub) { - const span = activeTransaction.startChild(spanContext); - Tracing._activities[Tracing._currentIndex] = { - name, - span, - }; - } - } else { - Tracing._activities[Tracing._currentIndex] = { - name, - }; - } - - Tracing._log(`[Tracing] pushActivity: ${name}#${Tracing._currentIndex}`); - Tracing._log('[Tracing] activies count', Object.keys(Tracing._activities).length); - if (options && typeof options.autoPopAfter === 'number') { - Tracing._log(`[Tracing] auto pop of: ${name}#${Tracing._currentIndex} in ${options.autoPopAfter}ms`); - const index = Tracing._currentIndex; - setTimeout(() => { - Tracing.popActivity(index, { - autoPop: true, - status: SpanStatus.DeadlineExceeded, - }); - }, options.autoPopAfter); - } - return Tracing._currentIndex++; - } - - /** - * Removes activity and finishes the span in case there is one - * @param id the id of the activity being removed - * @param spanData span data that can be updated - * - */ - public static popActivity(id: number, spanData?: { [key: string]: any }): void { - // The !id is on purpose to also fail with 0 - // Since 0 is returned by push activity in case there is no active transaction - if (!id) { - return; - } - - const activity = Tracing._activities[id]; - - if (activity) { - Tracing._log(`[Tracing] popActivity ${activity.name}#${id}`); - const span = activity.span; - if (span) { - if (spanData) { - Object.keys(spanData).forEach((key: string) => { - span.setData(key, spanData[key]); - if (key === 'status_code') { - span.setHttpStatus(spanData[key] as number); - } - if (key === 'status') { - span.setStatus(spanData[key] as SpanStatus); - } - }); - } - if (Tracing.options && Tracing.options.debug && Tracing.options.debug.spanDebugTimingInfo) { - Tracing._addSpanDebugInfo(span); - } - span.finish(); - } - // tslint:disable-next-line: no-dynamic-delete - delete Tracing._activities[id]; - } - - const count = Object.keys(Tracing._activities).length; - - Tracing._log('[Tracing] activies count', count); - - if (count === 0 && Tracing._activeTransaction) { - const timeout = Tracing.options && Tracing.options.idleTimeout; - Tracing._log(`[Tracing] Flushing Transaction in ${timeout}ms`); - // We need to add the timeout here to have the real endtimestamp of the transaction - // Remeber timestampWithMs is in seconds, timeout is in ms - const end = timestampWithMs() + timeout / 1000; - setTimeout(() => { - Tracing.finishIdleTransaction(end); - }, timeout); - } - } - - /** - * Get span based on activity id - */ - public static getActivitySpan(id: number): Span | undefined { - if (!id) { - return undefined; - } - const activity = Tracing._activities[id]; - if (activity) { - return activity.span; - } - return undefined; - } -} - -/** - * Creates breadcrumbs from XHR API calls - */ -function xhrCallback(handlerData: { [key: string]: any }): void { - if (!Tracing.options.traceXHR) { - return; - } - - // tslint:disable-next-line: no-unsafe-any - if (!handlerData || !handlerData.xhr || !handlerData.xhr.__sentry_xhr__) { - return; - } - - // tslint:disable: no-unsafe-any - const xhr = handlerData.xhr.__sentry_xhr__; - - if (!Tracing.options.shouldCreateSpanForRequest(xhr.url)) { - return; - } - - // We only capture complete, non-sentry requests - if (handlerData.xhr.__sentry_own_request__) { - return; - } - - if (handlerData.endTimestamp && handlerData.xhr.__sentry_xhr_activity_id__) { - Tracing.popActivity(handlerData.xhr.__sentry_xhr_activity_id__, handlerData.xhr.__sentry_xhr__); - return; - } - - handlerData.xhr.__sentry_xhr_activity_id__ = Tracing.pushActivity('xhr', { - data: { - ...xhr.data, - type: 'xhr', - }, - description: `${xhr.method} ${xhr.url}`, - op: 'http', - }); - - // Adding the trace header to the span - const activity = Tracing._activities[handlerData.xhr.__sentry_xhr_activity_id__]; - if (activity) { - const span = activity.span; - if (span && 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. - } - } - } - // tslint:enable: no-unsafe-any -} - -/** - * Creates breadcrumbs from fetch API calls - */ -function fetchCallback(handlerData: { [key: string]: any }): void { - // tslint:disable: no-unsafe-any - if (!Tracing.options.traceFetch) { - return; - } - - if (!Tracing.options.shouldCreateSpanForRequest(handlerData.fetchData.url)) { - return; - } - - if (handlerData.endTimestamp && handlerData.fetchData.__activity) { - Tracing.popActivity(handlerData.fetchData.__activity, handlerData.fetchData); - } else { - handlerData.fetchData.__activity = Tracing.pushActivity('fetch', { - data: { - ...handlerData.fetchData, - type: 'fetch', - }, - description: `${handlerData.fetchData.method} ${handlerData.fetchData.url}`, - op: 'http', - }); - - const activity = Tracing._activities[handlerData.fetchData.__activity]; - if (activity) { - const span = activity.span; - if (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) { - if (typeof headers.append === 'function') { - 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; - } - } - } - // tslint:enable: no-unsafe-any -} - -/** - * Creates transaction from navigation changes - */ -function historyCallback(_: { [key: string]: any }): void { - if (Tracing.options.startTransactionOnLocationChange && global && global.location) { - Tracing.finishIdleTransaction(timestampWithMs()); - Tracing.startIdleTransaction({ - name: Tracing.options.beforeNavigate(window.location), - op: 'navigation', - }); - } -} diff --git a/packages/tracing/src/integrations/types.ts b/packages/tracing/src/integrations/types.ts deleted file mode 100644 index 331cbee7ba57..000000000000 --- a/packages/tracing/src/integrations/types.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * A type returned by some APIs which contains a list of DOMString (strings). - * - * Copy DOMStringList interface so that user's dont have to include dom typings with Tracing integration - * Based on https://github.com/microsoft/TypeScript/blob/4cf0afe2662980ebcd8d444dbd13d8f47d06fcd5/lib/lib.dom.d.ts#L4051 - */ -interface DOMStringList { - /** - * Returns the number of strings in strings. - */ - readonly length: number; - /** - * Returns true if strings contains string, and false otherwise. - */ - contains(str: string): boolean; - /** - * Returns the string with index index from strings. - */ - item(index: number): string | null; - [index: number]: string; -} - -declare var DOMStringList: { - prototype: DOMStringList; - new (): DOMStringList; -}; - -/** - * The location (URL) of the object it is linked to. Changes done on it are reflected on the object it relates to. - * Both the Document and Window interface have such a linked Location, accessible via Document.location and Window.location respectively. - * - * Copy Location interface so that user's dont have to include dom typings with Tracing integration - * Based on https://github.com/microsoft/TypeScript/blob/4cf0afe2662980ebcd8d444dbd13d8f47d06fcd5/lib/lib.dom.d.ts#L9691 - */ -export interface Location { - /** - * Returns a DOMStringList object listing the origins of the ancestor browsing contexts, from the parent browsing context to the top-level browsing context. - */ - readonly ancestorOrigins: DOMStringList; - /** - * Returns the Location object's URL's fragment (includes leading "#" if non-empty). - * - * Can be set, to navigate to the same URL with a changed fragment (ignores leading "#"). - */ - hash: string; - /** - * Returns the Location object's URL's host and port (if different from the default port for the scheme). - * - * Can be set, to navigate to the same URL with a changed host and port. - */ - host: string; - /** - * Returns the Location object's URL's host. - * - * Can be set, to navigate to the same URL with a changed host. - */ - hostname: string; - /** - * Returns the Location object's URL. - * - * Can be set, to navigate to the given URL. - */ - href: string; - // tslint:disable-next-line: completed-docs - toString(): string; - /** - * Returns the Location object's URL's origin. - */ - readonly origin: string; - /** - * Returns the Location object's URL's path. - * - * Can be set, to navigate to the same URL with a changed path. - */ - pathname: string; - /** - * Returns the Location object's URL's port. - * - * Can be set, to navigate to the same URL with a changed port. - */ - port: string; - /** - * Returns the Location object's URL's scheme. - * - * Can be set, to navigate to the same URL with a changed scheme. - */ - protocol: string; - /** - * Returns the Location object's URL's query (includes leading "?" if non-empty). - * - * Can be set, to navigate to the same URL with a changed query (ignores leading "?"). - */ - search: string; - /** - * Navigates to the given URL. - */ - assign(url: string): void; - /** - * Reloads the current page. - */ - reload(): void; - /** @deprecated */ - // tslint:disable-next-line: unified-signatures completed-docs - reload(forcedReload: boolean): void; - /** - * Removes the current page from the session history and navigates to the given URL. - */ - replace(url: string): void; -} diff --git a/packages/tracing/test/browser/browsertracing.test.ts b/packages/tracing/test/browser/browsertracing.test.ts new file mode 100644 index 000000000000..effc6dcf8b5e --- /dev/null +++ b/packages/tracing/test/browser/browsertracing.test.ts @@ -0,0 +1,252 @@ +import { BrowserClient } from '@sentry/browser'; +import { Hub } from '@sentry/hub'; +// tslint:disable-next-line: no-implicit-dependencies +import { JSDOM } from 'jsdom'; + +import { BrowserTracing, BrowserTracingOptions, getMetaContent } from '../../src/browser/browsertracing'; +import { defaultRoutingInstrumentation } from '../../src/browser/router'; +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 }: any): void => { + mockChangeHistory = callback; + }, + }; +}); + +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 })); + document.head.innerHTML = ''; + }); + + afterEach(() => { + const transaction = getActiveTransaction(hub); + if (transaction) { + transaction.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, + routingInstrumentation: defaultRoutingInstrumentation, + startTransactionOnLocationChange: true, + startTransactionOnPageLoad: true, + }); + }); + + 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('calls beforeNavigate 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('is not created if beforeNavigate 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 use a custom beforeNavigate', () => { + 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); + }); + + it('is created with a default idleTimeout', () => { + 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 created with a custom idleTimeout', () => { + 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); + }); + }); + + // 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); + }); +}); + +/** Get active transaction from scope */ +function getActiveTransaction(hub: Hub): IdleTransaction | undefined { + const scope = hub.getScope(); + if (scope) { + return scope.getTransaction() as IdleTransaction; + } + + return 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 index e24f1fc6638d..017be5a5b898 100644 --- a/packages/tracing/test/hub.test.ts +++ b/packages/tracing/test/hub.test.ts @@ -1,5 +1,5 @@ import { BrowserClient } from '@sentry/browser'; -import { Hub, Scope } from '@sentry/hub'; +import { Hub } from '@sentry/hub'; import { addExtensionMethods } from '../src/hubextensions'; @@ -35,11 +35,6 @@ describe('Hub', () => { describe('spans', () => { describe('sampling', () => { - test('set tracesSampleRate 0 on span', () => { - const hub = new Hub(new BrowserClient({ tracesSampleRate: 0 })); - const span = hub.startSpan({}) as any; - expect(span.sampled).toBeUndefined(); - }); test('set tracesSampleRate 0 on transaction', () => { const hub = new Hub(new BrowserClient({ tracesSampleRate: 0 })); const transaction = hub.startTransaction({ name: 'foo' }); @@ -57,51 +52,5 @@ describe('Hub', () => { expect(child.sampled).toBeFalsy(); }); }); - - describe('startSpan', () => { - test('simple standalone Span', () => { - const hub = new Hub(new BrowserClient()); - const span = hub.startSpan({}) as any; - expect(span.spanId).toBeTruthy(); - }); - - test('simple standalone Transaction', () => { - const hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); - const transaction = hub.startTransaction({ name: 'transaction' }); - expect(transaction.spanId).toBeTruthy(); - // tslint:disable-next-line: no-unbound-method - expect(transaction.setName).toBeTruthy(); - }); - - test('Transaction inherits trace_id from span on scope', () => { - const myScope = new Scope(); - const hub = new Hub(new BrowserClient(), myScope); - const parentSpan = hub.startSpan({}) as any; - hub.configureScope(scope => { - scope.setSpan(parentSpan); - }); - // @ts-ignore - const span = hub.startSpan({ name: 'test' }) as any; - expect(span.trace_id).toEqual(parentSpan.trace_id); - }); - - test('create a child if there is a Span already on the scope', () => { - const myScope = new Scope(); - const hub = new Hub(new BrowserClient({ tracesSampleRate: 1 }), myScope); - const transaction = hub.startTransaction({ name: 'transaction' }); - hub.configureScope(scope => { - scope.setSpan(transaction); - }); - const span = hub.startSpan({}); - expect(span.traceId).toEqual(transaction.traceId); - expect(span.parentSpanId).toEqual(transaction.spanId); - hub.configureScope(scope => { - scope.setSpan(span); - }); - const span2 = hub.startSpan({}); - expect(span2.traceId).toEqual(span.traceId); - expect(span2.parentSpanId).toEqual(span.spanId); - }); - }); }); }); diff --git a/packages/tracing/test/idletransaction.test.ts b/packages/tracing/test/idletransaction.test.ts index 72b2418b7f29..563cfbd0a1b8 100644 --- a/packages/tracing/test/idletransaction.test.ts +++ b/packages/tracing/test/idletransaction.test.ts @@ -48,9 +48,8 @@ describe('IdleTransaction', () => { }); it('push and pops activities', () => { - const mockFinish = jest.fn(); const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); - transaction.finishIdleTransaction = mockFinish; + const mockFinish = jest.spyOn(transaction, 'finish'); transaction.initSpanRecorder(10); expect(transaction.activities).toMatchObject({}); @@ -76,10 +75,8 @@ describe('IdleTransaction', () => { }); it('does not finish if there are still active activities', () => { - const mockFinish = jest.fn(); const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); - - transaction.finish = mockFinish; + const mockFinish = jest.spyOn(transaction, 'finish'); transaction.initSpanRecorder(10); expect(transaction.activities).toMatchObject({}); @@ -95,19 +92,24 @@ describe('IdleTransaction', () => { }); it('calls beforeFinish callback before finishing', () => { - const mockCallback = jest.fn(); + const mockCallback1 = jest.fn(); + const mockCallback2 = jest.fn(); const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); transaction.initSpanRecorder(10); - transaction.beforeFinish(mockCallback); + transaction.registerBeforeFinishCallback(mockCallback1); + transaction.registerBeforeFinishCallback(mockCallback2); - expect(mockCallback).toHaveBeenCalledTimes(0); + expect(mockCallback1).toHaveBeenCalledTimes(0); + expect(mockCallback2).toHaveBeenCalledTimes(0); const span = transaction.startChild(); span.finish(); jest.runOnlyPendingTimers(); - expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback).toHaveBeenLastCalledWith(transaction); + expect(mockCallback1).toHaveBeenCalledTimes(1); + expect(mockCallback1).toHaveBeenLastCalledWith(transaction); + expect(mockCallback2).toHaveBeenCalledTimes(1); + expect(mockCallback2).toHaveBeenLastCalledWith(transaction); }); it('filters spans on finish', () => { @@ -124,7 +126,7 @@ describe('IdleTransaction', () => { const cancelledSpan = transaction.startChild({ startTimestamp: transaction.startTimestamp + 4 }); regularSpan.finish(regularSpan.startTimestamp + 4); - transaction.finishIdleTransaction(transaction.startTimestamp + 10); + transaction.finish(transaction.startTimestamp + 10); expect(transaction.spanRecorder).toBeDefined(); if (transaction.spanRecorder) { @@ -145,9 +147,8 @@ describe('IdleTransaction', () => { describe('heartbeat', () => { it('does not start heartbeat if there is no span recorder', () => { - const mockFinish = jest.fn(); const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); - transaction.finish = mockFinish; + const mockFinish = jest.spyOn(transaction, 'finish'); expect(mockFinish).toHaveBeenCalledTimes(0); @@ -164,9 +165,8 @@ describe('IdleTransaction', () => { expect(mockFinish).toHaveBeenCalledTimes(0); }); it('finishes a transaction after 3 beats', () => { - const mockFinish = jest.fn(); const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); - transaction.finish = mockFinish; + const mockFinish = jest.spyOn(transaction, 'finish'); transaction.initSpanRecorder(10); expect(mockFinish).toHaveBeenCalledTimes(0); @@ -185,9 +185,8 @@ describe('IdleTransaction', () => { }); it('resets after new activities are added', () => { - const mockFinish = jest.fn(); const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000); - transaction.finish = mockFinish; + const mockFinish = jest.spyOn(transaction, 'finish'); transaction.initSpanRecorder(10); expect(mockFinish).toHaveBeenCalledTimes(0); diff --git a/packages/tracing/test/span.test.ts b/packages/tracing/test/span.test.ts index 0fb373174259..5fed6cfbf9d4 100644 --- a/packages/tracing/test/span.test.ts +++ b/packages/tracing/test/span.test.ts @@ -320,50 +320,6 @@ describe('Span', () => { expect(spanTwo.toJSON().parent_span_id).toEqual(transaction.toJSON().span_id); }); }); - - describe('hub.startSpan', () => { - test('finish a transaction', () => { - const spy = jest.spyOn(hub as any, 'captureEvent') as any; - // @ts-ignore - const transaction = hub.startSpan({ 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 (deprecated way)', () => { - const spy = jest.spyOn(hub as any, 'captureEvent') as any; - // @ts-ignore - const transaction = hub.startSpan({ transaction: '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('startSpan with Span on the Scope should be a child', () => { - const spy = jest.spyOn(hub as any, 'captureEvent') as any; - const transaction = hub.startTransaction({ name: 'test' }); - const child1 = transaction.startChild(); - hub.configureScope(scope => { - scope.setSpan(child1); - }); - - const child2 = hub.startSpan({}); - child1.finish(); - child2.finish(); - transaction.finish(); - - expect(spy).toHaveBeenCalled(); - expect(spy.mock.calls[0][0].spans).toHaveLength(2); - expect(child2.parentSpanId).toEqual(child1.spanId); - }); - }); }); describe('getTraceContext', () => { diff --git a/yarn.lock b/yarn.lock index b638089c590c..64ea51d83ce2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1117,6 +1117,58 @@ universal-user-agent "^2.0.0" url-template "^2.0.8" +"@sentry/browser@5.19.0": + version "5.19.0" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.19.0.tgz#9189b6633fe45e54325e40b39345d9eabd171e4a" + integrity sha512-Cz8PnzC5NGfpHIGCmHLgA6iDEBVELwo4Il/iFweXjs4+VL4biw62lI32Q4iLCCpmX0t5UvrWBOnju2v8zuFIjA== + dependencies: + "@sentry/core" "5.19.0" + "@sentry/types" "5.19.0" + "@sentry/utils" "5.19.0" + tslib "^1.9.3" + +"@sentry/core@5.19.0": + version "5.19.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.19.0.tgz#31b08a0b46ae1ee6863447225b401ac2a777774c" + integrity sha512-ry1Zms6jrVQPEwmfywItyUhOgabPrykd8stR1x/u2t1YiISWlR813fE5nzdwgW5mxEitXz/ikTwvrfK3egsDWQ== + dependencies: + "@sentry/hub" "5.19.0" + "@sentry/minimal" "5.19.0" + "@sentry/types" "5.19.0" + "@sentry/utils" "5.19.0" + tslib "^1.9.3" + +"@sentry/hub@5.19.0": + version "5.19.0" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.19.0.tgz#f38e7745a4980d9fa6c5baeca5605e7e6fecd5ac" + integrity sha512-UFaQLa1XAa02ZcxUmN9GdDpGs3vHK1LpOyYooimX8ttWa4KAkMuP+O5uXH1TJabry6o/cRwYisg2k6PBSy8gxw== + dependencies: + "@sentry/types" "5.19.0" + "@sentry/utils" "5.19.0" + tslib "^1.9.3" + +"@sentry/minimal@5.19.0": + version "5.19.0" + resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.19.0.tgz#aa5a700618608ea79d270280fe77f04bbb44ed5a" + integrity sha512-3FHgirwOuOMF4VlwHboYObPT9c0S9b9y5FW0DGgNt8sJPezS00VaJti/38ZwOHQJ4fJ6ubt/Y3Kz0eBTVxMCCQ== + dependencies: + "@sentry/hub" "5.19.0" + "@sentry/types" "5.19.0" + tslib "^1.9.3" + +"@sentry/types@5.19.0": + version "5.19.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.19.0.tgz#30c6a05040b644d90337ef8b50d9d59c0872744d" + integrity sha512-NlHLS9mwCEimKUA5vClAwVhXFLsqSF3VJEXU+K61fF6NZx8zfJi/HTrIBtoM4iNSAt9o4XLQatC1liIpBC06tg== + +"@sentry/utils@5.19.0": + version "5.19.0" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.19.0.tgz#0e01f84aa67a1cf2ec71faab670230918ea7e9aa" + integrity sha512-EU/T9aJrI8isexRnyDx5InNw/HjSQ0nKI7YWdiwfFrJusqQ/uisnCGK7vw6gGHDgiAHMXW3TJ38NHpNBin6y7Q== + dependencies: + "@sentry/types" "5.19.0" + tslib "^1.9.3" + "@sinonjs/commons@^1", "@sinonjs/commons@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.4.0.tgz#7b3ec2d96af481d7a0321252e7b1c94724ec5a78" @@ -1397,6 +1449,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 +1505,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 +1899,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 +4664,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" From a51d71744cd6181f39bd4a0b5696e1ce73603d57 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 14 Jul 2020 07:44:42 -0400 Subject: [PATCH 06/18] feat: Add span creators to @sentry/tracing package (#2736) --- packages/tracing/src/browser/backgroundtab.ts | 32 ++ .../tracing/src/browser/browsertracing.ts | 109 ++++++- packages/tracing/src/browser/errors.ts | 30 ++ packages/tracing/src/browser/metrics.ts | 280 ++++++++++++++++++ packages/tracing/src/browser/request.ts | 240 +++++++++++++++ packages/tracing/src/browser/router.ts | 1 + packages/tracing/src/browser/utils.ts | 30 ++ packages/tracing/src/idletransaction.ts | 13 +- .../test/browser/backgroundtab.test.ts | 57 ++++ .../test/browser/browsertracing.test.ts | 236 +++++++++++---- packages/tracing/test/browser/errors.test.ts | 86 ++++++ packages/tracing/test/browser/request.test.ts | 49 +++ packages/tracing/test/idletransaction.test.ts | 4 +- 13 files changed, 1090 insertions(+), 77 deletions(-) create mode 100644 packages/tracing/src/browser/backgroundtab.ts create mode 100644 packages/tracing/src/browser/errors.ts create mode 100644 packages/tracing/src/browser/metrics.ts create mode 100644 packages/tracing/src/browser/request.ts create mode 100644 packages/tracing/src/browser/utils.ts create mode 100644 packages/tracing/test/browser/backgroundtab.test.ts create mode 100644 packages/tracing/test/browser/errors.test.ts create mode 100644 packages/tracing/test/browser/request.test.ts 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 index 1b07ebac3206..244f87c35de4 100644 --- a/packages/tracing/src/browser/browsertracing.ts +++ b/packages/tracing/src/browser/browsertracing.ts @@ -3,13 +3,25 @@ import { EventProcessor, Integration, Transaction as TransactionType, Transactio import { logger } from '@sentry/utils'; import { startIdleTransaction } from '../hubextensions'; -import { DEFAULT_IDLE_TIMEOUT } from '../idletransaction'; +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 { +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. @@ -50,6 +62,24 @@ export interface BrowserTracingOptions { 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; } /** @@ -69,9 +99,12 @@ export class BrowserTracing implements Integration { public options: BrowserTracingOptions = { beforeNavigate: defaultBeforeNavigate, idleTimeout: DEFAULT_IDLE_TIMEOUT, + markBackgroundTransactions: true, + maxTransactionDuration: DEFAULT_MAX_TRANSACTION_DURATION_SECONDS, routingInstrumentation: defaultRoutingInstrumentation, startTransactionOnLocationChange: true, startTransactionOnPageLoad: true, + ...defaultRequestInstrumentionOptions, }; /** @@ -81,12 +114,28 @@ export class BrowserTracing implements Integration { private _getCurrentHub?: () => Hub; - // navigationTransactionInvoker() -> Uses history API NavigationTransaction[] + 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, }; } @@ -96,13 +145,40 @@ export class BrowserTracing implements Integration { public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { this._getCurrentHub = getCurrentHub; - const { routingInstrumentation, startTransactionOnLocationChange, startTransactionOnPageLoad } = this.options; + 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. */ @@ -112,12 +188,13 @@ export class BrowserTracing implements Integration { return undefined; } - const { beforeNavigate, idleTimeout } = this.options; + 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) { @@ -126,8 +203,14 @@ export class BrowserTracing implements Integration { } const hub = this._getCurrentHub(); - logger.log(`[Tracing] starting ${ctx.op} idleTransaction on scope with context:`, ctx); - return startIdleTransaction(hub, ctx, idleTimeout, true) as TransactionType; + 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; } } @@ -155,3 +238,13 @@ 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/metrics.ts b/packages/tracing/src/browser/metrics.ts new file mode 100644 index 000000000000..8bd0722618e4 --- /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: end + timeOrigin, + op: 'browser', + startTimestamp: start + timeOrigin, + }); +} + +/** 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 index 6ce36eef5bd5..b7d0565ecad6 100644 --- a/packages/tracing/src/browser/router.ts +++ b/packages/tracing/src/browser/router.ts @@ -43,6 +43,7 @@ export function defaultRoutingInstrumentation( 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(); 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/idletransaction.ts b/packages/tracing/src/idletransaction.ts index 2b24549ddefe..5d45dd50958d 100644 --- a/packages/tracing/src/idletransaction.ts +++ b/packages/tracing/src/idletransaction.ts @@ -45,7 +45,7 @@ export class IdleTransactionSpanRecorder extends SpanRecorder { } } -export type BeforeFinishCallback = (transactionSpan: IdleTransaction) => void; +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. @@ -139,11 +139,14 @@ export class IdleTransaction extends Transaction { /** {@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); + callback(this, endTimestamp); } this.spanRecorder.spans = this.spanRecorder.spans.filter((span: Span) => { @@ -169,8 +172,6 @@ export class IdleTransaction extends Transaction { return keepSpan; }); - this._finished = true; - this.activities = {}; // this._onScope is true if the transaction was previously on the scope. if (this._onScope) { clearActiveTransaction(this._idleHub); @@ -213,7 +214,9 @@ export class IdleTransaction extends Transaction { const end = timestampWithMs() + timeout / 1000; setTimeout(() => { - this.finish(end); + if (!this._finished) { + this.finish(end); + } }, timeout); } } 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 index effc6dcf8b5e..5e7f695aed86 100644 --- a/packages/tracing/test/browser/browsertracing.test.ts +++ b/packages/tracing/test/browser/browsertracing.test.ts @@ -1,23 +1,37 @@ import { BrowserClient } from '@sentry/browser'; -import { Hub } from '@sentry/hub'; +import { Hub, makeMain } from '@sentry/hub'; // tslint:disable-next-line: no-implicit-dependencies import { JSDOM } from 'jsdom'; -import { BrowserTracing, BrowserTracingOptions, getMetaContent } from '../../src/browser/browsertracing'; +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 }: any): void => { - mockChangeHistory = callback; + 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 @@ -33,13 +47,17 @@ describe('BrowserTracing', () => { beforeEach(() => { jest.useFakeTimers(); hub = new Hub(new BrowserClient({ tracesSampleRate: 1 })); + makeMain(hub); document.head.innerHTML = ''; + + warnSpy.mockClear(); }); afterEach(() => { - const transaction = getActiveTransaction(hub); - if (transaction) { - transaction.finish(); + const activeTransaction = getActiveTransaction(); + if (activeTransaction) { + // Should unset off of scope. + activeTransaction.finish(); } }); @@ -62,12 +80,20 @@ describe('BrowserTracing', () => { 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' }); @@ -84,44 +110,102 @@ describe('BrowserTracing', () => { expect(transaction.op).toBe('pageload'); }); - it('calls beforeNavigate on transaction creation', () => { - const mockBeforeNavigation = jest.fn().mockReturnValue({ name: 'here/is/my/path' }); + it('trims all transactions', () => { createBrowserTracing(true, { - beforeNavigate: mockBeforeNavigation, routingInstrumentation: customRoutingInstrumentation, }); + const transaction = getActiveTransaction(hub) as IdleTransaction; - expect(transaction).toBeDefined(); + const span = transaction.startChild(); + span.finish(); - expect(mockBeforeNavigation).toHaveBeenCalledTimes(1); + if (span.endTimestamp) { + transaction.finish(span.endTimestamp + 12345); + } + expect(transaction.endTimestamp).toBe(span.endTimestamp); }); - it('is not created if beforeNavigate returns undefined', () => { - const mockBeforeNavigation = jest.fn().mockReturnValue(undefined); - createBrowserTracing(true, { - beforeNavigate: mockBeforeNavigation, - routingInstrumentation: customRoutingInstrumentation, + 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); }); - const transaction = getActiveTransaction(hub) as IdleTransaction; - expect(transaction).not.toBeDefined(); - expect(mockBeforeNavigation).toHaveBeenCalledTimes(1); + 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']); + }); }); - it('can use a custom beforeNavigate', () => { - const mockBeforeNavigation = jest.fn(ctx => ({ - ...ctx, - op: 'something-else', - })); - createBrowserTracing(true, { - beforeNavigate: mockBeforeNavigation, - routingInstrumentation: customRoutingInstrumentation, + 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); }); - const transaction = getActiveTransaction(hub) as IdleTransaction; - expect(transaction).toBeDefined(); - expect(transaction.op).toBe('something-else'); - 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', () => { @@ -136,32 +220,70 @@ describe('BrowserTracing', () => { expect(transaction.sampled).toBe(true); }); - it('is created with a default idleTimeout', () => { - createBrowserTracing(true, { routingInstrumentation: customRoutingInstrumentation }); - const mockFinish = jest.fn(); - const transaction = getActiveTransaction(hub) as IdleTransaction; - transaction.finish = mockFinish; + 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 - const span = transaction.startChild(); // activities = 1 - span.finish(); // activities = 0 + expect(mockFinish).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(DEFAULT_IDLE_TIMEOUT); + expect(mockFinish).toHaveBeenCalledTimes(1); + }); - 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); + }); }); - it('can be created with a custom idleTimeout', () => { - createBrowserTracing(true, { idleTimeout: 2000, routingInstrumentation: customRoutingInstrumentation }); - const mockFinish = jest.fn(); - const transaction = getActiveTransaction(hub) as IdleTransaction; - transaction.finish = mockFinish; + 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; - const span = transaction.startChild(); // activities = 1 - span.finish(); // activities = 0 + transaction.finish(transaction.startTimestamp + secToMs(customMaxTransactionDuration)); - expect(mockFinish).toHaveBeenCalledTimes(0); - jest.advanceTimersByTime(2000); - expect(mockFinish).toHaveBeenCalledTimes(1); + expect(transaction.status).toBe(undefined); + expect(transaction.tags.maxTransactionDurationExceeded).not.toBeDefined(); + }); }); }); @@ -240,13 +362,3 @@ describe('getMeta', () => { expect(meta).toBe(null); }); }); - -/** Get active transaction from scope */ -function getActiveTransaction(hub: Hub): IdleTransaction | undefined { - const scope = hub.getScope(); - if (scope) { - return scope.getTransaction() as IdleTransaction; - } - - return undefined; -} 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/idletransaction.test.ts b/packages/tracing/test/idletransaction.test.ts index 563cfbd0a1b8..44c00b8e3945 100644 --- a/packages/tracing/test/idletransaction.test.ts +++ b/packages/tracing/test/idletransaction.test.ts @@ -107,9 +107,9 @@ describe('IdleTransaction', () => { jest.runOnlyPendingTimers(); expect(mockCallback1).toHaveBeenCalledTimes(1); - expect(mockCallback1).toHaveBeenLastCalledWith(transaction); + expect(mockCallback1).toHaveBeenLastCalledWith(transaction, expect.any(Number)); expect(mockCallback2).toHaveBeenCalledTimes(1); - expect(mockCallback2).toHaveBeenLastCalledWith(transaction); + expect(mockCallback2).toHaveBeenLastCalledWith(transaction, expect.any(Number)); }); it('filters spans on finish', () => { From adfc0b7b0de662f0f7621390c5243532fb12d3e8 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 14 Jul 2020 07:50:40 -0400 Subject: [PATCH 07/18] fix: Update spelling of Tracing log --- packages/tracing/src/browser/browsertracing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tracing/src/browser/browsertracing.ts b/packages/tracing/src/browser/browsertracing.ts index 244f87c35de4..981cdcf99f19 100644 --- a/packages/tracing/src/browser/browsertracing.ts +++ b/packages/tracing/src/browser/browsertracing.ts @@ -184,7 +184,7 @@ export class BrowserTracing implements Integration { /** Create routing idle transaction. */ private _createRouteTransaction(context: TransactionContext): TransactionType | undefined { if (!this._getCurrentHub) { - logger.warn(`[Tracing] Did not creeate ${context.op} idleTransaction due to invalid _getCurrentHub`); + logger.warn(`[Tracing] Did not create ${context.op} idleTransaction due to invalid _getCurrentHub`); return undefined; } From 2c88eb944bf5e60f70e9a4a3c36c2a7da57b3ae4 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 14 Jul 2020 11:47:39 -0400 Subject: [PATCH 08/18] ref: Convert React and Vue Tracing to use active transaction (#2741) * ref: Make React Profiler use active transaction * ref: Make Vue Tracing using active transaction --- packages/integrations/src/vue.ts | 77 +++++++-------- packages/react/src/profiler.tsx | 130 ++++++++------------------ packages/react/test/profiler.test.tsx | 127 ++++++++++--------------- 3 files changed, 121 insertions(+), 213 deletions(-) diff --git a/packages/integrations/src/vue.ts b/packages/integrations/src/vue.ts index 6d62d811ca5e..dc9a176ccebf 100644 --- a/packages/integrations/src/vue.ts +++ b/packages/integrations/src/vue.ts @@ -1,14 +1,6 @@ -import { EventProcessor, Hub, Integration, IntegrationClass, Span } from '@sentry/types'; +import { EventProcessor, Hub, Integration, 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. - */ -const TRACING_GETTER = ({ - id: 'Tracing', -} as any) as IntegrationClass; - /** Global Vue object limited to the methods/attributes we require */ interface VueInstance { config: { @@ -71,7 +63,7 @@ interface TracingOptions { * Or to an array of specific component names (case-sensitive). */ trackComponents: boolean | string[]; - /** How long to wait until the tracked root activity is marked as finished and sent of to Sentry */ + /** How long to wait until the tracked root span is marked as finished and sent of to Sentry */ timeout: number; /** * List of hooks to keep track of during component lifecycle. @@ -137,7 +129,6 @@ export class Vue implements Integration { private readonly _componentsCache: { [key: string]: string } = {}; private _rootSpan?: Span; private _rootSpanTimer?: ReturnType; - private _tracingActivity?: number; /** * @inheritDoc @@ -221,27 +212,18 @@ export class Vue implements Integration { // On the first handler call (before), it'll be undefined, as `$once` will add it in the future. // However, on the second call (after), it'll be already in place. if (this._rootSpan) { - this._finishRootSpan(now, getCurrentHub); + this._finishRootSpan(now); } else { vm.$once(`hook:${hook}`, () => { - // Create an activity on the first event call. There'll be no second call, as rootSpan will be in place, + // Create an span on the first event call. There'll be no second call, as rootSpan will be in place, // thus new event handler won't be attached. - // 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. - const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER); - if (tracingIntegration) { - // tslint:disable-next-line:no-unsafe-any - this._tracingActivity = (tracingIntegration as any).constructor.pushActivity('Vue Application Render'); - // tslint:disable-next-line:no-unsafe-any - const transaction = (tracingIntegration as any).constructor.getTransaction(); - if (transaction) { - // tslint:disable-next-line:no-unsafe-any - this._rootSpan = transaction.startChild({ - description: 'Application Render', - op: 'Vue', - }); - } + const activeTransaction = getActiveTransaction(getCurrentHub()); + if (activeTransaction) { + this._rootSpan = activeTransaction.startChild({ + description: 'Application Render', + op: 'Vue', + }); } }); } @@ -264,7 +246,7 @@ export class Vue implements Integration { // However, on the second call (after), it'll be already in place. if (span) { span.finish(); - this._finishRootSpan(now, getCurrentHub); + this._finishRootSpan(now); } else { vm.$once(`hook:${hook}`, () => { if (this._rootSpan) { @@ -305,24 +287,15 @@ export class Vue implements Integration { }); }; - /** Finish top-level span and activity with a debounce configured using `timeout` option */ - private _finishRootSpan(timestamp: number, getCurrentHub: () => Hub): void { + /** Finish top-level span with a debounce configured using `timeout` option */ + private _finishRootSpan(timestamp: number): void { if (this._rootSpanTimer) { clearTimeout(this._rootSpanTimer); } this._rootSpanTimer = setTimeout(() => { - 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. - 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); - } - } + if (this._rootSpan) { + this._rootSpan.finish(timestamp); } }, this._options.tracingOptions.timeout); } @@ -333,7 +306,7 @@ export class Vue implements Integration { this._options.Vue.mixin({ beforeCreate(this: ViewModel): void { - if (getCurrentHub().getIntegration(TRACING_GETTER)) { + if (getActiveTransaction(getCurrentHub())) { // `this` points to currently rendered component applyTracingHooks(this, getCurrentHub); } else { @@ -405,3 +378,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..ccfcb4864b9c 100644 --- a/packages/react/src/profiler.tsx +++ b/packages/react/src/profiler.tsx @@ -1,81 +1,11 @@ -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 { Span, Transaction } from '@sentry/types'; +import { timestampWithMs } from '@sentry/utils'; import * as hoistNonReactStatic from 'hoist-non-react-statics'; import * as React from 'react'; export const UNKNOWN_COMPONENT = 'unknown'; -const TRACING_GETTER = ({ - id: 'Tracing', -} as any) as IntegrationClass; - -let globalTracingIntegration: Integration | null = null; -const getTracingIntegration = () => { - if (globalTracingIntegration) { - return globalTracingIntegration; - } - - globalTracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER); - 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 - */ -function pushActivity(name: string, op: string): number | null { - if (globalTracingIntegration === null) { - return null; - } - - // tslint:disable-next-line:no-unsafe-any - return (globalTracingIntegration as any).constructor.pushActivity(name, { - description: `<${name}>`, - op: `react.${op}`, - }); -} - -/** - * popActivity removes a React activity. - * Is a no-op if Tracing integration is not valid. - * @param activity id of activity that is being popped - */ -function popActivity(activity: number | null): void { - if (activity === null || globalTracingIntegration === null) { - return; - } - - // tslint:disable-next-line:no-unsafe-any - (globalTracingIntegration as any).constructor.popActivity(activity); -} - -/** - * 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 - */ -function getActivitySpan(activity: number | null): Span | undefined { - if (activity === null || globalTracingIntegration === null) { - return undefined; - } - - // tslint:disable-next-line:no-unsafe-any - return (globalTracingIntegration as any).constructor.getActivitySpan(activity) as Span | undefined; -} - export type ProfilerProps = { // The name of the component being profiled. name: string; @@ -95,12 +25,8 @@ export type ProfilerProps = { * spans based on component lifecycles. */ class Profiler extends React.Component { - // The activity representing how long it takes to mount a component. - public mountActivity: number | null = null; - // The span of the mount activity + // The span representing how long it takes to mount a component public mountSpan: Span | undefined = undefined; - // The span of the render - public renderSpan: Span | undefined = undefined; public static defaultProps: Partial = { disabled: false, @@ -116,18 +42,20 @@ class Profiler extends React.Component { return; } - if (getTracingIntegration()) { - 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(); + } } public componentDidUpdate({ updateProps, includeUpdates = true }: ProfilerProps): void { @@ -221,22 +149,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 +184,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..4783934b4e74 100644 --- a/packages/react/test/profiler.test.tsx +++ b/packages/react/test/profiler.test.tsx @@ -5,51 +5,36 @@ 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'); - }, + getScope: () => ({ + getTransaction: () => activeTransaction, + }), }), })); beforeEach(() => { - mockPushActivity.mockClear(); - mockPopActivity.mockClear(); - mockLoggerWarn.mockClear(); - mockGetActivitySpan.mockClear(); mockStartChild.mockClear(); + activeTransaction = new MockSpan({ op: 'pageload' }); }); describe('withProfiler', () => { @@ -75,30 +60,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 +88,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 +104,7 @@ describe('withProfiler', () => { const component = render(); component.unmount(); - expect(mockStartChild).toHaveBeenCalledTimes(0); + expect(mockStartChild).toHaveBeenCalledTimes(1); }); }); @@ -134,33 +112,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 +146,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 +160,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 +179,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: '', From 6d9f140aa57915516cb35c1d22b42f8c31698b20 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 14 Jul 2020 12:17:41 -0400 Subject: [PATCH 09/18] docs: Add upgrade guide --- CHANGELOG.md | 4 +- packages/apm/README.md | 3 ++ packages/tracing/README.md | 52 +++++++++++++++++++ .../tracing/src/browser/browsertracing.ts | 2 +- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3779f0672e1a..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,8 +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) -- [tracing] feat: `Add @sentry/tracing` (#2719) ## 5.19.0 diff --git a/packages/apm/README.md b/packages/apm/README.md index 1d03e21d0c32..deb003af8987 100644 --- a/packages/apm/README.md +++ b/packages/apm/README.md @@ -19,4 +19,7 @@ ## General +Note: This package is deprecated in favour of `@sentry/tracing` and will be removed in a future major release. We are still +publishing `@sentry/apm` packages for backwards compatibility. + This package contains extensions to the `@sentry/hub` to enable APM related functionality. It also provides integrations for Browser and Node that provide a good experience out of the box. diff --git a/packages/tracing/README.md b/packages/tracing/README.md index 05b45f2195f4..ce3719a23852 100644 --- a/packages/tracing/README.md +++ b/packages/tracing/README.md @@ -20,3 +20,55 @@ ## 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/src/browser/browsertracing.ts b/packages/tracing/src/browser/browsertracing.ts index 981cdcf99f19..4c3ef17319b2 100644 --- a/packages/tracing/src/browser/browsertracing.ts +++ b/packages/tracing/src/browser/browsertracing.ts @@ -47,7 +47,7 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions { /** * beforeNavigate is called before a pageload/navigation transaction is created and allows for users - * to set a custom navigation transaction name. Defaults behaviour is to return `window.location.pathname`. + * 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. */ From 54b8494ba50114e917260a69eecad2cc18b2a786 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 14 Jul 2020 15:43:57 -0400 Subject: [PATCH 10/18] fix: Track browser metrics properly --- packages/tracing/src/browser/metrics.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tracing/src/browser/metrics.ts b/packages/tracing/src/browser/metrics.ts index 8bd0722618e4..606e703bfacb 100644 --- a/packages/tracing/src/browser/metrics.ts +++ b/packages/tracing/src/browser/metrics.ts @@ -256,9 +256,9 @@ function addPerformanceNavigationTiming( } transaction.startChild({ description: event, - endTimestamp: end + timeOrigin, + endTimestamp: timeOrigin + msToSec(end), op: 'browser', - startTimestamp: start + timeOrigin, + startTimestamp: timeOrigin + msToSec(start), }); } From 6c4e9d5251bb83e6b76fa47d99c53b00c5e5ddce Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 15 Jul 2020 08:20:20 -0400 Subject: [PATCH 11/18] license: Use MIT license for @sentry/tracing --- packages/tracing/LICENSE | 21 +++++++++++++++++++++ packages/tracing/package.json | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 packages/tracing/LICENSE 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/package.json b/packages/tracing/package.json index e0f5355edb74..2cedc41d7231 100644 --- a/packages/tracing/package.json +++ b/packages/tracing/package.json @@ -5,7 +5,7 @@ "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tracing", "author": "Sentry", - "license": "BSD-3-Clause", + "license": "MIT", "engines": { "node": ">=6" }, From fee0ec291afc22209c94582d96a3f5aebe0c4407 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 15 Jul 2020 11:50:54 -0400 Subject: [PATCH 12/18] build: Ignore node 6 and 8 for tracing tests --- scripts/test.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 From 86b3506ef6b92d72929cab172b33cf193f172660 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 15 Jul 2020 15:26:24 -0400 Subject: [PATCH 13/18] chore: Bump version of @sentry/tracing --- packages/tracing/package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/tracing/package.json b/packages/tracing/package.json index 2cedc41d7231..aefa80c77038 100644 --- a/packages/tracing/package.json +++ b/packages/tracing/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tracing", - "version": "5.19.0", + "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", @@ -16,11 +16,11 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "5.19.0", - "@sentry/hub": "5.19.0", - "@sentry/minimal": "5.19.0", - "@sentry/types": "5.19.0", - "@sentry/utils": "5.19.0", + "@sentry/browser": "5.19.2", + "@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": { From 9b9c02a70a3e23b44b19315b84cd68bb68f1ddfe Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 16 Jul 2020 07:52:43 -0400 Subject: [PATCH 14/18] build: generate tracing bundles --- scripts/pack-and-upload.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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/* From 66b9609d13356aed1677f3910f3899d48a01e4eb Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 16 Jul 2020 07:59:48 -0400 Subject: [PATCH 15/18] fix: Remove circular dependency between span and transaction --- packages/tracing/src/idletransaction.ts | 4 ++-- packages/tracing/src/span.ts | 31 +++++++++++++++++++++++- packages/tracing/src/transaction.ts | 32 +------------------------ 3 files changed, 33 insertions(+), 34 deletions(-) diff --git a/packages/tracing/src/idletransaction.ts b/packages/tracing/src/idletransaction.ts index 5d45dd50958d..fc212ff46ef1 100644 --- a/packages/tracing/src/idletransaction.ts +++ b/packages/tracing/src/idletransaction.ts @@ -3,9 +3,9 @@ import { Hub } from '@sentry/hub'; import { TransactionContext } from '@sentry/types'; import { logger, timestampWithMs } from '@sentry/utils'; -import { Span } from './span'; +import { Span, SpanRecorder } from './span'; import { SpanStatus } from './spanstatus'; -import { SpanRecorder, Transaction } from './transaction'; +import { Transaction } from './transaction'; export const DEFAULT_IDLE_TIMEOUT = 1000; diff --git a/packages/tracing/src/span.ts b/packages/tracing/src/span.ts index eb6c49d42bab..bad9dc1fbc60 100644 --- a/packages/tracing/src/span.ts +++ b/packages/tracing/src/span.ts @@ -1,8 +1,8 @@ +// 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'; -import { SpanRecorder } from './transaction'; export const TRACEPARENT_REGEXP = new RegExp( '^[ \\t]*' + // whitespace @@ -12,6 +12,35 @@ export const TRACEPARENT_REGEXP = new RegExp( '[ \\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 */ diff --git a/packages/tracing/src/transaction.ts b/packages/tracing/src/transaction.ts index 9366fca34613..869724e59f2b 100644 --- a/packages/tracing/src/transaction.ts +++ b/packages/tracing/src/transaction.ts @@ -1,38 +1,8 @@ -// tslint:disable:max-classes-per-file import { getCurrentHub, Hub } from '@sentry/hub'; import { TransactionContext } from '@sentry/types'; import { isInstanceOf, logger } from '@sentry/utils'; -import { Span as SpanClass } from './span'; - -/** - * Keeps track of finished spans for a given transaction - * @internal - * @hideconstructor - * @hidden - */ -export class SpanRecorder { - private readonly _maxlen: number; - public spans: SpanClass[] = []; - - 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: SpanClass): void { - if (this.spans.length > this._maxlen) { - span.spanRecorder = undefined; - } else { - this.spans.push(span); - } - } -} +import { Span as SpanClass, SpanRecorder } from './span'; /** JSDoc */ export class Transaction extends SpanClass { From 149ff4633cca4d659eac8efae36c9405e3a990ae Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 16 Jul 2020 10:26:49 -0400 Subject: [PATCH 16/18] ref: Add side effects true to tracing --- packages/apm/README.md | 3 --- packages/tracing/package.json | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/apm/README.md b/packages/apm/README.md index deb003af8987..1d03e21d0c32 100644 --- a/packages/apm/README.md +++ b/packages/apm/README.md @@ -19,7 +19,4 @@ ## General -Note: This package is deprecated in favour of `@sentry/tracing` and will be removed in a future major release. We are still -publishing `@sentry/apm` packages for backwards compatibility. - This package contains extensions to the `@sentry/hub` to enable APM related functionality. It also provides integrations for Browser and Node that provide a good experience out of the box. diff --git a/packages/tracing/package.json b/packages/tracing/package.json index aefa80c77038..2d32c157ef33 100644 --- a/packages/tracing/package.json +++ b/packages/tracing/package.json @@ -82,5 +82,5 @@ } } }, - "sideEffects": false + "sideEffects": true } From 80edac4c78ecb853db0dfc67d2a57cf82ce81cea Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 16 Jul 2020 11:28:03 -0400 Subject: [PATCH 17/18] build: Only include @sentry/browser for bundle --- packages/tracing/package.json | 2 +- packages/tracing/rollup.config.js | 1 + packages/tracing/src/index.bundle.ts | 1 + yarn.lock | 52 ---------------------------- 4 files changed, 3 insertions(+), 53 deletions(-) diff --git a/packages/tracing/package.json b/packages/tracing/package.json index 2d32c157ef33..e51b95685127 100644 --- a/packages/tracing/package.json +++ b/packages/tracing/package.json @@ -16,7 +16,6 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "5.19.2", "@sentry/hub": "5.19.2", "@sentry/minimal": "5.19.2", "@sentry/types": "5.19.2", @@ -24,6 +23,7 @@ "tslib": "^1.9.3" }, "devDependencies": { + "@sentry/browser": "5.19.2", "@types/express": "^4.17.1", "@types/jsdom": "^16.2.3", "jest": "^24.7.1", diff --git a/packages/tracing/rollup.config.js b/packages/tracing/rollup.config.js index ca17a2f999bd..018af07360ce 100644 --- a/packages/tracing/rollup.config.js +++ b/packages/tracing/rollup.config.js @@ -27,6 +27,7 @@ const paths = { '@sentry/hub': ['../hub/src'], '@sentry/types': ['../types/src'], '@sentry/minimal': ['../minimal/src'], + '@sentry/browser': ['../browser/src'], }; const plugins = [ diff --git a/packages/tracing/src/index.bundle.ts b/packages/tracing/src/index.bundle.ts index e509bbee51c9..f04df5dbd1ae 100644 --- a/packages/tracing/src/index.bundle.ts +++ b/packages/tracing/src/index.bundle.ts @@ -1,3 +1,4 @@ +// tslint:disable: no-implicit-dependencies export { Breadcrumb, Request, diff --git a/yarn.lock b/yarn.lock index 64ea51d83ce2..a09c5d8d6573 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1117,58 +1117,6 @@ universal-user-agent "^2.0.0" url-template "^2.0.8" -"@sentry/browser@5.19.0": - version "5.19.0" - resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.19.0.tgz#9189b6633fe45e54325e40b39345d9eabd171e4a" - integrity sha512-Cz8PnzC5NGfpHIGCmHLgA6iDEBVELwo4Il/iFweXjs4+VL4biw62lI32Q4iLCCpmX0t5UvrWBOnju2v8zuFIjA== - dependencies: - "@sentry/core" "5.19.0" - "@sentry/types" "5.19.0" - "@sentry/utils" "5.19.0" - tslib "^1.9.3" - -"@sentry/core@5.19.0": - version "5.19.0" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.19.0.tgz#31b08a0b46ae1ee6863447225b401ac2a777774c" - integrity sha512-ry1Zms6jrVQPEwmfywItyUhOgabPrykd8stR1x/u2t1YiISWlR813fE5nzdwgW5mxEitXz/ikTwvrfK3egsDWQ== - dependencies: - "@sentry/hub" "5.19.0" - "@sentry/minimal" "5.19.0" - "@sentry/types" "5.19.0" - "@sentry/utils" "5.19.0" - tslib "^1.9.3" - -"@sentry/hub@5.19.0": - version "5.19.0" - resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.19.0.tgz#f38e7745a4980d9fa6c5baeca5605e7e6fecd5ac" - integrity sha512-UFaQLa1XAa02ZcxUmN9GdDpGs3vHK1LpOyYooimX8ttWa4KAkMuP+O5uXH1TJabry6o/cRwYisg2k6PBSy8gxw== - dependencies: - "@sentry/types" "5.19.0" - "@sentry/utils" "5.19.0" - tslib "^1.9.3" - -"@sentry/minimal@5.19.0": - version "5.19.0" - resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.19.0.tgz#aa5a700618608ea79d270280fe77f04bbb44ed5a" - integrity sha512-3FHgirwOuOMF4VlwHboYObPT9c0S9b9y5FW0DGgNt8sJPezS00VaJti/38ZwOHQJ4fJ6ubt/Y3Kz0eBTVxMCCQ== - dependencies: - "@sentry/hub" "5.19.0" - "@sentry/types" "5.19.0" - tslib "^1.9.3" - -"@sentry/types@5.19.0": - version "5.19.0" - resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.19.0.tgz#30c6a05040b644d90337ef8b50d9d59c0872744d" - integrity sha512-NlHLS9mwCEimKUA5vClAwVhXFLsqSF3VJEXU+K61fF6NZx8zfJi/HTrIBtoM4iNSAt9o4XLQatC1liIpBC06tg== - -"@sentry/utils@5.19.0": - version "5.19.0" - resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.19.0.tgz#0e01f84aa67a1cf2ec71faab670230918ea7e9aa" - integrity sha512-EU/T9aJrI8isexRnyDx5InNw/HjSQ0nKI7YWdiwfFrJusqQ/uisnCGK7vw6gGHDgiAHMXW3TJ38NHpNBin6y7Q== - dependencies: - "@sentry/types" "5.19.0" - tslib "^1.9.3" - "@sinonjs/commons@^1", "@sinonjs/commons@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.4.0.tgz#7b3ec2d96af481d7a0321252e7b1c94724ec5a78" From 384ef632c5d54fd9fb9e19f8431ac83cfc4682b2 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 16 Jul 2020 16:47:14 -0400 Subject: [PATCH 18/18] fix: Make sure vue and react are backwards compatible with @sentry/apm --- packages/integrations/src/vue.ts | 77 ++++++++++++++---- packages/react/src/profiler.tsx | 110 ++++++++++++++++++++++---- packages/react/test/profiler.test.tsx | 1 + 3 files changed, 158 insertions(+), 30 deletions(-) diff --git a/packages/integrations/src/vue.ts b/packages/integrations/src/vue.ts index dc9a176ccebf..74f84bf9bfd8 100644 --- a/packages/integrations/src/vue.ts +++ b/packages/integrations/src/vue.ts @@ -1,6 +1,22 @@ -import { EventProcessor, Hub, Integration, Scope, Span, Transaction } 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: { @@ -63,7 +79,7 @@ interface TracingOptions { * Or to an array of specific component names (case-sensitive). */ trackComponents: boolean | string[]; - /** How long to wait until the tracked root span is marked as finished and sent of to Sentry */ + /** How long to wait until the tracked root activity is marked as finished and sent of to Sentry */ timeout: number; /** * List of hooks to keep track of during component lifecycle. @@ -129,6 +145,7 @@ export class Vue implements Integration { private readonly _componentsCache: { [key: string]: string } = {}; private _rootSpan?: Span; private _rootSpanTimer?: ReturnType; + private _tracingActivity?: number; /** * @inheritDoc @@ -212,18 +229,37 @@ export class Vue implements Integration { // On the first handler call (before), it'll be undefined, as `$once` will add it in the future. // However, on the second call (after), it'll be already in place. if (this._rootSpan) { - this._finishRootSpan(now); + this._finishRootSpan(now, getCurrentHub); } else { vm.$once(`hook:${hook}`, () => { - // Create an span on the first event call. There'll be no second call, as rootSpan will be in place, + // Create an activity on the first event call. There'll be no second call, as rootSpan will be in place, // thus new event handler won't be attached. - const activeTransaction = getActiveTransaction(getCurrentHub()); - if (activeTransaction) { - this._rootSpan = activeTransaction.startChild({ - description: 'Application Render', - op: 'Vue', - }); + // 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 + this._tracingActivity = (tracingIntegration as any).constructor.pushActivity('Vue Application Render'); + // tslint:disable-next-line:no-unsafe-any + const transaction = (tracingIntegration as any).constructor.getTransaction(); + if (transaction) { + // tslint:disable-next-line:no-unsafe-any + this._rootSpan = transaction.startChild({ + description: 'Application Render', + op: 'Vue', + }); + } + // Use functionality from @sentry/tracing + } else { + const activeTransaction = getActiveTransaction(getCurrentHub()); + if (activeTransaction) { + this._rootSpan = activeTransaction.startChild({ + description: 'Application Render', + op: 'Vue', + }); + } } }); } @@ -246,7 +282,7 @@ export class Vue implements Integration { // However, on the second call (after), it'll be already in place. if (span) { span.finish(); - this._finishRootSpan(now); + this._finishRootSpan(now, getCurrentHub); } else { vm.$once(`hook:${hook}`, () => { if (this._rootSpan) { @@ -287,13 +323,25 @@ export class Vue implements Integration { }); }; - /** Finish top-level span with a debounce configured using `timeout` option */ - private _finishRootSpan(timestamp: number): void { + /** Finish top-level span and activity with a debounce configured using `timeout` option */ + private _finishRootSpan(timestamp: number, getCurrentHub: () => Hub): void { if (this._rootSpanTimer) { clearTimeout(this._rootSpanTimer); } this._rootSpanTimer = setTimeout(() => { + 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); + } + } + + // We should always finish the span, only should pop activity if using @sentry/apm if (this._rootSpan) { this._rootSpan.finish(timestamp); } @@ -306,7 +354,8 @@ export class Vue implements Integration { this._options.Vue.mixin({ beforeCreate(this: ViewModel): void { - if (getActiveTransaction(getCurrentHub())) { + // 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 { diff --git a/packages/react/src/profiler.tsx b/packages/react/src/profiler.tsx index ccfcb4864b9c..8cff4389ea83 100644 --- a/packages/react/src/profiler.tsx +++ b/packages/react/src/profiler.tsx @@ -1,11 +1,74 @@ import { getCurrentHub, Hub } from '@sentry/browser'; -import { Span, Transaction } from '@sentry/types'; +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'; export const UNKNOWN_COMPONENT = 'unknown'; +const TRACING_GETTER = ({ + id: 'Tracing', +} as any) as IntegrationClass; + +let globalTracingIntegration: Integration | null = null; +/** @deprecated remove when @sentry/apm no longer used */ +const getTracingIntegration = () => { + if (globalTracingIntegration) { + return globalTracingIntegration; + } + + globalTracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER); + return globalTracingIntegration; +}; + +/** + * 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) { + return null; + } + + // tslint:disable-next-line:no-unsafe-any + return (globalTracingIntegration as any).constructor.pushActivity(name, { + description: `<${name}>`, + op: `react.${op}`, + }); +} + +/** + * 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) { + return; + } + + // tslint:disable-next-line:no-unsafe-any + (globalTracingIntegration as any).constructor.popActivity(activity); +} + +/** + * 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) { + return undefined; + } + + // tslint:disable-next-line:no-unsafe-any + return (globalTracingIntegration as any).constructor.getActivitySpan(activity) as Span | undefined; +} + export type ProfilerProps = { // The name of the component being profiled. name: string; @@ -25,8 +88,10 @@ export type ProfilerProps = { * spans based on component lifecycles. */ class Profiler extends React.Component { - // The span representing how long it takes to mount a component - public mountSpan: Span | undefined = undefined; + // The activity representing how long it takes to mount a component. + private _mountActivity: number | null = null; + // The span of the mount activity + private _mountSpan: Span | undefined = undefined; public static defaultProps: Partial = { disabled: false, @@ -42,19 +107,32 @@ class Profiler extends React.Component { return; } - const activeTransaction = getActiveTransaction(); - if (activeTransaction) { - this.mountSpan = activeTransaction.startChild({ - description: `<${name}>`, - op: 'react.mount', - }); + // If they are using @sentry/apm, we need to push/pop activities + // tslint:disable-next-line: deprecation + if (getTracingIntegration()) { + // tslint:disable-next-line: deprecation + this._mountActivity = pushActivity(name, 'mount'); + } else { + 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 { - if (this.mountSpan) { - this.mountSpan.finish(); + 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; } } @@ -62,7 +140,7 @@ class Profiler extends React.Component { // 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]); @@ -70,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, }, @@ -88,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, }); } } diff --git a/packages/react/test/profiler.test.tsx b/packages/react/test/profiler.test.tsx index 4783934b4e74..faaed0a960c6 100644 --- a/packages/react/test/profiler.test.tsx +++ b/packages/react/test/profiler.test.tsx @@ -26,6 +26,7 @@ let activeTransaction: Record; jest.mock('@sentry/browser', () => ({ getCurrentHub: () => ({ + getIntegration: () => undefined, getScope: () => ({ getTransaction: () => activeTransaction, }),