From 62942361ab72ccf0e43768a127015793d06ca224 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 27 Feb 2018 14:30:14 -0500 Subject: [PATCH] Track JS errors in GA (#189) * Track errors in GA w raven-js; TODO: tests, readme Signed-off-by: Joe Farro * Include CSS selector with last error breadcrumb Signed-off-by: Joe Farro * README for GA error tracking Signed-off-by: Joe Farro * Misc cleanup Signed-off-by: Joe Farro * README info on GA Application Tracking Signed-off-by: Joe Farro * Misc fix to tracking README Signed-off-by: Joe Farro * Misc cleanup to raven message conversion to GA Signed-off-by: Joe Farro * Tests for tracking Signed-off-by: Joe Farro * Apply prettier to markdown files Signed-off-by: Joe Farro * Run prettier on *.md files Signed-off-by: Joe Farro * Add tracking.trackErrors feature flag to UI config Signed-off-by: Joe Farro * Upgrade prettier, add doc for tracking.trackErrors Signed-off-by: Joe Farro * Check for breadcrumbs when tracking errors Signed-off-by: Joe Farro * Fix typo, remove unnecessary NODE_ENV guard Signed-off-by: Joe Farro * Comments for get-tracking-version script Signed-off-by: Joe Farro Signed-off-by: vvvprabhakar --- flow-typed/npm/raven-js_v3.17.x.js | 226 +++++++++++ package.json | 14 +- scripts/deploy-docs.sh | 8 - scripts/get-tracking-version.js | 138 +++++++ src/components/App/Page.js | 2 +- src/components/App/Page.test.js | 4 +- src/components/App/TopNav.js | 5 +- src/components/DependencyGraph/index.js | 6 +- .../TraceTimelineViewer/SpanDetail/index.js | 6 +- src/constants/default-config.js | 79 ++-- src/index.js | 14 +- src/utils/DraggableManager/README.md | 13 +- src/utils/config/get-config.js | 22 +- src/utils/config/get-config.test.js | 67 +++- src/utils/tracking/README.md | 202 ++++++++++ src/utils/tracking/conv-raven-to-ga.js | 357 ++++++++++++++++++ .../conv-raven-to-ga.test.js} | 22 +- src/utils/tracking/fixtures.js | 221 +++++++++++ src/utils/tracking/index.js | 195 ++++++++++ src/utils/tracking/index.test.js | 124 ++++++ yarn.lock | 25 +- 21 files changed, 1640 insertions(+), 110 deletions(-) create mode 100644 flow-typed/npm/raven-js_v3.17.x.js delete mode 100755 scripts/deploy-docs.sh create mode 100755 scripts/get-tracking-version.js create mode 100644 src/utils/tracking/README.md create mode 100644 src/utils/tracking/conv-raven-to-ga.js rename src/utils/{metrics.js => tracking/conv-raven-to-ga.test.js} (60%) create mode 100644 src/utils/tracking/fixtures.js create mode 100644 src/utils/tracking/index.js create mode 100644 src/utils/tracking/index.test.js diff --git a/flow-typed/npm/raven-js_v3.17.x.js b/flow-typed/npm/raven-js_v3.17.x.js new file mode 100644 index 0000000000..020cd96c7a --- /dev/null +++ b/flow-typed/npm/raven-js_v3.17.x.js @@ -0,0 +1,226 @@ +// flow-typed signature: e1f97cc57b871f5647a2a5a8567b0b5b +// flow-typed version: 39e54508d9/raven-js_v3.17.x/flow_>=v0.38.x + +type LogLevel = 'critical' | 'error' | 'warning' | 'info' | 'debug'; + +type AutoBreadcrumbOptions = { + xhr?: boolean, + console?: boolean, + dom?: boolean, + location?: boolean, +}; + +type RavenInstrumentationOptions = { + tryCatch?: boolean, +}; + +type Breadcrumb = { + message?: string, + category?: string, + level?: LogLevel, + data?: any, + type?: BreadcrumbType, +}; + +type BreadcrumbType = 'navigation' | 'http'; + +type RavenOptions = { + /** The log level associated with this event. Default: error */ + level?: LogLevel, + + /** The name of the logger used by Sentry. Default: javascript */ + logger?: string, + + /** The environment of the application you are monitoring with Sentry */ + environment?: string, + + /** The release version of the application you are monitoring with Sentry */ + release?: string, + + /** The name of the server or device that the client is running on */ + serverName?: string, + + /** List of messages to be filtered out before being sent to Sentry. */ + ignoreErrors?: (RegExp | string)[], + + /** Similar to ignoreErrors, but will ignore errors from whole urls patching a regex pattern. */ + ignoreUrls?: (RegExp | string)[], + + /** The inverse of ignoreUrls. Only report errors from whole urls matching a regex pattern. */ + whitelistUrls?: (RegExp | string)[], + + /** An array of regex patterns to indicate which urls are a part of your app. */ + includePaths?: (RegExp | string)[], + + /** Additional data to be tagged onto the error. */ + tags?: { + [id: string]: string, + }, + + /** set to true to get the stack trace of your message */ + stacktrace?: boolean, + + extra?: any, + + /** In some cases you may see issues where Sentry groups multiple events together when they should be separate entities. In other cases, Sentry simply doesn’t group events together because they’re so sporadic that they never look the same. */ + fingerprint?: string[], + + /** A function which allows mutation of the data payload right before being sent to Sentry */ + dataCallback?: (data: any) => any, + + /** A callback function that allows you to apply your own filters to determine if the message should be sent to Sentry. */ + shouldSendCallback?: (data: any) => boolean, + + /** By default, Raven does not truncate messages. If you need to truncate characters for whatever reason, you may set this to limit the length. */ + maxMessageLength?: number, + + /** By default, Raven will truncate URLs as they appear in breadcrumbs and other meta interfaces to 250 characters in order to minimize bytes over the wire. This does *not* affect URLs in stack traces. */ + maxUrlLength?: number, + + /** Override the default HTTP data transport handler. */ + transport?: (options: RavenTransportOptions) => void, + + /** Allow use of private/secretKey. */ + allowSecretKey?: boolean, + + /** Enables/disables instrumentation of globals. */ + instrument?: boolean | RavenInstrumentationOptions, + + /** Enables/disables automatic collection of breadcrumbs. */ + autoBreadcrumbs?: boolean | AutoBreadcrumbOptions, +}; + +type RavenTransportOptions = { + url: string, + data: any, + auth: { + sentry_version: string, + sentry_client: string, + sentry_key: string, + }, + onSuccess: () => void, + onFailure: () => void, +}; + +declare module 'raven-js' { + declare type RavenPlugin = { + (raven: Raven, ...args: any[]): Raven, + }; + + declare class Raven { + /** Raven.js version. */ + VERSION: string; + + Plugins: { [id: string]: RavenPlugin }; + + /* + * Allow Raven to be configured as soon as it is loaded + * It uses a global RavenConfig = {dsn: '...', config: {}} + */ + afterLoad(): void; + + /* + * Allow multiple versions of Raven to be installed. + * Strip Raven from the global context and returns the instance. + */ + noConflict(): this; + + /** Configure Raven with a DSN and extra options */ + config(dsn: string, options?: RavenOptions): this; + + /* + * Installs a global window.onerror error handler + * to capture and report uncaught exceptions. + * At this point, install() is required to be called due + * to the way TraceKit is set up. + */ + install(): this; + + /** Adds a plugin to Raven */ + addPlugin(plugin: RavenPlugin, ...pluginArgs: any[]): this; + + /* + * Wrap code within a context so Raven can capture errors + * reliably across domains that is executed immediately. + */ + context(func: Function, ...args: any[]): void; + context(options: RavenOptions, func: Function, ...args: any[]): void; + + /** Wrap code within a context and returns back a new function to be executed */ + wrap(func: Function): Function; + wrap(options: RavenOptions, func: Function): Function; + wrap(func: T): T; + wrap(options: RavenOptions, func: T): T; + + /** Uninstalls the global error handler. */ + uninstall(): this; + + /** Manually capture an exception and send it over to Sentry */ + captureException(ex: Error, options?: RavenOptions): this; + + /** Manually send a message to Sentry */ + captureMessage(msg: string, options?: RavenOptions): this; + + /** Log a breadcrumb */ + captureBreadcrumb(crumb: Breadcrumb): this; + + /** + * Clear the user context, removing the user data that would be sent to Sentry. + */ + setUserContext(): this; + + /** Set a user to be sent along with the payload. */ + setUserContext(user: { + id?: string, + username?: string, + email?: string, + }): this; + + /** Merge extra attributes to be sent along with the payload. */ + setExtraContext(context: Object): this; + + /** Merge tags to be sent along with the payload. */ + setTagsContext(tags: Object): this; + + /** Clear all of the context. */ + clearContext(): this; + + /** Get a copy of the current context. This cannot be mutated.*/ + getContext(): Object; + + /** Override the default HTTP data transport handler. */ + setTransport(transportFunction: (options: RavenTransportOptions) => void): this; + + /** Set environment of application */ + setEnvironment(environment: string): this; + + /** Set release version of application */ + setRelease(release: string): this; + + /** Get the latest raw exception that was captured by Raven.*/ + lastException(): Error; + + /** An event id is a globally unique id for the event that was just sent. This event id can be used to find the exact event from within Sentry. */ + lastEventId(): string; + + /** If you need to conditionally check if raven needs to be initialized or not, you can use the isSetup function. It will return true if Raven is already initialized. */ + isSetup(): boolean; + + /** Specify a function that allows mutation of the data payload right before being sent to Sentry. */ + setDataCallback(data: any, orig?: any): this; + + /** Specify a callback function that allows you to mutate or filter breadcrumbs when they are captured. */ + setBreadcrumbCallback(data: any, orig?: any): this; + + /** Specify a callback function that allows you to apply your own filters to determine if the message should be sent to Sentry. */ + setShouldSendCallback(data: any, orig?: any): this; + + /** Show Sentry user feedback dialog */ + showReportDialog(options: Object): void; + + /** Configure Raven DSN */ + setDSN(dsn: string): void; + } + + declare export default Raven +} diff --git a/package.json b/package.json index 12617786c2..b3fa8cfcc4 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "husky": "^0.14.3", "less-vars-to-js": "^1.2.1", "lint-staged": "^4.0.3", - "prettier": "^1.5.3", + "prettier": "^1.10.2", "react-app-rewire-less": "^2.1.0", "react-app-rewired": "^1.4.0", "react-scripts": "^1.0.11", @@ -63,10 +63,11 @@ "moment": "^2.18.1", "prop-types": "^15.5.10", "query-string": "^5.0.0", + "raven-js": "^3.22.1", "react": "^16.0.0", "react-dimensions": "^1.3.0", "react-dom": "^16.0.0", - "react-ga": "^2.2.0", + "react-ga": "^2.4.1", "react-helmet": "^5.1.3", "react-icons": "^2.2.7", "react-metrics": "^2.3.2", @@ -89,8 +90,9 @@ }, "scripts": { "start": "react-app-rewired start", - "start:docs": "REACT_APP_DEMO=true react-scripts start", - "build": "react-app-rewired build", + "start:ga-debug": + "REACT_APP_GA_DEBUG=1 REACT_APP_VSN_STATE=$(./scripts/get-tracking-version.js) react-app-rewired start", + "build": "REACT_APP_VSN_STATE=$(./scripts/get-tracking-version.js) react-app-rewired build", "eject": "react-scripts eject", "test": "CI=1 react-app-rewired test --env=jsdom --color", "test-dev": "react-app-rewired test --env=jsdom", @@ -98,8 +100,7 @@ "lint": "npm run eslint && npm run prettier && npm run flow && npm run check-license", "eslint": "eslint src", "check-license": "./scripts/check-license.sh", - "prettier": - "prettier --write 'src/**/*.{css,js,json}' '*.{css,js,json}' && prettier --write --print-width 999999 'src/**/*.md' '*.md'", + "prettier": "prettier --write 'src/**/*.{css,js,json,md}' '*.{css,js,json,md}'", "flow": "glow", "precommit": "lint-staged" }, @@ -113,6 +114,7 @@ }, "prettier": { "printWidth": 110, + "proseWrap": "never", "singleQuote": true, "trailingComma": "es5" }, diff --git a/scripts/deploy-docs.sh b/scripts/deploy-docs.sh deleted file mode 100755 index b134956481..0000000000 --- a/scripts/deploy-docs.sh +++ /dev/null @@ -1,8 +0,0 @@ -# from the create-react-app post-build message: -git commit -am "Save local changes" -git checkout -B gh-pages -git add -f build -git commit -am "Rebuild website" -git filter-branch -f --prune-empty --subdirectory-filter build -git push -f origin gh-pages -git checkout - diff --git a/scripts/get-tracking-version.js b/scripts/get-tracking-version.js new file mode 100755 index 0000000000..923415c4aa --- /dev/null +++ b/scripts/get-tracking-version.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node + +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// See the comment on `getVersion(..)` for details on what this script does. + +const spawnSync = require('child_process').spawnSync; + +const version = require('../package.json').version; + +function cleanRemoteUrl(url) { + return url.replace(/^(.*?@|.*?\/\/)|\.git\s*$/gi, '').replace(/:/g, '/'); +} + +function cleanBranchNames(pointsAt) { + const branch = pointsAt.replace(/"/g, '').split('\n')[0]; + const i = branch.indexOf(' '); + const objName = branch.slice(0, i); + let refName = branch.slice(i + 1); + if (refName.indexOf('detached') > -1) { + refName = '(detached)'; + } + return { objName, refName }; +} + +function getChanged(shortstat, status) { + const rv = { hasChanged: false, files: 0, insertions: 0, deletions: 0, untracked: 0 }; + const joiner = []; + const regex = /(\d+) (.)/g; + let match = regex.exec(shortstat); + while (match) { + const [, n, type] = match; + switch (type) { + case 'f': + rv.files = Number(n); + joiner.push(`${n}f`); + break; + case 'i': + rv.insertions = Number(n); + joiner.push(`+${n}`); + break; + case 'd': + rv.deletions = Number(n); + joiner.push(`-${n}`); + break; + default: + throw new Error(`Invalid diff type: ${type}`); + } + match = regex.exec(shortstat); + } + const untracked = status && status.split('\n').filter(line => line[0] === '?').length; + if (untracked) { + rv.untracked = untracked; + joiner.push(`${untracked}?`); + } + rv.pretty = joiner.join(' '); + rv.hasChanged = Boolean(joiner.length); + return rv; +} + +// This util function, which can be used via the CLI or as a module, outputs +// a JSON blob indicating the git state of a repo. It defaults to checking the +// repo at ".", but accepts a working directory. +// +// The output is along the lines of the following: +// +// { +// "version": "0.0.1", +// "remote": "github.com/jaegertracing/jaeger-ui", +// "objName": "64fbc13", +// "changed": { +// "hasChanged": true, +// "files": 1, +// "insertions": 21, +// "deletions": 0, +// "untracked": 0, +// "pretty": "1f +21" +// }, +// "refName": "issue-39-track-js-errors", +// "pretty": "0.0.1 | github.com/jaegertracing/jaeger-ui | 64fbc13 | 1f +21 | issue-39-track-js-errors" +// } +// +// * version: The package.json version +// * remote: The git remote URL (normalized) +// * objName: The short SHA +// * changed: Indicates any changes in the repo +// * changed.pretty: formatted as "2f +3 -4 5?", which indicates two modified +// files having three insertions, 4 deletions, and 5 untracked files +// * refName: The name of the current branch, "(detached)" when the head is detached +// * pretty: A human-readable representation of the above fields +function getVersion(cwd) { + const opts = { cwd, encoding: 'utf8' }; + const url = spawnSync('git', ['remote', 'get-url', '--push', 'origin'], opts).stdout; + const branch = spawnSync( + 'git', + ['branch', '--points-at', 'HEAD', '--format="%(objectname:short) %(refname:short)"'], + opts + ).stdout; + const shortstat = spawnSync('git', ['diff-index', '--shortstat', 'HEAD'], opts).stdout; + const status = spawnSync('git', ['status', '--porcelain', '-uall'], opts).stdout; + + const { objName, refName } = cleanBranchNames(branch); + const remote = cleanRemoteUrl(url); + const joiner = [version, remote, objName]; + const changed = getChanged(shortstat, status); + if (changed.hasChanged) { + joiner.push(changed.pretty); + } + joiner.push(refName); + const rv = { + version, + remote, + objName, + changed, + refName, + pretty: joiner.join(' | '), + }; + return rv; +} + +if (require.main === module) { + const vsn = getVersion(process.argv[2] || '.'); + process.stdout.write(JSON.stringify(vsn)); +} else { + module.exports = getVersion; +} diff --git a/src/components/App/Page.js b/src/components/App/Page.js index 79854f69fc..53cca06ec1 100644 --- a/src/components/App/Page.js +++ b/src/components/App/Page.js @@ -23,7 +23,7 @@ import { withRouter } from 'react-router-dom'; import TopNav from './TopNav'; import type { Config } from '../../types/config'; -import { trackPageView } from '../../utils/metrics'; +import { trackPageView } from '../../utils/tracking'; import './Page.css'; diff --git a/src/components/App/Page.test.js b/src/components/App/Page.test.js index a0f2d8f65c..6761b444e8 100644 --- a/src/components/App/Page.test.js +++ b/src/components/App/Page.test.js @@ -14,13 +14,13 @@ /* eslint-disable import/first */ jest.mock('./TopNav', () => () =>
); -jest.mock('../../utils/metrics'); +jest.mock('../../utils/tracking'); import React from 'react'; import { mount } from 'enzyme'; import { mapStateToProps, PageImpl as Page } from './Page'; -import { trackPageView } from '../../utils/metrics'; +import { trackPageView } from '../../utils/tracking'; describe('mapStateToProps()', () => { it('maps state to props', () => { diff --git a/src/components/App/TopNav.js b/src/components/App/TopNav.js index 3b545652c2..146d105735 100644 --- a/src/components/App/TopNav.js +++ b/src/components/App/TopNav.js @@ -16,12 +16,11 @@ import React from 'react'; import { Dropdown, Icon, Menu } from 'antd'; -import _get from 'lodash/get'; import { Link } from 'react-router-dom'; import TraceIDSearchInput from './TraceIDSearchInput'; import type { ConfigMenuItem, ConfigMenuGroup } from '../../types/config'; -import getConfig from '../../utils/config/get-config'; +import { getConfigValue } from '../../utils/config/get-config'; import prefixUrl from '../../utils/prefix-url'; type TopNavProps = { @@ -36,7 +35,7 @@ const NAV_LINKS = [ }, ]; -if (_get(getConfig(), 'dependencies.menuEnabled')) { +if (getConfigValue('dependencies.menuEnabled')) { NAV_LINKS.push({ to: prefixUrl('/dependencies'), text: 'Dependencies', diff --git a/src/components/DependencyGraph/index.js b/src/components/DependencyGraph/index.js index d7b382eb05..350828d275 100644 --- a/src/components/DependencyGraph/index.js +++ b/src/components/DependencyGraph/index.js @@ -14,7 +14,6 @@ import React, { Component } from 'react'; import { Tabs } from 'antd'; -import _get from 'lodash/get'; import PropTypes from 'prop-types'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; @@ -27,7 +26,7 @@ import * as jaegerApiActions from '../../actions/jaeger-api'; import { FALLBACK_DAG_MAX_NUM_SERVICES } from '../../constants'; import { nodesPropTypes, linksPropTypes } from '../../propTypes/dependencies'; import { formatDependenciesAsNodesAndLinks } from '../../selectors/dependencies'; -import getConfig from '../../utils/config/get-config'; +import { getConfigValue } from '../../utils/config/get-config'; import './index.css'; @@ -39,8 +38,7 @@ export const GRAPH_TYPES = { DAG: { type: 'DAG', name: 'DAG' }, }; -const dagMaxNumServices = - _get(getConfig(), 'dependencies.dagMaxNumServices') || FALLBACK_DAG_MAX_NUM_SERVICES; +const dagMaxNumServices = getConfigValue('dependencies.dagMaxNumServices') || FALLBACK_DAG_MAX_NUM_SERVICES; export default class DependencyGraphPage extends Component { static propTypes = { diff --git a/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js b/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js index f0df932f28..8e7c13ff36 100644 --- a/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js +++ b/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js @@ -59,7 +59,11 @@ export default function SpanDetail(props: SpanDetailProps) {

{operationName}

- +
diff --git a/src/constants/default-config.js b/src/constants/default-config.js index cff63f9f3c..94335badc9 100644 --- a/src/constants/default-config.js +++ b/src/constants/default-config.js @@ -16,47 +16,62 @@ import deepFreeze from 'deep-freeze'; import { FALLBACK_DAG_MAX_NUM_SERVICES } from './index'; -export default deepFreeze({ - dependencies: { - dagMaxNumServices: FALLBACK_DAG_MAX_NUM_SERVICES, - menuEnabled: true, - }, - menu: [ +export default deepFreeze( + Object.defineProperty( { - label: 'About Jaeger', - items: [ - { - label: 'GitHub', - url: 'https://github.com/uber/jaeger', - }, - { - label: 'Docs', - url: 'http://jaeger.readthedocs.io/en/latest/', - }, - { - label: 'Twitter', - url: 'https://twitter.com/JaegerTracing', - }, + dependencies: { + dagMaxNumServices: FALLBACK_DAG_MAX_NUM_SERVICES, + menuEnabled: true, + }, + tracking: { + gaID: null, + trackErrors: true, + }, + menu: [ { - label: 'Discussion Group', - url: 'https://groups.google.com/forum/#!forum/jaeger-tracing', - }, - { - label: 'Gitter.im', - url: 'https://gitter.im/jaegertracing/Lobby', - }, - { - label: 'Blog', - url: 'https://medium.com/jaegertracing/', + label: 'About Jaeger', + items: [ + { + label: 'GitHub', + url: 'https://github.com/uber/jaeger', + }, + { + label: 'Docs', + url: 'http://jaeger.readthedocs.io/en/latest/', + }, + { + label: 'Twitter', + url: 'https://twitter.com/JaegerTracing', + }, + { + label: 'Discussion Group', + url: 'https://groups.google.com/forum/#!forum/jaeger-tracing', + }, + { + label: 'Gitter.im', + url: 'https://gitter.im/jaegertracing/Lobby', + }, + { + label: 'Blog', + url: 'https://medium.com/jaegertracing/', + }, + ], }, ], }, - ], -}); + // fields that should be individually merged vs wholesale replaced + '__mergeFields', + { value: ['tracking', 'dependencies'] } + ) +); export const deprecations = [ { formerKey: 'dependenciesMenuEnabled', currentKey: 'dependencies.menuEnabled', }, + { + formerKey: 'gaTrackingID', + currentKey: 'tracking.gaID', + }, ]; diff --git a/src/index.js b/src/index.js index 004ee47082..c0d45b58a4 100644 --- a/src/index.js +++ b/src/index.js @@ -12,15 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -/* eslint-disable import/first */ - import React from 'react'; import ReactDOM from 'react-dom'; import { document } from 'global'; import JaegerUIApp from './components/App'; -import { init as initTracking } from './utils/metrics'; +import { context as trackingContext } from './utils/tracking'; +/* eslint-disable import/first */ import 'u-basscss/css/flexbox.css'; import 'u-basscss/css/layout.css'; import 'u-basscss/css/margin.css'; @@ -28,11 +27,12 @@ import 'u-basscss/css/padding.css'; import 'u-basscss/css/position.css'; import 'u-basscss/css/typography.css'; -initTracking(); - const UI_ROOT_ID = 'jaeger-ui-root'; -/* istanbul ignore if */ -if (document && process.env.NODE_ENV !== 'test') { +if (trackingContext) { + trackingContext.context(() => { + ReactDOM.render(, document.getElementById(UI_ROOT_ID)); + }); +} else { ReactDOM.render(, document.getElementById(UI_ROOT_ID)); } diff --git a/src/utils/DraggableManager/README.md b/src/utils/DraggableManager/README.md index d45d6c5632..59b1b939e2 100644 --- a/src/utils/DraggableManager/README.md +++ b/src/utils/DraggableManager/README.md @@ -68,7 +68,7 @@ Note: Not all handlers are always necessary. See "Mouse events need to be piped console.log('position along the width: ', localX / width); }} /> -
; +
``` In other words, DraggableManager instances convert the data to the relevant context. (The "relevant context" is, naturally, varies... see the `getBounds()` constructor parameter below). @@ -107,15 +107,20 @@ For instance, if implementing a draggable divider (see `DividerDemo.js` and the ```jsx
-
; +
``` But, if implementing the ability to drag a sub-range (see `RegionDemo.js` and the bottom of demo gif), you generally want to show a vertical line at the mouse cursor until the dragging starts (`onMouseDown`), then you want to draw the region being dragged. So, the `onMouseMove`, `onMouseLeave` and `onMouseDown` handlers are necessary: ```jsx -
+
{/* Draw visuals for the currently dragged range, otherwise empty */} -
; +
``` ### `getBounds()` constructor parameter diff --git a/src/utils/config/get-config.js b/src/utils/config/get-config.js index e3fa0a85e7..c490d700fd 100644 --- a/src/utils/config/get-config.js +++ b/src/utils/config/get-config.js @@ -14,6 +14,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import _get from 'lodash/get'; + import processDeprecation from './process-deprecation'; import defaultConfig, { deprecations } from '../../constants/default-config'; @@ -35,10 +37,26 @@ export default function getConfig() { return { ...defaultConfig }; } const embedded = getJaegerUiConfig(); + if (!embedded) { + return { ...defaultConfig }; + } // check for deprecated config values - if (embedded && Array.isArray(deprecations)) { + if (Array.isArray(deprecations)) { deprecations.forEach(deprecation => processDeprecation(embedded, deprecation, !haveWarnedDeprecations)); haveWarnedDeprecations = true; } - return { ...defaultConfig, ...embedded }; + const rv = { ...defaultConfig, ...embedded }; + // __mergeFields config values should be merged instead of fully replaced + const keys = defaultConfig.__mergeFields || []; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (typeof embedded[key] === 'object' && embedded[key] !== null) { + rv[key] = { ...defaultConfig[key], ...embedded[key] }; + } + } + return rv; +} + +export function getConfigValue(path: string) { + return _get(getConfig(), path); } diff --git a/src/utils/config/get-config.test.js b/src/utils/config/get-config.test.js index d3fec37aa1..a2ed3f8854 100644 --- a/src/utils/config/get-config.test.js +++ b/src/utils/config/get-config.test.js @@ -15,16 +15,10 @@ /* eslint-disable no-console, import/first */ jest.mock('./process-deprecation'); -jest.mock('../../constants/default-config', () => { - const actual = require.requireActual('../../constants/default-config'); - // make sure there are deprecations - const deprecations = [{ currentKey: 'current.key', formerKey: 'former.key' }]; - return { default: actual.default, deprecations }; -}); -import getConfig from './get-config'; +import getConfig, { getConfigValue } from './get-config'; import processDeprecation from './process-deprecation'; -import defaultConfig from '../../constants/default-config'; +import defaultConfig, { deprecations } from '../../constants/default-config'; describe('getConfig()', () => { let oldWarn; @@ -63,25 +57,68 @@ describe('getConfig()', () => { window.getJaegerUiConfig = getJaegerUiConfig; }); + it('returns the default config when the embedded config is `null`', () => { + embedded = null; + expect(getConfig()).toEqual(defaultConfig); + }); + it('merges the defaultConfig with the embedded config ', () => { embedded = { novel: 'prop' }; expect(getConfig()).toEqual({ ...defaultConfig, ...embedded }); }); - it('gives precedence to the embedded config', () => { - embedded = {}; - Object.keys(defaultConfig).forEach(key => { - embedded[key] = key; + describe('overwriting precedence and merging', () => { + describe('fields not in __mergeFields', () => { + it('gives precedence to the embedded config', () => { + const mergeFields = new Set(defaultConfig.__mergeFields); + const keys = Object.keys(defaultConfig).filter(k => !mergeFields.has(k)); + embedded = {}; + keys.forEach(key => { + embedded[key] = key; + }); + expect(getConfig()).toEqual({ ...defaultConfig, ...embedded }); + }); + }); + + describe('fields in __mergeFields', () => { + it('gives precedence to non-objects in embedded', () => { + embedded = {}; + defaultConfig.__mergeFields.forEach((k, i) => { + embedded[k] = i ? true : null; + }); + expect(getConfig()).toEqual({ ...defaultConfig, ...embedded }); + }); + + it('merges object values', () => { + embedded = {}; + const key = defaultConfig.__mergeFields[0]; + if (!key) { + throw new Error('invalid __mergeFields'); + } + embedded[key] = { a: true, b: false }; + const expected = { ...defaultConfig, ...embedded }; + expected[key] = { ...defaultConfig[key], ...embedded[key] }; + expect(getConfig()).toEqual(expected); + }); }); - expect(getConfig()).toEqual(embedded); }); it('processes deprecations every time `getConfig` is invoked', () => { processDeprecation.mockClear(); getConfig(); - expect(processDeprecation.mock.calls.length).toBe(1); + expect(processDeprecation.mock.calls.length).toBe(deprecations.length); getConfig(); - expect(processDeprecation.mock.calls.length).toBe(2); + expect(processDeprecation.mock.calls.length).toBe(2 * deprecations.length); }); }); }); + +describe('getConfigValue(...)', () => { + it('returns embedded paths, e.g. "a.b"', () => { + expect(getConfigValue('dependencies.menuEnabled')).toBe(true); + }); + + it('handles non-existent paths"', () => { + expect(getConfigValue('not.a.real.path')).toBe(undefined); + }); +}); diff --git a/src/utils/tracking/README.md b/src/utils/tracking/README.md new file mode 100644 index 0000000000..33d431896b --- /dev/null +++ b/src/utils/tracking/README.md @@ -0,0 +1,202 @@ +# Google Analytics (GA) Tracking In Jaeger UI + +Page-views and errors are tracked in production when a GA tracking ID is provided in the UI config and error tracking is not disabled via the UI config. See the [documentation](http://jaeger.readthedocs.io/en/latest/deployment/#ui-configuration) for details on the UI config. + +The page-view tracking is pretty basic, so details aren't provided. The GA tracking is configured with [App Tracking](https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#apptracking) data. These fields, described [below](#app-tracking), can be used as a secondary dimension when viewing event data in GA. The error tracking is described, [below](#error-tracking). + +## App Tracking + +The following fields are sent for each GA session: + +* [Application Name](https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#appName) + * Set to `Jaeger UI` +* [Application ID](https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#appId) + * Set to `github.com/jaegertracing/jaeger-ui` +* [Application Version](https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#appVersion) + * Example: `0.0.1 | github.com/jaegertracing/jaeger-ui | 8c50c6c | 2f +2 -12 | master` + * A dynamic value set to: ` | | | | ` + * Truncated to 96 characters + * **version** - `package.json#version` + * **git remote** - `git remote get-url --push origin`, normalized + * **short SHA** - `git branch --points-at HEAD --format="%(objectname:short)"` + * **diff shortstat** - A compacted `git diff-index --shortstat HEAD` + * E.g. `2f +3 -4 5?` + * 2 modified files, having + * 3 insertions and + * 4 deletions + * 5 untracked files + * **branch name** - `$ git branch --points-at HEAD --format="%(refname:short)"` + * `(detached)` is used when HEAD is detached because the SHA is already noted + +## Error Tracking + +Raven.js is used to capture error data ([GitHub](https://github.com/getsentry/raven-js), [docs](https://docs.sentry.io/clients/javascript/)). Once captured, the error data is transformed and sent to GA. + +### How Are Errors Being Tracked In GA? + +For every error we learn of, two GA calls are issued: + +* An [exception](https://developers.google.com/analytics/devguides/collection/analyticsjs/exceptions) +* An [event](https://developers.google.com/analytics/devguides/collection/analyticsjs/events) + +GA exception tracking is pretty minimal, allowing just a [150 byte string](https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#exDescription). So, in addition to the exception, an event with additional data is also issued. + +* [Category](https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#eventCategory) - The page type the error occurred on +* [Action](https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#eventAction) - Error information with a compacted stack trace (sans sourcemaps, at this time) +* [Label](https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#eventLabel) - A compact form of the breadcrumbs +* [Value](https://developers.google.com/analytics/devguides/collection/analyticsjs/field-reference#eventValue) - The duration of the session when the error occurred (in seconds) + +#### Event: Category - Which Page + +The category indicates which type of page the error occurred on, and will be one of the following: + +* `jaeger/home/error` - The site root +* `jaeger/search/error` - The search page +* `jaeger/trace/error` - The trace page +* `jaeger/dependencies/error` - The Dependencies page + +#### Event: Action - Error Info + +The action contains: + +* The error message (truncated to 149 characters) +* A compact form of the git status (SHA and diff shortstat) +* The page URL with the origin and path-prefix removed (truncated to 50 characters) +* A compact form of the stack trace (without the benefit of sourcemaps, at this time) + +For example, the following error: + +``` +Error: test-sentry + at o (main.1ae26b34.js:1) + at e.value (main.1ae26b34.js:1) + at scrollToNextVisibleSpan (main.1ae26b34.js:1) + at e.exports [as fireCallback] (main.1ae26b34.js:1) + at e.exports [as handleKey] (main.1ae26b34.js:1) + at e.exports (chunk.6b341ae2.js:1) + at HTMLBodyElement.r (chunk.6b341ae2.js:1) +``` + +Might be tracked as the following event action (without the comments): + +``` +! test-sentry # error message +/trace/abc123def # relevant portion of the URL +8c50c6c 2f +33 -56 1? # commit SHA and 2 edited files, 1 unknown file + # stack trace starts +> main.1ae26b34.js # source file for the following frames +o # function `o` in main.1ae26b34.js +e.value # function `e.value` in main.1ae26b34.js +scrollToNextVisibleSpan # etc... +e.exports [as fireCallback] +e.exports [as handleKey] +> chunk.6b341ae2.js # source file for the following frames +e.exports # function `e.exports` in chunk.6b341ae2.js +HTMLBodyElement.r # also in chunk.6b341ae2.js +``` + +The `+33 -56` means there are 33 inserted lines and 56 deleted lines in the edits made to the two tracked files. + +Note: The git status is determined when the build is generated or when `yarn start` is initially executed to start the dev server. + +#### Event: Label - Breadcrumbs + +The label contains: + +* The error message (truncated to 149 characters) +* The type of page the error occurred on +* The duration, in seconds, of the session when the error occurred +* A compact form of the git status (SHA and diff shortstat) +* A compact form of breadcrumbs, with older entries preceding newer entries + +For example, the following label: + +``` +! Houston we have a problem +trace +18 +8c50c6c 2f +34 -56 1? + +[tr|404] + +sr +[svc][op]cic + +sd +[sr]c3 + +tr +cc{.SpanTreeOffset.is-parent >.SpanTreeOffset--iconWrapper} +! test-sentry +``` + +Indicates: + +* `! Houston...` - The error message is `Error: Houston we have a problem` +* `trace` - The error occurred on the trace page +* `18` - The error occurred 18 seconds into the session +* `8c50c6c 2f +34 -56 1?` - The build was generated from commit `8c50c6c` with two modified files and one untracked file +* The sequence of events indicated by the breadcrumbs is (oldest to most recent): + * On the first page of the session + * `[tr|404]` - A HTTP call to fetch a trace returned a `404` status code + * `sr` - Next, on the search page + * `[svc]` - The services were fetched with a `200` status code + * `[op]` - The operations for a service were fetched with a `200` status code + * `c` - 1 click + * `i` - 1 text input + * `c` - 1 click + * `sd` - Next, on a search page showing results + * `[sr]` - A HTTP call to execute a search returned a `200` status code + * `c3` - 3 click UI interactions + * `tr` - Next, on a trace page + * `cc` - 2 clicks + * `c{.SpanTree...}` - The second click is the last UI breadcrumb, so it is shown with a CSS selector related to the click event target. The CSS selector is "related" instead of "identifying" because it's been simplified. + * `! test-sentry` - An error with the message `Error: test-sentry` + * The error being tracked occurred — implicit as the next event + +The cryptic encoding for the breadcrumbs is used to fit as much of the event history into the 500 characters as possible. It might turn out that fewer events with more details is preferable. In which case, the payload will be adjusted. For now, the encoding is: + +* `[sym]` - A fetch to `sym` resulted in a `200` status code, possible values for `sym` are: + * `svc` - Fetch the services for the search page + * `op` - Fetch the operations for a service + * `sr` - Execute a search + * `tr` - Fetch a trace + * `dp` - Fetch the dependency data + * `??` - Unknown fetch (should not happen) +* `[sym|NNN]` - The status code was `NNN`, omitted for `200` status codes +* `\n\nsym\n` - Navigation to `sym` + * Page navigation tokens are on their own line and have an empty line above them, e.g. empty lines separate events that occurred on different pages + * `sym` indicates the type of page, valid values are: + * `dp` - Dependencies page + * `tr` - Trace page + * `sd` - Search page with search results + * `sr` - Search page + * `rt` - The root page + * `??` - Uknown page (should not happen) +* `c` or `i` - Indicates a user interaction + * `c` is click + * `i` is input + * `cN` - Indicates `c` occurred `N` consecutive times, e.g. 3 clicks would be `c3` and `i2` is two input breadcrumbs + * `c{selector}` - Indicates `c` was the last UI breadcrumb, and the CSS selector `selector` describes the event target + * Takes for the form `i{selector}` for input events +* `! ` - A previous error that was tracked, truncated to 58 characters + * The first occurrence of `/error/i` is removed + * The first `:` is replaced with `!` + +### [Sentry](https://github.com/getsentry) Is Not Being Used + +Using Sentry is currently under consideration. In the meantime, errors can be tracked with GA. + +### Why Use Raven.js + +You get a lot for free when using Raven.js: + +* [Breadcrumbs](https://docs.sentry.io/learn/breadcrumbs/), which include: + * [`fetch`](https://github.com/getsentry/raven-js/blob/master/src/raven.js#L1242) HTTP requests + * [Previous errors](https://github.com/getsentry/raven-js/blob/master/src/raven.js#L1872) + * Some [UI events](https://github.com/getsentry/raven-js/blob/master/src/raven.js#L870) (click and input) + * [URL changes](https://github.com/getsentry/raven-js/blob/master/src/raven.js#L945) +* Stack traces are [normalized](https://github.com/getsentry/raven-js/blob/f8eec063c95f70d8978f895284946bd278748d97/vendor/TraceKit/tracekit.js) +* Some global handlers are added + +Implementing the above from scratch would require substantial effort. Meanwhile, Raven.js is well tested. diff --git a/src/utils/tracking/conv-raven-to-ga.js b/src/utils/tracking/conv-raven-to-ga.js new file mode 100644 index 0000000000..95e24f7490 --- /dev/null +++ b/src/utils/tracking/conv-raven-to-ga.js @@ -0,0 +1,357 @@ +// @flow + +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* eslint-disable camelcase */ + +import prefixUrl from '../prefix-url'; + +const UNKNOWN_SYM = { sym: '??', word: '??' }; + +const NAV_SYMBOLS = [ + { sym: 'dp', word: 'dependencies', rx: /^\/dep/i }, + { sym: 'tr', word: 'trace', rx: /^\/trace/i }, + { sym: 'sd', word: 'search', rx: /^\/search\?./i }, + { sym: 'sr', word: 'search', rx: /^\/search/i }, + { sym: 'rt', word: 'home', rx: /^\/$/ }, +]; + +const FETCH_SYMBOLS = [ + { sym: 'svc', word: '', rx: /^\/api\/services$/i }, + { sym: 'op', word: '', rx: /^\/api\/.*?operations$/i }, + { sym: 'sr', word: '', rx: /^\/api\/traces\?/i }, + { sym: 'tr', word: '', rx: /^\/api\/traces\/./i }, + { sym: 'dp', word: '', rx: /^\/api\/dep/i }, + { sym: '__IGNORE__', word: '', rx: /\.js(\.map)?$/i }, +]; + +// eslint-disable-next-line no-console +const warn = console.warn.bind(console); + +// common aspect of local URLs +const origin = window.location.origin + prefixUrl(''); + +// truncate and use "~" instead of ellipsis bc it's shorter +function truncate(str, len, front = false) { + if (str.length > len) { + if (!front) { + return `${str.slice(0, len - 1)}~`; + } + return `~${str.slice(1 - len)}`; + } + return str; +} + +// Replace newlines with "|" and collapse whitespace to " " +function collapseWhitespace(value) { + return value + .trim() + .replace(/\n/g, '|') + .replace(/\s\s+/g, ' ') + .trim(); +} + +// shorten URLs to eitehr a short code or a word +function getSym(syms, str) { + for (let i = 0; i < syms.length; i++) { + const { rx } = syms[i]; + if (rx.test(str)) { + return syms[i]; + } + } + warn(`Unable to find symbol for: "${str}"`); + return UNKNOWN_SYM; +} + +// Convert an error message to a shorter string with the first "error" removed, +// a leading "! " added, and the first ":" replaced with "!". +// +// Error: Houston we have a problem +// ! Houston we have a problem +// +// Error: HTTP Error: Fetch failed +// ! HTTP Error: Fetch failed +// +// TypeError: Awful things are happening +// ! Type! Awful things are happening +// +// The real error message +// ! The real error message +function convErrorMessage(message, maxLen = 0) { + let msg = collapseWhitespace(message); + const parts = ['! ']; + const j = msg.indexOf(':'); + if (j > -1) { + const start = msg + .slice(0, j) + .replace(/error/i, '') + .trim(); + if (start) { + parts.push(start, '! '); + } + msg = msg.slice(j + 1); + } + parts.push(msg.trim()); + const rv = parts.join(''); + return maxLen ? truncate(rv, maxLen) : parts.join(''); +} + +// Convert an exception to the error message and a compacted stack trace. The +// message is truncated to 149 characters. The strack trace is compacted to the +// following format: +// From (array of objects): +// { filename: 'http://origin/static/js/main.js', function: 'aFn' } +// { filename: 'http://origin/static/js/main.js', function: 'bFn' } +// { filename: 'http://origin/static/js/chunk.js', function: 'cFn' } +// { filename: 'http://origin/static/js/chunk.js', function: 'dFn' } +// To (string): +// > main.js +// aFn +// bFn +// > chunk.js +// cFn +// dFn +function convException(errValue) { + const message = convErrorMessage(`${errValue.type}: ${errValue.value}`, 149); + const frames = errValue.stacktrace.frames.map(fr => { + const filename = fr.filename.replace(origin, '').replace(/^\/static\/js\//i, ''); + const fn = collapseWhitespace(fr.function); + return { filename, fn }; + }); + const joiner = []; + let lastFile = ''; + for (let i = frames.length - 1; i >= 0; i--) { + const { filename, fn } = frames[i]; + if (lastFile !== filename) { + joiner.push(`> ${filename}`); + lastFile = filename; + } + joiner.push(fn); + } + return { message, stack: joiner.join('\n') }; +} + +// Convert a navigation breadcrumb to one of the following string tokens: +// "dp" - dependencies page +// "tr" - trace page +// "sd" - search page with search results +// "sr" - search page +// "rt" - the root page +function convNav(to: string) { + const sym = getSym(NAV_SYMBOLS, to); + return sym.sym; +} + +// Convert a HTTP fetch breadcrumb to a string token in one of the two +// following forms: +// "[SYM]" +// "[SYM|NNN]" +// Where "SYM" is one of: +// "svc" - fetch the services for the search page +// "op" - fetch the operations for a service +// "sr" - execute a search +// "tr" - fetch a trace +// "dp" - fetch the dependency data +// And, "NNN" is a non-200 status code. +function convFetch(data: { url: string, status_code: number }) { + const { url, status_code } = data; + const statusStr = status_code === 200 ? '' : `|${status_code}`; + const sym = getSym(FETCH_SYMBOLS, url); + if (sym.sym === '__IGNORE__') { + return null; + } + return `[${sym.sym}${statusStr}]`; +} + +// Reduce the selector to something similar, but more compact. This is an +// informal reduction, i.e. the selector may actually function completely +// differently, but it should suffice as a reference for UI events. The +// intention is to trim the selector to something more compact but still +// recognizable. +// +// Some examples of the conversion: +// +// div.ub-relative. > span > span.detail-row-expanded-accent +// => .detail-row-expanded-accent +// +// header > div.TracePageHeader--titleRow > button.ant-btn.ub-mr2[type="button"] +// => .TracePageHeader--titleRow >.ant-btn[type="button"] +// +// span.SpanTreeOffset.is-parent > span.SpanTreeOffset--iconWrapper +// => .SpanTreeOffset.is-parent >.SpanTreeOffset--iconWrapper +// +// div > div > div.AccordianLogs > a.AccordianLogs--header. +// => .AccordianLogs >.AccordianLogs--header +// +// body > div > div > div.ant-modal-wrap. +// => .ant-modal-wrap +// +// a.ub-flex-auto.ub-mr2 > h1.TracePageHeader--title +// => .TracePageHeader--title +function compressCssSelector(selector) { + return ( + selector + // cut dangling dots, "div. > div" to "div > div" + .replace(/\.(?=\s|$)/g, '') + // cut ub-* class names, "a.ub-p.is-ok" to "a.is-ok" + .replace(/\.ub-[^. [:]+/g, '') + // cut leading tags, "div > a > .cls" to ".cls" + .replace(/^(\w+ > )+/, '') + // cut tag names when there is also a class, "a.is-ok" to ".is-ok" + .replace(/(^| )\w+?(?=\.)/g, '$1') + // cut the first space in child selectors, ".is-ok > .yuh" to ".is-ok >.yuh" + .replace(/ > /g, ' >') + ); +} + +// Convert the breadcrumbs to a compact string, discarding quite a lot of +// information. +// +// Navigation and HTTP fetch breadcrumbs are described above in `convFetch()` +// and `convNav()`. +// +// Previously logged errors captured by sentry are truncated to 58 characters +// and placed on their own line. Further, the first occurrence of "error" is +// removed and the first ":" is replaced with "!". E.g. the message: +// "Error: some error here with a very long message that will be truncated" +// Becomes: +// "\n! some error here with a very long message that will be t~\n" +// +// UI breadcrumbs are reduced to the first letter after the "ui.". And, +// repeated tokens are compacted to the form: +// "tN" +// Where "t" is the event type ("c" is click, "i" is input) and "N" is the +// total number of times it occured in that sequence. E.g. "c2" indicates +// two "ui.click" breadcrumbs. +// +// The chronological ordering of the breadcrumbs is older events precede newer +// events. This ordering was kept because it's easier to see which page events +// occurred on. +function convBreadcrumbs(crumbs) { + if (!Array.isArray(crumbs) || !crumbs.length) { + return ''; + } + // the last UI breadcrumb has the CSS selector included + let iLastUi = -1; + for (let i = crumbs.length - 1; i >= 0; i--) { + if (crumbs[i].category.slice(0, 2) === 'ui') { + iLastUi = i; + break; + } + } + let joiner: string[] = []; + // note when we're on a newline to avoid extra newlines + let onNewLine = true; + for (let i = 0; i < crumbs.length; i++) { + const c = crumbs[i]; + const cStart = c.category.split('.')[0]; + switch (cStart) { + case 'fetch': { + const fetched = convFetch(c.data); + if (fetched) { + joiner.push(fetched); + onNewLine = false; + } + break; + } + + case 'navigation': { + const nav = `${onNewLine ? '' : '\n'}\n${convNav(c.data.to)}\n`; + joiner.push(nav); + onNewLine = true; + break; + } + + case 'ui': { + if (i === iLastUi) { + const selector = compressCssSelector(c.message); + joiner.push(`${c.category[3]}{${selector}}`); + } else { + joiner.push(c.category[3]); + } + onNewLine = false; + break; + } + + case 'sentry': { + const msg = convErrorMessage(c.message, 58); + joiner.push(`${onNewLine ? '' : '\n'}${msg}\n`); + onNewLine = true; + break; + } + + default: + // skip + } + } + joiner = joiner.filter(Boolean); + // combine repeating UI chars, e.g. ["c","c","c","c"] -> ["c","4"] + let c = ''; + let ci = -1; + const compacted = joiner.reduce((accum: string[], value: string, j: number): string[] => { + if (value === c) { + return accum; + } + if (c) { + if (j - ci > 1) { + accum.push(String(j - ci)); + } + c = ''; + ci = -1; + } + accum.push(value); + if (value.length === 1) { + c = value; + ci = j; + } + return accum; + }, []); + if (c && ci !== joiner.length - 1) { + compacted.push(String(joiner.length - ci)); + } + return compacted + .join('') + .trim() + .replace(/\n\n\n/g, '\n'); +} + +// Create the GA label value from the message, page, duration, git info, and +// breadcrumbs. See <./README.md> for details. +function getLabel(message, page, duration, git, breadcrumbs) { + const header = [message, page, duration, git, ''].filter(v => v != null).join('\n'); + const crumbs = convBreadcrumbs(breadcrumbs); + return `${header}\n${truncate(crumbs, 498 - header.length, true)}`; +} + +// Convert the Raven exception data to something that can be sent to Google +// Analytics. See <./README.md> for details. +export default function convRavenToGa({ data }: RavenTransportOptions) { + const { breadcrumbs, exception, extra, request, tags } = data; + const { message, stack } = convException(exception.values[0]); + const url = truncate(request.url.replace(origin, ''), 50); + const { word: page } = getSym(NAV_SYMBOLS, url); + const value = Math.round(extra['session:duration'] / 1000); + const category = `jaeger/${page}/error`; + let action = [message, tags && tags.git, url, '', stack].filter(v => v != null).join('\n'); + action = truncate(action, 499); + const label = getLabel(message, page, value, tags && tags.git, breadcrumbs && breadcrumbs.values); + return { + message, + category, + action, + label, + value, + }; +} diff --git a/src/utils/metrics.js b/src/utils/tracking/conv-raven-to-ga.test.js similarity index 60% rename from src/utils/metrics.js rename to src/utils/tracking/conv-raven-to-ga.test.js index 6b443137af..58b6ef2661 100644 --- a/src/utils/metrics.js +++ b/src/utils/tracking/conv-raven-to-ga.test.js @@ -12,18 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import ReactGA from 'react-ga'; +import convRavenToGa from './conv-raven-to-ga'; +import { RAVEN_PAYLOAD, RAVEN_TO_GA } from './fixtures'; -import getConfig from './config/get-config'; - -export function init() { - const config = getConfig(); - if (process.env.NODE_ENV === 'production' && config.gaTrackingID) { - ReactGA.initialize(config.gaTrackingID); - } -} - -export function trackPageView(pathname, search) { - const pagePath = search ? `${pathname}?${search}` : pathname; - ReactGA.pageview(pagePath); -} +describe('convRavenToGa()', () => { + it('converts the raven-js payload to { category, action, label, value }', () => { + const data = convRavenToGa(RAVEN_PAYLOAD); + expect(data).toEqual(RAVEN_TO_GA); + }); +}); diff --git a/src/utils/tracking/fixtures.js b/src/utils/tracking/fixtures.js new file mode 100644 index 0000000000..0f0c9f57b5 --- /dev/null +++ b/src/utils/tracking/fixtures.js @@ -0,0 +1,221 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import deepFreeze from 'deep-freeze'; + +const poeExcerpt = ` +Excerpt of Alone, by Edgar Allen Poe: +"Then—in my childhood—in the dawn +Of a most stormy life—was drawn +From the red cliff of the mountain— +From the sun that ’round me roll’d +In its autumn tint of gold— +From the lightning in the sky +As it pass’d me flying by— +From the thunder, and the storm +And the cloud that took the form +(When the rest of Heaven was blue) +Of a demon in my view—" +3/17/1829`; + +module.exports.RAVEN_PAYLOAD = deepFreeze({ + data: { + request: { + url: 'http://localhost/trace/565c1f00385ebd0b', + }, + exception: { + values: [ + { + type: 'Error', + value: 'test-sentry', + stacktrace: { + frames: [ + { + filename: 'http://localhost/static/js/ultra-long-func.js', + function: poeExcerpt, + }, + { + filename: 'http://localhost/static/js/b.js', + function: 'fnBb', + }, + { + filename: 'http://localhost/static/js/b.js', + function: 'fnBa', + }, + { + filename: 'http://localhost/static/js/a.js', + function: 'fnAb', + }, + { + filename: 'http://localhost/static/js/a.js', + function: 'fnAa', + }, + { + filename: 'http://localhost/static/js/a.js', + function: 'HTMLBodyElement.wrapped', + }, + ], + }, + }, + ], + }, + tags: { + git: 'SHA shortstat', + }, + extra: { + 'session:duration': 10952, + }, + breadcrumbs: { + values: [ + { + category: 'sentry', + message: '6 Breadcrumbs should be truncated from the top (oldest)', + }, + { + category: 'sentry', + message: '5 Breadcrumbs should be truncated from the top (oldest)', + }, + { + category: 'sentry', + message: '4 Breadcrumbs should be truncated from the top (oldest)', + }, + { + category: 'sentry', + message: '3 Breadcrumbs should be truncated from the top (oldest)', + }, + { + category: 'sentry', + message: '2 Breadcrumbs should be truncated from the top (oldest)', + }, + { + category: 'sentry', + message: '1 Breadcrumbs should be truncated from the top (oldest)', + }, + { + category: 'sentry', + message: '0 Breadcrumbs should be truncated from the top (oldest)', + }, + { + type: 'http', + category: 'fetch', + data: { + url: '/api/traces/565c1f00385ebd0b', + status_code: 200, + }, + }, + { + type: 'http', + category: 'fetch', + data: { + url: '/api/traces/565c1f00385ebd0b', + status_code: 404, + }, + }, + { + type: 'http', + category: 'fetch', + data: { + url: '/unknown/url/1', + status_code: 200, + }, + }, + { + category: 'navigation', + data: { + to: '/trace/cde2457775afa8d2', + }, + }, + { + category: 'navigation', + data: { + to: '/uknonwn/url', + }, + }, + { + category: 'sentry', + message: 'Error: test-sentry', + }, + { + category: 'sentry', + message: + "TypeError: A very long message that will be truncated and reduced to a faint flicker of it's former glory", + }, + { + category: 'ui.click', + }, + { + category: 'ui.input', + }, + { + category: 'ui.click', + }, + { + category: 'ui.click', + }, + { + category: 'ui.input', + }, + { + category: 'ui.input', + }, + { + category: 'ui.input', + message: 'header > ul.LabeledList.TracePageHeader--overviewItems', + }, + ], + }, + }, +}); + +const action = `! test-sentry +SHA shortstat +/trace/565c1f00385ebd0b + +> a.js +HTMLBodyElement.wrapped +fnAa +fnAb +> b.js +fnBa +fnBb +> ultra-long-func.js +Excerpt of Alone, by Edgar Allen Poe:|"Then—in my childhood—in the dawn|Of a most stormy life—was drawn|From the red cliff of the mountain—|From the sun that ’round me roll’d|In its autumn tint of gold—|From the lightning in the sky|As it pass’d me flying by—|From the thunder, and the storm|And the cloud that took the form|(When the rest of Heaven was blue)|Of a d~`; + +const label = `! test-sentry +trace +11 +SHA shortstat + +~om the top (oldest) +! 4 Breadcrumbs should be truncated from the top (oldest) +! 3 Breadcrumbs should be truncated from the top (oldest) +! 2 Breadcrumbs should be truncated from the top (oldest) +! 1 Breadcrumbs should be truncated from the top (oldest) +! 0 Breadcrumbs should be truncated from the top (oldest) +[tr][tr|404][??] + +tr + +?? +! test-sentry +! Type! A very long message that will be truncated and re~ +cic2i2i{.LabeledList.TracePageHeader--overviewItems}`; + +module.exports.RAVEN_TO_GA = deepFreeze({ + action, + label, + message: '! test-sentry', + category: 'jaeger/trace/error', + value: 11, +}); diff --git a/src/utils/tracking/index.js b/src/utils/tracking/index.js new file mode 100644 index 0000000000..195ce28b2e --- /dev/null +++ b/src/utils/tracking/index.js @@ -0,0 +1,195 @@ +// @flow + +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import _get from 'lodash/get'; +import queryString from 'query-string'; +import ReactGA from 'react-ga'; +import Raven from 'raven-js'; + +import convRavenToGa from './conv-raven-to-ga'; +import getConfig from '../config/get-config'; + +type EventData = { + category: string, + action?: string, + label?: string, + value?: number, +}; + +const EVENT_LENGTHS = { + action: 499, + category: 149, + label: 499, +}; + +// Util so "0" and "false" become false +const isTruish = value => Boolean(value) && value !== '0' && value !== 'false'; + +const isProd = process.env.NODE_ENV === 'production'; +const isDev = process.env.NODE_ENV === 'development'; +const isTest = process.env.NODE_ENV === 'test'; + +// In test mode if development and envvar REACT_APP_GA_DEBUG is true-ish +const isDebugMode = + (isDev && isTruish(process.env.REACT_APP_GA_DEBUG)) || + isTruish(queryString.parse(_get(window, 'location.search'))['ga-debug']); + +const config = getConfig(); +const gaID = _get(config, 'tracking.gaID'); +// enable for tests, debug or if in prod with a GA ID +const isGaEnabled = isTest || isDebugMode || (isProd && Boolean(gaID)); +const isErrorsEnabled = isDebugMode || (isGaEnabled && Boolean(_get(config, 'tracking.trackErrors'))); + +/* istanbul ignore next */ +function logTrackingCalls() { + const calls = ReactGA.testModeAPI.calls; + for (let i = 0; i < calls.length; i++) { + // eslint-disable-next-line no-console + console.log('[react-ga]', ...calls[i]); + } + calls.length = 0; +} + +export function trackPageView(pathname: string, search: ?string) { + if (isGaEnabled) { + const pagePath = search ? `${pathname}${search}` : pathname; + ReactGA.pageview(pagePath); + if (isDebugMode) { + logTrackingCalls(); + } + } +} + +export function trackError(description: string) { + if (isGaEnabled) { + let msg = description; + if (!/^jaeger/i.test(msg)) { + msg = `jaeger/${msg}`; + } + msg = msg.slice(0, 149); + ReactGA.exception({ description: msg, fatal: false }); + if (isDebugMode) { + logTrackingCalls(); + } + } +} + +export function trackEvent(data: EventData) { + if (isGaEnabled) { + const event = {}; + let category = data.category; + if (!category) { + category = 'jaeger/event'; + } else if (!/^jaeger/i.test(category)) { + category = `jaeger/${category}`.slice(0, EVENT_LENGTHS.category); + } else { + category = category.slice(0, EVENT_LENGTHS.category); + } + event.category = category; + event.action = data.action ? data.action.slice(0, EVENT_LENGTHS.action) : 'jaeger/action'; + if (data.label) { + event.label = data.label.slice(0, EVENT_LENGTHS.label); + } + if (data.value != null) { + event.value = Number(data.value); + } + ReactGA.event(event); + if (isDebugMode) { + logTrackingCalls(); + } + } +} + +function trackRavenError(ravenData: RavenTransportOptions) { + const data = convRavenToGa(ravenData); + if (isDebugMode) { + /* istanbul ignore next */ + Object.keys(data).forEach(key => { + if (key === 'message') { + return; + } + let valueLen = ''; + if (typeof data[key] === 'string') { + valueLen = `- value length: ${data[key].length}`; + } + // eslint-disable-next-line no-console + console.log(key, valueLen); + // eslint-disable-next-line no-console + console.log(data[key]); + }); + } + trackError(data.message); + trackEvent(data); +} + +// Tracking needs to be initialized when this file is imported, e.g. early in +// the process of initializing the app, so Raven can wrap various resources, +// like `fetch()`, and generate breadcrumbs from them. + +if (isGaEnabled) { + let versionShort; + let versionLong; + if (process.env.REACT_APP_VSN_STATE) { + try { + const data = JSON.parse(process.env.REACT_APP_VSN_STATE); + const joiner = [data.objName]; + if (data.changed.hasChanged) { + joiner.push(data.changed.pretty); + } + versionShort = joiner.join(' '); + versionLong = data.pretty; + } catch (_) { + versionShort = process.env.REACT_APP_VSN_STATE; + versionLong = process.env.REACT_APP_VSN_STATE; + } + versionLong = versionLong.length > 99 ? `${versionLong.slice(0, 96)}...` : versionLong; + } else { + versionShort = 'unknown'; + versionLong = 'unknown'; + } + const gaConfig = { testMode: isTest || isDebugMode, titleCase: false }; + ReactGA.initialize(gaID || 'debug-mode', gaConfig); + ReactGA.set({ + appId: 'github.com/jaegertracing/jaeger-ui', + appName: 'Jaeger UI', + appVersion: versionLong, + }); + if (isErrorsEnabled) { + const ravenConfig = { + autoBreadcrumbs: { + xhr: true, + console: false, + dom: true, + location: true, + }, + environment: process.env.NODE_ENV || 'unkonwn', + transport: trackRavenError, + tags: {}, + }; + if (versionShort && versionShort !== 'unknown') { + ravenConfig.tags.git = versionShort; + } + Raven.config('https://fakedsn@omg.com/1', ravenConfig).install(); + window.onunhandledrejection = function trackRejectedPromise(evt) { + Raven.captureException(evt.reason); + }; + } + if (isDebugMode) { + logTrackingCalls(); + } +} + +export const context = isErrorsEnabled ? Raven : null; diff --git a/src/utils/tracking/index.test.js b/src/utils/tracking/index.test.js new file mode 100644 index 0000000000..3f4c59896d --- /dev/null +++ b/src/utils/tracking/index.test.js @@ -0,0 +1,124 @@ +// Copyright (c) 2017 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* eslint-disable import/first */ +jest.mock('./conv-raven-to-ga', () => () => ({ message: 'jaeger/a' })); + +jest.mock('./index', () => { + process.env.REACT_APP_VSN_STATE = '{}'; + return require.requireActual('./index'); +}); + +import ReactGA from 'react-ga'; + +import * as tracking from './index'; + +let longStr = '---'; +function getStr(len: number) { + while (longStr.length < len) { + longStr += longStr.slice(0, len - longStr.length); + } + return longStr.slice(0, len); +} + +describe('tracking', () => { + let calls; + + beforeEach(() => { + calls = ReactGA.testModeAPI.calls; + calls.length = 0; + }); + + describe('trackPageView', () => { + it('tracks a page view', () => { + tracking.trackPageView('a', 'b'); + expect(calls).toEqual([['send', { hitType: 'pageview', page: 'ab' }]]); + }); + + it('ignores search when it is falsy', () => { + tracking.trackPageView('a'); + expect(calls).toEqual([['send', { hitType: 'pageview', page: 'a' }]]); + }); + }); + + describe('trackError', () => { + it('tracks an error', () => { + tracking.trackError('a'); + expect(calls).toEqual([ + ['send', { hitType: 'exception', exDescription: jasmine.any(String), exFatal: false }], + ]); + }); + + it('ensures "jaeger" is prepended', () => { + tracking.trackError('a'); + expect(calls).toEqual([['send', { hitType: 'exception', exDescription: 'jaeger/a', exFatal: false }]]); + }); + + it('truncates if needed', () => { + const str = `jaeger/${getStr(200)}`; + tracking.trackError(str); + expect(calls).toEqual([ + ['send', { hitType: 'exception', exDescription: str.slice(0, 149), exFatal: false }], + ]); + }); + }); + + describe('trackEvent', () => { + it('tracks an event', () => { + tracking.trackEvent({ value: 10 }); + expect(calls).toEqual([ + [ + 'send', + { + hitType: 'event', + eventCategory: jasmine.any(String), + eventAction: jasmine.any(String), + eventValue: 10, + }, + ], + ]); + }); + + it('prepends "jaeger/" to the category, if needed', () => { + tracking.trackEvent({ category: 'a' }); + expect(calls).toEqual([ + ['send', { hitType: 'event', eventCategory: 'jaeger/a', eventAction: jasmine.any(String) }], + ]); + }); + + it('truncates values, if needed', () => { + const str = `jaeger/${getStr(600)}`; + tracking.trackEvent({ category: str, action: str, label: str }); + expect(calls).toEqual([ + [ + 'send', + { + hitType: 'event', + eventCategory: str.slice(0, 149), + eventAction: str.slice(0, 499), + eventLabel: str.slice(0, 499), + }, + ], + ]); + }); + }); + + it('converting raven-js errors', () => { + window.onunhandledrejection({ reason: new Error('abc') }); + expect(calls).toEqual([ + ['send', { hitType: 'exception', exDescription: jasmine.any(String), exFatal: false }], + ['send', { hitType: 'event', eventCategory: jasmine.any(String), eventAction: jasmine.any(String) }], + ]); + }); +}); diff --git a/yarn.lock b/yarn.lock index 8e0dbef6b9..c61d77539c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6807,9 +6807,9 @@ preserve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" -prettier@^1.5.3: - version "1.8.2" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.8.2.tgz#bff83e7fd573933c607875e5ba3abbdffb96aeb8" +prettier@^1.10.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.10.2.tgz#1af8356d1842276a99a5b5529c82dd9e9ad3cc93" pretty-bytes@^4.0.2: version "4.0.2" @@ -7010,6 +7010,10 @@ range-parser@^1.0.3, range-parser@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" +raven-js@^3.22.1: + version "3.22.1" + resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.22.1.tgz#1117f00dfefaa427ef6e1a7d50bbb1fb998a24da" + raw-body@2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89" @@ -7399,13 +7403,12 @@ react-error-overlay@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-4.0.0.tgz#d198408a85b4070937a98667f500c832f86bd5d4" -react-ga@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/react-ga/-/react-ga-2.2.0.tgz#45235de1356e4d988d9b82214d615a08489c9291" - dependencies: - create-react-class "^15.5.2" - object-assign "^4.0.1" - prop-types "^15.5.6" +react-ga@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/react-ga/-/react-ga-2.4.1.tgz#dfbd5f028ed39a07067f7a8bf57dc0d240000767" + optionalDependencies: + prop-types "^15.6.0" + react "^15.6.2 || ^16.0" react-helmet@^5.1.3: version "5.1.3" @@ -7637,7 +7640,7 @@ react-vis@^1.7.2: react-motion "^0.4.8" react-test-renderer "^15.5.4" -react@^16.0.0: +"react@^15.6.2 || ^16.0", react@^16.0.0: version "16.2.0" resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba" dependencies: