From b69174788d5a93f7ff1c1e4f1a77b71e6bcc7eca Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Thu, 8 Oct 2020 10:53:01 +0200 Subject: [PATCH] Add more Telemetry to Data Explorer (#242) * Add Telemetry to command bar buttons * Count and report # of files/notebooks/directories in myNotebook to telemetry * Add resource tree clicks to Telemetry * Log to Telemetry: opened notebook cell counts by type, kernelspec name * Fix unit test * Move Telemetry processor call in notebook traceNotebookTelemetry action from reducer to epic. Use action to trace other info. * Fix react duplicate key error * Log notebook cell context menu actions * Reformat and cleanup * Move resource tree tracing code out of render(). Only call once when tree is updated * Fix build issues --- .../Controls/TreeComponent/TreeComponent.tsx | 9 +- .../__snapshots__/TreeComponent.test.tsx.snap | 2 +- .../CommandBar/CommandBarComponentAdapter.tsx | 2 +- .../Menus/CommandBar/CommandBarUtil.test.tsx | 9 +- .../Menus/CommandBar/CommandBarUtil.tsx | 330 +++++++++--------- .../Notebook/NotebookComponent/epics.ts | 125 ++++++- .../Notebook/NotebookComponent/reducers.ts | 13 - .../Notebook/NotebookRenderer/Toolbar.tsx | 58 ++- src/Explorer/Tree/ResourceTreeAdapter.tsx | 29 +- src/Shared/Telemetry/TelemetryConstants.ts | 16 +- 10 files changed, 389 insertions(+), 204 deletions(-) diff --git a/src/Explorer/Controls/TreeComponent/TreeComponent.tsx b/src/Explorer/Controls/TreeComponent/TreeComponent.tsx index 520021d20..1ceef8320 100644 --- a/src/Explorer/Controls/TreeComponent/TreeComponent.tsx +++ b/src/Explorer/Controls/TreeComponent/TreeComponent.tsx @@ -18,6 +18,8 @@ import { import TriangleDownIcon from "../../../../images/Triangle-down.svg"; import TriangleRightIcon from "../../../../images/Triangle-right.svg"; import LoadingIndicator_3Squares from "../../../../images/LoadingIndicator_3Squares.gif"; +import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; +import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants"; export interface TreeNodeMenuItem { label: string; @@ -276,7 +278,12 @@ export class TreeNodeComponent extends React.Component { + menuItem.onClick(); + TelemetryProcessor.trace(Action.ClickResourceTreeNodeContextMenuItem, ActionModifiers.Mark, { + label: menuItem.label + }); + }, onRenderIcon: (props: any) => })) }} diff --git a/src/Explorer/Controls/TreeComponent/__snapshots__/TreeComponent.test.tsx.snap b/src/Explorer/Controls/TreeComponent/__snapshots__/TreeComponent.test.tsx.snap index a46f92cb3..40f5cf5d1 100644 --- a/src/Explorer/Controls/TreeComponent/__snapshots__/TreeComponent.test.tsx.snap +++ b/src/Explorer/Controls/TreeComponent/__snapshots__/TreeComponent.test.tsx.snap @@ -191,7 +191,7 @@ exports[`TreeNodeComponent renders a simple node (sorted children, expanded) 1`] "className": undefined, "disabled": true, "key": "menuLabel", - "onClick": undefined, + "onClick": [Function], "onRenderIcon": [Function], "text": "menuLabel", }, diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx index 9ad9b482b..ea816b2f6 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx @@ -10,7 +10,7 @@ import * as ViewModels from "../../../Contracts/ViewModels"; import { CommandBarComponentButtonFactory } from "./CommandBarComponentButtonFactory"; import { CommandBar, ICommandBarItemProps } from "office-ui-fabric-react/lib/CommandBar"; import { StyleConstants } from "../../../Common/Constants"; -import { CommandBarUtil } from "./CommandBarUtil"; +import * as CommandBarUtil from "./CommandBarUtil"; import Explorer from "../../Explorer"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; diff --git a/src/Explorer/Menus/CommandBar/CommandBarUtil.test.tsx b/src/Explorer/Menus/CommandBar/CommandBarUtil.test.tsx index 0c6b899c6..ef164a2b4 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarUtil.test.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarUtil.test.tsx @@ -1,4 +1,4 @@ -import { CommandBarUtil } from "./CommandBarUtil"; +import * as CommandBarUtil from "./CommandBarUtil"; import * as ViewModels from "../../../Contracts/ViewModels"; import { ICommandBarItemProps } from "office-ui-fabric-react/lib/CommandBar"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; @@ -8,7 +8,7 @@ describe("CommandBarUtil tests", () => { return { iconSrc: "icon", iconAlt: "label", - onCommandClick: (e: React.SyntheticEvent): void => {}, + onCommandClick: jest.fn(), commandButtonLabel: "label", ariaLabel: "ariaLabel", hasPopup: true, @@ -29,11 +29,14 @@ describe("CommandBarUtil tests", () => { expect(!converted.split); expect(converted.iconProps.imageProps.src).toEqual(btn.iconSrc); expect(converted.iconProps.imageProps.alt).toEqual(btn.iconAlt); - expect(converted.onClick).toEqual(btn.onCommandClick); expect(converted.text).toEqual(btn.commandButtonLabel); expect(converted.ariaLabel).toEqual(btn.ariaLabel); expect(converted.disabled).toEqual(btn.disabled); expect(converted.className).toEqual(btn.className); + + // Click gets called + converted.onClick(); + expect(btn.onCommandClick).toBeCalled(); }); it("should convert NavbarButtonConfig to split button", () => { diff --git a/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx b/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx index ca9ac1723..67cc7671c 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx @@ -11,177 +11,187 @@ import ChevronDownIcon from "../../../../images/Chevron_down.svg"; import { ArcadiaMenuPicker } from "../../Controls/Arcadia/ArcadiaMenuPicker"; import { MemoryTrackerComponent } from "./MemoryTrackerComponent"; import { MemoryUsageInfo } from "../../../Contracts/DataModels"; +import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; +import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants"; /** - * Utilities for CommandBar + * Convert our NavbarButtonConfig to UI Fabric buttons + * @param btns */ -export class CommandBarUtil { - /** - * Convert our NavbarButtonConfig to UI Fabric buttons - * @param btns - */ - public static convertButton(btns: CommandButtonComponentProps[], backgroundColor: string): ICommandBarItemProps[] { - const buttonHeightPx = StyleConstants.CommandBarButtonHeight; +export const convertButton = (btns: CommandButtonComponentProps[], backgroundColor: string): ICommandBarItemProps[] => { + const buttonHeightPx = StyleConstants.CommandBarButtonHeight; - return btns - .filter(btn => btn) - .map( - (btn: CommandButtonComponentProps, index: number): ICommandBarItemProps => { - if (btn.isDivider) { - return CommandBarUtil.createDivider(btn.commandButtonLabel); - } + return btns + .filter(btn => btn) + .map( + (btn: CommandButtonComponentProps, index: number): ICommandBarItemProps => { + if (btn.isDivider) { + return createDivider(btn.commandButtonLabel); + } - const isSplit = !!btn.children && btn.children.length > 0; - - const result: ICommandBarItemProps = { - iconProps: { - style: { - width: StyleConstants.CommandBarIconWidth, // 16 - alignSelf: btn.iconName ? "baseline" : undefined - }, - imageProps: btn.iconSrc ? { src: btn.iconSrc, alt: btn.iconAlt } : undefined, - iconName: btn.iconName + const isSplit = !!btn.children && btn.children.length > 0; + const label = btn.commandButtonLabel || btn.tooltipText; + const result: ICommandBarItemProps = { + iconProps: { + style: { + width: StyleConstants.CommandBarIconWidth, // 16 + alignSelf: btn.iconName ? "baseline" : undefined }, - onClick: btn.onCommandClick, - key: `${btn.commandButtonLabel}${index}`, - text: btn.commandButtonLabel || btn.tooltipText, - "data-test": btn.commandButtonLabel || btn.tooltipText, - title: btn.tooltipText, - name: btn.commandButtonLabel || btn.tooltipText, - disabled: btn.disabled, - ariaLabel: btn.ariaLabel, - buttonStyles: { - root: { - backgroundColor: backgroundColor, - height: buttonHeightPx, - paddingRight: 0, - paddingLeft: 0, - minWidth: 24, - marginLeft: isSplit ? 0 : 5, - marginRight: isSplit ? 0 : 5 + imageProps: btn.iconSrc ? { src: btn.iconSrc, alt: btn.iconAlt } : undefined, + iconName: btn.iconName + }, + onClick: (ev?: React.MouseEvent | React.KeyboardEvent) => { + btn.onCommandClick(ev); + TelemetryProcessor.trace(Action.ClickCommandBarButton, ActionModifiers.Mark, { label }); + }, + key: `${btn.commandButtonLabel}${index}`, + text: label, + "data-test": label, + title: btn.tooltipText, + name: label, + disabled: btn.disabled, + ariaLabel: btn.ariaLabel, + buttonStyles: { + root: { + backgroundColor: backgroundColor, + height: buttonHeightPx, + paddingRight: 0, + paddingLeft: 0, + minWidth: 24, + marginLeft: isSplit ? 0 : 5, + marginRight: isSplit ? 0 : 5 + }, + rootDisabled: { + backgroundColor: backgroundColor, + pointerEvents: "auto" + }, + splitButtonMenuButton: { + backgroundColor: backgroundColor, + selectors: { + ":hover": { backgroundColor: StyleConstants.AccentLight } }, - rootDisabled: { - backgroundColor: backgroundColor, - pointerEvents: "auto" - }, - splitButtonMenuButton: { - backgroundColor: backgroundColor, - selectors: { - ":hover": { backgroundColor: StyleConstants.AccentLight } - }, - width: 16 - }, - label: { fontSize: StyleConstants.mediumFontSize }, - rootHovered: { backgroundColor: StyleConstants.AccentLight }, - rootPressed: { backgroundColor: StyleConstants.AccentLight }, - splitButtonMenuButtonExpanded: { - backgroundColor: StyleConstants.AccentExtra, - selectors: { - ":hover": { backgroundColor: StyleConstants.AccentLight } - } - }, - splitButtonDivider: { - display: "none" - }, - icon: { - paddingLeft: 0, - paddingRight: 0 - }, - splitButtonContainer: { - marginLeft: 5, - marginRight: 5 + width: 16 + }, + label: { fontSize: StyleConstants.mediumFontSize }, + rootHovered: { backgroundColor: StyleConstants.AccentLight }, + rootPressed: { backgroundColor: StyleConstants.AccentLight }, + splitButtonMenuButtonExpanded: { + backgroundColor: StyleConstants.AccentExtra, + selectors: { + ":hover": { backgroundColor: StyleConstants.AccentLight } } }, - className: btn.className, - id: btn.id + splitButtonDivider: { + display: "none" + }, + icon: { + paddingLeft: 0, + paddingRight: 0 + }, + splitButtonContainer: { + marginLeft: 5, + marginRight: 5 + } + }, + className: btn.className, + id: btn.id + }; + + if (isSplit) { + // It's a split button + result.split = true; + + result.subMenuProps = { + items: convertButton(btn.children, backgroundColor), + styles: { + list: { + // TODO Figure out how to do it the proper way with subComponentStyles. + // TODO Remove all this crazy styling once we adopt Ui-Fabric Azure themes + selectors: { + ".ms-ContextualMenu-itemText": { fontSize: StyleConstants.mediumFontSize }, + ".ms-ContextualMenu-link:hover": { backgroundColor: StyleConstants.AccentLight }, + ".ms-ContextualMenu-icon": { width: 16, height: 16 } + } + } + } }; - if (isSplit) { - // It's a split button - result.split = true; - - result.subMenuProps = { - items: CommandBarUtil.convertButton(btn.children, backgroundColor), - styles: { - list: { - // TODO Figure out how to do it the proper way with subComponentStyles. - // TODO Remove all this crazy styling once we adopt Ui-Fabric Azure themes - selectors: { - ".ms-ContextualMenu-itemText": { fontSize: StyleConstants.mediumFontSize }, - ".ms-ContextualMenu-link:hover": { backgroundColor: StyleConstants.AccentLight }, - ".ms-ContextualMenu-icon": { width: 16, height: 16 } - } - } - } - }; - - result.menuIconProps = { - iconType: IconType.image, - style: { - width: 12, - paddingLeft: 1, - paddingTop: 6 - }, - imageProps: { src: ChevronDownIcon, alt: btn.iconAlt } - }; - } - - if (btn.isDropdown) { - const selectedChild = _.find(btn.children, child => child.dropdownItemKey === btn.dropdownSelectedKey); - result.name = selectedChild?.commandButtonLabel || btn.dropdownPlaceholder; - - const dropdownStyles: Partial = { - root: { margin: 5 }, - dropdown: { width: btn.dropdownWidth }, - title: { fontSize: 12, height: 30, lineHeight: 28 }, - dropdownItem: { fontSize: 12, lineHeight: 28, minHeight: 30 }, - dropdownItemSelected: { fontSize: 12, lineHeight: 28, minHeight: 30 } - }; - - result.commandBarButtonAs = (props: IComponentAsProps) => { - return ( - , option?: IDropdownOption, index?: number): void => - btn.children[index].onCommandClick(event) - } - options={btn.children.map((child: CommandButtonComponentProps) => ({ - key: child.dropdownItemKey, - text: child.commandButtonLabel - }))} - styles={dropdownStyles} - /> - ); - }; - } - - if (btn.isArcadiaPicker && btn.arcadiaProps) { - result.commandBarButtonAs = () => ; - } - - return result; + result.menuIconProps = { + iconType: IconType.image, + style: { + width: 12, + paddingLeft: 1, + paddingTop: 6 + }, + imageProps: { src: ChevronDownIcon, alt: btn.iconAlt } + }; } - ); - } - public static createDivider(key: string): ICommandBarItemProps { - return { - onRender: () => ( -
- -
- ), - iconOnly: true, - disabled: true, - key: key - }; - } + if (btn.isDropdown) { + const selectedChild = _.find(btn.children, child => child.dropdownItemKey === btn.dropdownSelectedKey); + result.name = selectedChild?.commandButtonLabel || btn.dropdownPlaceholder; - public static createMemoryTracker(key: string, memoryUsageInfo: Observable): ICommandBarItemProps { - return { - key, - onRender: () => - }; - } -} + const dropdownStyles: Partial = { + root: { margin: 5 }, + dropdown: { width: btn.dropdownWidth }, + title: { fontSize: 12, height: 30, lineHeight: 28 }, + dropdownItem: { fontSize: 12, lineHeight: 28, minHeight: 30 }, + dropdownItemSelected: { fontSize: 12, lineHeight: 28, minHeight: 30 } + }; + + const onDropdownChange = ( + event: React.FormEvent, + option?: IDropdownOption, + index?: number + ): void => { + btn.children[index].onCommandClick(event); + TelemetryProcessor.trace(Action.ClickCommandBarButton, ActionModifiers.Mark, { label: option.text }); + }; + + result.commandBarButtonAs = (props: IComponentAsProps) => { + return ( + ({ + key: child.dropdownItemKey, + text: child.commandButtonLabel + }))} + styles={dropdownStyles} + /> + ); + }; + } + + if (btn.isArcadiaPicker && btn.arcadiaProps) { + result.commandBarButtonAs = () => ; + } + + return result; + } + ); +}; + +export const createDivider = (key: string): ICommandBarItemProps => { + return { + onRender: () => ( +
+ +
+ ), + iconOnly: true, + disabled: true, + key: key + }; +}; + +export const createMemoryTracker = ( + key: string, + memoryUsageInfo: Observable +): ICommandBarItemProps => { + return { + key, + onRender: () => + }; +}; diff --git a/src/Explorer/Notebook/NotebookComponent/epics.ts b/src/Explorer/Notebook/NotebookComponent/epics.ts index c2f61d498..f99785a59 100644 --- a/src/Explorer/Notebook/NotebookComponent/epics.ts +++ b/src/Explorer/Notebook/NotebookComponent/epics.ts @@ -32,24 +32,27 @@ import { import { message, JupyterMessage, Channels, createMessage, childOf, ofMessageType } from "@nteract/messaging"; import { sessions, kernels } from "rx-jupyter"; import { RecordOf } from "immutable"; +import { AnyAction } from "redux"; import * as Constants from "../../../Common/Constants"; import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils"; import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent"; import * as CdbActions from "./actions"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; -import { Action as TelemetryAction } from "../../../Shared/Telemetry/TelemetryConstants"; +import { Action as TelemetryAction, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants"; import { CdbAppState } from "./types"; import { decryptJWTToken } from "../../../Utils/AuthorizationUtils"; import * as TextFile from "./contents/file/text-file"; import { NotebookUtil } from "../NotebookUtil"; import { FileSystemUtil } from "../FileSystemUtil"; +import * as cdbActions from "../NotebookComponent/actions"; +import { Areas } from "../../../Common/Constants"; interface NotebookServiceConfig extends JupyterServerConfig { userPuid?: string; } -const logToTelemetry = (state: CdbAppState, title: string, error?: string) => { +const logFailureToTelemetry = (state: CdbAppState, title: string, error?: string) => { TelemetryProcessor.traceFailure(TelemetryAction.NotebookErrorNotification, { databaseAccountName: state.cdb.databaseAccountName, defaultExperience: state.cdb.defaultExperience, @@ -311,7 +314,7 @@ export const launchWebSocketKernelEpic = ( kernelSpecToLaunch = currentKernelspecs.defaultKernelName; const msg = `No kernelspec name specified to launch, using default kernel: ${kernelSpecToLaunch}`; NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, msg); - logToTelemetry(state$.value, "Launching alternate kernel", msg); + logFailureToTelemetry(state$.value, "Launching alternate kernel", msg); } else { return of( actions.launchKernelFailed({ @@ -337,7 +340,7 @@ export const launchWebSocketKernelEpic = ( msg += ` Using default kernel: ${kernelSpecToLaunch}`; } NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, msg); - logToTelemetry(state$.value, "Launching alternate kernel", msg); + logFailureToTelemetry(state$.value, "Launching alternate kernel", msg); } const sessionPayload = { @@ -634,7 +637,7 @@ const notificationsToUserEpic = (action$: Observable, state$: StateObservab const title = "Kernel restart"; const msg = "Kernel successfully restarted"; NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, msg); - logToTelemetry(state$.value, title, msg); + logFailureToTelemetry(state$.value, title, msg); break; } case actions.RESTART_KERNEL_FAILED: @@ -645,7 +648,7 @@ const notificationsToUserEpic = (action$: Observable, state$: StateObservab const title = "Save failure"; const msg = `Failed to save notebook: ${(action as actions.SaveFailed).payload.error}`; NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, msg); - logToTelemetry(state$.value, title, msg); + logFailureToTelemetry(state$.value, title, msg); break; } case actions.FETCH_CONTENT_FAILED: { @@ -654,7 +657,7 @@ const notificationsToUserEpic = (action$: Observable, state$: StateObservab const title = "Fetching content failure"; const msg = `Failed to fetch notebook content: ${filepath}, error: ${typedAction.payload.error}`; NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, msg); - logToTelemetry(state$.value, title, msg); + logFailureToTelemetry(state$.value, title, msg); break; } } @@ -679,7 +682,7 @@ const handleKernelConnectionLostEpic = ( const msg = "Notebook was disconnected from kernel"; NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, msg); - logToTelemetry(state, "Error", "Kernel connection error"); + logFailureToTelemetry(state, "Error", "Kernel connection error"); const host = selectors.currentHost(state); const serverConfig: NotebookServiceConfig = selectors.serverConfig(host as RecordOf); @@ -692,7 +695,7 @@ const handleKernelConnectionLostEpic = ( const msg = "Restarted kernel too many times. Please reload the page to enable Data Explorer to restart the kernel automatically."; NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, msg); - logToTelemetry(state, "Kernel restart error", msg); + logFailureToTelemetry(state, "Kernel restart error", msg); const explorer = window.dataExplorer; if (explorer) { @@ -844,6 +847,105 @@ const closeContentFailedToFetchEpic = ( ); }; +const traceNotebookTelemetryEpic = ( + action$: Observable, + state$: StateObservable +): Observable<{}> => { + return action$.pipe( + ofType(cdbActions.TRACE_NOTEBOOK_TELEMETRY), + mergeMap((action: cdbActions.TraceNotebookTelemetryAction) => { + const state = state$.value; + + TelemetryProcessor.trace(action.payload.action, action.payload.actionModifier, { + ...action.payload.data, + databaseAccountName: state.cdb.databaseAccountName, + defaultExperience: state.cdb.defaultExperience, + dataExplorerArea: Areas.Notebook + }); + return EMPTY; + }) + ); +}; + +/** + * Log notebook information to telemetry + * # raw cells, # markdown cells, # code cells, total + * @param action$ + * @param state$ + */ +const traceNotebookInfoEpic = ( + action$: Observable, + state$: StateObservable +): Observable<{} | cdbActions.TraceNotebookTelemetryAction> => { + return action$.pipe( + ofType(actions.FETCH_CONTENT_FULFILLED), + mergeMap((action: { payload: any }) => { + const state = state$.value; + const contentRef = action.payload.contentRef; + const model = selectors.model(state, { contentRef }); + + // If it's not a notebook, we shouldn't be here + if (!model || model.type !== "notebook") { + return EMPTY; + } + + const dataToLog = { + nbCodeCells: 0, + nbRawCells: 0, + nbMarkdownCells: 0, + nbCells: 0 + }; + for (let [id, cell] of selectors.notebook.cellMap(model)) { + switch (cell.cell_type) { + case "code": + dataToLog.nbCodeCells++; + break; + case "markdown": + dataToLog.nbMarkdownCells++; + break; + case "raw": + dataToLog.nbRawCells++; + break; + } + dataToLog.nbCells++; + } + + return of( + cdbActions.traceNotebookTelemetry({ + action: TelemetryAction.NotebooksFetched, + actionModifier: ActionModifiers.Mark, + data: dataToLog + }) + ); + }) + ); +}; + +/** + * Log Kernel spec to start + * @param action$ + * @param state$ + */ +const traceNotebookKernelEpic = ( + action$: Observable, + state$: StateObservable +): Observable => { + return action$.pipe( + ofType(actions.LAUNCH_KERNEL_SUCCESSFUL), + mergeMap((action: { payload: any; type: string }) => { + return of( + cdbActions.traceNotebookTelemetry({ + action: TelemetryAction.NotebooksKernelSpecName, + actionModifier: ActionModifiers.Mark, + data: { + kernelSpecName: action.payload.kernel.name + } + }) + ); + }) + ); +}; + export const allEpics = [ addInitialCodeCellEpic, focusInitialCodeCellEpic, @@ -856,5 +958,8 @@ export const allEpics = [ executeFocusedCellAndFocusNextEpic, closeUnsupportedMimetypesEpic, closeContentFailedToFetchEpic, - restartWebSocketKernelEpic + restartWebSocketKernelEpic, + traceNotebookTelemetryEpic, + traceNotebookInfoEpic, + traceNotebookKernelEpic ]; diff --git a/src/Explorer/Notebook/NotebookComponent/reducers.ts b/src/Explorer/Notebook/NotebookComponent/reducers.ts index 533118030..18830f382 100644 --- a/src/Explorer/Notebook/NotebookComponent/reducers.ts +++ b/src/Explorer/Notebook/NotebookComponent/reducers.ts @@ -1,7 +1,5 @@ import { actions, CoreRecord, reducers as nteractReducers } from "@nteract/core"; import { Action } from "redux"; -import { Areas } from "../../../Common/Constants"; -import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; import * as cdbActions from "./actions"; import { CdbRecord } from "./types"; @@ -72,17 +70,6 @@ export const cdbReducer = (state: CdbRecord, action: Action) => { return state.set("hoveredCellId", typedAction.payload.cellId); } - case cdbActions.TRACE_NOTEBOOK_TELEMETRY: { - const typedAction = action as cdbActions.TraceNotebookTelemetryAction; - TelemetryProcessor.trace(typedAction.payload.action, typedAction.payload.actionModifier, { - ...typedAction.payload.data, - databaseAccountName: state.databaseAccountName, - defaultExperience: state.defaultExperience, - dataExplorerArea: Areas.Notebook - }); - return state; - } - case cdbActions.UPDATE_NOTEBOOK_PARENT_DOM_ELTS: { const typedAction = action as cdbActions.UpdateNotebookParentDomEltAction; var parentEltsMap = state.get("currentNotebookParentElements"); diff --git a/src/Explorer/Notebook/NotebookRenderer/Toolbar.tsx b/src/Explorer/Notebook/NotebookRenderer/Toolbar.tsx index 2966a4b1c..453a2a995 100644 --- a/src/Explorer/Notebook/NotebookRenderer/Toolbar.tsx +++ b/src/Explorer/Notebook/NotebookRenderer/Toolbar.tsx @@ -14,6 +14,8 @@ import { CellToolbarContext } from "@nteract/stateful-components"; import { CellType, CellId } from "@nteract/commutable"; import * as selectors from "@nteract/selectors"; import { RecordOf } from "immutable"; +import * as cdbActions from "../NotebookComponent/actions"; +import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants"; export interface ComponentProps { contentRef: ContentRef; @@ -29,6 +31,7 @@ interface DispatchProps { moveCell: (destinationId: CellId, above: boolean) => void; clearOutputs: () => void; deleteCell: () => void; + traceNotebookTelemetry: (action: Action, actionModifier?: string, data?: any) => void; } interface StateProps { @@ -48,12 +51,18 @@ class BaseToolbar extends React.PureComponent { + this.props.executeCell(); + this.props.traceNotebookTelemetry(Action.NotebooksExecuteCellFromMenu, ActionModifiers.Mark); + } }, { key: "Clear Outputs", text: "Clear Outputs", - onClick: this.props.clearOutputs + onClick: () => { + this.props.clearOutputs(); + this.props.traceNotebookTelemetry(Action.NotebooksClearOutputsFromMenu, ActionModifiers.Mark); + } }, { key: "Divider", @@ -64,31 +73,43 @@ class BaseToolbar extends React.PureComponent { + this.props.insertCodeCellAbove(); + this.props.traceNotebookTelemetry(Action.NotebooksInsertCodeCellAboveFromMenu, ActionModifiers.Mark); + } }, { key: "Insert Code Cell Below", text: "Insert Code Cell Below", - onClick: this.props.insertCodeCellBelow + onClick: () => { + this.props.insertCodeCellBelow(); + this.props.traceNotebookTelemetry(Action.NotebooksInsertCodeCellBelowFromMenu, ActionModifiers.Mark); + } }, { key: "Insert Text Cell Above", text: "Insert Text Cell Above", - onClick: this.props.insertTextCellAbove + onClick: () => { + this.props.insertTextCellAbove(); + this.props.traceNotebookTelemetry(Action.NotebooksInsertTextCellAboveFromMenu, ActionModifiers.Mark); + } }, { key: "Insert Text Cell Below", text: "Insert Text Cell Below", - onClick: this.props.insertTextCellBelow + onClick: () => { + this.props.insertTextCellBelow(); + this.props.traceNotebookTelemetry(Action.NotebooksInsertTextCellBelowFromMenu, ActionModifiers.Mark); + } }, { - key: "Divider", + key: "Divider3", itemType: ContextualMenuItemType.Divider } ]); @@ -98,7 +119,10 @@ class BaseToolbar extends React.PureComponent this.props.moveCell(this.props.cellIdAbove, true) + onClick: () => { + this.props.moveCell(this.props.cellIdAbove, true); + this.props.traceNotebookTelemetry(Action.NotebooksMoveCellUpFromMenu, ActionModifiers.Mark); + } }); } @@ -106,13 +130,16 @@ class BaseToolbar extends React.PureComponent this.props.moveCell(this.props.cellIdBelow, false) + onClick: () => { + this.props.moveCell(this.props.cellIdBelow, false); + this.props.traceNotebookTelemetry(Action.NotebooksMoveCellDownFromMenu, ActionModifiers.Mark); + } }); } if (moveItems.length > 0) { moveItems.push({ - key: "Divider", + key: "Divider4", itemType: ContextualMenuItemType.Divider }); items = items.concat(moveItems); @@ -121,7 +148,10 @@ class BaseToolbar extends React.PureComponent { + this.props.deleteCell(); + this.props.traceNotebookTelemetry(Action.DeleteCellFromMenu, ActionModifiers.Mark); + } }); const menuItemLabel = "More"; @@ -156,7 +186,9 @@ const mapDispatchToProps = ( moveCell: (destinationId: CellId, above: boolean) => dispatch(actions.moveCell({ id, contentRef, destinationId, above })), clearOutputs: () => dispatch(actions.clearOutputs({ id, contentRef })), - deleteCell: () => dispatch(actions.deleteCell({ id, contentRef })) + deleteCell: () => dispatch(actions.deleteCell({ id, contentRef })), + traceNotebookTelemetry: (action: Action, actionModifier?: string, data?: any) => + dispatch(cdbActions.traceNotebookTelemetry({ action, actionModifier, data })) }); const makeMapStateToProps = (state: AppState, ownProps: ComponentProps): ((state: AppState) => StateProps) => { diff --git a/src/Explorer/Tree/ResourceTreeAdapter.tsx b/src/Explorer/Tree/ResourceTreeAdapter.tsx index 487b4efa3..2a0f0d134 100644 --- a/src/Explorer/Tree/ResourceTreeAdapter.tsx +++ b/src/Explorer/Tree/ResourceTreeAdapter.tsx @@ -74,6 +74,30 @@ export class ResourceTreeAdapter implements ReactAdapter { this.triggerRender(); } + private traceMyNotebookTreeInfo() { + const myNotebooksTree = this.myNotebooksContentRoot; + if (myNotebooksTree.children) { + // Count 1st generation children (tree is lazy-loaded) + const nodeCounts = { files: 0, notebooks: 0, directories: 0 }; + myNotebooksTree.children.forEach(treeNode => { + switch ((treeNode as NotebookContentItem).type) { + case NotebookContentItemType.File: + nodeCounts.files++; + break; + case NotebookContentItemType.Directory: + nodeCounts.directories++; + break; + case NotebookContentItemType.Notebook: + nodeCounts.notebooks++; + break; + default: + break; + } + }); + TelemetryProcessor.trace(Action.RefreshResourceTreeMyNotebooks, ActionModifiers.Mark, { ...nodeCounts }); + } + } + public renderComponent(): JSX.Element { const dataRootNode = this.buildDataTree(); const notebooksRootNode = this.buildNotebooksTrees(); @@ -116,7 +140,10 @@ export class ResourceTreeAdapter implements ReactAdapter { // Only if notebook server is available we can refresh if (this.container.notebookServerInfo().notebookServerEndpoint) { refreshTasks.push( - this.container.refreshContentItem(this.myNotebooksContentRoot).then(() => this.triggerRender()) + this.container.refreshContentItem(this.myNotebooksContentRoot).then(() => { + this.triggerRender(); + this.traceMyNotebookTreeInfo(); + }) ); } diff --git a/src/Shared/Telemetry/TelemetryConstants.ts b/src/Shared/Telemetry/TelemetryConstants.ts index 8bfc0b943..8b3f5db4e 100644 --- a/src/Shared/Telemetry/TelemetryConstants.ts +++ b/src/Shared/Telemetry/TelemetryConstants.ts @@ -71,8 +71,22 @@ export enum Action { NotebooksGitHubManageRepo, NotebooksGitHubCommit, NotebooksGitHubDisconnect, + NotebooksFetched, + NotebooksKernelSpecName, + NotebooksExecuteCellFromMenu, + NotebooksClearOutputsFromMenu, + NotebooksInsertCodeCellAboveFromMenu, + NotebooksInsertCodeCellBelowFromMenu, + NotebooksInsertTextCellAboveFromMenu, + NotebooksInsertTextCellBelowFromMenu, + NotebooksMoveCellUpFromMenu, + NotebooksMoveCellDownFromMenu, + DeleteCellFromMenu, OpenTerminal, - CreateMongoCollectionWithWildcardIndex + CreateMongoCollectionWithWildcardIndex, + ClickCommandBarButton, + RefreshResourceTreeMyNotebooks, + ClickResourceTreeNodeContextMenuItem } export const ActionModifiers = {