From 4b56493789299c93748e1312e6d73caecb64f209 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu, 8 Dec 2022 11:28:40 +0100 Subject: [PATCH] Elasticsearch: Add tracking for plugin adoption stats (#59954) * Elasticsearch: Add tracking * Update public/app/plugins/datasource/elasticsearch/tracking.ts Co-authored-by: Sven Grossmann * Update public/app/plugins/datasource/elasticsearch/tracking.ts Co-authored-by: Sven Grossmann * Update public/app/plugins/datasource/elasticsearch/tracking.ts Co-authored-by: Sven Grossmann * Refactor getLineLimit * Update public/app/plugins/datasource/elasticsearch/tracking.ts Co-authored-by: Sven Grossmann * Update public/app/plugins/datasource/elasticsearch/tracking.ts Co-authored-by: Sven Grossmann * Update not tracking for volume queries Co-authored-by: Sven Grossmann --- .../elasticsearch/datasource.test.ts | 53 +++++- .../datasource/elasticsearch/datasource.ts | 11 +- .../datasource/elasticsearch/module.test.ts | 152 ++++++++++++++++++ .../datasource/elasticsearch/module.ts | 8 +- .../datasource/elasticsearch/tracking.ts | 129 +++++++++++++++ 5 files changed, 347 insertions(+), 6 deletions(-) create mode 100644 public/app/plugins/datasource/elasticsearch/module.test.ts create mode 100644 public/app/plugins/datasource/elasticsearch/tracking.ts diff --git a/public/app/plugins/datasource/elasticsearch/datasource.test.ts b/public/app/plugins/datasource/elasticsearch/datasource.test.ts index 2c582baca57..09b6dc8d122 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.test.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.test.ts @@ -18,7 +18,7 @@ import { TimeRange, toUtc, } from '@grafana/data'; -import { BackendSrvRequest, FetchResponse } from '@grafana/runtime'; +import { BackendSrvRequest, FetchResponse, reportInteraction } from '@grafana/runtime'; import { backendSrv } from 'app/core/services/backend_srv'; // will use the version in __mocks__ import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { TemplateSrv } from 'app/features/templating/template_srv'; @@ -35,6 +35,7 @@ const ELASTICSEARCH_MOCK_URL = 'http://elasticsearch.local'; jest.mock('@grafana/runtime', () => ({ ...(jest.requireActual('@grafana/runtime') as unknown as object), getBackendSrv: () => backendSrv, + reportInteraction: jest.fn(), getDataSourceSrv: () => { return { getInstanceSettings: () => { @@ -275,6 +276,23 @@ describe('ElasticDatasource', () => { const { body } = await runScenario(); expect(body.query.bool.filter[1].query_string.query).toBe('escape\\:test'); }); + + it('should report query interaction', async () => { + await runScenario(); + expect(reportInteraction).toHaveBeenCalledWith( + 'grafana_elasticsearch_query_executed', + expect.objectContaining({ + alias: '$varAlias', + app: 'test', + has_data: true, + has_error: false, + line_limit: undefined, + query_type: 'metric', + simultaneously_sent_query_count: 1, + with_lucene_query: true, + }) + ); + }); }); describe('When issuing logs query with interval pattern', () => { @@ -344,6 +362,23 @@ describe('ElasticDatasource', () => { expect(links[0].url).toBe('http://localhost:3000/${__value.raw}'); expect(links[0].title).toBe('Custom Label'); }); + + it('should report query interaction', async () => { + await setupDataSource(); + expect(reportInteraction).toHaveBeenCalledWith( + 'grafana_elasticsearch_query_executed', + expect.objectContaining({ + alias: '$varAlias', + app: undefined, + has_data: true, + has_error: false, + line_limit: undefined, + query_type: 'logs', + simultaneously_sent_query_count: 1, + with_lucene_query: true, + }) + ); + }); }); describe('When issuing document query', () => { @@ -380,6 +415,22 @@ describe('ElasticDatasource', () => { const { body } = await runScenario(); expect(body.size).toBe(500); }); + it('should report query interaction', async () => { + await runScenario(); + expect(reportInteraction).toHaveBeenCalledWith( + 'grafana_elasticsearch_query_executed', + expect.objectContaining({ + alias: undefined, + app: 'test', + has_data: false, + has_error: false, + line_limit: undefined, + query_type: 'raw_document', + simultaneously_sent_query_count: 1, + with_lucene_query: true, + }) + ); + }); }); describe('When getting an error on response', () => { diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts index 33e11d60eff..49e775194c9 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -1,6 +1,6 @@ import { cloneDeep, find, first as _first, isNumber, isObject, isString, map as _map } from 'lodash'; import { generate, lastValueFrom, Observable, of, throwError } from 'rxjs'; -import { catchError, first, map, mergeMap, skipWhile, throwIfEmpty } from 'rxjs/operators'; +import { catchError, first, map, mergeMap, skipWhile, throwIfEmpty, tap } from 'rxjs/operators'; import { DataFrame, @@ -50,9 +50,11 @@ import { } from './components/QueryEditor/MetricAggregationsEditor/aggregations'; import { metricAggregationConfig } from './components/QueryEditor/MetricAggregationsEditor/utils'; import { defaultBucketAgg, hasMetricOfType } from './queryDef'; +import { trackQuery } from './tracking'; import { DataLinkConfig, ElasticsearchOptions, ElasticsearchQuery, TermsQuery } from './types'; import { coerceESVersion, getScriptValue, isSupportedVersion } from './utils'; +export const REF_ID_STARTER_LOG_VOLUME = 'log-volume-'; // Those are metadata fields as defined in https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-fields.html#_identity_metadata_fields. // custom fields can start with underscores, therefore is not safe to exclude anything that starts with one. const ELASTIC_META_FIELDS = [ @@ -616,7 +618,7 @@ export class ElasticDatasource }); const logsVolumeQuery: ElasticsearchQuery = { - refId: target.refId, + refId: `${REF_ID_STARTER_LOG_VOLUME}${target.refId}`, query: target.query, metrics: [{ type: 'count', id: '1' }], timeField, @@ -636,7 +638,7 @@ export class ElasticDatasource const shouldRunTroughBackend = request.app === CoreApp.Explore && config.featureToggles.elasticsearchBackendMigration; if (shouldRunTroughBackend) { - return super.query(request); + return super.query(request).pipe(tap((response) => trackQuery(response, request.targets, request.app))); } let payload = ''; const targets = this.interpolateVariablesInQueries(cloneDeep(request.targets), request.scopedVars); @@ -718,7 +720,8 @@ export class ElasticDatasource } return er.getTimeSeries(); - }) + }), + tap((response) => trackQuery(response, request.targets, request.app)) ); } diff --git a/public/app/plugins/datasource/elasticsearch/module.test.ts b/public/app/plugins/datasource/elasticsearch/module.test.ts new file mode 100644 index 00000000000..d86a40ff06f --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/module.test.ts @@ -0,0 +1,152 @@ +import { DashboardLoadedEvent } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import './module'; + +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + getAppEvents: () => ({ + subscribe: jest.fn((_, handler) => { + // Trigger test event + handler( + new DashboardLoadedEvent({ + dashboardId: 'dashboard123', + orgId: 1, + userId: 2, + grafanaVersion: 'v9.0.0', + queries: { + elasticsearch: [ + { + alias: '', + bucketAggs: [], + datasource: { + type: 'elasticsearch', + uid: 'PE50363A9B6833EE7', + }, + metrics: [ + { + id: '1', + settings: { + limit: '501', + }, + type: 'logs', + }, + ], + query: 'abc:def', + refId: 'A', + timeField: '@timestamp', + }, + { + alias: '', + bucketAggs: [], + datasource: { + type: 'elasticsearch', + uid: 'es1', + }, + metrics: [ + { + id: '1', + settings: { + size: '600', + }, + type: 'raw_data', + }, + ], + query: '', + }, + { + alias: 'alias', + bucketAggs: [ + { + field: '@timestamp', + id: '2', + settings: { + interval: 'auto', + }, + type: 'date_histogram', + }, + ], + datasource: { + type: 'elasticsearch', + uid: 'es1', + }, + metrics: [ + { + id: '3', + type: 'count', + }, + ], + query: 'abc:def', + }, + { + alias: '', + bucketAggs: [], + datasource: { + type: 'elasticsearch', + uid: 'PE50363A9B6833EE7', + }, + metrics: [ + { + id: '1', + settings: { + size: '600', + }, + type: 'raw_document', + }, + ], + query: '', + refId: 'A', + timeField: '@timestamp', + }, + { + alias: '', + bucketAggs: [ + { + field: '@timestamp', + id: '2', + settings: { + interval: 'auto', + }, + type: 'date_histogram', + }, + ], + datasource: { + type: 'elasticsearch', + uid: 'es1', + }, + metrics: [ + { + field: 'counter', + id: '1', + type: 'avg', + }, + ], + query: '$test:abc', + }, + ], + }, + }) + ); + }), + }), + }; +}); + +describe('queriesOnInitDashboard', () => { + it('should report a grafana_elasticsearch_dashboard_loaded interaction ', () => { + expect(reportInteraction).toHaveBeenCalledWith('grafana_elasticsearch_dashboard_loaded', { + grafana_version: 'v9.0.0', + dashboard_id: 'dashboard123', + org_id: 1, + queries_count: 5, + queries_with_changed_line_limit_count: 1, + queries_with_lucene_query_count: 3, + queries_with_template_variables_count: 1, + raw_data_queries_count: 1, + raw_document_queries_count: 1, + logs_queries_count: 1, + metric_queries_count: 2, + }); + }); +}); diff --git a/public/app/plugins/datasource/elasticsearch/module.ts b/public/app/plugins/datasource/elasticsearch/module.ts index f1b5a850cb4..d6c0b285eb1 100644 --- a/public/app/plugins/datasource/elasticsearch/module.ts +++ b/public/app/plugins/datasource/elasticsearch/module.ts @@ -1,7 +1,13 @@ -import { DataSourcePlugin } from '@grafana/data'; +import { DashboardLoadedEvent, DataSourcePlugin } from '@grafana/data'; +import { getAppEvents } from '@grafana/runtime'; import { QueryEditor } from './components/QueryEditor'; import { ConfigEditor } from './configuration/ConfigEditor'; import { ElasticDatasource } from './datasource'; +import { onDashboardLoadedHandler } from './tracking'; +import { ElasticsearchQuery } from './types'; export const plugin = new DataSourcePlugin(ElasticDatasource).setQueryEditor(QueryEditor).setConfigEditor(ConfigEditor); + +// Subscribe to on dashboard loaded event so that we can track plugin adoption +getAppEvents().subscribe>(DashboardLoadedEvent, onDashboardLoadedHandler); diff --git a/public/app/plugins/datasource/elasticsearch/tracking.ts b/public/app/plugins/datasource/elasticsearch/tracking.ts new file mode 100644 index 00000000000..20da97e0bb8 --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/tracking.ts @@ -0,0 +1,129 @@ +import { DashboardLoadedEvent, DataQueryResponse } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { variableRegex } from 'app/features/variables/utils'; + +import { REF_ID_STARTER_LOG_VOLUME } from './datasource'; +import pluginJson from './plugin.json'; +import { ElasticsearchQuery } from './types'; + +type ElasticSearchOnDashboardLoadedTrackingEvent = { + grafana_version?: string; + dashboard_id?: string; + org_id?: number; + + /* The number of Elasticsearch queries present in the dashboard*/ + queries_count: number; + + /* The number of Elasticsearch logs queries present in the dashboard*/ + logs_queries_count: number; + + /* The number of Elasticsearch metric queries present in the dashboard*/ + metric_queries_count: number; + + /* The number of Elasticsearch raw data queries present in the dashboard*/ + raw_data_queries_count: number; + + /* The number of Elasticsearch raw documents queries present in the dashboard*/ + raw_document_queries_count: number; + + /* The number of Elasticsearch queries with used template variables present in the dashboard*/ + queries_with_template_variables_count: number; + + /* The number of Elasticsearch queries with changed line limit present in the dashboard*/ + queries_with_changed_line_limit_count: number; + + /* The number of Elasticsearch queries with lucene query present in the dashboard*/ + queries_with_lucene_query_count: number; +}; + +export const onDashboardLoadedHandler = ({ + payload: { dashboardId, orgId, grafanaVersion, queries }, +}: DashboardLoadedEvent) => { + try { + // We only want to track visible ElasticSearch queries + const elasticsearchQueries = queries[pluginJson.id].filter((query) => !query.hide); + if (!elasticsearchQueries?.length) { + return; + } + + const queriesWithTemplateVariables = elasticsearchQueries.filter(isQueryWithTemplateVariables); + const queriesWithLuceneQuery = elasticsearchQueries.filter((query) => !!query.query); + const logsQueries = elasticsearchQueries.filter((query) => getQueryType(query) === 'logs'); + const metricQueries = elasticsearchQueries.filter((query) => getQueryType(query) === 'metric'); + const rawDataQueries = elasticsearchQueries.filter((query) => getQueryType(query) === 'raw_data'); + const rawDocumentQueries = elasticsearchQueries.filter((query) => getQueryType(query) === 'raw_document'); + const queriesWithChangedLineLimit = elasticsearchQueries.filter(isQueryWithChangedLineLimit); + + const event: ElasticSearchOnDashboardLoadedTrackingEvent = { + grafana_version: grafanaVersion, + dashboard_id: dashboardId, + org_id: orgId, + queries_count: elasticsearchQueries.length, + logs_queries_count: logsQueries.length, + metric_queries_count: metricQueries.length, + raw_data_queries_count: rawDataQueries.length, + raw_document_queries_count: rawDocumentQueries.length, + queries_with_template_variables_count: queriesWithTemplateVariables.length, + queries_with_changed_line_limit_count: queriesWithChangedLineLimit.length, + queries_with_lucene_query_count: queriesWithLuceneQuery.length, + }; + + reportInteraction('grafana_elasticsearch_dashboard_loaded', event); + } catch (error) { + console.error('error in elasticsearch tracking handler', error); + } +}; + +const getQueryType = (query: ElasticsearchQuery): string | undefined => { + if (!query.metrics || !query.metrics.length) { + return undefined; + } + const nonMetricQueryTypes = ['logs', 'raw_data', 'raw_document']; + if (nonMetricQueryTypes.includes(query.metrics[0].type)) { + return query.metrics[0].type; + } + return 'metric'; +}; + +const getLineLimit = (query: ElasticsearchQuery): number | undefined => { + if (query.metrics?.[0]?.type !== 'logs') { + return undefined; + } + + const lineLimit = query.metrics?.[0].settings?.limit; + return lineLimit ? parseInt(lineLimit, 10) : undefined; +}; + +const isQueryWithChangedLineLimit = (query: ElasticsearchQuery): boolean => { + const lineLimit = getLineLimit(query); + return lineLimit !== undefined && lineLimit !== 500; +}; + +const isQueryWithTemplateVariables = (query: ElasticsearchQuery): boolean => { + return variableRegex.test(query.query ?? ''); +}; + +const shouldNotReportBasedOnRefId = (refId: string): boolean => { + if (refId.startsWith(REF_ID_STARTER_LOG_VOLUME)) { + return true; + } + return false; +}; + +export function trackQuery(response: DataQueryResponse, queries: ElasticsearchQuery[], app: string): void { + for (const query of queries) { + if (shouldNotReportBasedOnRefId(query.refId)) { + return; + } + reportInteraction('grafana_elasticsearch_query_executed', { + app, + with_lucene_query: query.query ? true : false, + query_type: getQueryType(query), + line_limit: getLineLimit(query), + alias: query.alias, + has_error: response.error !== undefined, + has_data: response.data.some((frame) => frame.length > 0), + simultaneously_sent_query_count: queries.length, + }); + } +}