diff --git a/src/plugins/dashboard/server/usage/dashboard_telemetry.ts b/src/plugins/dashboard/server/usage/dashboard_telemetry.ts index ea95ca6059fae8..c2a823fa73687b 100644 --- a/src/plugins/dashboard/server/usage/dashboard_telemetry.ts +++ b/src/plugins/dashboard/server/usage/dashboard_telemetry.ts @@ -14,7 +14,10 @@ import { EmbeddablePersistableStateService } from '@kbn/embeddable-plugin/common import { type ControlGroupTelemetry, CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common'; import { DashboardAttributes, SavedDashboardPanel } from '../../common/content_management'; -import { TASK_ID, DashboardTelemetryTaskState } from './dashboard_telemetry_collection_task'; +import { TASK_ID } from './dashboard_telemetry_collection_task'; +import { type LatestTaskStateSchema } from './task_state'; + +// TODO: Merge with LatestTaskStateSchema export interface DashboardCollectorData { panels: { total: number; @@ -127,7 +130,7 @@ export async function collectDashboardTelemetry(taskManager: TaskManagerStartCon const latestTaskState = await getLatestTaskState(taskManager); if (latestTaskState !== null) { - const state = latestTaskState[0].state as DashboardTelemetryTaskState; + const state = latestTaskState[0].state as LatestTaskStateSchema; return state.telemetry; } diff --git a/src/plugins/dashboard/server/usage/dashboard_telemetry_collection_task.ts b/src/plugins/dashboard/server/usage/dashboard_telemetry_collection_task.ts index 549481770f2542..0020f414f5bfc5 100644 --- a/src/plugins/dashboard/server/usage/dashboard_telemetry_collection_task.ts +++ b/src/plugins/dashboard/server/usage/dashboard_telemetry_collection_task.ts @@ -15,12 +15,12 @@ import { } from '@kbn/task-manager-plugin/server'; import { EmbeddableSetup } from '@kbn/embeddable-plugin/server'; import { CoreSetup, Logger, SavedObjectReference } from '@kbn/core/server'; +import { stateSchemaByVersion, emptyState, type LatestTaskStateSchema } from './task_state'; import { controlsCollectorFactory, collectPanelsByType, getEmptyDashboardData, - DashboardCollectorData, } from './dashboard_telemetry'; import { injectReferences } from '../../common'; import { DashboardAttributesAndReferences } from '../../common/types'; @@ -32,11 +32,6 @@ import { DashboardAttributes, SavedDashboardPanel } from '../../common/content_m const TELEMETRY_TASK_TYPE = 'dashboard_telemetry'; export const TASK_ID = `Dashboard-${TELEMETRY_TASK_TYPE}`; -export interface DashboardTelemetryTaskState { - runs: number; - telemetry: DashboardCollectorData; -} - export function initializeDashboardTelemetryTask( logger: Logger, core: CoreSetup, @@ -60,6 +55,7 @@ function registerDashboardTelemetryTask( [TELEMETRY_TASK_TYPE]: { title: 'Dashboard telemetry collection task', timeout: '2m', + stateSchemaByVersion, createTaskRunner: dashboardTaskRunner(logger, core, embeddable), }, }); @@ -70,7 +66,7 @@ async function scheduleTasks(logger: Logger, taskManager: TaskManagerStartContra return await taskManager.ensureScheduled({ id: TASK_ID, taskType: TELEMETRY_TASK_TYPE, - state: { byDate: {}, suggestionsByDate: {}, saved: {}, runs: 0 }, + state: emptyState, params: {}, }); } catch (e) { @@ -80,7 +76,7 @@ async function scheduleTasks(logger: Logger, taskManager: TaskManagerStartContra export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable: EmbeddableSetup) { return ({ taskInstance }: RunContext) => { - const { state } = taskInstance; + const state = taskInstance.state as LatestTaskStateSchema; const getEsClient = async () => { const [coreStart] = await core.getStartServices(); @@ -172,11 +168,12 @@ export function dashboardTaskRunner(logger: Logger, core: CoreSetup, embeddable: ); } + const updatedState: LatestTaskStateSchema = { + runs: state.runs + 1, + telemetry: dashboardData, + }; return { - state: { - runs: (state.runs || 0) + 1, - telemetry: dashboardData, - }, + state: updatedState, runAt: getNextMidnight(), }; } catch (e) { diff --git a/src/plugins/dashboard/server/usage/task_state.test.ts b/src/plugins/dashboard/server/usage/task_state.test.ts new file mode 100644 index 00000000000000..10fd64da2f68a2 --- /dev/null +++ b/src/plugins/dashboard/server/usage/task_state.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { cloneDeep } from 'lodash'; +import { stateSchemaByVersion } from './task_state'; + +describe('telemetry task state', () => { + describe('v1', () => { + const v1 = stateSchemaByVersion[1]; + it('should work on empty object when running the up migration', () => { + const result = v1.up({}); + expect(result).toMatchInlineSnapshot(` + Object { + "runs": 0, + "telemetry": undefined, + } + `); + }); + + it(`shouldn't overwrite properties when running the up migration`, () => { + const state = { + runs: 1, + telemetry: { + panels: { + total: 2, + by_reference: 3, + by_value: 4, + by_type: { + foo: 5, + }, + }, + controls: { + total: 6, + chaining_system: { foo: 7 }, + label_position: { foo: 8 }, + ignore_settings: { foo: 9 }, + by_type: { foo: 10 }, + }, + }, + }; + const result = v1.up(cloneDeep(state)); + expect(result).toEqual(state); + }); + + it(`should migrate the old default state that didn't match the schema`, () => { + const result = v1.up({ byDate: {}, suggestionsByDate: {}, saved: {}, runs: 0 }); + expect(result).toMatchInlineSnapshot(` + Object { + "runs": 0, + "telemetry": undefined, + } + `); + }); + + it('should drop unknown properties when running the up migration', () => { + const state = { foo: true }; + const result = v1.up(state); + expect(result).not.toHaveProperty('foo'); + }); + }); +}); diff --git a/src/plugins/dashboard/server/usage/task_state.ts b/src/plugins/dashboard/server/usage/task_state.ts new file mode 100644 index 00000000000000..b24ff0958d3ca9 --- /dev/null +++ b/src/plugins/dashboard/server/usage/task_state.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema, type TypeOf } from '@kbn/config-schema'; + +/** + * WARNING: Do not modify the existing versioned schema(s) below, instead define a new version (ex: 2, 3, 4). + * This is required to support zero-downtime upgrades and rollbacks. See https://github.com/elastic/kibana/issues/155764. + * + * As you add a new schema version, don't forget to change latestTaskStateSchema variable to reference the latest schema. + * For example, changing stateSchemaByVersion[1].schema to stateSchemaByVersion[2].schema. + */ +export const stateSchemaByVersion = { + 1: { + // A task that was created < 8.10 will go through this "up" migration + // to ensure it matches the v1 schema. + up: (state: Record) => ({ + runs: typeof state.runs === 'number' ? state.runs : 0, + telemetry: state.telemetry + ? { + panels: { + total: state.telemetry.panels?.total || 0, + by_reference: state.telemetry.panels?.by_reference || 0, + by_value: state.telemetry.panels?.by_value || 0, + by_type: state.telemetry.panels?.by_type || {}, + }, + controls: { + total: state.telemetry.controls?.total || 0, + chaining_system: state.telemetry.controls?.chaining_system || {}, + label_position: state.telemetry.controls?.label_position || {}, + ignore_settings: state.telemetry.controls?.ignore_settings || {}, + by_type: state.telemetry.controls?.by_type || {}, + }, + } + : undefined, + }), + schema: schema.object({ + runs: schema.number(), + telemetry: schema.maybe( + schema.object({ + panels: schema.object({ + total: schema.number(), + by_reference: schema.number(), + by_value: schema.number(), + by_type: schema.recordOf( + schema.string(), + schema.object({ + total: schema.number(), + by_reference: schema.number(), + by_value: schema.number(), + details: schema.recordOf(schema.string(), schema.number()), + }) + ), + }), + controls: schema.object({ + total: schema.number(), + chaining_system: schema.recordOf(schema.string(), schema.number()), + label_position: schema.recordOf(schema.string(), schema.number()), + ignore_settings: schema.recordOf(schema.string(), schema.number()), + by_type: schema.recordOf( + schema.string(), + schema.object({ + total: schema.number(), + details: schema.recordOf(schema.string(), schema.number()), + }) + ), + }), + }) + ), + }), + }, +}; + +const latestTaskStateSchema = stateSchemaByVersion[1].schema; +export type LatestTaskStateSchema = TypeOf; + +export const emptyState: LatestTaskStateSchema = { + runs: 0, + telemetry: undefined, +};