mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[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
0e07892217
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
<!--
### Checklist
Check the PR satisfies following conditions.
Reviewers should verify this PR satisfies this list as well.
- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
### Identify risks
Does this PR introduce any risks? For example, consider risks like hard
to test bugs, performance regression, potential of data loss.
Describe the risk, its severity, and mitigation for each identified
risk. Invite stakeholders and evaluate how to proceed before merging.
- [ ] [See some risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx)
- [ ] ...
-->
---------
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
15ee621d10
commit
fd4e551340
31 changed files with 743 additions and 356 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -23,4 +23,7 @@ xpack.wciExternalServer.enabled: true
|
|||
xpack.spaces.maxSpaces: 1
|
||||
|
||||
## Content Connectors in stack management
|
||||
xpack.contentConnectors.enabled: false
|
||||
xpack.contentConnectors.enabled: false
|
||||
|
||||
## Disable Kibana Product Intercept
|
||||
xpack.product_intercept.enabled: false
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
|
||||
|
|
|
@ -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?)',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -6,3 +6,4 @@
|
|||
*/
|
||||
|
||||
export { InterceptPrompter } from './prompter';
|
||||
export type { Intercept } from './prompter';
|
||||
|
|
|
@ -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<InterceptPrompter['start']>['registerIntercept']
|
||||
>[0]['steps'] = [
|
||||
const interceptSteps: Intercept['steps'] = [
|
||||
{
|
||||
id: 'start' as const,
|
||||
title: 'Hello',
|
||||
|
@ -84,14 +82,13 @@ describe('ProductInterceptPrompter', () => {
|
|||
},
|
||||
];
|
||||
|
||||
const intercept: Parameters<ReturnType<InterceptPrompter['start']>['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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<CoreSetup, 'analytics' | 'notifications'>;
|
||||
type ProductInterceptPrompterStartDeps = Omit<
|
||||
InterceptServiceStartDeps,
|
||||
|
@ -24,6 +27,12 @@ export class InterceptPrompter {
|
|||
private userInterceptRunPersistenceService = new UserInterceptRunPersistenceService();
|
||||
private interceptDialogService = new InterceptDialogService();
|
||||
private queueIntercept?: ReturnType<InterceptDialogService['start']>['add'];
|
||||
// observer for page visibility changes, shared across all intercepts
|
||||
private pageHidden$?: Rx.Observable<boolean>;
|
||||
// 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<Omit<Intercept, 'id'>>;
|
||||
}
|
||||
) {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<SavedObject<InterceptInteractionUserRecordAttributes> | null> {
|
||||
|
@ -131,7 +131,7 @@ export class InterceptUserInteractionService {
|
|||
}
|
||||
}
|
||||
|
||||
public async recordUserInteractionForTrigger(
|
||||
private async recordUserInteractionForTrigger(
|
||||
userId: string,
|
||||
triggerId: string,
|
||||
data: InterceptInteractionUserRecordAttributes['metadata']
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
"id": "productIntercept",
|
||||
"browser": true,
|
||||
"server": true,
|
||||
"requiredPlugins": ["intercepts"],
|
||||
"requiredPlugins": ["intercepts", "cloud"],
|
||||
"requiredBundles": [],
|
||||
"configPath": ["xpack", "product_intercept"]
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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<PromptTelemetry['start']>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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<Intercept, 'id'> => {
|
||||
return {
|
||||
steps: [
|
||||
{
|
||||
id: 'start',
|
||||
title: i18n.translate('productIntercept.prompter.step.start.title', {
|
||||
defaultMessage: 'Help us improve {productOffering}',
|
||||
values: {
|
||||
productOffering,
|
||||
},
|
||||
}),
|
||||
content: () => (
|
||||
<EuiText size="s" key="productInterceptPrompterStartContent">
|
||||
<FormattedMessage
|
||||
id="productIntercept.prompter.step.start.content"
|
||||
defaultMessage="We are always looking for ways to improve {productOffering}. Please take a moment to share your feedback with us."
|
||||
values={{ productOffering }}
|
||||
/>
|
||||
</EuiText>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<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 {productOffering}?',
|
||||
values: {
|
||||
productOffering,
|
||||
},
|
||||
}),
|
||||
content: ({ onValue }) => {
|
||||
return (
|
||||
<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 (
|
||||
<EuiText size="s" key="productInterceptPrompterCompletionContent">
|
||||
<FormattedMessage
|
||||
id="productIntercept.prompter.step.completion.content"
|
||||
defaultMessage="If you'd like to participate in future research to help improve {productOffering}, <link>click here</link>."
|
||||
values={{
|
||||
productOffering,
|
||||
link: (chunks) => (
|
||||
<EuiLink external target="_blank" href={surveyUrl.toString()}>
|
||||
{chunks}
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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, <link>click here</link>.",
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<unknown>) {
|
||||
this.logger = initContext.logger.get();
|
||||
this.config = initContext.config.get<ServerConfigSchema>();
|
||||
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 {};
|
||||
|
|
|
@ -18,7 +18,8 @@
|
|||
"@kbn/config-schema",
|
||||
"@kbn/i18n",
|
||||
"@kbn/intercepts-plugin",
|
||||
"@kbn/i18n-react"
|
||||
"@kbn/i18n-react",
|
||||
"@kbn/cloud-plugin"
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -168,5 +168,6 @@
|
|||
"@kbn/test-suites-xpack-platform",
|
||||
"@kbn/core-chrome-browser",
|
||||
"@kbn/event-log-plugin",
|
||||
"@kbn/intercepts-plugin"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue