diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index 2087d10b4555c..f7cadbc6433e7 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -8,7 +8,6 @@ import * as Joi from 'joi'; import { resolve } from 'path'; import { LegacyPluginInitializer } from 'src/legacy/types'; import KbnServer, { Server } from 'src/legacy/server/kbn_server'; -import { CoreSetup } from 'src/core/server'; import mappings from './mappings.json'; import { PLUGIN_ID, getEditPath } from './common'; import { lensServerPlugin } from './server'; @@ -84,11 +83,12 @@ export const lens: LegacyPluginInitializer = kibana => { // Set up with the new platform plugin lifecycle API. const plugin = lensServerPlugin(); - plugin.setup(({ - http: { - ...kbnServer.newPlatform.setup.core.http, - }, - } as unknown) as CoreSetup); + plugin.setup(kbnServer.newPlatform.setup.core, { + // Legacy APIs + savedObjects: server.savedObjects, + usage: server.usage, + config: server.config(), + }); server.events.on('stop', () => { plugin.stop(); diff --git a/x-pack/legacy/plugins/lens/mappings.json b/x-pack/legacy/plugins/lens/mappings.json index 8eccf22eb2235..832d152eb77a1 100644 --- a/x-pack/legacy/plugins/lens/mappings.json +++ b/x-pack/legacy/plugins/lens/mappings.json @@ -8,8 +8,7 @@ "type": "keyword" }, "state": { - "enabled": false, - "type": "object" + "type": "flattened" }, "expression": { "index": false, diff --git a/x-pack/legacy/plugins/lens/server/plugin.tsx b/x-pack/legacy/plugins/lens/server/plugin.tsx index 9c33889a514a4..fdcfe122f60ce 100644 --- a/x-pack/legacy/plugins/lens/server/plugin.tsx +++ b/x-pack/legacy/plugins/lens/server/plugin.tsx @@ -4,14 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Plugin, CoreSetup } from 'src/core/server'; +import { KibanaConfig } from 'src/legacy/server/kbn_server'; +import { Plugin, CoreSetup, SavedObjectsLegacyService } from 'src/core/server'; import { setupRoutes } from './routes'; +import { registerLensUsageCollector } from './usage'; export class LensServer implements Plugin<{}, {}, {}, {}> { constructor() {} - setup(core: CoreSetup) { + setup( + core: CoreSetup, + plugins: { + savedObjects: SavedObjectsLegacyService; + usage: { + collectorSet: { + makeUsageCollector: (options: unknown) => unknown; + register: (options: unknown) => unknown; + }; + }; + config: KibanaConfig; + } + ) { setupRoutes(core); + registerLensUsageCollector(core, plugins); return {}; } diff --git a/x-pack/legacy/plugins/lens/server/usage/collectors.ts b/x-pack/legacy/plugins/lens/server/usage/collectors.ts new file mode 100644 index 0000000000000..75f422088ed81 --- /dev/null +++ b/x-pack/legacy/plugins/lens/server/usage/collectors.ts @@ -0,0 +1,45 @@ +/* + * 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 { KibanaConfig } from 'src/legacy/server/kbn_server'; +import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { CoreSetup, SavedObjectsLegacyService } from 'src/core/server'; +import { getVisualizationCounts } from './visualization_counts'; +import { LensUsage } from './types'; + +export function registerLensUsageCollector( + core: CoreSetup, + plugins: { + savedObjects: SavedObjectsLegacyService; + usage: { + collectorSet: { + makeUsageCollector: (options: unknown) => unknown; + register: (options: unknown) => unknown; + }; + }; + config: KibanaConfig; + } +) { + const lensUsageCollector = plugins.usage.collectorSet.makeUsageCollector({ + type: 'lens', + fetch: async (callCluster: CallCluster): Promise => { + try { + return getVisualizationCounts(callCluster, plugins.config); + } 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: {}, + }; + } + }, + isReady: () => true, + }); + plugins.usage.collectorSet.register(lensUsageCollector); +} diff --git a/x-pack/legacy/plugins/lens/server/usage/index.ts b/x-pack/legacy/plugins/lens/server/usage/index.ts new file mode 100644 index 0000000000000..4dd74057e0877 --- /dev/null +++ b/x-pack/legacy/plugins/lens/server/usage/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 './collectors'; diff --git a/x-pack/legacy/plugins/lens/server/usage/types.ts b/x-pack/legacy/plugins/lens/server/usage/types.ts new file mode 100644 index 0000000000000..909566b09ac8f --- /dev/null +++ b/x-pack/legacy/plugins/lens/server/usage/types.ts @@ -0,0 +1,14 @@ +/* + * 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 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; +} diff --git a/x-pack/legacy/plugins/lens/server/usage/visualization_counts.ts b/x-pack/legacy/plugins/lens/server/usage/visualization_counts.ts new file mode 100644 index 0000000000000..0558963374514 --- /dev/null +++ b/x-pack/legacy/plugins/lens/server/usage/visualization_counts.ts @@ -0,0 +1,92 @@ +/* + * 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 { KibanaConfig } from 'src/legacy/server/kbn_server'; +import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch'; +import { SearchParams, SearchResponse } from 'elasticsearch'; +import { LensUsage } from './types'; + +type ClusterSearchType = ( + endpoint: 'search', + params: SearchParams & { + rest_total_hits_as_int: boolean; + }, + options?: CallClusterOptions +) => Promise>; + +export async function getVisualizationCounts( + callCluster: ClusterSearchType, + config: KibanaConfig +): Promise { + const scriptedMetric = { + scripted_metric: { + // Each cluster collects its own type data in a key-value Map that looks like: + // { lnsDatatable: 5, area_stacked: 3 } + init_script: 'state.types = [:]', + // The map script relies on having flattened keyword mapping for the Lens saved object, + // without this kind of mapping we would not be able to access `lens.state` in painless + map_script: ` + String visType = doc['lens.visualizationType'].value; + String niceType = visType == 'lnsXY' ? doc['lens.state.visualization.preferredSeriesType'].value : visType; + state.types.put(niceType, state.types.containsKey(niceType) ? state.types.get(niceType) + 1 : 1); + `, + // Combine script is executed per cluster, but we already have a key-value pair per cluster. + // Despite docs that say this is optional, this script can't be blank. + combine_script: 'return state', + // Reduce script is executed across all clusters, so we need to add up all the total from each cluster + // This also needs to account for having no data + reduce_script: ` + Map result = [:]; + for (Map m : states.toArray()) { + if (m !== null) { + for (String k : m.keySet()) { + result.put(k, result.containsKey(k) ? result.get(k) + m.get(k) : m.get(k)); + } + } + } + return result; + `, + }, + }; + + const results = await callCluster('search', { + index: config.get('kibana.index'), + rest_total_hits_as_int: true, + body: { + query: { + bool: { + filter: [{ term: { type: 'lens' } }], + }, + }, + aggs: { + groups: { + filters: { + filters: { + last30: { bool: { filter: { range: { updated_at: { gte: 'now-30d' } } } } }, + last90: { bool: { filter: { range: { updated_at: { gte: 'now-90d' } } } } }, + overall: { match_all: {} }, + }, + }, + aggs: { + byType: scriptedMetric, + }, + }, + }, + size: 0, + }, + }); + + 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, + }; +} diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 3cb74307c3c09..0a1611a3982d5 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -4,11 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +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 }: FtrProviderContext) { +export default function({ getService, getPageObjects, ...rest }: FtrProviderContext) { const PageObjects = getPageObjects([ 'header', 'common', @@ -112,5 +116,34 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { // 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); + }); }); }