diff --git a/CHANGELOG.md b/CHANGELOG.md index d959aa0f59..b11ae5473f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changes merged into master +### [#191](https://github.com/jaegertracing/jaeger-ui/pull/191) Add GA event tracking for actions in trace view + +* Partially addresses [#157](https://github.com/jaegertracing/jaeger-ui/issues/157) - Enhanced Google Analytics integration + +### [#198](https://github.com/jaegertracing/jaeger-ui/pull/198) Use `` and config webpack at runtime to allow path prefix + +* Fix [#42](https://github.com/jaegertracing/jaeger-ui/issues/42) - No support for Jaeger behind a reverse proxy + +### [#195](https://github.com/jaegertracing/jaeger-ui/pull/195) Handle Error stored in redux trace.traces + +* Fix [#166](https://github.com/jaegertracing/jaeger-ui/issues/166) - JS error on search page after viewing 404 trace + ### [#192](https://github.com/jaegertracing/jaeger-ui/pull/192) Change fallback trace name to be more informative * Fix [#190](https://github.com/jaegertracing/jaeger-ui/issues/190) - Change `cannot-find-trace-name` to `trace-without-root-span` diff --git a/src/components/TracePage/KeyboardShortcutsHelp.css b/src/components/TracePage/KeyboardShortcutsHelp.css index b7c0990072..1cafbe6a76 100644 --- a/src/components/TracePage/KeyboardShortcutsHelp.css +++ b/src/components/TracePage/KeyboardShortcutsHelp.css @@ -24,7 +24,6 @@ limitations under the License. border: 1px solid #e8e8e8; border-bottom: 1px solid #ddd; color: #000; - margin-right: 0.4em; font-family: monospace; padding: 0.25em 0.3em; } diff --git a/src/components/TracePage/KeyboardShortcutsHelp.js b/src/components/TracePage/KeyboardShortcutsHelp.js index bf6b9cc96d..f2037ecf61 100644 --- a/src/components/TracePage/KeyboardShortcutsHelp.js +++ b/src/components/TracePage/KeyboardShortcutsHelp.js @@ -18,6 +18,7 @@ import React from 'react'; import { Button, Modal, Table } from 'antd'; import { kbdMappings } from './keyboard-shortcuts'; +import track from './KeyboardShortcutsHelp.track'; import './KeyboardShortcutsHelp.css'; @@ -56,13 +57,14 @@ function convertKeys(keyConfig: string | string[]): string[][] { } function helpModal() { + track(); const data = []; Object.keys(kbdMappings).forEach(title => { const keyConfigs = convertKeys(kbdMappings[title]); data.push( ...keyConfigs.map(config => ({ key: String(config), - kbds: config.map(s => {s}), + kbds: {config.join(' ')}, description: descriptions[title], })) ); diff --git a/src/components/TracePage/KeyboardShortcutsHelp.track.js b/src/components/TracePage/KeyboardShortcutsHelp.track.js new file mode 100644 index 0000000000..4655d0c9df --- /dev/null +++ b/src/components/TracePage/KeyboardShortcutsHelp.track.js @@ -0,0 +1,22 @@ +// @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 { OPEN } from '../../utils/tracking/common'; +import { trackEvent } from '../../utils/tracking'; + +const CATEGORY = 'jaeger/ux/trace/kbd-modal'; + +export default trackEvent.bind(null, CATEGORY, OPEN); diff --git a/src/components/TracePage/SpanGraph/ViewingLayer.js b/src/components/TracePage/SpanGraph/ViewingLayer.js index 174fc6d889..6c2a063b0a 100644 --- a/src/components/TracePage/SpanGraph/ViewingLayer.js +++ b/src/components/TracePage/SpanGraph/ViewingLayer.js @@ -28,7 +28,7 @@ import './ViewingLayer.css'; type ViewingLayerProps = { height: number, numTicks: number, - updateViewRangeTime: (number, number) => void, + updateViewRangeTime: (number, number, ?string) => void, updateNextViewRangeTime: ViewRangeTimeUpdate => void, viewRange: ViewRange, }; @@ -188,7 +188,7 @@ export default class ViewingLayer extends React.PureComponent { @@ -220,7 +220,7 @@ export default class ViewingLayer extends React.PureComponent', () => { wrapper.instance()._handleReframeDragEnd({ manager, value }); expect(manager.resetBounds.mock.calls).toEqual([[]]); const calls = props.updateViewRangeTime.mock.calls; - expect(calls).toEqual([[value, value]]); + expect(calls).toEqual([[value, value, 'minimap']]); }); it('handles dragged left (anchor is greater)', () => { @@ -154,7 +154,7 @@ describe('', () => { expect(manager.resetBounds.mock.calls).toEqual([[]]); const calls = props.updateViewRangeTime.mock.calls; - expect(calls).toEqual([[value, anchor]]); + expect(calls).toEqual([[value, anchor, 'minimap']]); }); it('handles dragged right (anchor is less)', () => { @@ -167,7 +167,7 @@ describe('', () => { expect(manager.resetBounds.mock.calls).toEqual([[]]); const calls = props.updateViewRangeTime.mock.calls; - expect(calls).toEqual([[anchor, value]]); + expect(calls).toEqual([[anchor, value, 'minimap']]); }); }); }); @@ -251,7 +251,7 @@ describe('', () => { instance._handleScrubberDragEnd(_case.dragUpdate); expect(wrapper.state('preventCursorLine')).toBe(false); expect(manager.resetBounds.mock.calls).toEqual([[]]); - expect(props.updateViewRangeTime).lastCalledWith(..._case.viewRangeUpdate); + expect(props.updateViewRangeTime).lastCalledWith(..._case.viewRangeUpdate, 'minimap'); }); }); }); diff --git a/src/components/TracePage/TracePageHeader.js b/src/components/TracePage/TracePageHeader.js index aa06c67fc5..af35859782 100644 --- a/src/components/TracePage/TracePageHeader.js +++ b/src/components/TracePage/TracePageHeader.js @@ -22,6 +22,7 @@ import IoIosFilingOutline from 'react-icons/lib/io/ios-filing-outline'; import { Link } from 'react-router-dom'; import * as markers from './TracePageHeader.markers'; +import { trackAltViewOpen } from './TracePageHeader.track'; import KeyboardShortcutsHelp from './KeyboardShortcutsHelp'; import LabeledList from '../common/LabeledList'; import { FALLBACK_TRACE_NAME } from '../../constants'; @@ -109,12 +110,22 @@ export default function TracePageHeader(props: TracePageHeaderProps) { const viewMenu = ( - + Trace JSON - + Trace JSON (unadjusted) diff --git a/src/components/TracePage/TracePageHeader.track.js b/src/components/TracePage/TracePageHeader.track.js new file mode 100644 index 0000000000..023b18f749 --- /dev/null +++ b/src/components/TracePage/TracePageHeader.track.js @@ -0,0 +1,27 @@ +// @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 { getToggleValue, OPEN } from '../../utils/tracking/common'; +import { trackEvent } from '../../utils/tracking'; + +const CATEGORY_ALT_VIEW = 'jaeger/ux/trace/alt-view'; +const CATEGORY_SLIM_HEADER = 'jaeger/ux/trace/slim-header'; + +// use a closure instead of bind to prevent forwarding any arguments to trackEvent() +export const trackAltViewOpen = () => trackEvent(CATEGORY_ALT_VIEW, OPEN); + +export const trackSlimHeaderToggle = (isOpen: boolean) => + trackEvent(CATEGORY_SLIM_HEADER, getToggleValue(isOpen)); diff --git a/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js b/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js index 946b8df61e..d0eb11d797 100644 --- a/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js +++ b/src/components/TracePage/TraceTimelineViewer/SpanDetail/AccordianKeyValues.js @@ -25,7 +25,7 @@ import KeyValuesTable from './KeyValuesTable'; import './AccordianKeyValues.css'; type AccordianKeyValuesProps = { - className: ?string, + className?: ?string, data: { key: string, value: any }[], highContrast?: boolean, isOpen: boolean, @@ -86,5 +86,6 @@ export default function AccordianKeyValues(props: AccordianKeyValuesProps) { } AccordianKeyValues.defaultProps = { + className: null, highContrast: false, }; diff --git a/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js b/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js index 8e7c13ff36..cec43234ad 100644 --- a/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js +++ b/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.js @@ -1,3 +1,5 @@ +// @flow + // Copyright (c) 2017 Uber Technologies, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.js b/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.js index 333a61390a..ba3fc2838a 100644 --- a/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.js +++ b/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.js @@ -30,7 +30,7 @@ type TimelineHeaderRowProps = { numTicks: number, onColummWidthChange: number => void, updateNextViewRangeTime: ViewRangeTimeUpdate => void, - updateViewRangeTime: (number, number) => void, + updateViewRangeTime: (number, number, ?string) => void, viewRangeTime: ViewRangeTime, }; diff --git a/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.js b/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.js index 16d52f3de5..42bfd34770 100644 --- a/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.js +++ b/src/components/TracePage/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.js @@ -31,7 +31,7 @@ type TimelineViewingLayerProps = { */ boundsInvalidator: ?any, updateNextViewRangeTime: ViewRangeTimeUpdate => void, - updateViewRangeTime: (number, number) => void, + updateViewRangeTime: (number, number, ?string) => void, viewRangeTime: ViewRangeTime, }; @@ -179,7 +179,7 @@ export default class TimelineViewingLayer extends React.PureComponent', () => { wrapper.setProps({ viewRangeTime }); instance._draggerReframe._onDragEnd({ manager, value }); expect(manager.resetBounds.mock.calls).toEqual([[]]); - expect(props.updateViewRangeTime.mock.calls).toEqual([[anchor, shift]]); + expect(props.updateViewRangeTime.mock.calls).toEqual([[anchor, shift, 'timeline-header']]); }); }); diff --git a/src/components/TracePage/TraceTimelineViewer/duck.js b/src/components/TracePage/TraceTimelineViewer/duck.js index 2217c60e5c..ed8cda9300 100644 --- a/src/components/TracePage/TraceTimelineViewer/duck.js +++ b/src/components/TracePage/TraceTimelineViewer/duck.js @@ -45,7 +45,7 @@ export function newInitialState({ spanNameColumnWidth = null, traceID = null } = }; } -const actionTypes = generateActionTypes('@jaeger-ui/trace-timeline-viewer', [ +export const actionTypes = generateActionTypes('@jaeger-ui/trace-timeline-viewer', [ 'SET_TRACE', 'SET_SPAN_NAME_COLUMN_WIDTH', 'CHILDREN_TOGGLE', diff --git a/src/components/TracePage/TraceTimelineViewer/duck.track.js b/src/components/TracePage/TraceTimelineViewer/duck.track.js new file mode 100644 index 0000000000..77fa53d616 --- /dev/null +++ b/src/components/TracePage/TraceTimelineViewer/duck.track.js @@ -0,0 +1,68 @@ +// @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 type { Store } from 'redux'; + +import { actionTypes as types } from './duck'; +import { trackEvent } from '../../../utils/tracking'; +import { getToggleValue } from '../../../utils/tracking/common'; + +const ACTION_RESIZE = 'resize'; + +const CATEGORY_BASE = 'jaeger/ux/trace/timeline'; +// export for tests +export const CATEGORY_TAGS = `${CATEGORY_BASE}/tags`; +export const CATEGORY_PROCESS = `${CATEGORY_BASE}/process`; +export const CATEGORY_LOGS = `${CATEGORY_BASE}/logs`; +export const CATEGORY_LOGS_ITEM = `${CATEGORY_BASE}/logs/item`; +export const CATEGORY_COLUMN = `${CATEGORY_BASE}/column`; +export const CATEGORY_PARENT = `${CATEGORY_BASE}/parent`; +export const CATEGORY_ROW = `${CATEGORY_BASE}/row`; + +function trackParent(store: Store, action: any) { + const st = store.getState(); + const { spanID } = action.payload; + const traceID = st.traceTimeline.traceID; + const isHidden = st.traceTimeline.childrenHiddenIDs.has(spanID); + const span = st.trace.traces[traceID].spans.find(sp => sp.spanID === spanID); + if (span) { + trackEvent(CATEGORY_PARENT, getToggleValue(!isHidden), span.depth); + } +} + +const logs = (isOpen: boolean) => trackEvent(CATEGORY_LOGS, getToggleValue(isOpen)); +const logsItem = (isOpen: boolean) => trackEvent(CATEGORY_LOGS_ITEM, getToggleValue(isOpen)); +const process = (isOpen: boolean) => trackEvent(CATEGORY_PROCESS, getToggleValue(isOpen)); +const tags = (isOpen: boolean) => trackEvent(CATEGORY_TAGS, getToggleValue(isOpen)); +const detailRow = (isOpen: boolean) => trackEvent(CATEGORY_ROW, getToggleValue(isOpen)); +const columnWidth = (_, action) => + trackEvent(CATEGORY_COLUMN, ACTION_RESIZE, Math.round(action.payload.width * 1000)); + +const getDetail = (store, action) => store.getState().traceTimeline.detailStates.get(action.payload.spanID); + +export const middlewareHooks = { + [types.CHILDREN_TOGGLE]: trackParent, + [types.DETAIL_TOGGLE]: (store, action) => detailRow(Boolean(getDetail(store, action))), + [types.DETAIL_TAGS_TOGGLE]: (store, action) => tags(getDetail(store, action).isTagsOpen), + [types.DETAIL_PROCESS_TOGGLE]: (store, action) => process(getDetail(store, action).isProcessOpen), + [types.DETAIL_LOGS_TOGGLE]: (store, action) => logs(getDetail(store, action).logs.isOpen), + [types.DETAIL_LOG_ITEM_TOGGLE]: (store, action) => { + const detail = getDetail(store, action); + const { logItem } = action.payload; + logsItem(detail.logs.openedItems.has(logItem)); + }, + [types.SET_SPAN_NAME_COLUMN_WIDTH]: columnWidth, +}; diff --git a/src/components/TracePage/TraceTimelineViewer/duck.track.test.js b/src/components/TracePage/TraceTimelineViewer/duck.track.test.js new file mode 100644 index 0000000000..87950d2b7c --- /dev/null +++ b/src/components/TracePage/TraceTimelineViewer/duck.track.test.js @@ -0,0 +1,116 @@ +// 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('../../../utils/tracking'); + +import DetailState from './SpanDetail/DetailState'; +import * as track from './duck.track'; +import { actionTypes as types } from './duck'; +import { trackEvent } from '../../../utils/tracking'; + +describe('middlewareHooks', () => { + const traceID = 'ABC'; + const spanID = 'abc'; + const spanDepth = 123; + const columnWidth = { real: 0.15, tracked: 150 }; + const payload = { spanID }; + const state = { + trace: { + traces: { + [traceID]: { + spans: [{ spanID, depth: spanDepth }], + }, + }, + }, + traceTimeline: { + traceID, + childrenHiddenIDs: new Map(), + detailStates: new Map([[spanID, new DetailState()]]), + }, + }; + const store = { + getState() { + return state; + }, + }; + + beforeEach(trackEvent.mockClear); + + const cases = [ + { + msg: 'tracks a GA event for resizing the span name column', + type: types.SET_SPAN_NAME_COLUMN_WIDTH, + payloadCustom: { width: columnWidth.real }, + category: track.CATEGORY_COLUMN, + extraTrackArgs: [columnWidth.tracked], + }, + { + msg: 'tracks a GA event for collapsing a parent', + type: types.CHILDREN_TOGGLE, + category: track.CATEGORY_PARENT, + extraTrackArgs: [123], + }, + { + msg: 'tracks a GA event for toggling a detail row', + type: types.DETAIL_TOGGLE, + category: track.CATEGORY_ROW, + }, + { + msg: 'tracks a GA event for toggling the span tags', + type: types.DETAIL_TAGS_TOGGLE, + category: track.CATEGORY_TAGS, + }, + { + msg: 'tracks a GA event for toggling the span tags', + type: types.DETAIL_PROCESS_TOGGLE, + category: track.CATEGORY_PROCESS, + }, + { + msg: 'tracks a GA event for toggling the span logs view', + type: types.DETAIL_LOGS_TOGGLE, + category: track.CATEGORY_LOGS, + }, + { + msg: 'tracks a GA event for toggling the span logs view', + type: types.DETAIL_LOG_ITEM_TOGGLE, + payloadCustom: { ...payload, logItem: {} }, + category: track.CATEGORY_LOGS_ITEM, + }, + ]; + + cases.forEach(_case => { + const { msg, type, category, extraTrackArgs = [], payloadCustom = null } = _case; + it(msg, () => { + const action = { type, payload: payloadCustom || payload }; + track.middlewareHooks[type](store, action); + expect(trackEvent.mock.calls.length).toBe(1); + expect(trackEvent.mock.calls[0]).toEqual([category, expect.any(String), ...extraTrackArgs]); + }); + }); + + it('has the correct keys and they refer to functions', () => { + expect(Object.keys(track.middlewareHooks).sort()).toEqual( + [ + types.CHILDREN_TOGGLE, + types.DETAIL_TOGGLE, + types.DETAIL_TAGS_TOGGLE, + types.DETAIL_PROCESS_TOGGLE, + types.DETAIL_LOGS_TOGGLE, + types.DETAIL_LOG_ITEM_TOGGLE, + types.SET_SPAN_NAME_COLUMN_WIDTH, + ].sort() + ); + }); +}); diff --git a/src/components/TracePage/TraceTimelineViewer/index.js b/src/components/TracePage/TraceTimelineViewer/index.js index d03daf878e..c3bc9a0469 100644 --- a/src/components/TracePage/TraceTimelineViewer/index.js +++ b/src/components/TracePage/TraceTimelineViewer/index.js @@ -33,7 +33,7 @@ type TraceTimelineViewerProps = { textFilter: ?string, trace: Trace, updateNextViewRangeTime: ViewRangeTimeUpdate => void, - updateViewRangeTime: (number, number) => void, + updateViewRangeTime: (number, number, ?string) => void, viewRange: ViewRange, }; diff --git a/src/components/TracePage/index.js b/src/components/TracePage/index.js index c1f8f35c8b..6344585ec5 100644 --- a/src/components/TracePage/index.js +++ b/src/components/TracePage/index.js @@ -25,12 +25,14 @@ import { bindActionCreators } from 'redux'; import ArchiveNotifier from './ArchiveNotifier'; import { actions as archiveActions } from './ArchiveNotifier/duck'; +import { trackFilter, trackRange } from './index.track'; import type { CombokeysHandler, ShortcutCallbacks } from './keyboard-shortcuts'; import { init as initShortcuts, reset as resetShortcuts } from './keyboard-shortcuts'; import { cancel as cancelScroll, scrollBy, scrollTo } from './scroll-page'; import ScrollManager from './ScrollManager'; import SpanGraph from './SpanGraph'; import TracePageHeader from './TracePageHeader'; +import { trackSlimHeaderToggle } from './TracePageHeader.track'; import TraceTimelineViewer from './TraceTimelineViewer'; import type { ViewRange, ViewRangeTimeUpdate } from './types'; import ErrorMessage from '../common/ErrorMessage'; @@ -130,7 +132,7 @@ export default class TracePage extends React.PureComponent this._adjustViewRange(a, b); + const adjViewRange = (a: number, b: number) => this._adjustViewRange(a, b, 'kbd'); const shortcutCallbacks = makeShortcutCallbacks(adjViewRange); shortcutCallbacks.scrollPageDown = scrollPageDown; shortcutCallbacks.scrollPageUp = scrollPageUp; @@ -169,7 +171,7 @@ export default class TracePage extends React.PureComponent { @@ -199,10 +201,14 @@ export default class TracePage extends React.PureComponent { + trackFilter(textFilter); this.setState({ textFilter }); }; - updateViewRangeTime = (start: number, end: number) => { + updateViewRangeTime = (start: number, end: number, trackSrc?: string) => { + if (trackSrc) { + trackRange(trackSrc, [start, end], this.state.viewRange.time.current); + } const time = { current: [start, end] }; const viewRange = { ...this.state.viewRange, time }; this.setState({ viewRange }); @@ -215,7 +221,9 @@ export default class TracePage extends React.PureComponent { - this.setState({ slimView: !this.state.slimView }); + const { slimView } = this.state; + trackSlimHeaderToggle(!slimView); + this.setState({ slimView: !slimView }); }; archiveTrace = () => { diff --git a/src/components/TracePage/index.test.js b/src/components/TracePage/index.test.js index 5a11c6e692..3452124724 100644 --- a/src/components/TracePage/index.test.js +++ b/src/components/TracePage/index.test.js @@ -12,13 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. +/* eslint-disable import/first */ +jest.mock('./index.track'); jest.mock('./keyboard-shortcuts'); jest.mock('./scroll-page'); // mock these to enable mount() jest.mock('./SpanGraph'); +jest.mock('./TracePageHeader.track'); jest.mock('./TraceTimelineViewer'); -/* eslint-disable import/first */ import React from 'react'; import sinon from 'sinon'; import { shallow, mount } from 'enzyme'; @@ -30,10 +32,12 @@ import TracePage, { shortcutConfig, VIEW_MIN_RANGE, } from './index'; +import * as track from './index.track'; import { reset as resetShortcuts } from './keyboard-shortcuts'; import { cancel as cancelScroll } from './scroll-page'; import SpanGraph from './SpanGraph'; import TracePageHeader from './TracePageHeader'; +import { trackSlimHeaderToggle } from './TracePageHeader.track'; import TraceTimelineViewer from './TraceTimelineViewer'; import ErrorMessage from '../common/ErrorMessage'; import LoadingIndicator from '../common/LoadingIndicator'; @@ -288,6 +292,49 @@ describe('', () => { expect(timeline.prop('viewRange')).toEqual(viewRange); }); }); + + describe('GA tracking', () => { + let header; + let spanGraph; + + function refreshWrappers() { + header = wrapper.find(TracePageHeader); + spanGraph = wrapper.find(SpanGraph); + } + + beforeEach(() => { + wrapper = mount(); + // use the method directly because it is a `ref` prop + wrapper.instance().setHeaderHeight({ clientHeight: 1 }); + wrapper.update(); + refreshWrappers(); + }); + + it('tracks setting the header to slim-view', () => { + const { onSlimViewClicked } = header.props(); + trackSlimHeaderToggle.mockReset(); + onSlimViewClicked(true); + onSlimViewClicked(false); + expect(trackSlimHeaderToggle.mock.calls).toEqual([[true], [false]]); + }); + + it('tracks setting or clearing the filter', () => { + const { updateTextFilter } = header.props(); + track.trackFilter.mockClear(); + updateTextFilter('abc'); + updateTextFilter(''); + expect(track.trackFilter.mock.calls).toEqual([['abc'], ['']]); + }); + + it('tracks changes to the viewRange', () => { + const src = 'some-source'; + const { updateViewRangeTime } = spanGraph.props(); + track.trackRange.mockClear(); + const range = [0.25, 0.75]; + updateViewRangeTime(...range, src); + expect(track.trackRange.mock.calls).toEqual([[src, range, [0, 1]]]); + }); + }); }); describe('mapDispatchToProps()', () => { diff --git a/src/components/TracePage/index.track.js b/src/components/TracePage/index.track.js new file mode 100644 index 0000000000..12bc98c0e1 --- /dev/null +++ b/src/components/TracePage/index.track.js @@ -0,0 +1,58 @@ +// @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 _throttle from 'lodash/throttle'; + +import { trackEvent } from '../../utils/tracking'; + +// export for tests +export const CATEGORY_RANGE = 'jaeger/ux/trace/range'; +export const CATEGORY_FILTER = 'jaeger/ux/trace/range'; + +// export for tests +export const ACTION_FILTER_SET = 'set'; +export const ACTION_FILTER_CLEAR = 'clear'; +export const ACTION_RANGE_REFRAME = 'reframe'; +export const ACTION_RANGE_SHIFT = 'shift'; + +const trackFilterSet = _throttle(trackEvent.bind(null, CATEGORY_FILTER, ACTION_FILTER_SET), 750, { + leading: false, +}); + +const trackFilterClear = _throttle(trackEvent.bind(null, CATEGORY_FILTER, ACTION_FILTER_CLEAR), 750, { + leading: false, +}); + +export const trackFilter = (value: any) => (value ? trackFilterSet() : trackFilterClear()); + +function getRangeAction(current: [number, number], next: [number, number]) { + const [curStart, curEnd] = current; + const [nxStart, nxEnd] = next; + if (curStart === nxStart || curEnd === nxEnd) { + return ACTION_RANGE_SHIFT; + } + const dStart = (curStart - nxStart).toPrecision(7); + const dEnd = (curEnd - nxEnd).toPrecision(7); + if (dStart === dEnd) { + return ACTION_RANGE_SHIFT; + } + return ACTION_RANGE_REFRAME; +} + +export function trackRange(source: string, current: [number, number], next: [number, number]) { + const action = getRangeAction(current, next); + trackEvent(CATEGORY_RANGE, action, source); +} diff --git a/src/components/TracePage/index.track.test.js b/src/components/TracePage/index.track.test.js new file mode 100644 index 0000000000..a8d2dad23a --- /dev/null +++ b/src/components/TracePage/index.track.test.js @@ -0,0 +1,137 @@ +// 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('lodash/throttle', () => jest.fn(fn => fn)); +jest.mock('../../utils/tracking'); + +import _throttle from 'lodash/throttle'; + +import { + ACTION_FILTER_CLEAR, + ACTION_FILTER_SET, + ACTION_RANGE_REFRAME, + ACTION_RANGE_SHIFT, + CATEGORY_FILTER, + CATEGORY_RANGE, + trackFilter, + trackRange, +} from './index.track'; +import { trackEvent } from '../../utils/tracking'; + +describe('trackFilter', () => { + beforeEach(() => { + trackEvent.mockClear(); + }); + + it('uses lodash throttle with 750ms and leading: false', () => { + const calls = _throttle.mock.calls; + expect(calls.length).toBe(2); + expect(calls).toEqual([ + [expect.any(Function), 750, { leading: false }], + [expect.any(Function), 750, { leading: false }], + ]); + }); + + it('tracks filter set when setting values', () => { + expect(trackEvent.mock.calls.length).toBe(0); + trackFilter('abc'); + expect(trackEvent.mock.calls.length).toBe(1); + expect(trackEvent.mock.calls[0]).toEqual([CATEGORY_FILTER, ACTION_FILTER_SET]); + }); + + it('tracks filter clear when clearing the value', () => { + expect(trackEvent.mock.calls.length).toBe(0); + trackFilter(); + expect(trackEvent.mock.calls.length).toBe(1); + expect(trackEvent.mock.calls[0]).toEqual([CATEGORY_FILTER, ACTION_FILTER_CLEAR]); + }); +}); + +describe('trackRange', () => { + beforeEach(() => { + trackEvent.mockClear(); + }); + + const cases = [ + { + msg: 'returns shift if start is unchanged', + rangeType: ACTION_RANGE_SHIFT, + source: `${Math.random()}`, + from: [0, 0.5], + to: [0, 0.6], + }, + { + msg: 'returns shift if end is unchanged', + rangeType: ACTION_RANGE_SHIFT, + source: `${Math.random()}`, + from: [0, 0.5], + to: [0.1, 0.5], + }, + { + msg: 'returns shift if increasing start and end by same amount', + rangeType: ACTION_RANGE_SHIFT, + source: `${Math.random()}`, + from: [0.25, 0.75], + to: [0.5, 1], + }, + { + msg: 'returns shift if decreasing start and end by same amount', + rangeType: ACTION_RANGE_SHIFT, + source: `${Math.random()}`, + from: [0.25, 0.75], + to: [0, 0.5], + }, + { + msg: 'returns reframe if increasing start and end by different amounts', + rangeType: ACTION_RANGE_REFRAME, + source: `${Math.random()}`, + from: [0.25, 0.75], + to: [0.35, 1], + }, + { + msg: 'returns reframe if decreasing start and end by different amounts', + rangeType: ACTION_RANGE_REFRAME, + source: `${Math.random()}`, + from: [0.25, 0.75], + to: [0, 0.65], + }, + { + msg: 'returns reframe when widening to a superset', + rangeType: ACTION_RANGE_REFRAME, + source: `${Math.random()}`, + from: [0.25, 0.75], + to: [0, 1], + }, + { + msg: 'returns reframe when contracting to a subset', + rangeType: ACTION_RANGE_REFRAME, + source: `${Math.random()}`, + from: [0.25, 0.75], + to: [0.45, 0.55], + }, + ]; + + cases.forEach(_case => { + const { msg, rangeType, source, from, to } = _case; + + it(msg, () => { + expect(trackEvent.mock.calls.length).toBe(0); + trackRange(source, from, to); + expect(trackEvent.mock.calls.length).toBe(1); + expect(trackEvent.mock.calls[0]).toEqual([CATEGORY_RANGE, rangeType, source]); + }); + }); +}); diff --git a/src/index.js b/src/index.js index 6b91e0109f..f8da2bfb77 100644 --- a/src/index.js +++ b/src/index.js @@ -24,6 +24,7 @@ import { document } from 'global'; import JaegerUIApp from './components/App'; import { context as trackingContext } from './utils/tracking'; +// these need to go after the App import /* eslint-disable import/first */ import 'u-basscss/css/flexbox.css'; import 'u-basscss/css/layout.css'; diff --git a/src/middlewares/index.js b/src/middlewares/index.js index ada8bc0560..a5fbf72d3a 100644 --- a/src/middlewares/index.js +++ b/src/middlewares/index.js @@ -20,6 +20,8 @@ import { replace } from 'react-router-redux'; import { searchTraces, fetchServiceOperations } from '../actions/jaeger-api'; import prefixUrl from '../utils/prefix-url'; +export { default as trackMiddleware } from './track'; + /** * Middleware to load "operations" for a particular service. */ diff --git a/src/middlewares/track.js b/src/middlewares/track.js new file mode 100644 index 0000000000..5d3ce67f46 --- /dev/null +++ b/src/middlewares/track.js @@ -0,0 +1,32 @@ +// @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 { middlewareHooks } from '../components/TracePage/TraceTimelineViewer/duck.track'; +import { isGaEnabled } from '../utils/tracking'; + +function trackingMiddleware(store: { getState: () => any }) { + return function inner(next: any => void) { + return function core(action: any) { + const { type } = action; + if (typeof middlewareHooks[type] === 'function') { + middlewareHooks[type](store, action); + } + return next(action); + }; + }; +} + +export default (isGaEnabled ? trackingMiddleware : undefined); diff --git a/src/types/index.js b/src/types/index.js index 5a6cf31d1d..55ca7d2028 100644 --- a/src/types/index.js +++ b/src/types/index.js @@ -20,12 +20,9 @@ type KeyValuePair = { key: string, - type: string, - value: string, + value: any, }; -export type Tag = KeyValuePair; - export type Log = { timestamp: number, fields: Array, @@ -33,7 +30,7 @@ export type Log = { export type Process = { serviceName: string, - tags: Array, + tags: Array, }; export type SpanReference = { @@ -50,7 +47,7 @@ export type SpanData = { startTime: number, duration: number, logs: Array, - tags: Array, + tags: Array, references: Array, }; diff --git a/src/utils/configure-store.js b/src/utils/configure-store.js index 2263dab424..ab0f5c2a4f 100644 --- a/src/utils/configure-store.js +++ b/src/utils/configure-store.js @@ -31,7 +31,10 @@ export default function configureStore(history) { }), compose( applyMiddleware( - ...[...Object.keys(jaegerMiddlewares).map(key => jaegerMiddlewares[key]), routerMiddleware(history)] + ...Object.keys(jaegerMiddlewares) + .map(key => jaegerMiddlewares[key]) + .filter(Boolean), + routerMiddleware(history) ), process.env.NODE_ENV !== 'production' && window && window.devToolsExtension ? window.devToolsExtension() diff --git a/src/utils/get-last-xform-cacher.test.js b/src/utils/get-last-xform-cacher.test.js index 3efdeb383e..8ab0245df4 100644 --- a/src/utils/get-last-xform-cacher.test.js +++ b/src/utils/get-last-xform-cacher.test.js @@ -24,7 +24,7 @@ beforeEach(() => { }); it('returns a function', () => { - expect(cacher).toEqual(jasmine.any(Function)); + expect(cacher).toEqual(expect.any(Function)); }); it('handles the first invocation where nothing is cached', () => { diff --git a/src/utils/tracking/common.js b/src/utils/tracking/common.js new file mode 100644 index 0000000000..031988629c --- /dev/null +++ b/src/utils/tracking/common.js @@ -0,0 +1,22 @@ +// @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. + +export const OPEN = 'open'; +export const CLOSE = 'close'; + +export function getToggleValue(value: any) { + return value ? CLOSE : OPEN; +} diff --git a/src/utils/tracking/conv-raven-to-ga.js b/src/utils/tracking/conv-raven-to-ga.js index 95e24f7490..b07a6d1926 100644 --- a/src/utils/tracking/conv-raven-to-ga.js +++ b/src/utils/tracking/conv-raven-to-ga.js @@ -127,7 +127,7 @@ 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); + const fn = collapseWhitespace(fr.function || '??'); return { filename, fn }; }); const joiner = []; diff --git a/src/utils/tracking/index.js b/src/utils/tracking/index.js index 195ce28b2e..c52ee37721 100644 --- a/src/utils/tracking/index.js +++ b/src/utils/tracking/index.js @@ -22,13 +22,6 @@ 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, @@ -50,7 +43,7 @@ const isDebugMode = 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)); +export const isGaEnabled = isTest || isDebugMode || (isProd && Boolean(gaID)); const isErrorsEnabled = isDebugMode || (isGaEnabled && Boolean(_get(config, 'tracking.trackErrors'))); /* istanbul ignore next */ @@ -87,24 +80,30 @@ export function trackError(description: string) { } } -export function trackEvent(data: EventData) { +export function trackEvent( + category: string, + action: string, + labelOrValue?: ?string | ?number, + value?: ?number +) { 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); + if (!/^jaeger/i.test(category)) { + event.category = `jaeger/${category}`.slice(0, EVENT_LENGTHS.category); } else { - category = category.slice(0, EVENT_LENGTHS.category); + event.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); + event.action = action.slice(0, EVENT_LENGTHS.action); + if (labelOrValue != null) { + if (typeof labelOrValue === 'string') { + event.label = labelOrValue.slice(0, EVENT_LENGTHS.action); + } else { + // value should be an int + event.value = Math.round(labelOrValue); + } } - if (data.value != null) { - event.value = Number(data.value); + if (value != null) { + event.value = Math.round(value); } ReactGA.event(event); if (isDebugMode) { @@ -114,25 +113,9 @@ export function trackEvent(data: EventData) { } 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); + const { message, category, action, label, value } = convRavenToGa(ravenData); + trackError(message); + trackEvent(category, action, label, value); } // Tracking needs to be initialized when this file is imported, e.g. early in diff --git a/src/utils/tracking/index.test.js b/src/utils/tracking/index.test.js index 3f4c59896d..1ca9cdeb79 100644 --- a/src/utils/tracking/index.test.js +++ b/src/utils/tracking/index.test.js @@ -13,7 +13,11 @@ // limitations under the License. /* eslint-disable import/first */ -jest.mock('./conv-raven-to-ga', () => () => ({ message: 'jaeger/a' })); +jest.mock('./conv-raven-to-ga', () => () => ({ + category: 'jaeger/a', + action: 'some-action', + message: 'jaeger/a', +})); jest.mock('./index', () => { process.env.REACT_APP_VSN_STATE = '{}'; @@ -56,7 +60,7 @@ describe('tracking', () => { it('tracks an error', () => { tracking.trackError('a'); expect(calls).toEqual([ - ['send', { hitType: 'exception', exDescription: jasmine.any(String), exFatal: false }], + ['send', { hitType: 'exception', exDescription: expect.any(String), exFatal: false }], ]); }); @@ -76,30 +80,33 @@ describe('tracking', () => { describe('trackEvent', () => { it('tracks an event', () => { - tracking.trackEvent({ value: 10 }); + const category = 'jaeger/some-category'; + const action = 'some-action'; + tracking.trackEvent(category, action); expect(calls).toEqual([ [ 'send', { hitType: 'event', - eventCategory: jasmine.any(String), - eventAction: jasmine.any(String), - eventValue: 10, + eventCategory: category, + eventAction: action, }, ], ]); }); it('prepends "jaeger/" to the category, if needed', () => { - tracking.trackEvent({ category: 'a' }); + const category = 'some-category'; + const action = 'some-action'; + tracking.trackEvent(category, action); expect(calls).toEqual([ - ['send', { hitType: 'event', eventCategory: 'jaeger/a', eventAction: jasmine.any(String) }], + ['send', { hitType: 'event', eventCategory: `jaeger/${category}`, eventAction: action }], ]); }); it('truncates values, if needed', () => { const str = `jaeger/${getStr(600)}`; - tracking.trackEvent({ category: str, action: str, label: str }); + tracking.trackEvent(str, str, str); expect(calls).toEqual([ [ 'send', @@ -117,8 +124,8 @@ describe('tracking', () => { 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) }], + ['send', { hitType: 'exception', exDescription: expect.any(String), exFatal: false }], + ['send', { hitType: 'event', eventCategory: expect.any(String), eventAction: expect.any(String) }], ]); }); });