From 0545cbbf6f1e17d7b9f796e21b269c25bfc9bcc7 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Wed, 16 Oct 2019 00:31:10 -0400 Subject: [PATCH] [Lens] Track actions in the UI by time (#47919) (#48354) * [Lens] Track actions in the UI by time * Switch collector to use task data * Report summarized version of task data when requested * Track a more complete set of metrics * Collect suggestion events separately * Pre-aggregate by date in localStorage * Add integration tests * Fix test linter * Fix telemetry naming and run at midnight instead of every minute * Improve cleanup at app level * Fix lint errors * Remove unused mock * Fix tests * Fix types * Update event names and fix local tracking * Respond to review comments * Fix task time * Fix test --- .../plugins/apm/typings/elasticsearch.ts | 21 +- x-pack/legacy/plugins/lens/index.ts | 1 + x-pack/legacy/plugins/lens/mappings.json | 16 ++ .../plugins/lens/public/app_plugin/app.tsx | 14 ++ .../plugins/lens/public/app_plugin/plugin.tsx | 18 ++ .../lens/public/drag_drop/drag_drop.test.tsx | 26 +- .../lens/public/drag_drop/drag_drop.tsx | 4 +- .../editor_frame/chart_switch.tsx | 3 + .../editor_frame/suggestion_panel.tsx | 5 + .../editor_frame/workspace_panel.tsx | 57 +++-- .../change_indexpattern.tsx | 2 + .../public/indexpattern_plugin/datapanel.tsx | 10 +- .../dimension_panel/dimension_panel.tsx | 6 + .../dimension_panel/field_select.tsx | 3 + .../dimension_panel/popover_editor.tsx | 6 + .../public/indexpattern_plugin/field_item.tsx | 2 + .../public/lens_ui_telemetry/factory.test.ts | 110 ++++++++ .../lens/public/lens_ui_telemetry/factory.ts | 134 ++++++++++ .../lens/public/lens_ui_telemetry/index.ts | 7 + .../xy_config_panel.tsx | 8 +- x-pack/legacy/plugins/lens/server/plugin.tsx | 8 +- .../plugins/lens/server/routes/index.ts | 13 +- .../plugins/lens/server/routes/telemetry.ts | 107 ++++++++ .../plugins/lens/server/usage/collectors.ts | 121 ++++++++- .../legacy/plugins/lens/server/usage/index.ts | 1 + .../legacy/plugins/lens/server/usage/task.ts | 237 ++++++++++++++++++ .../legacy/plugins/lens/server/usage/types.ts | 30 ++- .../lens/server/usage/visualization_counts.ts | 16 +- .../legacy/plugins/xpack_main/xpack_main.d.ts | 1 + .../test/api_integration/apis/lens/index.ts | 1 + .../api_integration/apis/lens/telemetry.ts | 216 ++++++++++++++++ .../test/functional/apps/lens/smokescreen.ts | 32 --- 32 files changed, 1133 insertions(+), 103 deletions(-) create mode 100644 x-pack/legacy/plugins/lens/public/lens_ui_telemetry/factory.test.ts create mode 100644 x-pack/legacy/plugins/lens/public/lens_ui_telemetry/factory.ts create mode 100644 x-pack/legacy/plugins/lens/public/lens_ui_telemetry/index.ts create mode 100644 x-pack/legacy/plugins/lens/server/routes/telemetry.ts create mode 100644 x-pack/legacy/plugins/lens/server/usage/task.ts create mode 100644 x-pack/test/api_integration/apis/lens/telemetry.ts diff --git a/x-pack/legacy/plugins/apm/typings/elasticsearch.ts b/x-pack/legacy/plugins/apm/typings/elasticsearch.ts index 10cd7bcbf4f38..4da52c773a666 100644 --- a/x-pack/legacy/plugins/apm/typings/elasticsearch.ts +++ b/x-pack/legacy/plugins/apm/typings/elasticsearch.ts @@ -64,11 +64,22 @@ declare module 'elasticsearch' { // eslint-disable-next-line @typescript-eslint/prefer-interface type FiltersAggregation = { - buckets: Array< - { - doc_count: number; - } & SubAggregation - >; + // The filters aggregation can have named filters or anonymous filters, + // which changes the structure of the return + // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-filters-aggregation.html + buckets: SubAggregationMap extends { + filters: { filters: Record }; + } + ? { + [key in keyof SubAggregationMap['filters']['filters']]: { + doc_count: number; + } & SubAggregation; + } + : Array< + { + doc_count: number; + } & SubAggregation + >; }; type SamplerAggregation = SubAggregation< diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index 7acd62502d428..a8984bd80fb1c 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -61,6 +61,7 @@ export const lens: LegacyPluginInitializer = kibana => { savedObjects: server.savedObjects, usage: server.usage, config: server.config(), + server, }); server.events.on('stop', () => { diff --git a/x-pack/legacy/plugins/lens/mappings.json b/x-pack/legacy/plugins/lens/mappings.json index 832d152eb77a1..8304cf9c9cb64 100644 --- a/x-pack/legacy/plugins/lens/mappings.json +++ b/x-pack/legacy/plugins/lens/mappings.json @@ -15,5 +15,21 @@ "type": "keyword" } } + }, + "lens-ui-telemetry": { + "properties": { + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "date": { + "type": "date" + }, + "count": { + "type": "integer" + } + } } } diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index 640f1fdb99a21..1152a3de77181 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -25,6 +25,7 @@ import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_reac import { Document, SavedObjectStore } from '../persistence'; import { EditorFrameInstance } from '../types'; import { NativeRenderer } from '../native_renderer'; +import { trackUiEvent } from '../lens_ui_telemetry'; interface State { isLoading: boolean; @@ -84,6 +85,8 @@ export function App({ const subscription = dataShim.filter.filterManager.getUpdates$().subscribe({ next: () => { setState(s => ({ ...s, filters: dataShim.filter.filterManager.getFilters() })); + + trackUiEvent('app_filters_updated'); }, }); return () => { @@ -191,6 +194,16 @@ export function App({ screenTitle={'lens'} onQuerySubmit={payload => { const { dateRange, query } = payload; + + if ( + dateRange.from !== state.dateRange.fromDate || + dateRange.to !== state.dateRange.toDate + ) { + trackUiEvent('app_date_change'); + } else { + trackUiEvent('app_query_change'); + } + setState(s => ({ ...s, dateRange: { @@ -309,6 +322,7 @@ export function App({ } }) .catch(() => { + trackUiEvent('save_failed'); core.notifications.toasts.addDanger( i18n.translate('xpack.lens.app.docSavingError', { defaultMessage: 'Error saving document', diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx index 2668946fec47b..1f8779bb68b81 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx @@ -25,6 +25,12 @@ import { } from '../datatable_visualization_plugin'; import { App } from './app'; import { EditorFrameInstance } from '../types'; +import { + LensReportManager, + setReportManager, + stopReportManager, + trackUiEvent, +} from '../lens_ui_telemetry'; export interface LensPluginStartDependencies { data: DataPublicPluginStart; @@ -63,7 +69,16 @@ export class AppPlugin { this.instance = editorFrameStartInterface.createInstance({}); + setReportManager( + new LensReportManager({ + storage: new Storage(localStorage), + basePath: core.http.basePath.get(), + http: core.http, + }) + ); + const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { + trackUiEvent('loaded'); return ( ; } @@ -106,6 +122,8 @@ export class AppPlugin { this.instance.unmount(); } + stopReportManager(); + // TODO this will be handled by the plugin platform itself indexPatternDatasourceStop(); xyVisualizationStop(); diff --git a/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx index 7471039a482bf..17a6afa9b70ff 100644 --- a/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx +++ b/x-pack/legacy/plugins/lens/public/drag_drop/drag_drop.test.tsx @@ -71,7 +71,7 @@ describe('DragDrop', () => { const component = mount( - + Hello! @@ -87,6 +87,30 @@ describe('DragDrop', () => { expect(onDrop).toBeCalledWith('hola'); }); + test('drop function is not called on droppable=false', async () => { + const preventDefault = jest.fn(); + const stopPropagation = jest.fn(); + const setDragging = jest.fn(); + const onDrop = jest.fn(); + + const component = mount( + + + Hello! + + + ); + + component + .find('[data-test-subj="lnsDragDrop"]') + .simulate('drop', { preventDefault, stopPropagation }); + + expect(preventDefault).toBeCalled(); + expect(stopPropagation).toBeCalled(); + expect(setDragging).toBeCalledWith(undefined); + expect(onDrop).not.toHaveBeenCalled(); + }); + test('droppable is reflected in the className', () => { const component = render( ; @@ -121,7 +122,8 @@ export function DragDrop(props: Props) { setState({ ...state, isActive: false }); setDragging(undefined); - if (onDrop) { + if (onDrop && droppable) { + trackUiEvent('drop_total'); onDrop(dragging); } }; diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx index 1c94460034019..26db1224eb352 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/chart_switch.tsx @@ -18,6 +18,7 @@ import { i18n } from '@kbn/i18n'; import { Visualization, FramePublicAPI, Datasource } from '../../types'; import { Action } from './state_management'; import { getSuggestions, switchToSuggestion, Suggestion } from './suggestion_helpers'; +import { trackUiEvent } from '../../lens_ui_telemetry'; interface VisualizationSelection { visualizationId: string; @@ -76,6 +77,8 @@ export function ChartSwitch(props: Props) { const commitSelection = (selection: VisualizationSelection) => { setFlyoutOpen(false); + trackUiEvent(`chart_switch`); + switchToSuggestion( props.framePublicAPI, props.dispatch, diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx index de25c2f7f638b..7d1f400f484e1 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/suggestion_panel.tsx @@ -27,6 +27,7 @@ import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; import { ExpressionRenderer } from '../../../../../../../src/legacy/core_plugins/expressions/public'; import { prependDatasourceExpression, prependKibanaContext } from './expression_helpers'; import { debouncedComponent } from '../../debounced_component'; +import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry'; const MAX_SUGGESTIONS_DISPLAYED = 5; @@ -227,6 +228,7 @@ export function SuggestionPanel({ function rollbackToCurrentVisualization() { if (lastSelectedSuggestion !== -1) { + trackSuggestionEvent('back_to_current'); setLastSelectedSuggestion(-1); dispatch({ type: 'ROLLBACK_SUGGESTION', @@ -261,6 +263,7 @@ export function SuggestionPanel({ data-test-subj="lensSubmitSuggestion" size="xs" onClick={() => { + trackUiEvent('suggestion_confirmed'); dispatch({ type: 'SUBMIT_SUGGESTION', }); @@ -307,9 +310,11 @@ export function SuggestionPanel({ ExpressionRenderer={ExpressionRendererComponent} key={index} onSelect={() => { + trackUiEvent('suggestion_clicked'); if (lastSelectedSuggestion === index) { rollbackToCurrentVisualization(); } else { + trackSuggestionEvent(`position_${index}_of_${suggestions.length}`); setLastSelectedSuggestion(index); switchToSuggestion(frame, dispatch, suggestion); } diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx index fce2144e93bcf..1fb1af46f6f0d 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_plugin/editor_frame/workspace_panel.tsx @@ -25,6 +25,7 @@ import { DragDrop, DragContext } from '../../drag_drop'; import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; import { buildExpression } from './expression_helpers'; import { debouncedComponent } from '../../debounced_component'; +import { trackUiEvent } from '../../lens_ui_telemetry'; export interface WorkspacePanelProps { activeVisualizationId: string | null; @@ -91,8 +92,37 @@ export function InnerWorkspacePanel({ return suggestions.find(s => s.visualizationId === activeVisualizationId) || suggestions[0]; }, [dragDropContext.dragging]); + const [expressionError, setExpressionError] = useState(undefined); + + const activeVisualization = activeVisualizationId + ? visualizationMap[activeVisualizationId] + : null; + const expression = useMemo(() => { + try { + return buildExpression({ + visualization: activeVisualization, + visualizationState, + datasourceMap, + datasourceStates, + framePublicAPI, + }); + } catch (e) { + setExpressionError(e.toString()); + } + }, [ + activeVisualization, + visualizationState, + datasourceMap, + datasourceStates, + framePublicAPI.dateRange, + framePublicAPI.query, + framePublicAPI.filters, + ]); + function onDrop() { if (suggestionForDraggedField) { + trackUiEvent('drop_onto_workspace'); + trackUiEvent(expression ? 'drop_non_empty' : 'drop_empty'); switchToSuggestion( framePublicAPI, dispatch, @@ -146,33 +176,6 @@ export function InnerWorkspacePanel({ } function renderVisualization() { - const [expressionError, setExpressionError] = useState(undefined); - - const activeVisualization = activeVisualizationId - ? visualizationMap[activeVisualizationId] - : null; - const expression = useMemo(() => { - try { - return buildExpression({ - visualization: activeVisualization, - visualizationState, - datasourceMap, - datasourceStates, - framePublicAPI, - }); - } catch (e) { - setExpressionError(e.toString()); - } - }, [ - activeVisualization, - visualizationState, - datasourceMap, - datasourceStates, - framePublicAPI.dateRange, - framePublicAPI.query, - framePublicAPI.filters, - ]); - useEffect(() => { // reset expression error if component attempts to run it again if (expressionError) { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/change_indexpattern.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/change_indexpattern.tsx index c101a8bfb7f08..dc42b97cce08d 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/change_indexpattern.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/change_indexpattern.tsx @@ -9,6 +9,7 @@ import React, { useState } from 'react'; import { EuiButtonEmpty, EuiPopover, EuiSelectable, EuiButtonEmptyProps } from '@elastic/eui'; import { EuiSelectableProps } from '@elastic/eui/src/components/selectable/selectable'; import { IndexPatternRef } from './types'; +import { trackUiEvent } from '../lens_ui_telemetry'; export type ChangeIndexPatternTriggerProps = EuiButtonEmptyProps & { label: string; @@ -74,6 +75,7 @@ export function ChangeIndexPattern({ const choice = (choices.find(({ checked }) => checked) as unknown) as { value: string; }; + trackUiEvent('indexpattern_changed'); onChangeIndexPattern(choice.value); setPopoverIsOpen(false); }} diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx index 89daaad00f11d..7fa8175e1857c 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/datapanel.tsx @@ -35,6 +35,7 @@ import { IndexPatternField, IndexPatternRef, } from './types'; +import { trackUiEvent } from '../lens_ui_telemetry'; import { syncExistingFields } from './loader'; import { fieldExists } from './pure_helpers'; @@ -301,6 +302,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ defaultMessage: 'Clear name and type filters', }), onClick: () => { + trackUiEvent('indexpattern_filters_cleared'); setLocalState(s => ({ ...s, nameFilter: '', @@ -370,14 +372,15 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ key={type} icon={localState.typeFilter.includes(type) ? 'check' : 'empty'} data-test-subj={`typeFilter-${type}`} - onClick={() => + onClick={() => { + trackUiEvent('indexpattern_type_filter_toggled'); setLocalState(s => ({ ...s, typeFilter: localState.typeFilter.includes(type) ? localState.typeFilter.filter(t => t !== type) : [...localState.typeFilter, type], - })) - } + })); + }} > {fieldTypeNames[type]} @@ -388,6 +391,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ compressed checked={!showEmptyFields} onChange={() => { + trackUiEvent('indexpattern_existence_toggled'); onToggleEmptyFields(); }} label={i18n.translate('xpack.lens.indexPatterns.toggleEmptyFieldsSwitch', { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx index 81be4e393a8f2..c0c774a225642 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/dimension_panel.tsx @@ -22,6 +22,7 @@ import { DragContextState, ChildDragDropProvider, DragDrop } from '../../drag_dr import { changeColumn, deleteColumn } from '../state_helpers'; import { isDraggedField, hasField } from '../utils'; import { IndexPatternPrivateState, IndexPatternField } from '../types'; +import { trackUiEvent } from '../../lens_ui_telemetry'; export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & { state: IndexPatternPrivateState; @@ -137,6 +138,10 @@ export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPan field: droppedItem.field, }); + trackUiEvent('drop_onto_dimension'); + const hasData = Object.values(props.state.layers).some(({ columns }) => columns.length); + trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty'); + props.setState( changeColumn({ state: props.state, @@ -170,6 +175,7 @@ export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPan defaultMessage: 'Remove configuration', })} onClick={() => { + trackUiEvent('indexpattern_dimension_removed'); props.setState( deleteColumn({ state: props.state, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx index b258df789748b..7eeffdff383e1 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/field_select.tsx @@ -18,6 +18,7 @@ import { LensFieldIcon } from '../lens_field_icon'; import { DataType } from '../../types'; import { OperationFieldSupportMatrix } from './dimension_panel'; import { IndexPattern, IndexPatternField, IndexPatternPrivateState } from '../types'; +import { trackUiEvent } from '../../lens_ui_telemetry'; import { fieldExists } from '../pure_helpers'; export type FieldChoice = @@ -170,6 +171,8 @@ export function FieldSelect({ return; } + trackUiEvent('indexpattern_dimension_field_changed'); + onChoose((choices[0].value as unknown) as FieldChoice); }} renderOption={(option, searchValue) => { diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx index 080f80b1cc476..bc46143b5f3b5 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/dimension_panel/popover_editor.tsx @@ -34,6 +34,7 @@ import { FieldSelect } from './field_select'; import { hasField } from '../utils'; import { BucketNestingEditor } from './bucket_nesting_editor'; import { IndexPattern, IndexPatternField } from '../types'; +import { trackUiEvent } from '../../lens_ui_telemetry'; const operationPanels = getOperationDisplay(); @@ -162,10 +163,12 @@ export function PopoverEditor(props: PopoverEditorProps) { } else { setInvalidOperationType(operationType); } + trackUiEvent(`indexpattern_dimension_operation_${operationType}`); return; } if (!compatibleWithCurrentField) { setInvalidOperationType(operationType); + trackUiEvent(`indexpattern_dimension_operation_${operationType}`); return; } if (incompatibleSelectedOperationType) { @@ -182,6 +185,9 @@ export function PopoverEditor(props: PopoverEditorProps) { indexPattern: currentIndexPattern, field: hasField(selectedColumn) ? fieldMap[selectedColumn.sourceField] : undefined, }); + trackUiEvent( + `indexpattern_dimension_operation_from_${selectedColumn.operationType}_to_${operationType}` + ); setState( changeColumn({ state, diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx index f9db1e9f4f550..41a4bd3549dc1 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_plugin/field_item.tsx @@ -45,6 +45,7 @@ import { DatasourceDataPanelProps, DataType } from '../types'; import { BucketedAggregation, FieldStatsResponse } from '../../common'; import { IndexPattern, IndexPatternField } from './types'; import { LensFieldIcon, getColorForDataType } from './lens_field_icon'; +import { trackUiEvent } from '../lens_ui_telemetry'; export interface FieldItemProps { core: DatasourceDataPanelProps['core']; @@ -141,6 +142,7 @@ export function FieldItem(props: FieldItemProps) { function togglePopover() { setOpen(!infoIsOpen); if (!infoIsOpen) { + trackUiEvent('indexpattern_field_info_click'); fetchData(); } } diff --git a/x-pack/legacy/plugins/lens/public/lens_ui_telemetry/factory.test.ts b/x-pack/legacy/plugins/lens/public/lens_ui_telemetry/factory.test.ts new file mode 100644 index 0000000000000..9b65cbefaf799 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/lens_ui_telemetry/factory.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + LensReportManager, + setReportManager, + stopReportManager, + trackUiEvent, + trackSuggestionEvent, +} from './factory'; +import { Storage } from 'src/legacy/core_plugins/data/public/types'; +import { coreMock } from 'src/core/public/mocks'; +import { HttpServiceBase } from 'kibana/public'; + +jest.useFakeTimers(); + +const createMockStorage = () => { + let lastData = { events: {}, suggestionEvents: {} }; + return { + get: jest.fn().mockImplementation(() => lastData), + set: jest.fn().mockImplementation((key, value) => { + lastData = value; + }), + remove: jest.fn(), + clear: jest.fn(), + }; +}; + +describe('Lens UI telemetry', () => { + let storage: jest.Mocked; + let http: jest.Mocked; + let dateSpy: jest.SpyInstance; + + beforeEach(() => { + dateSpy = jest + .spyOn(Date, 'now') + .mockImplementation(() => new Date(Date.UTC(2019, 9, 23)).valueOf()); + + storage = createMockStorage(); + http = coreMock.createSetup().http; + http.post.mockClear(); + const fakeManager = new LensReportManager({ + http, + storage, + basePath: '/basepath', + }); + setReportManager(fakeManager); + }); + + afterEach(() => { + stopReportManager(); + dateSpy.mockRestore(); + }); + + it('should write immediately and track local state', () => { + trackUiEvent('loaded'); + + expect(storage.set).toHaveBeenCalledWith('lens-ui-telemetry', { + events: expect.any(Object), + suggestionEvents: {}, + }); + + trackSuggestionEvent('reload'); + + expect(storage.set).toHaveBeenLastCalledWith('lens-ui-telemetry', { + events: expect.any(Object), + suggestionEvents: expect.any(Object), + }); + }); + + it('should post the results after waiting 10 seconds, if there is data', async () => { + jest.runOnlyPendingTimers(); + + http.post.mockResolvedValue({}); + + expect(http.post).not.toHaveBeenCalled(); + expect(storage.set).toHaveBeenCalledTimes(0); + + trackUiEvent('load'); + expect(storage.set).toHaveBeenCalledTimes(1); + + jest.runOnlyPendingTimers(); + + expect(http.post).toHaveBeenCalledWith(`/basepath/api/lens/telemetry`, { + body: JSON.stringify({ + events: { + '2019-10-23': { + load: 1, + }, + }, + suggestionEvents: {}, + }), + }); + }); + + it('should keep its local state after an http error', () => { + http.post.mockRejectedValue('http error'); + + trackUiEvent('load'); + expect(storage.set).toHaveBeenCalledTimes(1); + + jest.runOnlyPendingTimers(); + + expect(http.post).toHaveBeenCalled(); + expect(storage.set).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/legacy/plugins/lens/public/lens_ui_telemetry/factory.ts b/x-pack/legacy/plugins/lens/public/lens_ui_telemetry/factory.ts new file mode 100644 index 0000000000000..d264e9c77c463 --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/lens_ui_telemetry/factory.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import { HttpServiceBase } from 'src/core/public'; + +import { Storage } from 'src/legacy/core_plugins/data/public/types'; +import { BASE_API_URL } from '../../common'; + +const STORAGE_KEY = 'lens-ui-telemetry'; + +let reportManager: LensReportManager; + +export function setReportManager(newManager: LensReportManager) { + if (reportManager) { + reportManager.stop(); + } + reportManager = newManager; +} + +export function stopReportManager() { + if (reportManager) { + reportManager.stop(); + } +} + +export function trackUiEvent(name: string) { + if (reportManager) { + reportManager.trackEvent(name); + } +} + +export function trackSuggestionEvent(name: string) { + if (reportManager) { + reportManager.trackSuggestionEvent(name); + } +} + +export class LensReportManager { + private events: Record> = {}; + private suggestionEvents: Record> = {}; + + private storage: Storage; + private http: HttpServiceBase; + private basePath: string; + private timer: ReturnType; + + constructor({ + storage, + http, + basePath, + }: { + storage: Storage; + http: HttpServiceBase; + basePath: string; + }) { + this.storage = storage; + this.http = http; + this.basePath = basePath; + + this.readFromStorage(); + + this.timer = setInterval(() => { + this.postToServer(); + }, 10000); + } + + public trackEvent(name: string) { + this.readFromStorage(); + this.trackTo(this.events, name); + } + + public trackSuggestionEvent(name: string) { + this.readFromStorage(); + this.trackTo(this.suggestionEvents, name); + } + + public stop() { + if (this.timer) { + clearInterval(this.timer); + } + } + + private readFromStorage() { + const data = this.storage.get(STORAGE_KEY); + if (data && typeof data.events === 'object' && typeof data.suggestionEvents === 'object') { + this.events = data.events; + this.suggestionEvents = data.suggestionEvents; + } + } + + private async postToServer() { + this.readFromStorage(); + if (Object.keys(this.events).length || Object.keys(this.suggestionEvents).length) { + try { + await this.http.post(`${this.basePath}${BASE_API_URL}/telemetry`, { + body: JSON.stringify({ + events: this.events, + suggestionEvents: this.suggestionEvents, + }), + }); + this.events = {}; + this.suggestionEvents = {}; + this.write(); + } catch (e) { + // Silent error because events will be reported during the next timer + } + } + } + + private trackTo(target: Record>, name: string) { + const date = moment() + .utc() + .format('YYYY-MM-DD'); + if (!target[date]) { + target[date] = { + [name]: 1, + }; + } else if (!target[date][name]) { + target[date][name] = 1; + } else { + target[date][name] += 1; + } + + this.write(); + } + + private write() { + this.storage.set(STORAGE_KEY, { events: this.events, suggestionEvents: this.suggestionEvents }); + } +} diff --git a/x-pack/legacy/plugins/lens/public/lens_ui_telemetry/index.ts b/x-pack/legacy/plugins/lens/public/lens_ui_telemetry/index.ts new file mode 100644 index 0000000000000..79575a59f565e --- /dev/null +++ b/x-pack/legacy/plugins/lens/public/lens_ui_telemetry/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './factory'; diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx index d6a014d9f8050..9461229313f4c 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization_plugin/xy_config_panel.tsx @@ -27,6 +27,7 @@ import { NativeRenderer } from '../native_renderer'; import { MultiColumnEditor } from '../multi_column_editor'; import { generateId } from '../id_generator'; import { isHorizontalChart, isHorizontalSeries } from './state_helpers'; +import { trackUiEvent } from '../lens_ui_telemetry'; const isNumericMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number'; const isBucketed = (op: OperationMetadata) => op.isBucketed; @@ -105,7 +106,10 @@ function LayerSettings({ iconType: t.icon || 'empty', }))} idSelected={layer.seriesType} - onChange={seriesType => setSeriesType(seriesType as SeriesType)} + onChange={seriesType => { + trackUiEvent('xy_change_layer_display'); + setSeriesType(seriesType as SeriesType); + }} isIconOnly buttonSize="compressed" /> @@ -149,6 +153,7 @@ export function XYConfigPanel(props: VisualizationProps) { setState(updateLayer(state, { ...layer, seriesType }, index)) } removeLayer={() => { + trackUiEvent('xy_layer_removed'); frame.removeLayers([layer.layerId]); setState({ ...state, layers: state.layers.filter(l => l !== layer) }); }} @@ -258,6 +263,7 @@ export function XYConfigPanel(props: VisualizationProps) { defaultMessage: 'Add layer', })} onClick={() => { + trackUiEvent('xy_layer_added'); const usedSeriesTypes = _.uniq(state.layers.map(layer => layer.seriesType)); setState({ ...state, diff --git a/x-pack/legacy/plugins/lens/server/plugin.tsx b/x-pack/legacy/plugins/lens/server/plugin.tsx index 37a87d0530339..a4c8e9b268df5 100644 --- a/x-pack/legacy/plugins/lens/server/plugin.tsx +++ b/x-pack/legacy/plugins/lens/server/plugin.tsx @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaConfig } from 'src/legacy/server/kbn_server'; +import { Server, KibanaConfig } from 'src/legacy/server/kbn_server'; import { Plugin, CoreSetup, SavedObjectsLegacyService } from 'src/core/server'; import { setupRoutes } from './routes'; -import { registerLensUsageCollector } from './usage'; +import { registerLensUsageCollector, initializeLensTelemetry } from './usage'; export class LensServer implements Plugin<{}, {}, {}, {}> { setup( @@ -21,10 +21,12 @@ export class LensServer implements Plugin<{}, {}, {}, {}> { }; }; config: KibanaConfig; + server: Server; } ) { - setupRoutes(core); + setupRoutes(core, plugins); registerLensUsageCollector(core, plugins); + initializeLensTelemetry(core, plugins); return {}; } diff --git a/x-pack/legacy/plugins/lens/server/routes/index.ts b/x-pack/legacy/plugins/lens/server/routes/index.ts index 5aa54271aaa77..79690b40395d6 100644 --- a/x-pack/legacy/plugins/lens/server/routes/index.ts +++ b/x-pack/legacy/plugins/lens/server/routes/index.ts @@ -4,11 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup } from 'src/core/server'; +import { KibanaConfig } from 'src/legacy/server/kbn_server'; +import { CoreSetup, SavedObjectsLegacyService } from 'src/core/server'; import { existingFieldsRoute } from './existing_fields'; import { initFieldsRoute } from './field_stats'; +import { initLensUsageRoute } from './telemetry'; -export function setupRoutes(setup: CoreSetup) { +export function setupRoutes( + setup: CoreSetup, + plugins: { + savedObjects: SavedObjectsLegacyService; + config: KibanaConfig; + } +) { existingFieldsRoute(setup); initFieldsRoute(setup); + initLensUsageRoute(setup, plugins); } diff --git a/x-pack/legacy/plugins/lens/server/routes/telemetry.ts b/x-pack/legacy/plugins/lens/server/routes/telemetry.ts new file mode 100644 index 0000000000000..895b77cfa8978 --- /dev/null +++ b/x-pack/legacy/plugins/lens/server/routes/telemetry.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { KibanaConfig } from 'src/legacy/server/kbn_server'; +import { CoreSetup, SavedObjectsLegacyService } from 'src/core/server'; +import { schema } from '@kbn/config-schema'; +import { BASE_API_URL } from '../../common'; + +export function getSavedObjectsClient( + savedObjects: SavedObjectsLegacyService, + callAsInternalUser: unknown +) { + const { SavedObjectsClient, getSavedObjectsRepository } = savedObjects; + const internalRepository = getSavedObjectsRepository(callAsInternalUser); + return new SavedObjectsClient(internalRepository); +} + +// This route is responsible for taking a batch of click events from the browser +// and writing them to saved objects +export async function initLensUsageRoute( + setup: CoreSetup, + plugins: { + savedObjects: SavedObjectsLegacyService; + config: KibanaConfig; + } +) { + const router = setup.http.createRouter(); + router.post( + { + path: `${BASE_API_URL}/telemetry`, + validate: { + body: schema.object({ + events: schema.mapOf(schema.string(), schema.mapOf(schema.string(), schema.number())), + suggestionEvents: schema.mapOf( + schema.string(), + schema.mapOf(schema.string(), schema.number()) + ), + }), + }, + }, + async (context, req, res) => { + const { dataClient } = context.core.elasticsearch; + + const { events, suggestionEvents } = req.body; + + try { + const client = getSavedObjectsClient(plugins.savedObjects, dataClient.callAsCurrentUser); + + const allEvents: Array<{ + type: 'lens-ui-telemetry'; + attributes: {}; + }> = []; + + events.forEach((subMap, date) => { + subMap.forEach((count, key) => { + allEvents.push({ + type: 'lens-ui-telemetry', + attributes: { + name: key, + date, + count, + type: 'regular', + }, + }); + }); + }); + suggestionEvents.forEach((subMap, date) => { + subMap.forEach((count, key) => { + allEvents.push({ + type: 'lens-ui-telemetry', + attributes: { + name: key, + date, + count, + type: 'suggestion', + }, + }); + }); + }); + + if (allEvents.length) { + await client.bulkCreate(allEvents); + } + + return res.ok({ body: {} }); + } catch (e) { + if (e.status === 404) { + return res.notFound(); + } + if (e.isBoom) { + if (e.output.statusCode === 404) { + return res.notFound(); + } + return res.internalError(e.output.message); + } else { + return res.internalError({ + body: Boom.internal(e.message || e.name), + }); + } + } + } + ); +} diff --git a/x-pack/legacy/plugins/lens/server/usage/collectors.ts b/x-pack/legacy/plugins/lens/server/usage/collectors.ts index 75f422088ed81..9a58026002ade 100644 --- a/x-pack/legacy/plugins/lens/server/usage/collectors.ts +++ b/x-pack/legacy/plugins/lens/server/usage/collectors.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaConfig } from 'src/legacy/server/kbn_server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import moment from 'moment'; +import { get } from 'lodash'; +import { Server, KibanaConfig } from 'src/legacy/server/kbn_server'; import { CoreSetup, SavedObjectsLegacyService } from 'src/core/server'; -import { getVisualizationCounts } from './visualization_counts'; -import { LensUsage } from './types'; +import { LensUsage, LensTelemetryState } from './types'; export function registerLensUsageCollector( core: CoreSetup, @@ -21,25 +21,120 @@ export function registerLensUsageCollector( }; }; config: KibanaConfig; + server: Server; } ) { + let isCollectorReady = false; + async function determineIfTaskManagerIsReady() { + let isReady = false; + try { + isReady = await isTaskManagerReady(plugins.server); + } catch (err) {} // eslint-disable-line + + if (isReady) { + isCollectorReady = true; + } else { + setTimeout(determineIfTaskManagerIsReady, 500); + } + } + determineIfTaskManagerIsReady(); + const lensUsageCollector = plugins.usage.collectorSet.makeUsageCollector({ type: 'lens', - fetch: async (callCluster: CallCluster): Promise => { + fetch: async (): Promise => { try { - return getVisualizationCounts(callCluster, plugins.config); + const docs = await getLatestTaskState(plugins.server); + // get the accumulated state from the recurring task + const state: LensTelemetryState = get(docs, '[0].state'); + + const events = getDataByDate(state.byDate); + const suggestions = getDataByDate(state.suggestionsByDate); + + return { + ...state.saved, + events_30_days: events.last30, + events_90_days: events.last90, + suggestion_events_30_days: suggestions.last30, + suggestion_events_90_days: suggestions.last90, + }; } catch (err) { return { - saved_total: 0, - saved_last_30_days: 0, - saved_last_90_days: 0, - visualization_types_overall: {}, - visualization_types_last_30_days: {}, - visualization_types_last_90_days: {}, + saved_overall_total: 0, + saved_30_days_total: 0, + saved_90_days_total: 0, + saved_overall: {}, + saved_30_days: {}, + saved_90_days: {}, + + events_30_days: {}, + events_90_days: {}, + suggestion_events_30_days: {}, + suggestion_events_90_days: {}, }; } }, - isReady: () => true, + isReady: () => isCollectorReady, }); plugins.usage.collectorSet.register(lensUsageCollector); } + +function addEvents(prevEvents: Record, newEvents: Record) { + Object.keys(newEvents).forEach(key => { + prevEvents[key] = (prevEvents[key] || 0) + newEvents[key]; + }); +} + +async function isTaskManagerReady(server: Server) { + return (await getLatestTaskState(server)) !== null; +} + +async function getLatestTaskState(server: Server) { + const taskManager = server.plugins.task_manager!; + + try { + const result = await taskManager.fetch({ + query: { bool: { filter: { term: { _id: `task:Lens-lens_telemetry` } } } }, + }); + return result.docs; + } catch (err) { + const errMessage = err && err.message ? err.message : err.toString(); + /* + The usage service WILL to try to fetch from this collector before the task manager has been initialized, because the + task manager has to wait for all plugins to initialize first. It's fine to ignore it as next time around it will be + initialized (or it will throw a different type of error) + */ + if (!errMessage.includes('NotInitialized')) { + throw err; + } + } + + return null; +} + +function getDataByDate(dates: Record>) { + const byDate = Object.keys(dates || {}).map(dateStr => parseInt(dateStr, 10)); + + const last30: Record = {}; + const last90: Record = {}; + + const last30Timestamp = moment() + .subtract(30, 'days') + .unix(); + const last90Timestamp = moment() + .subtract(90, 'days') + .unix(); + + byDate.forEach(dateKey => { + if (dateKey >= last30Timestamp) { + addEvents(last30, dates[dateKey]); + addEvents(last90, dates[dateKey]); + } else if (dateKey > last90Timestamp) { + addEvents(last90, dates[dateKey]); + } + }); + + return { + last30, + last90, + }; +} diff --git a/x-pack/legacy/plugins/lens/server/usage/index.ts b/x-pack/legacy/plugins/lens/server/usage/index.ts index 4dd74057e0877..e7c09fdda3c05 100644 --- a/x-pack/legacy/plugins/lens/server/usage/index.ts +++ b/x-pack/legacy/plugins/lens/server/usage/index.ts @@ -5,3 +5,4 @@ */ export * from './collectors'; +export * from './task'; diff --git a/x-pack/legacy/plugins/lens/server/usage/task.ts b/x-pack/legacy/plugins/lens/server/usage/task.ts new file mode 100644 index 0000000000000..ba74bcd240886 --- /dev/null +++ b/x-pack/legacy/plugins/lens/server/usage/task.ts @@ -0,0 +1,237 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import KbnServer, { Server } from 'src/legacy/server/kbn_server'; +import { CoreSetup } from 'src/core/server'; +import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch'; +// This import has the side effect of allowing us to use the elasticsearch type +// extensions below. Without this import, the compiler is unable to find these +// in tests +import {} from '../../../apm/typings/elasticsearch'; +import { + SearchParams, + DeleteDocumentByQueryParams, + SearchResponse, + DeleteDocumentByQueryResponse, + AggregationSearchResponseWithTotalHitsAsInt, +} from 'elasticsearch'; +import { XPackMainPlugin } from '../../../xpack_main/xpack_main'; +import { RunContext } from '../../../task_manager'; +import { getVisualizationCounts } from './visualization_counts'; + +// This task is responsible for running daily and aggregating all the Lens click event objects +// into daily rolled-up documents, which will be used in reporting click stats + +const TELEMETRY_TASK_TYPE = 'lens_telemetry'; + +export const TASK_ID = `Lens-${TELEMETRY_TASK_TYPE}`; + +type ClusterSearchType = ( + endpoint: 'search', + params: SearchParams & { + rest_total_hits_as_int: boolean; + }, + options?: CallClusterOptions +) => Promise>; +type ClusterDeleteType = ( + endpoint: 'deleteByQuery', + params: DeleteDocumentByQueryParams, + options?: CallClusterOptions +) => Promise; + +export function initializeLensTelemetry(core: CoreSetup, { server }: { server: Server }) { + registerLensTelemetryTask(core, { server }); + scheduleTasks(server); +} + +function registerLensTelemetryTask(core: CoreSetup, { server }: { server: Server }) { + const taskManager = server.plugins.task_manager!; + taskManager.registerTaskDefinitions({ + [TELEMETRY_TASK_TYPE]: { + title: 'Lens telemetry fetch task', + type: TELEMETRY_TASK_TYPE, + timeout: '1m', + createTaskRunner: telemetryTaskRunner(server), + }, + }); +} + +function scheduleTasks(server: Server) { + const taskManager = server.plugins.task_manager; + const { kbnServer } = (server.plugins.xpack_main as XPackMainPlugin & { + status: { plugin: { kbnServer: KbnServer } }; + }).status.plugin; + + kbnServer.afterPluginsInit(() => { + // The code block below can't await directly within "afterPluginsInit" + // callback due to circular dependency The server isn't "ready" until + // this code block finishes. Migrations wait for server to be ready before + // executing. Saved objects repository waits for migrations to finish before + // finishing the request. To avoid this, we'll await within a separate + // function block. + (async () => { + try { + await taskManager!.schedule({ + id: TASK_ID, + taskType: TELEMETRY_TASK_TYPE, + state: { byDate: {}, suggestionsByDate: {}, saved: {}, runs: 0 }, + params: {}, + }); + } catch (e) { + server.log(['warning', 'telemetry'], `Error scheduling task, received ${e.message}`); + } + })(); + }); +} + +export async function getDailyEvents( + kibanaIndex: string, + callCluster: ClusterSearchType & ClusterDeleteType +): Promise<{ + byDate: Record>; + suggestionsByDate: Record>; +}> { + const aggs = { + daily: { + date_histogram: { + field: 'lens-ui-telemetry.date', + calendar_interval: '1d', + min_doc_count: 1, + }, + aggs: { + groups: { + filters: { + filters: { + suggestionEvents: { + bool: { + filter: { + term: { 'lens-ui-telemetry.type': 'suggestion' }, + }, + }, + }, + regularEvents: { + bool: { + must_not: { + term: { 'lens-ui-telemetry.type': 'suggestion' }, + }, + }, + }, + }, + }, + aggs: { + names: { + terms: { field: 'lens-ui-telemetry.name', size: 100 }, + aggs: { + sums: { sum: { field: 'lens-ui-telemetry.count' } }, + }, + }, + }, + }, + }, + }, + }; + + const metrics: AggregationSearchResponseWithTotalHitsAsInt< + unknown, + { + body: { aggs: typeof aggs }; + } + > = await callCluster('search', { + index: kibanaIndex, + rest_total_hits_as_int: true, + body: { + query: { + bool: { + filter: [ + { term: { type: 'lens-ui-telemetry' } }, + { range: { 'lens-ui-telemetry.date': { gte: 'now-90d/d' } } }, + ], + }, + }, + aggs, + }, + size: 0, + }); + + const byDateByType: Record> = {}; + const suggestionsByDate: Record> = {}; + + metrics.aggregations!.daily.buckets.forEach(daily => { + const byType: Record = byDateByType[daily.key] || {}; + daily.groups.buckets.regularEvents.names.buckets.forEach(bucket => { + byType[bucket.key] = (bucket.sums.value || 0) + (byType[daily.key] || 0); + }); + byDateByType[daily.key] = byType; + + const suggestionsByType: Record = suggestionsByDate[daily.key] || {}; + daily.groups.buckets.suggestionEvents.names.buckets.forEach(bucket => { + suggestionsByType[bucket.key] = + (bucket.sums.value || 0) + (suggestionsByType[daily.key] || 0); + }); + suggestionsByDate[daily.key] = suggestionsByType; + }); + + // Always delete old date because we don't report it + await callCluster('deleteByQuery', { + index: kibanaIndex, + waitForCompletion: true, + body: { + query: { + bool: { + filter: [ + { term: { type: 'lens-ui-telemetry' } }, + { range: { 'lens-ui-telemetry.date': { lt: 'now-90d/d' } } }, + ], + }, + }, + }, + }); + + return { + byDate: byDateByType, + suggestionsByDate, + }; +} + +export function telemetryTaskRunner(server: Server) { + return ({ taskInstance }: RunContext) => { + const { state } = taskInstance; + const callCluster = server.plugins.elasticsearch.getCluster('admin').callWithInternalUser; + + return { + async run() { + const kibanaIndex = server.config().get('kibana.index'); + + return Promise.all([ + getDailyEvents(kibanaIndex, callCluster), + getVisualizationCounts(callCluster, server.config()), + ]) + .then(([lensTelemetry, lensVisualizations]) => { + return { + state: { + runs: (state.runs || 0) + 1, + byDate: (lensTelemetry && lensTelemetry.byDate) || {}, + suggestionsByDate: (lensTelemetry && lensTelemetry.suggestionsByDate) || {}, + saved: lensVisualizations, + }, + runAt: getNextMidnight(), + }; + }) + .catch(errMsg => + server.log(['warning'], `Error executing lens telemetry task: ${errMsg}`) + ); + }, + }; + }; +} + +function getNextMidnight() { + return moment() + .add(1, 'day') + .startOf('day') + .toDate(); +} diff --git a/x-pack/legacy/plugins/lens/server/usage/types.ts b/x-pack/legacy/plugins/lens/server/usage/types.ts index 909566b09ac8f..364f265dacfb4 100644 --- a/x-pack/legacy/plugins/lens/server/usage/types.ts +++ b/x-pack/legacy/plugins/lens/server/usage/types.ts @@ -4,11 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -export interface LensUsage { - visualization_types_overall: Record; - visualization_types_last_30_days: Record; - visualization_types_last_90_days: Record; - saved_total: number; - saved_last_30_days: number; - saved_last_90_days: number; +export interface LensTelemetryState { + runs: number; + byDate: Record>; + suggestionsByDate: Record>; + saved: LensVisualizationUsage; } + +export interface LensVisualizationUsage { + saved_overall: Record; + saved_30_days: Record; + saved_90_days: Record; + saved_overall_total: number; + saved_30_days_total: number; + saved_90_days_total: number; +} + +export interface LensClickUsage { + events_30_days: Record; + events_90_days: Record; + suggestion_events_30_days: Record; + suggestion_events_90_days: Record; +} + +export type LensUsage = LensVisualizationUsage & LensClickUsage; diff --git a/x-pack/legacy/plugins/lens/server/usage/visualization_counts.ts b/x-pack/legacy/plugins/lens/server/usage/visualization_counts.ts index 0558963374514..7c1dc5fca6d85 100644 --- a/x-pack/legacy/plugins/lens/server/usage/visualization_counts.ts +++ b/x-pack/legacy/plugins/lens/server/usage/visualization_counts.ts @@ -7,7 +7,7 @@ import { KibanaConfig } from 'src/legacy/server/kbn_server'; import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch'; import { SearchParams, SearchResponse } from 'elasticsearch'; -import { LensUsage } from './types'; +import { LensVisualizationUsage } from './types'; type ClusterSearchType = ( endpoint: 'search', @@ -20,7 +20,7 @@ type ClusterSearchType = ( export async function getVisualizationCounts( callCluster: ClusterSearchType, config: KibanaConfig -): Promise { +): Promise { const scriptedMetric = { scripted_metric: { // Each cluster collects its own type data in a key-value Map that looks like: @@ -82,11 +82,11 @@ export async function getVisualizationCounts( const buckets = results.aggregations.groups.buckets; return { - visualization_types_overall: buckets.overall.byType.value.types, - visualization_types_last_30_days: buckets.last30.byType.value.types, - visualization_types_last_90_days: buckets.last90.byType.value.types, - saved_total: buckets.overall.doc_count, - saved_last_30_days: buckets.last30.doc_count, - saved_last_90_days: buckets.last90.doc_count, + saved_overall: buckets.overall.byType.value.types, + saved_30_days: buckets.last30.byType.value.types, + saved_90_days: buckets.last90.byType.value.types, + saved_overall_total: buckets.overall.doc_count, + saved_30_days_total: buckets.last30.doc_count, + saved_90_days_total: buckets.last90.doc_count, }; } diff --git a/x-pack/legacy/plugins/xpack_main/xpack_main.d.ts b/x-pack/legacy/plugins/xpack_main/xpack_main.d.ts index 8ae2801dededb..2a197811cc032 100644 --- a/x-pack/legacy/plugins/xpack_main/xpack_main.d.ts +++ b/x-pack/legacy/plugins/xpack_main/xpack_main.d.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import KbnServer from 'src/legacy/server/kbn_server'; import { Feature, FeatureWithAllOrReadPrivileges } from '../../../plugins/features/server'; import { XPackInfo, XPackInfoOptions } from './server/lib/xpack_info'; export { XPackFeature } from './server/lib/xpack_info'; diff --git a/x-pack/test/api_integration/apis/lens/index.ts b/x-pack/test/api_integration/apis/lens/index.ts index e0dac4a9ad296..6d60f6d2d5ac2 100644 --- a/x-pack/test/api_integration/apis/lens/index.ts +++ b/x-pack/test/api_integration/apis/lens/index.ts @@ -10,5 +10,6 @@ export default function lensApiIntegrationTests({ loadTestFile }: FtrProviderCon describe('Lens', () => { loadTestFile(require.resolve('./existing_fields')); loadTestFile(require.resolve('./field_stats')); + loadTestFile(require.resolve('./telemetry')); }); } diff --git a/x-pack/test/api_integration/apis/lens/telemetry.ts b/x-pack/test/api_integration/apis/lens/telemetry.ts new file mode 100644 index 0000000000000..5bba8848fe1d9 --- /dev/null +++ b/x-pack/test/api_integration/apis/lens/telemetry.ts @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import expect from '@kbn/expect'; +import { KibanaConfig } from 'src/legacy/server/kbn_server'; +import { Client, SearchParams } from 'elasticsearch'; + +import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +import { getDailyEvents } from '../../../../legacy/plugins/lens/server/usage/task'; +import { getVisualizationCounts } from '../../../../legacy/plugins/lens/server/usage/visualization_counts'; + +const COMMON_HEADERS = { + 'kbn-xsrf': 'some-xsrf-token', +}; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const es: Client = getService('es'); + const callCluster: CallCluster = (((path: 'search', searchParams: SearchParams) => { + return es[path].call(es, searchParams); + }) as unknown) as CallCluster; + + async function assertExpectedSavedObjects(num: number) { + // Make sure that new/deleted docs are available to search + await es.indices.refresh({ + index: '.kibana', + }); + + const { count } = await es.count({ + index: '.kibana', + q: 'type:lens-ui-telemetry', + }); + + expect(count).to.be(num); + } + + describe('lens telemetry', () => { + beforeEach(async () => { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:lens-ui-telemetry', + waitForCompletion: true, + refresh: 'wait_for', + }); + }); + + afterEach(async () => { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:lens-ui-telemetry', + waitForCompletion: true, + refresh: 'wait_for', + }); + }); + + it('should do nothing on empty post', async () => { + await supertest + .post('/api/lens/telemetry') + .set(COMMON_HEADERS) + .send({ + events: {}, + suggestionEvents: {}, + }) + .expect(200); + + await assertExpectedSavedObjects(0); + }); + + it('should write a document per results', async () => { + await supertest + .post('/api/lens/telemetry') + .set(COMMON_HEADERS) + .send({ + events: { + '2019-10-13': { loaded: 5, dragged: 2 }, + '2019-10-14': { loaded: 1 }, + }, + suggestionEvents: { + '2019-10-13': { switched: 2 }, + }, + }) + .expect(200); + + await assertExpectedSavedObjects(4); + }); + + it('should delete older telemetry documents while running', async () => { + const olderDate = moment() + .subtract(100, 'days') + .valueOf(); + await es.index({ + index: '.kibana', + type: '_doc', + body: { + type: 'lens-ui-telemetry', + 'lens-ui-telemetry': { + date: olderDate, + name: 'load', + type: 'regular', + count: 5, + }, + }, + refresh: 'wait_for', + }); + + const result = await getDailyEvents('.kibana', callCluster); + + expect(result).to.eql({ + byDate: {}, + suggestionsByDate: {}, + }); + + await assertExpectedSavedObjects(0); + }); + + it('should aggregate the individual events into daily totals by type', async () => { + // Dates are set to midnight in the aggregation, so let's make this easier for the test + const date1 = moment() + .utc() + .subtract(10, 'days') + .startOf('day') + .valueOf(); + const date2 = moment() + .utc() + .subtract(20, 'days') + .startOf('day') + .valueOf(); + + function getEvent(name: string, date: number, type = 'regular') { + return { + type: 'lens-ui-telemetry', + 'lens-ui-telemetry': { + date, + name, + type, + count: 5, + }, + }; + } + + await es.bulk({ + refresh: 'wait_for', + body: [ + { index: { _index: '.kibana' } }, + getEvent('load', date1), + { index: { _index: '.kibana' } }, + getEvent('load', date1), + { index: { _index: '.kibana' } }, + getEvent('load', date1), + { index: { _index: '.kibana' } }, + getEvent('load', date2), + { index: { _index: '.kibana' } }, + getEvent('revert', date1, 'suggestion'), + ], + }); + + const result = await getDailyEvents('.kibana', callCluster); + + expect(result).to.eql({ + byDate: { + [date1]: { + load: 15, + }, + [date2]: { + load: 5, + }, + }, + suggestionsByDate: { + [date1]: { + revert: 5, + }, + [date2]: {}, + }, + }); + + await assertExpectedSavedObjects(5); + }); + + it('should collect telemetry on saved visualization types with a painless script', async () => { + const esArchiver = getService('esArchiver'); + + await esArchiver.loadIfNeeded('lens/basic'); + + const results = await getVisualizationCounts(callCluster, { + // Fake KibanaConfig service + get() { + return '.kibana'; + }, + has: () => false, + } as KibanaConfig); + + expect(results).to.have.keys([ + 'saved_overall', + 'saved_30_days', + 'saved_90_days', + 'saved_overall_total', + 'saved_30_days_total', + 'saved_90_days_total', + ]); + + expect(results.saved_overall).to.eql({ + lnsMetric: 1, + }); + expect(results.saved_overall_total).to.eql(1); + + await esArchiver.unload('lens/basic'); + }); + }); +}; diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index c511fcd997217..3346f2ff77036 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -6,10 +6,7 @@ import _ from 'lodash'; import expect from '@kbn/expect'; -import { Client, SearchParams } from 'elasticsearch'; -import { KibanaConfig } from 'src/legacy/server/kbn_server'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { getVisualizationCounts } from '../../../../legacy/plugins/lens/server/usage/visualization_counts'; // eslint-disable-next-line import/no-default-export export default function({ getService, getPageObjects, ...rest }: FtrProviderContext) { @@ -113,34 +110,5 @@ export default function({ getService, getPageObjects, ...rest }: FtrProviderCont // legend item(s), so we're using a class selector here. expect(await find.allByCssSelector('.echLegendItem')).to.have.length(3); }); - - it('should collect telemetry on saved visualization types with a painless script', async () => { - const es: Client = getService('es'); - const callCluster = (path: 'search', searchParams: SearchParams) => - es[path].call(es, searchParams); - - const results = await getVisualizationCounts(callCluster, { - // Fake KibanaConfig service - get(key: string) { - return '.kibana'; - }, - has: () => false, - } as KibanaConfig); - - expect(results).to.have.keys([ - 'visualization_types_overall', - 'visualization_types_last_30_days', - 'visualization_types_last_90_days', - 'saved_total', - 'saved_last_30_days', - 'saved_last_90_days', - ]); - - expect(results.visualization_types_overall).to.eql({ - lnsMetric: 1, - bar_stacked: 1, - }); - expect(results.saved_total).to.eql(2); - }); }); }