From fd4e551340a2044adc2d9309ea35dfe1ebd664d6 Mon Sep 17 00:00:00 2001 From: "Eyo O. Eyo" <7893459+eokoneyo@users.noreply.github.com> Date: Mon, 23 Jun 2025 20:45:31 +0200 Subject: [PATCH] [Intercept] Setup intervals for intercept in Kibana offerings (#221743) ## Summary - Adds configuration for the product intercept in `oblt`, `es` and `security` serverless offerings, alongsides stateful offering too. The configuration provided sets the intercept to display every 90days, this is configurable through the config `xpack.product_intercept.interval`. The intercept can also be turned off through the config `xpack.product_intercept.enabled` - Also tweaks prompter timer implementation to accommodate inherent [issue with long timer delays](https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value) in the browser - Adjusts the signature of the `registerIntercept` method, such that a deferred value to be evaluated when the intercept is to be displayed is passed. This unlocks the ability to have consumers provide dynamically imported modules that provide the config for the intercept, see https://github.com/elastic/kibana/pull/221743/commits/0e07892217b043b7488b3cab54a567931258a211 for an example. ### How to test - Add the following config to your `kibana.dev.yml` file; ```yml xpack.product_intercept.enabled: true # we set the interval to 30s so the wait long period to display the intercept is bearable xpack.product_intercept.interval: '30s' ``` - Start kibana in stateful, and serverless mode, in either scenario you should be presented the product intercept, with the intercept specifically stating the current product the user is interacting with. See below for an example of observability solution; https://github.com/user-attachments/assets/6ca6baf2-58d3-4002-ac94-ec6e9a0902ae --------- Co-authored-by: Elastic Machine --- .github/CODEOWNERS | 1 + config/serverless.chat.yml | 5 +- config/serverless.yml | 4 + src/platform/test/common/config.js | 2 - src/platform/test/functional/config.base.js | 3 - .../test_suites/core_plugins/rendering.ts | 1 + .../private/intercepts/common/constants.ts | 4 +- .../private/intercepts/public/index.ts | 1 + .../intercepts/public/prompter/index.ts | 1 + .../public/prompter/prompter.test.ts | 461 ++++++++++-------- .../intercepts/public/prompter/prompter.ts | 89 +++- .../server/services/intercept_trigger.test.ts | 4 +- .../server/services/intercept_trigger.ts | 9 +- .../services/intercept_user_interaction.ts | 4 +- .../product_intercept/common/config.ts | 4 +- .../product_intercept/common/constants.ts | 6 + .../private/product_intercept/kibana.jsonc | 2 +- .../private/product_intercept/public/index.ts | 5 +- .../public/intercept_registration_config.tsx | 154 ++++++ .../product_intercept/public/plugin.ts | 168 ++----- .../product_intercept/server/plugin.ts | 15 +- .../private/product_intercept/tsconfig.json | 3 +- .../apis/intercepts/index.ts | 15 + .../apis/intercepts/interaction_apis.ts | 53 ++ .../apis/intercepts/trigger_apis.ts | 78 +++ .../configs/serverless/oblt.index.ts | 1 + .../configs/serverless/search.index.ts | 1 + .../configs/serverless/security.index.ts | 1 + .../configs/stateful/platform.index.ts | 1 + x-pack/test/tsconfig.json | 1 + x-pack/test_serverless/shared/config.base.ts | 2 - 31 files changed, 743 insertions(+), 356 deletions(-) create mode 100644 x-pack/platform/plugins/private/product_intercept/public/intercept_registration_config.tsx create mode 100644 x-pack/test/api_integration/deployment_agnostic/apis/intercepts/index.ts create mode 100644 x-pack/test/api_integration/deployment_agnostic/apis/intercepts/interaction_apis.ts create mode 100644 x-pack/test/api_integration/deployment_agnostic/apis/intercepts/trigger_apis.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 787828c89e98..32039274b174 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2829,6 +2829,7 @@ src/platform/testfunctional/page_objects/solution_navigation.ts @elastic/appex-s /x-pack/test_serverless/functional/page_objects/svl_common_navigation.ts @elastic/appex-sharedux /x-pack/test_serverless/functional/page_objects/svl_sec_landing_page.ts @elastic/appex-sharedux /x-pack/test_serverless/functional/test_suites/security/ftr/navigation.ts @elastic/appex-sharedux +/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/*.ts @elastic/appex-sharedux # OpenAPI spec files oas_docs/.spectral.yaml @elastic/platform-docs diff --git a/config/serverless.chat.yml b/config/serverless.chat.yml index 23b429b97d93..fc8fa396e976 100644 --- a/config/serverless.chat.yml +++ b/config/serverless.chat.yml @@ -23,4 +23,7 @@ xpack.wciExternalServer.enabled: true xpack.spaces.maxSpaces: 1 ## Content Connectors in stack management -xpack.contentConnectors.enabled: false \ No newline at end of file +xpack.contentConnectors.enabled: false + +## Disable Kibana Product Intercept +xpack.product_intercept.enabled: false diff --git a/config/serverless.yml b/config/serverless.yml index 5ef349b9f25c..ccddd0077f0b 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -266,3 +266,7 @@ xpack.dataUsage.enableExperimental: ['dataUsageDisabled'] ## Content Connectors in stack management xpack.contentConnectors.enabled: true + +## Enable Kibana Product Intercept +xpack.product_intercept.enabled: true +xpack.product_intercept.interval: '90d' diff --git a/src/platform/test/common/config.js b/src/platform/test/common/config.js index 74c4b8e41db0..d47745d6c4c7 100644 --- a/src/platform/test/common/config.js +++ b/src/platform/test/common/config.js @@ -84,8 +84,6 @@ export default function () { pattern: '[%date][%level][%logger] %message %meta', }, })}`, - // disable product intercept for all ftr tests by default - '--xpack.intercepts.enabled=false', ], }, }; diff --git a/src/platform/test/functional/config.base.js b/src/platform/test/functional/config.base.js index 6d1610a79bb7..44ea067ccc5f 100644 --- a/src/platform/test/functional/config.base.js +++ b/src/platform/test/functional/config.base.js @@ -42,9 +42,6 @@ export default async function ({ readConfigFile }) { // disable fleet task that writes to metrics.fleet_server.* data streams, impacting functional tests `--xpack.task_manager.unsafe.exclude_task_types=${JSON.stringify(['Fleet-Metrics-Task'])}`, - - // disable product intercept - '--xpack.intercepts.enabled=false', ], }, diff --git a/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts b/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts index f2ec000f8958..0a642d3323c3 100644 --- a/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -305,6 +305,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.index_management.editableIndexSettings (all?|limited?|never)', 'xpack.index_management.enableMappingsSourceFieldSection (boolean?|never)', 'xpack.index_management.dev.enableSemanticText (boolean?)', + 'xpack.intercepts.enabled (boolean?)', 'xpack.license_management.ui.enabled (boolean?)', 'xpack.maps.preserveDrawingBuffer (boolean?)', 'xpack.maps.showMapsInspectorAdapter (boolean?)', diff --git a/x-pack/platform/plugins/private/intercepts/common/constants.ts b/x-pack/platform/plugins/private/intercepts/common/constants.ts index 1e7d6e2ecd2e..bba88b6afca9 100644 --- a/x-pack/platform/plugins/private/intercepts/common/constants.ts +++ b/x-pack/platform/plugins/private/intercepts/common/constants.ts @@ -5,7 +5,7 @@ * 2.0. */ -export const TRIGGER_INFO_API_ROUTE = '/internal/product_intercept/trigger_info' as const; +export const TRIGGER_INFO_API_ROUTE = '/internal/api/intercepts/trigger_info' as const; export const TRIGGER_USER_INTERACTION_METADATA_API_ROUTE = - '/internal/api/intercept/user_interaction/{triggerId}' as const; + '/internal/api/intercepts/user_interaction/{triggerId}' as const; diff --git a/x-pack/platform/plugins/private/intercepts/public/index.ts b/x-pack/platform/plugins/private/intercepts/public/index.ts index 2f5a8f2ee261..023f6b039a1d 100644 --- a/x-pack/platform/plugins/private/intercepts/public/index.ts +++ b/x-pack/platform/plugins/private/intercepts/public/index.ts @@ -8,6 +8,7 @@ import type { PluginInitializerContext } from '@kbn/core/public'; import { InterceptPublicPlugin } from './plugin'; export type { InterceptsSetup, InterceptsStart } from './plugin'; +export type { Intercept } from './prompter'; export function plugin(initializerContext: PluginInitializerContext) { return new InterceptPublicPlugin(initializerContext); diff --git a/x-pack/platform/plugins/private/intercepts/public/prompter/index.ts b/x-pack/platform/plugins/private/intercepts/public/prompter/index.ts index 54584ebc9e4f..fbc63ebb8157 100644 --- a/x-pack/platform/plugins/private/intercepts/public/prompter/index.ts +++ b/x-pack/platform/plugins/private/intercepts/public/prompter/index.ts @@ -6,3 +6,4 @@ */ export { InterceptPrompter } from './prompter'; +export type { Intercept } from './prompter'; diff --git a/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.test.ts b/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.test.ts index 75d49044a794..f91a958985be 100644 --- a/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.test.ts +++ b/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.test.ts @@ -11,7 +11,7 @@ import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks'; import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks'; import { renderingServiceMock } from '@kbn/core-rendering-browser-mocks'; import { InterceptDialogService } from './service'; -import { InterceptPrompter } from './prompter'; +import { InterceptPrompter, Intercept } from './prompter'; import { TRIGGER_INFO_API_ROUTE } from '../../common/constants'; import type { TriggerInfo } from '../../common/types'; @@ -64,9 +64,7 @@ describe('ProductInterceptPrompter', () => { const mockQueueInterceptFn = jest.fn(); - const interceptSteps: Parameters< - ReturnType['registerIntercept'] - >[0]['steps'] = [ + const interceptSteps: Intercept['steps'] = [ { id: 'start' as const, title: 'Hello', @@ -84,14 +82,13 @@ describe('ProductInterceptPrompter', () => { }, ]; - const intercept: Parameters['registerIntercept']>[0] = - { - id: 'test-intercept', - steps: interceptSteps, - onFinish: jest.fn(), - onDismiss: jest.fn(), - onProgress: jest.fn(), - }; + const intercept: Intercept = { + id: 'test-intercept', + steps: interceptSteps, + onFinish: jest.fn(), + onDismiss: jest.fn(), + onProgress: jest.fn(), + }; beforeEach(() => { jest.useFakeTimers(); @@ -121,7 +118,10 @@ describe('ProductInterceptPrompter', () => { triggerIntervalInMs: 1000, }); - const intercept$ = registerIntercept(intercept); + const intercept$ = registerIntercept({ + id: intercept.id, + config: () => Promise.resolve(intercept), + }); expect(intercept$).toBeInstanceOf(Rx.Observable); }); @@ -132,7 +132,10 @@ describe('ProductInterceptPrompter', () => { triggerIntervalInMs: 1000, }); - const intercept$ = registerIntercept(intercept); + const intercept$ = registerIntercept({ + id: intercept.id, + config: () => Promise.resolve(intercept), + }); expect(intercept$).toBeInstanceOf(Rx.Observable); @@ -152,233 +155,305 @@ describe('ProductInterceptPrompter', () => { subscription.unsubscribe(); }); - it('adds an intercept if the user has not already encountered the next scheduled run', async () => { - const triggerInfo: TriggerInfo = { - registeredAt: new Date( - '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' - ).toISOString(), - triggerIntervalInMs: 30000, - recurrent: true, - }; + describe('within safeTimer Interval bounds', () => { + const triggerIntervalInMs = 30000; // 30 seconds - const triggerRuns = 30; - const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; + it('adds an intercept if the user has not already encountered the next scheduled run', async () => { + const triggerInfo: TriggerInfo = { + registeredAt: new Date( + '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' + ).toISOString(), + triggerIntervalInMs, + recurrent: true, + }; - // return the configured trigger info - jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); - jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); + const triggerRuns = 30; + const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; - // set system time to time in the future, where there would have been 30 runs of the received trigger, - // with just about 1/3 of the time before the next trigger - jest.setSystemTime( - new Date( - Date.parse(triggerInfo.registeredAt) + - triggerInfo.triggerIntervalInMs * triggerRuns + - triggerInfo.triggerIntervalInMs - - timeInMsTillNextRun - ) - ); + // return the configured trigger info + jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); + jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); - const subscriptionHandler = jest.fn(); + // set system time to time in the future, where there would have been 30 runs of the received trigger, + // with just about 1/3 of the time before the next trigger + jest.setSystemTime( + new Date( + Date.parse(triggerInfo.registeredAt) + + triggerInfo.triggerIntervalInMs * triggerRuns + + triggerInfo.triggerIntervalInMs - + timeInMsTillNextRun + ) + ); - const intercept$ = registerIntercept(intercept); + const subscriptionHandler = jest.fn(); - const subscription = intercept$.subscribe(subscriptionHandler); - - await jest.runOnlyPendingTimersAsync(); - - expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { - body: JSON.stringify({ triggerId: intercept.id }), - }); - - jest.advanceTimersByTime(timeInMsTillNextRun); - - expect(mockQueueInterceptFn).toHaveBeenCalledWith( - expect.objectContaining({ + const intercept$ = registerIntercept({ id: intercept.id, - runId: triggerRuns + 1, - }) - ); + config: () => Promise.resolve(intercept), + }); - subscription.unsubscribe(); - }); + const subscription = intercept$.subscribe(subscriptionHandler); - it('does not add an intercept if the user has already encountered the currently scheduled run', async () => { - const triggerInfo: TriggerInfo = { - registeredAt: new Date( - '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' - ).toISOString(), - triggerIntervalInMs: 30000, - recurrent: true, - }; + await jest.runOnlyPendingTimersAsync(); - const triggerRuns = 30; - const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; + expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { + body: JSON.stringify({ triggerId: intercept.id }), + }); - // return the configured trigger info - jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); - jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns + 1 }); + expect(mockQueueInterceptFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: intercept.id, + runId: triggerRuns + 1, + }) + ); - // set system time to time in the future, where there would have been 30 runs of the received trigger, - // with just about 1/3 of the time before the next trigger - jest.setSystemTime( - new Date( - Date.parse(triggerInfo.registeredAt) + - triggerInfo.triggerIntervalInMs * triggerRuns + - triggerInfo.triggerIntervalInMs - - timeInMsTillNextRun - ) - ); - - const subscriptionHandler = jest.fn(); - - const intercept$ = registerIntercept(intercept); - - const subscription = intercept$.subscribe(subscriptionHandler); - - await jest.runOnlyPendingTimersAsync(); - - expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { - body: JSON.stringify({ triggerId: intercept.id }), + subscription.unsubscribe(); }); - jest.advanceTimersByTime(timeInMsTillNextRun); + it('does not add an intercept if the user has already encountered the currently scheduled run', async () => { + const triggerInfo: TriggerInfo = { + registeredAt: new Date( + '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' + ).toISOString(), + triggerIntervalInMs, + recurrent: true, + }; - expect(mockQueueInterceptFn).not.toHaveBeenCalled(); + const triggerRuns = 30; + const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; - subscription.unsubscribe(); - }); + // return the configured trigger info + jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); + jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns + 1 }); - it('does not add an intercept if the trigger is expected to be shown only once and the user already encountered that single run of the intercept', async () => { - const triggerInfo = { - registeredAt: new Date( - '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' - ).toISOString(), - triggerIntervalInMs: 30000, - recurrent: false, - }; + // set system time to time in the future, where there would have been 30 runs of the received trigger, + // with just about 1/3 of the time before the next trigger + jest.setSystemTime( + new Date( + Date.parse(triggerInfo.registeredAt) + + triggerInfo.triggerIntervalInMs * triggerRuns + + triggerInfo.triggerIntervalInMs - + timeInMsTillNextRun + ) + ); - const triggerRuns = 30; - const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; + const subscriptionHandler = jest.fn(); - // return the configured trigger info - jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); - // configure a user that encountered the intercept on the 30th run - jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); + const intercept$ = registerIntercept({ + id: intercept.id, + config: () => Promise.resolve(intercept), + }); - // set system time to time in the future, where there would have been 30 runs of the received trigger, - // with just about 1/3 of the time before the next trigger - jest.setSystemTime( - new Date( - Date.parse(triggerInfo.registeredAt) + - triggerInfo.triggerIntervalInMs * triggerRuns + - triggerInfo.triggerIntervalInMs - - timeInMsTillNextRun - ) - ); + const subscription = intercept$.subscribe(subscriptionHandler); - const subscriptionHandler = jest.fn(); + await jest.runOnlyPendingTimersAsync(); - const intercept$ = registerIntercept(intercept); + expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { + body: JSON.stringify({ triggerId: intercept.id }), + }); - const subscription = intercept$.subscribe(subscriptionHandler); + jest.advanceTimersByTime(timeInMsTillNextRun); - await jest.runOnlyPendingTimersAsync(); + expect(mockQueueInterceptFn).not.toHaveBeenCalled(); - expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { - body: JSON.stringify({ triggerId: intercept.id }), + subscription.unsubscribe(); }); - jest.advanceTimersByTime(timeInMsTillNextRun); + it('does not add an intercept if the trigger is expected to be shown only once and the user already encountered that single run of the intercept', async () => { + const triggerInfo = { + registeredAt: new Date( + '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' + ).toISOString(), + triggerIntervalInMs, + recurrent: false, + }; - // we should not queue the intercept, - // because the user already encountered it especially that it's a one off - expect(mockQueueInterceptFn).not.toHaveBeenCalled(); + const triggerRuns = 30; + const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; - subscription.unsubscribe(); - }); + // return the configured trigger info + jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); + // configure a user that encountered the intercept on the 30th run + jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); - it('queue another intercept automatically after the configured trigger interval when the time for displaying the intercept for the initial run has elapsed', async () => { - const triggerInfo = { - registeredAt: new Date( - '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' - ).toISOString(), - triggerIntervalInMs: 30000, - recurrent: true, - }; + // set system time to time in the future, where there would have been 30 runs of the received trigger, + // with just about 1/3 of the time before the next trigger + jest.setSystemTime( + new Date( + Date.parse(triggerInfo.registeredAt) + + triggerInfo.triggerIntervalInMs * triggerRuns + + triggerInfo.triggerIntervalInMs - + timeInMsTillNextRun + ) + ); - const triggerRuns = 30; - const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; + const subscriptionHandler = jest.fn(); - // return the configured trigger info - jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); - jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); + const intercept$ = registerIntercept({ + id: intercept.id, + config: () => Promise.resolve(intercept), + }); - // set system time to time in the future, where there would have been 30 runs of the received trigger, - // with just about 1/3 of the time before the next trigger - jest.setSystemTime( - new Date( - Date.parse(triggerInfo.registeredAt) + - triggerInfo.triggerIntervalInMs * triggerRuns + - triggerInfo.triggerIntervalInMs - - timeInMsTillNextRun - ) - ); + const subscription = intercept$.subscribe(subscriptionHandler); - const _intercept = { - ...intercept, - id: 'test-repeat-intercept', - }; + await jest.runOnlyPendingTimersAsync(); - const subscriptionHandler = jest.fn(({ lastInteractedInterceptId }) => { - // simulate persistence of the user interaction with the intercept - jest - .spyOn(http, 'get') - .mockResolvedValue({ lastInteractedInterceptId: lastInteractedInterceptId + 1 }); + expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { + body: JSON.stringify({ triggerId: intercept.id }), + }); + + jest.advanceTimersByTime(timeInMsTillNextRun); + + // we should not queue the intercept, + // because the user already encountered it especially that it's a one off + expect(mockQueueInterceptFn).not.toHaveBeenCalled(); + + subscription.unsubscribe(); }); - const intercept$ = registerIntercept(_intercept); + it('queues another intercept automatically after the configured trigger interval when the time for displaying the intercept for the initial run has elapsed', async () => { + const triggerInfo = { + registeredAt: new Date( + '26 March 2025 19:08 GMT+0100 (Central European Standard Time)' + ).toISOString(), + triggerIntervalInMs, + recurrent: true, + }; - const subscription = intercept$.subscribe(subscriptionHandler); + const triggerRuns = 30; + const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; - await jest.runOnlyPendingTimersAsync(); + // return the configured trigger info + jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); + jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); - expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { - body: JSON.stringify({ triggerId: _intercept.id }), - }); + // set system time to time in the future, where there would have been 30 runs of the received trigger, + // with just about 1/3 of the time before the next trigger + jest.setSystemTime( + new Date( + Date.parse(triggerInfo.registeredAt) + + triggerInfo.triggerIntervalInMs * triggerRuns + + triggerInfo.triggerIntervalInMs - + timeInMsTillNextRun + ) + ); - jest.advanceTimersByTime(timeInMsTillNextRun); + const _intercept = { + ...intercept, + id: 'test-repeat-intercept', + }; - expect(mockQueueInterceptFn).toHaveBeenCalledWith( - expect.objectContaining({ + const subscriptionHandler = jest.fn(({ lastInteractedInterceptId }) => { + // simulate persistence of the user interaction with the intercept + jest + .spyOn(http, 'get') + .mockResolvedValue({ lastInteractedInterceptId: lastInteractedInterceptId + 1 }); + }); + + const intercept$ = registerIntercept({ id: _intercept.id, - runId: triggerRuns + 1, - }) - ); + config: () => Promise.resolve(_intercept), + }); - expect(subscriptionHandler).toHaveBeenCalledWith( - expect.objectContaining({ - lastInteractedInterceptId: triggerRuns, - }) - ); + const subscription = intercept$.subscribe(subscriptionHandler); - // advance to next run and wait for all promises to resolve - await jest.advanceTimersByTimeAsync(triggerInfo.triggerIntervalInMs); + await jest.runOnlyPendingTimersAsync(); - expect(mockQueueInterceptFn).toHaveBeenLastCalledWith( - expect.objectContaining({ - id: _intercept.id, - runId: triggerRuns + 2, - }) - ); + expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { + body: JSON.stringify({ triggerId: _intercept.id }), + }); - expect(subscriptionHandler).toHaveBeenCalledWith( - expect.objectContaining({ - lastInteractedInterceptId: triggerRuns + 1, - }) - ); + jest.advanceTimersByTime(timeInMsTillNextRun); - subscription.unsubscribe(); + expect(mockQueueInterceptFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: _intercept.id, + runId: triggerRuns + 1, + }) + ); + + expect(subscriptionHandler).toHaveBeenCalledWith( + expect.objectContaining({ + lastInteractedInterceptId: triggerRuns, + }) + ); + + // advance to next run and wait for all promises to resolve + await jest.advanceTimersByTimeAsync(triggerInfo.triggerIntervalInMs); + + expect(mockQueueInterceptFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + id: _intercept.id, + runId: triggerRuns + 2, + }) + ); + + expect(subscriptionHandler).toHaveBeenCalledWith( + expect.objectContaining({ + lastInteractedInterceptId: triggerRuns + 1, + }) + ); + + subscription.unsubscribe(); + }); + }); + + describe('outside safeTimer Interval bounds', () => { + const triggerIntervalInMs = 30 * 24 * 60 * 60 * 1000; // 30 days + + it('handles a trigger interval that exceeds the safe timer bounds gracefully', async () => { + const triggerInfo: TriggerInfo = { + registeredAt: new Date( + '28 May 2025 19:08 GMT+0100 (Central European Standard Time)' + ).toISOString(), + triggerIntervalInMs, + recurrent: true, + }; + + const triggerRuns = 30; + const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3; + + // have call to http handler return predefined values + jest.spyOn(http, 'post').mockResolvedValue(triggerInfo); + jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: triggerRuns }); + + // set system time to time in the future, where there would have been 30 runs of the received trigger, + // with just about 1/3 of the time before the next trigger + jest.setSystemTime( + new Date( + Date.parse(triggerInfo.registeredAt) + + triggerInfo.triggerIntervalInMs * triggerRuns + + triggerInfo.triggerIntervalInMs - + timeInMsTillNextRun + ) + ); + + const subscriptionHandler = jest.fn(); + + const intercept$ = registerIntercept({ + id: intercept.id, + config: () => Promise.resolve(intercept), + }); + const subscription = intercept$.subscribe(subscriptionHandler); + + // cause call to trigger info api route to happen + await jest.runOnlyPendingTimersAsync(); + expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, { + body: JSON.stringify({ triggerId: intercept.id }), + }); + + // advance time to point in time when next run should happen + await jest.advanceTimersByTimeAsync(timeInMsTillNextRun); + + expect(mockQueueInterceptFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: intercept.id, + runId: triggerRuns + 1, + }) + ); + + subscription.unsubscribe(); + }); }); }); }); diff --git a/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.ts b/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.ts index b931c35ce535..d811d2296d15 100644 --- a/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.ts +++ b/x-pack/platform/plugins/private/intercepts/public/prompter/prompter.ts @@ -7,12 +7,15 @@ import * as Rx from 'rxjs'; import type { CoreStart, CoreSetup } from '@kbn/core/public'; +import { apm } from '@elastic/apm-rum'; import { InterceptDialogService, InterceptServiceStartDeps } from './service'; import { UserInterceptRunPersistenceService } from './service/user_intercept_run_persistence_service'; import { Intercept } from './service'; import { TRIGGER_INFO_API_ROUTE } from '../../common/constants'; import { TriggerInfo } from '../../common/types'; +export type { Intercept } from './service'; + type ProductInterceptPrompterSetupDeps = Pick; type ProductInterceptPrompterStartDeps = Omit< InterceptServiceStartDeps, @@ -24,6 +27,12 @@ export class InterceptPrompter { private userInterceptRunPersistenceService = new UserInterceptRunPersistenceService(); private interceptDialogService = new InterceptDialogService(); private queueIntercept?: ReturnType['add']; + // observer for page visibility changes, shared across all intercepts + private pageHidden$?: Rx.Observable; + // Defines safe timer bound at 24 days, javascript browser timers are not reliable for longer intervals + // see https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value, + // rxjs can do longer intervals, but we want to avoid the risk of running into issues with browser timers. + private readonly MAX_TIMER_INTERVAL = 0x7b98a000; // 24 days in milliseconds setup({ analytics, notifications }: ProductInterceptPrompterSetupDeps) { this.interceptDialogService.setup({ analytics, notifications }); @@ -41,6 +50,11 @@ export class InterceptPrompter { staticAssetsHelper: http.staticAssets, })); + this.pageHidden$ = Rx.fromEvent(document, 'visibilitychange').pipe( + Rx.map(() => document.hidden), + Rx.startWith(document.hidden) + ); + return { /** * Configures the intercept journey that will be shown to the user, and returns an observable @@ -56,7 +70,10 @@ export class InterceptPrompter { getUserTriggerData$: ReturnType< UserInterceptRunPersistenceService['start'] >['getUserTriggerData$'], - intercept: Intercept + intercept: { + id: Intercept['id']; + config: () => Promise>; + } ) { let nextRunId: number; @@ -70,36 +87,76 @@ export class InterceptPrompter { .pipe(Rx.filter((response) => !!response)) .pipe( Rx.mergeMap((response) => { - const now = Date.now(); + // anchor for all calculations, this is the time when the trigger was registered + const timePoint = Date.now(); + let diff = 0; // Calculate the number of runs since the trigger was registered const runs = Math.floor( - (diff = now - Date.parse(response.registeredAt)) / response.triggerIntervalInMs + (diff = timePoint - Date.parse(response.registeredAt)) / response.triggerIntervalInMs ); nextRunId = runs + 1; - // Calculate the time until the next run - const nextRun = nextRunId * response.triggerIntervalInMs - diff; + return this.pageHidden$!.pipe( + Rx.switchMap((isHidden) => { + if (isHidden) return Rx.EMPTY; - return Rx.timer(nextRun, response.triggerIntervalInMs).pipe( - Rx.switchMap(() => getUserTriggerData$(intercept.id)), - Rx.takeWhile((triggerData) => { - // Stop the timer if lastInteractedInterceptId is defined and matches nextRunId - if (!response.recurrent && triggerData.lastInteractedInterceptId) { - return false; - } - return true; + return Rx.timer( + Math.min(nextRunId * response.triggerIntervalInMs - diff, this.MAX_TIMER_INTERVAL), + Math.min(response.triggerIntervalInMs, this.MAX_TIMER_INTERVAL) + ).pipe( + Rx.switchMap((timerIterationCount) => { + if (response.triggerIntervalInMs < this.MAX_TIMER_INTERVAL) { + return getUserTriggerData$(intercept.id); + } else { + const timeElapsedSinceRegistration = + diff + this.MAX_TIMER_INTERVAL * timerIterationCount; + + const timeTillTriggerEvent = + nextRunId * response.triggerIntervalInMs - timeElapsedSinceRegistration; + + if (timeTillTriggerEvent <= this.MAX_TIMER_INTERVAL) { + // trigger event would happen sometime within this current slice + // set up a single use timer that will emit the trigger event + return Rx.timer(timeTillTriggerEvent).pipe( + Rx.switchMap(() => { + return getUserTriggerData$(intercept.id); + }) + ); + } else { + // current timer slice requires no action + return Rx.EMPTY; + } + } + }), + Rx.takeWhile((triggerData) => { + // Stop the timer if lastInteractedInterceptId is defined and matches nextRunId + if (!response.recurrent && triggerData.lastInteractedInterceptId) { + return false; + } + return true; + }) + ); }) ); }) ) .pipe( - Rx.tap((triggerData) => { + Rx.tap(async (triggerData) => { if (nextRunId !== triggerData.lastInteractedInterceptId) { - this.queueIntercept?.({ ...intercept, runId: nextRunId }); - nextRunId++; + try { + const interceptConfig = await intercept.config(); + this.queueIntercept?.({ id: intercept.id, runId: nextRunId, ...interceptConfig }); + nextRunId++; + } catch (err) { + apm.captureError(err, { + labels: { + interceptId: intercept.id, + }, + }); + } } }) ); diff --git a/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.test.ts b/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.test.ts index 7b2d4f127ea0..25865be5d1be 100644 --- a/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.test.ts +++ b/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.test.ts @@ -8,7 +8,7 @@ import { coreMock } from '@kbn/core/server/mocks'; import { InterceptTriggerService } from './intercept_trigger'; import { interceptTriggerRecordSavedObject } from '../saved_objects'; -import { ISavedObjectsRepository } from '@kbn/core/server'; +import type { ISavedObjectsRepository } from '@kbn/core/server'; describe('InterceptTriggerService', () => { describe('#setup', () => { @@ -18,7 +18,7 @@ describe('InterceptTriggerService', () => { const coreSetupMock = coreMock.createSetup(); interceptTrigger.setup(coreSetupMock, {} as any, { - kibanaVersion: '8.0.0', + kibanaVersion: '9.1.0', }); expect(coreSetupMock.savedObjects.registerType).toHaveBeenCalledWith( diff --git a/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.ts b/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.ts index 524147b3135e..ac28d1a52096 100644 --- a/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.ts +++ b/x-pack/platform/plugins/private/intercepts/server/services/intercept_trigger.ts @@ -6,6 +6,7 @@ */ import { type CoreSetup, type CoreStart, type Logger } from '@kbn/core/server'; +import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import type { ISavedObjectsRepository } from '@kbn/core-saved-objects-api-server'; import { interceptTriggerRecordSavedObject, type InterceptTriggerRecord } from '../saved_objects'; @@ -54,7 +55,13 @@ export class InterceptTriggerService { triggerId ); } catch (err) { - this.logger?.error(err); + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + // If the task is not found, it means it's not registered yet, so we return null + return null; + } else { + this.logger?.error(`Error fetching registered task: ${err.message}`); + return null; + } } return result?.attributes ?? null; diff --git a/x-pack/platform/plugins/private/intercepts/server/services/intercept_user_interaction.ts b/x-pack/platform/plugins/private/intercepts/server/services/intercept_user_interaction.ts index 7100c9abf856..0ba2c9e96bd1 100644 --- a/x-pack/platform/plugins/private/intercepts/server/services/intercept_user_interaction.ts +++ b/x-pack/platform/plugins/private/intercepts/server/services/intercept_user_interaction.ts @@ -107,7 +107,7 @@ export class InterceptUserInteractionService { // returns an id scoped to the current user private getSavedObjectId = (triggerId: string, userId: string) => `${triggerId}:${userId}`; - public async getUserInteractionSavedObject( + private async getUserInteractionSavedObject( userId: string, triggerId: string ): Promise | null> { @@ -131,7 +131,7 @@ export class InterceptUserInteractionService { } } - public async recordUserInteractionForTrigger( + private async recordUserInteractionForTrigger( userId: string, triggerId: string, data: InterceptInteractionUserRecordAttributes['metadata'] diff --git a/x-pack/platform/plugins/private/product_intercept/common/config.ts b/x-pack/platform/plugins/private/product_intercept/common/config.ts index dff1800bd5f9..95e80a075c4f 100644 --- a/x-pack/platform/plugins/private/product_intercept/common/config.ts +++ b/x-pack/platform/plugins/private/product_intercept/common/config.ts @@ -18,10 +18,10 @@ export const configSchema = schema.object({ * It's worth noting that if the intercept plugin is disabled this setting will have no effect. */ enabled: schema.boolean({ - defaultValue: false, + defaultValue: true, }), interval: schema.string({ - defaultValue: '30m', + defaultValue: '90d', validate(value) { if (!/^[0-9]+(d|h|m|s)$/.test(value)) { return 'must be a supported duration string'; diff --git a/x-pack/platform/plugins/private/product_intercept/common/constants.ts b/x-pack/platform/plugins/private/product_intercept/common/constants.ts index 4ec24c6c9079..08ef75c6f8c8 100644 --- a/x-pack/platform/plugins/private/product_intercept/common/constants.ts +++ b/x-pack/platform/plugins/private/product_intercept/common/constants.ts @@ -5,4 +5,10 @@ * 2.0. */ +/** + * The ID of the product intercept trigger definition. + * This is used to register the trigger definition with the intercepts plugin. + */ export const TRIGGER_DEF_ID = 'productInterceptTrigger' as const; + +export const UPGRADE_TRIGGER_DEF_PREFIX_ID = 'productUpgradeInterceptTrigger' as const; diff --git a/x-pack/platform/plugins/private/product_intercept/kibana.jsonc b/x-pack/platform/plugins/private/product_intercept/kibana.jsonc index a20095263c13..a05a95d63665 100644 --- a/x-pack/platform/plugins/private/product_intercept/kibana.jsonc +++ b/x-pack/platform/plugins/private/product_intercept/kibana.jsonc @@ -9,7 +9,7 @@ "id": "productIntercept", "browser": true, "server": true, - "requiredPlugins": ["intercepts"], + "requiredPlugins": ["intercepts", "cloud"], "requiredBundles": [], "configPath": ["xpack", "product_intercept"] } diff --git a/x-pack/platform/plugins/private/product_intercept/public/index.ts b/x-pack/platform/plugins/private/product_intercept/public/index.ts index 61c33807e97a..682bdc09166b 100644 --- a/x-pack/platform/plugins/private/product_intercept/public/index.ts +++ b/x-pack/platform/plugins/private/product_intercept/public/index.ts @@ -5,11 +5,12 @@ * 2.0. */ +import type { PluginInitializerContext } from '@kbn/core/public'; import { ProductInterceptPublicPlugin } from './plugin'; /** * @internal */ -export function plugin() { - return new ProductInterceptPublicPlugin(); +export function plugin(ctx: PluginInitializerContext) { + return new ProductInterceptPublicPlugin(ctx); } diff --git a/x-pack/platform/plugins/private/product_intercept/public/intercept_registration_config.tsx b/x-pack/platform/plugins/private/product_intercept/public/intercept_registration_config.tsx new file mode 100644 index 000000000000..2770e9607b60 --- /dev/null +++ b/x-pack/platform/plugins/private/product_intercept/public/intercept_registration_config.tsx @@ -0,0 +1,154 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiLink, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { Intercept } from '@kbn/intercepts-plugin/public'; +import type { PromptTelemetry } from './telemetry'; +import { NPSScoreInput } from './components'; + +interface ProductInterceptRegistrationHandlerParams { + productOffering: string; + surveyUrl: URL; + eventReporter: ReturnType; +} + +/** + * @description Returns the registration configuration for the product intercept. + * This configuration defines the steps and content of the intercept + * that prompts users for feedback on their experience with the product. + */ +export const productInterceptRegistrationConfig = ({ + eventReporter, + surveyUrl, + productOffering, +}: ProductInterceptRegistrationHandlerParams): Omit => { + return { + steps: [ + { + id: 'start', + title: i18n.translate('productIntercept.prompter.step.start.title', { + defaultMessage: 'Help us improve {productOffering}', + values: { + productOffering, + }, + }), + content: () => ( + + + + ), + }, + { + id: 'satisfaction', + title: i18n.translate('productIntercept.prompter.step.satisfaction.title', { + defaultMessage: 'Overall, how satisfied or dissatisfied are you with {productOffering}?', + values: { + productOffering, + }, + }), + content: ({ onValue }) => { + return ( + + ); + }, + }, + { + id: 'ease', + title: i18n.translate('productIntercept.prompter.step.ease.title', { + defaultMessage: 'Overall, how difficult or easy is it to use {productOffering}?', + values: { + productOffering, + }, + }), + content: ({ onValue }) => { + return ( + + ); + }, + }, + { + id: 'completion', + title: i18n.translate('productIntercept.prompter.step.completion.title', { + defaultMessage: 'Thanks for the feedback!', + }), + content: () => { + return ( + + ( + + {chunks} + + ), + }} + /> + + ); + }, + }, + ], + onProgress: ({ stepId, stepResponse, runId }) => { + eventReporter.reportInterceptInteractionProgress({ + interceptRunId: runId, + metricId: stepId, + value: Number(stepResponse), + }); + }, + onFinish: ({ response: feedbackResponse, runId }) => { + eventReporter.reportInterceptInteraction({ + interactionType: 'completion', + interceptRunId: runId, + }); + }, + onDismiss: ({ runId }) => { + // still update user profile run count, a dismissal is still an interaction + eventReporter.reportInterceptInteraction({ + interactionType: 'dismissal', + interceptRunId: runId, + }); + }, + }; +}; diff --git a/x-pack/platform/plugins/private/product_intercept/public/plugin.ts b/x-pack/platform/plugins/private/product_intercept/public/plugin.ts index 5aa69596237b..8dcafc8c79e6 100644 --- a/x-pack/platform/plugins/private/product_intercept/public/plugin.ts +++ b/x-pack/platform/plugins/private/product_intercept/public/plugin.ts @@ -6,156 +6,76 @@ */ import { Subscription } from 'rxjs'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiLink, EuiText } from '@elastic/eui'; -import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import capitalize from 'lodash/capitalize'; +import { type CoreSetup, type CoreStart, Plugin } from '@kbn/core/public'; +import type { PluginInitializerContext } from '@kbn/core/public'; import { InterceptsStart } from '@kbn/intercepts-plugin/public'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { type CloudStart } from '@kbn/cloud-plugin/public'; -import { NPSScoreInput } from './components'; import { PromptTelemetry } from './telemetry'; -import { TRIGGER_DEF_ID } from '../common/constants'; +import { TRIGGER_DEF_ID, UPGRADE_TRIGGER_DEF_PREFIX_ID } from '../common/constants'; interface ProductInterceptPluginStartDeps { intercepts: InterceptsStart; + cloud: CloudStart; } export class ProductInterceptPublicPlugin implements Plugin { private readonly telemetry = new PromptTelemetry(); private interceptSubscription?: Subscription; + private upgradeInterceptSubscription?: Subscription; + private readonly buildVersion: string; + + constructor(ctx: PluginInitializerContext) { + this.buildVersion = ctx.env.packageInfo.version; + } setup(core: CoreSetup) { return this.telemetry.setup({ analytics: core.analytics }); } - start(core: CoreStart, { intercepts }: ProductInterceptPluginStartDeps) { + start(core: CoreStart, { intercepts, cloud }: ProductInterceptPluginStartDeps) { const eventReporter = this.telemetry.start({ analytics: core.analytics, }); - this.interceptSubscription = intercepts - .registerIntercept?.({ - id: TRIGGER_DEF_ID, - steps: [ - { - id: 'start', - title: i18n.translate('productIntercept.prompter.step.start.title', { - defaultMessage: 'Help us improve Kibana', - }), - content: () => - React.createElement( - EuiText, - { key: 'productInterceptPrompterStartContent', size: 's' }, - i18n.translate('productIntercept.prompter.step.start.content', { - defaultMessage: - 'We are always looking for ways to improve Kibana. Please take a moment to share your feedback with us.', - }) + const productOffering = `Elastic ${capitalize(cloud.serverless.projectType || '')}`.trim(); + + void (async () => { + const currentUser = await core.security.authc.getCurrentUser(); + + const surveyUrl = new URL('https://ela.st/kibana-product-survey'); + + surveyUrl.searchParams.set('uid', String(currentUser.profile_uid || null)); + surveyUrl.searchParams.set('pid', String(cloud.serverless.projectId || null)); + surveyUrl.searchParams.set('solution', String(cloud.serverless.projectType || null)); + + [this.upgradeInterceptSubscription, this.interceptSubscription] = [ + TRIGGER_DEF_ID, + `${UPGRADE_TRIGGER_DEF_PREFIX_ID}:${this.buildVersion}`, + ].map((triggerId) => + intercepts + .registerIntercept?.({ + id: triggerId, + config: () => + import('./intercept_registration_config').then( + ({ productInterceptRegistrationConfig: registrationConfig }) => + registrationConfig({ + productOffering, + surveyUrl, + eventReporter, + }) ), - }, - { - id: 'satisfaction', - title: i18n.translate('productIntercept.prompter.step.satisfaction.title', { - defaultMessage: 'Overall, how satisfied or dissatisfied are you with Kibana?', - }), - content: ({ onValue }) => { - return React.createElement(NPSScoreInput, { - lowerBoundHelpText: i18n.translate( - 'productIntercept.prompter.step.satisfaction.lowerBoundDescriptionText', - { - defaultMessage: 'Very dissatisfied', - } - ), - upperBoundHelpText: i18n.translate( - 'productIntercept.prompter.step.satisfaction.upperBoundDescriptionText', - { - defaultMessage: 'Very satisfied', - } - ), - onChange: onValue, - }); - }, - }, - { - id: 'ease', - title: i18n.translate('productIntercept.prompter.step.ease.title', { - defaultMessage: 'Overall, how difficult or easy is it to use Kibana?', - }), - content: ({ onValue }) => { - return React.createElement(NPSScoreInput, { - lowerBoundHelpText: i18n.translate( - 'productIntercept.prompter.step.ease.lowerBoundDescriptionText', - { - defaultMessage: 'Very difficult', - } - ), - upperBoundHelpText: i18n.translate( - 'productIntercept.prompter.step.ease.upperBoundDescriptionText', - { - defaultMessage: 'Very easy', - } - ), - onChange: onValue, - }); - }, - }, - { - id: 'completion', - title: i18n.translate('productIntercept.prompter.step.completion.title', { - defaultMessage: 'Thanks for the feedback!', - }), - content: () => { - return React.createElement( - EuiText, - { size: 's' }, - React.createElement(FormattedMessage, { - id: 'productIntercept.prompter.step.completion.content', - defaultMessage: - "If you'd like to participate in future research to help improve kibana, click here.", - values: { - link: (chunks) => - React.createElement( - EuiLink, - { - external: true, - href: 'https://www.elastic.co/feedback', - target: '_blank', - }, - chunks - ), - }, - }) - ); - }, - }, - ], - onProgress: ({ stepId, stepResponse, runId }) => { - eventReporter.reportInterceptInteractionProgress({ - interceptRunId: runId, - metricId: stepId, - value: Number(stepResponse), - }); - }, - onFinish: ({ response: feedbackResponse, runId }) => { - eventReporter.reportInterceptInteraction({ - interactionType: 'completion', - interceptRunId: runId, - }); - }, - onDismiss: ({ runId }) => { - // still update user profile run count, a dismissal is still an interaction - eventReporter.reportInterceptInteraction({ - interactionType: 'dismissal', - interceptRunId: runId, - }); - }, - }) - .subscribe(); + }) + .subscribe() + ); + })(); return {}; } stop() { this.interceptSubscription?.unsubscribe(); + this.upgradeInterceptSubscription?.unsubscribe(); } } diff --git a/x-pack/platform/plugins/private/product_intercept/server/plugin.ts b/x-pack/platform/plugins/private/product_intercept/server/plugin.ts index 7e33c8a89c86..fb558cc99769 100644 --- a/x-pack/platform/plugins/private/product_intercept/server/plugin.ts +++ b/x-pack/platform/plugins/private/product_intercept/server/plugin.ts @@ -13,7 +13,7 @@ import { type Logger, } from '@kbn/core/server'; import type { InterceptSetup, InterceptStart } from '@kbn/intercepts-plugin/server'; -import { TRIGGER_DEF_ID } from '../common/constants'; +import { TRIGGER_DEF_ID, UPGRADE_TRIGGER_DEF_PREFIX_ID } from '../common/constants'; import { ServerConfigSchema } from '../common/config'; interface ProductInterceptServerPluginSetup { @@ -32,10 +32,13 @@ export class ProductInterceptServerPlugin { private readonly logger: Logger; private readonly config: ServerConfigSchema; + private readonly buildVersion: string; + private readonly upgradeInterval: string = '14d'; constructor(initContext: PluginInitializerContext) { this.logger = initContext.logger.get(); this.config = initContext.config.get(); + this.buildVersion = initContext.env.packageInfo.version; } setup(core: CoreSetup, {}: ProductInterceptServerPluginSetup) { @@ -45,9 +48,17 @@ export class ProductInterceptServerPlugin start(core: CoreStart, { intercepts }: ProductInterceptServerPluginStart) { if (this.config.enabled) { void intercepts.registerTriggerDefinition?.(TRIGGER_DEF_ID, () => { - this.logger.debug('Registering kibana product trigger definition'); + this.logger.debug('Registering global product intercept trigger definition'); return { triggerAfter: this.config.interval }; }); + + void intercepts.registerTriggerDefinition?.( + `${UPGRADE_TRIGGER_DEF_PREFIX_ID}:${this.buildVersion}`, + () => { + this.logger.debug('Registering global product upgrade intercept trigger definition'); + return { triggerAfter: this.upgradeInterval, isRecurrent: false }; + } + ); } return {}; diff --git a/x-pack/platform/plugins/private/product_intercept/tsconfig.json b/x-pack/platform/plugins/private/product_intercept/tsconfig.json index 61a03247e383..294f34e9ab08 100644 --- a/x-pack/platform/plugins/private/product_intercept/tsconfig.json +++ b/x-pack/platform/plugins/private/product_intercept/tsconfig.json @@ -18,7 +18,8 @@ "@kbn/config-schema", "@kbn/i18n", "@kbn/intercepts-plugin", - "@kbn/i18n-react" + "@kbn/i18n-react", + "@kbn/cloud-plugin" ], "exclude": ["target/**/*"] } diff --git a/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/index.ts b/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/index.ts new file mode 100644 index 000000000000..66ff0c757956 --- /dev/null +++ b/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/index.ts @@ -0,0 +1,15 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) { + describe('Intercepts API', function () { + loadTestFile(require.resolve('./interaction_apis')); + loadTestFile(require.resolve('./trigger_apis')); + }); +} diff --git a/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/interaction_apis.ts b/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/interaction_apis.ts new file mode 100644 index 000000000000..4058edbfe940 --- /dev/null +++ b/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/interaction_apis.ts @@ -0,0 +1,53 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { TRIGGER_USER_INTERACTION_METADATA_API_ROUTE } from '@kbn/intercepts-plugin/common/constants'; +import { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { + const roleScopedSupertest = getService('roleScopedSupertest'); + + describe('Intercept User Interaction APIs', () => { + describe(`GET ${TRIGGER_USER_INTERACTION_METADATA_API_ROUTE}`, () => { + it('should return 200 with empty object when no interaction exists', async () => { + const supertest = await roleScopedSupertest.getSupertestWithRoleScope('viewer', { + useCookieHeader: true, // denotes authenticated user + withInternalHeaders: true, + }); + + const response = await supertest + .get(TRIGGER_USER_INTERACTION_METADATA_API_ROUTE.replace('{triggerId}', 'test-trigger')) + .expect(200); + + expect(response.body).to.eql({}); + }); + }); + + describe(`POST ${TRIGGER_USER_INTERACTION_METADATA_API_ROUTE}`, () => { + it('should successfully record intercept interaction and return the last saved value', async () => { + const supertest = await roleScopedSupertest.getSupertestWithRoleScope('viewer', { + useCookieHeader: true, // denotes authenticated user + withInternalHeaders: true, + }); + + const interactionRecord = { lastInteractedInterceptId: 1 }; + + await supertest + .post(TRIGGER_USER_INTERACTION_METADATA_API_ROUTE.replace('{triggerId}', 'test-trigger')) + .send(interactionRecord) + .expect(201); + + const response = await supertest + .get(TRIGGER_USER_INTERACTION_METADATA_API_ROUTE.replace('{triggerId}', 'test-trigger')) + .expect(200); + + expect(response.body).to.eql(interactionRecord); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/trigger_apis.ts b/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/trigger_apis.ts new file mode 100644 index 000000000000..b2f8c13c466c --- /dev/null +++ b/x-pack/test/api_integration/deployment_agnostic/apis/intercepts/trigger_apis.ts @@ -0,0 +1,78 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { interceptTriggerRecordSavedObject } from '@kbn/intercepts-plugin/server/saved_objects'; +import { TRIGGER_INFO_API_ROUTE } from '@kbn/intercepts-plugin/common/constants'; +import { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { + const roleScopedSupertest = getService('roleScopedSupertest'); + const kibanaServer = getService('kibanaServer'); + + describe('Intercept Trigger APIs', () => { + before(async () => { + await kibanaServer.savedObjects.clean({ + types: [interceptTriggerRecordSavedObject.name], + }); + }); + + after(async () => { + await kibanaServer.savedObjects.clean({ + types: [interceptTriggerRecordSavedObject.name], + }); + }); + + describe(`POST ${TRIGGER_INFO_API_ROUTE}`, () => { + it('should return 204 when no trigger info exists', async () => { + const supertest = await roleScopedSupertest.getSupertestWithRoleScope('viewer', { + useCookieHeader: true, // favorite only works with Cookie header + withInternalHeaders: true, + }); + + const response = await supertest + .post(TRIGGER_INFO_API_ROUTE) + .send({ triggerId: 'non-existent-trigger' }) + .expect(204); + + expect(response.body).to.be.empty(); + }); + + it('should return 200 with trigger info when it exists', async () => { + const triggerId = 'test-trigger'; + const now = new Date().toISOString(); + const interval = '1h'; + + const supertest = await roleScopedSupertest.getSupertestWithRoleScope('viewer', { + useCookieHeader: true, // denotes authenticated user + withInternalHeaders: true, + }); + + await kibanaServer.savedObjects.create({ + id: triggerId, + type: interceptTriggerRecordSavedObject.name, + overwrite: true, + attributes: { + firstRegisteredAt: now, + triggerAfter: interval, + recurrent: true, + installedOn: '9.1.0', + }, + }); + + const response = await supertest + .post(TRIGGER_INFO_API_ROUTE) + .send({ triggerId }) + .expect(200); + + expect(response.body).to.have.property('registeredAt', now); + expect(response.body).to.have.property('triggerIntervalInMs', 3600000); // 1h in ms + expect(response.body).to.have.property('recurrent', true); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/deployment_agnostic/configs/serverless/oblt.index.ts b/x-pack/test/api_integration/deployment_agnostic/configs/serverless/oblt.index.ts index b41dae4075e5..c06b7f133118 100644 --- a/x-pack/test/api_integration/deployment_agnostic/configs/serverless/oblt.index.ts +++ b/x-pack/test/api_integration/deployment_agnostic/configs/serverless/oblt.index.ts @@ -23,5 +23,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) loadTestFile(require.resolve('../../apis/observability/slo')); loadTestFile(require.resolve('../../apis/observability/onboarding')); loadTestFile(require.resolve('../../apis/observability/incident_management')); + loadTestFile(require.resolve('../../apis/intercepts')); }); } diff --git a/x-pack/test/api_integration/deployment_agnostic/configs/serverless/search.index.ts b/x-pack/test/api_integration/deployment_agnostic/configs/serverless/search.index.ts index 97db4bf32d47..60f44c89ff5e 100644 --- a/x-pack/test/api_integration/deployment_agnostic/configs/serverless/search.index.ts +++ b/x-pack/test/api_integration/deployment_agnostic/configs/serverless/search.index.ts @@ -15,5 +15,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) loadTestFile(require.resolve('../../apis/core')); loadTestFile(require.resolve('../../apis/management')); loadTestFile(require.resolve('../../apis/saved_objects_management')); + loadTestFile(require.resolve('../../apis/intercepts')); }); } diff --git a/x-pack/test/api_integration/deployment_agnostic/configs/serverless/security.index.ts b/x-pack/test/api_integration/deployment_agnostic/configs/serverless/security.index.ts index 9e750ccf898f..8f043bb3d306 100644 --- a/x-pack/test/api_integration/deployment_agnostic/configs/serverless/security.index.ts +++ b/x-pack/test/api_integration/deployment_agnostic/configs/serverless/security.index.ts @@ -16,5 +16,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) loadTestFile(require.resolve('../../apis/management')); loadTestFile(require.resolve('../../apis/painless_lab')); loadTestFile(require.resolve('../../apis/saved_objects_management')); + loadTestFile(require.resolve('../../apis/intercepts')); }); } diff --git a/x-pack/test/api_integration/deployment_agnostic/configs/stateful/platform.index.ts b/x-pack/test/api_integration/deployment_agnostic/configs/stateful/platform.index.ts index ddaf3dd9c5f6..57df1ec9a107 100644 --- a/x-pack/test/api_integration/deployment_agnostic/configs/stateful/platform.index.ts +++ b/x-pack/test/api_integration/deployment_agnostic/configs/stateful/platform.index.ts @@ -15,5 +15,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext) loadTestFile(require.resolve('../../apis/management')); loadTestFile(require.resolve('../../apis/painless_lab')); loadTestFile(require.resolve('../../apis/saved_objects_management')); + loadTestFile(require.resolve('../../apis/intercepts')); }); } diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index aeeade6d29bc..679e0838cb70 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -168,5 +168,6 @@ "@kbn/test-suites-xpack-platform", "@kbn/core-chrome-browser", "@kbn/event-log-plugin", + "@kbn/intercepts-plugin" ] } diff --git a/x-pack/test_serverless/shared/config.base.ts b/x-pack/test_serverless/shared/config.base.ts index 1c0ca0696cfc..02dff6d120e3 100644 --- a/x-pack/test_serverless/shared/config.base.ts +++ b/x-pack/test_serverless/shared/config.base.ts @@ -181,8 +181,6 @@ export default async () => { `--xpack.cloud.deployments_url=/deployments`, `--xpack.cloud.organization_url=/account/`, `--xpack.cloud.users_and_roles_url=/account/members/`, - // disable product intercept for all ftr tests by default - '--xpack.intercepts.enabled=false', ], },