[MVP] Product Intercept Dialog (#209571)

## Summary

This PR aims to answer questions about the implementation details for
https://github.com/elastic/kibana-team/issues/1328

We'd like to trigger an intercept dialog to users at specific time
intervals that vary and depend on different parameters, see the PRD
linked in the aforementioned issue.

This MVP takes an approach such that the constraints listed below are
resolved;
- Have the intercept be specific to a single user
- Define a strategy to configure triggers that can vary for the
intercept that's not dependent on the client

How does this work?
- A generic plugin has been created which when declared as dependency
can be used to register and schedule an intercept that should be
presented to the user, this plugin is what's been used for the product
intercept dialog.
- To handle rendering the UI component that gets displayed to the users,
in this PR we hook into core's existing notification system, through the
notification coordinator system that's been created so that we don't
have a situation where an intercept is being displayed whilst a user
might have a toast being rendered to them, if there's an intercept to be
displayed said intercept would not be displayed till the user is
completely done interacting with the toast. [See it's implementation
details
here](src/core/packages/notifications/browser-internal/src/notification_coordinator.ts)
and
[here](x-pack/platform/plugins/private/intercepts/public/prompter/service/intercept_dialog_service.tsx)
for how it's integrated.
- The plugin provides some bootstrap data through an endpoint that every
user calls on page load, ideally this would need to happen just the once
on page load, we then compute when a user should see a trigger
leveraging the bootstrap data provided for the particular registered
trigger in question, the returned data returns the following data as
seen below;

<img width="476" alt="Screenshot 2025-03-27 at 18 01 12"
src="https://github.com/user-attachments/assets/c747b6c8-70d0-4305-b555-ec9998b180c1"
/>


Given we have this data we might then have a flow for triggering the
intercept on the client based of the bootstrap data like so;

	```mermaid
	flowchart TD
	 A[State Bootstrap] --> B{Has data?}
	 B -->|No| C(Do Nothing)
B -->|Yes| D{Does computed runs since trigger registration match stored
user trigger run feedback value?}
	 D -->|No| E[setup timer to display an intercept in that time]
E -->|on completion| G[setup interval to display intercept in the
future]
	 D -->|Yes| G
	 G -->|repeat| G
    ```
	reloading the page restarts the entire process.

## Telemetry

The intercept component provided by defaults records telemetry for
intercept registration, acknowledgement (differentiated into dismissal
and completion), alongside registration overload.


## Visuals


<!--
https://github.com/user-attachments/assets/b39a506c-a119-40e8-9152-258d78691f28
-->

<!--
https://github.com/user-attachments/assets/f564b4bc-9ad9-4e19-8158-6e154ef52fc2
-->

<img width="738" alt="Screenshot 2025-05-07 at 19 41 23"
src="https://github.com/user-attachments/assets/902c6d0b-9299-44bd-8808-4ad97227d0da"
/>

## Testing this PR

- Pull this branch to your machine
- Add the following to your `kibana.dev.yml`, to enable the intercept to
run and be visible

```yml
xpack.intercepts.enabled: true
xpack.product_intercept.enabled: true
xpack.product_intercept.interval: '30s'
```

<!--

### 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: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Eyo O. Eyo 2025-05-27 16:57:58 +02:00 committed by GitHub
parent b6d71ea7e5
commit f635e2a3b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 3342 additions and 3 deletions

2
.github/CODEOWNERS vendored
View file

@ -878,12 +878,14 @@ x-pack/platform/plugins/private/global_search_providers @elastic/appex-sharedux
x-pack/platform/plugins/private/graph @elastic/kibana-visualizations
x-pack/platform/plugins/private/grokdebugger @elastic/kibana-management
x-pack/platform/plugins/private/index_lifecycle_management @elastic/kibana-management
x-pack/platform/plugins/private/intercepts @elastic/appex-sharedux
x-pack/platform/plugins/private/license_api_guard @elastic/kibana-management
x-pack/platform/plugins/private/logstash @elastic/logstash
x-pack/platform/plugins/private/monitoring @elastic/stack-monitoring
x-pack/platform/plugins/private/monitoring_collection @elastic/stack-monitoring
x-pack/platform/plugins/private/observability_ai_assistant_management @elastic/obs-ai-assistant
x-pack/platform/plugins/private/painless_lab @elastic/kibana-management
x-pack/platform/plugins/private/product_intercept @elastic/appex-sharedux
x-pack/platform/plugins/private/remote_clusters @elastic/kibana-management
x-pack/platform/plugins/private/reporting @elastic/response-ops
x-pack/platform/plugins/private/rollup @elastic/kibana-management

View file

@ -156,6 +156,7 @@ mapped_pages:
| [inferenceEndpoint](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/inference_endpoint/README.md) | A Kibana plugin |
| [infra](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/infra/README.md) | This is the home of the infra plugin, which aims to provide a solution for the infrastructure monitoring use-case within Kibana. |
| [ingestPipelines](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/ingest_pipelines/README.md) | The ingest_pipelines plugin provides Kibana support for Elasticsearch's ingest pipelines. |
| [intercepts](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/intercepts/README.md) | Contains business logic and orchestration for displaying the intercept dialog suited to the needs of Kibana, and is made available so that other solution teams might leverage this to register and schedule an intercept of their choosing |
| [inventory](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/inventory/README.md) | Home of the Inventory plugin, which renders the... inventory. |
| [lens](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/lens/readme.md) | Lens is a visualization editor allowing to quickly and easily configure compelling visualizations to use on dashboards and canvas workpads. |
| [licenseApiGuard](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/license_api_guard/README.md) | This plugin is used by ES UI plugins to reject API requests when the plugin is unsupported by the user's license. |
@ -182,6 +183,7 @@ mapped_pages:
| [osquery](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/osquery/README.md) | This plugin adds extended support to Security Solution Fleet Osquery integration |
| [painlessLab](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/painless_lab/README.md) | This plugin helps users learn how to use the Painless scripting language. |
| [productDocBase](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/shared/ai_infra/product_doc_base/README.md) | This plugin contains the product documentation base service. |
| [productIntercept](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/product_intercept/README.md) | This is a standalone plugin that leverages the intercept plugin to display product intercept used to gather information that is turn used to compute CSAT about user's experience of Kibana. |
| [profiling](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/profiling/README.md) | Universal Profiling provides fleet-wide, whole-system, continuous profiling with zero instrumentation. Get a comprehensive understanding of what lines of code are consuming compute resources throughout your entire fleet by visualizing your data in Kibana using the flamegraph, stacktraces, and top functions views. |
| [profilingDataAccess](https://github.com/elastic/kibana/blob/main/x-pack/solutions/observability/plugins/profiling_data_access) | WARNING: Missing or empty README. |
| [remoteClusters](https://github.com/elastic/kibana/blob/main/x-pack/platform/plugins/private/remote_clusters/README.md) | This plugin helps users manage their remote clusters, which enable cross-cluster search and cross-cluster replication. |

View file

@ -603,6 +603,7 @@
"@kbn/inspector-plugin": "link:src/platform/plugins/shared/inspector",
"@kbn/interactive-setup-plugin": "link:src/platform/plugins/private/interactive_setup",
"@kbn/interactive-setup-test-endpoints-plugin": "link:src/platform/test/interactive_setup_api_integration/plugins/test_endpoints",
"@kbn/intercepts-plugin": "link:x-pack/platform/plugins/private/intercepts",
"@kbn/interpreter": "link:src/platform/packages/shared/kbn-interpreter",
"@kbn/inventory-plugin": "link:x-pack/solutions/observability/plugins/inventory",
"@kbn/io-ts-utils": "link:src/platform/packages/shared/kbn-io-ts-utils",
@ -741,6 +742,7 @@
"@kbn/presentation-util-plugin": "link:src/platform/plugins/shared/presentation_util",
"@kbn/product-doc-base-plugin": "link:x-pack/platform/plugins/shared/ai_infra/product_doc_base",
"@kbn/product-doc-common": "link:x-pack/platform/packages/shared/ai-infra/product-doc-common",
"@kbn/product-intercept-plugin": "link:x-pack/platform/plugins/private/product_intercept",
"@kbn/profiler-cli": "link:x-pack/platform/packages/shared/kbn-profiler-cli",
"@kbn/profiling-data-access-plugin": "link:x-pack/solutions/observability/plugins/profiling_data_access",
"@kbn/profiling-plugin": "link:x-pack/solutions/observability/plugins/profiling",

View file

@ -746,6 +746,13 @@
"use_space_awareness_migration_started_at",
"use_space_awareness_migration_status"
],
"intercept_interaction_record": [],
"intercept_trigger_record": [
"firstRegisteredAt",
"installedOn",
"recurrent",
"triggerAfter"
],
"inventory-view": [],
"kql-telemetry": [],
"legacy-url-alias": [

View file

@ -2484,6 +2484,27 @@
}
}
},
"intercept_interaction_record": {
"dynamic": false,
"properties": {}
},
"intercept_trigger_record": {
"dynamic": false,
"properties": {
"firstRegisteredAt": {
"type": "date"
},
"installedOn": {
"type": "keyword"
},
"recurrent": {
"type": "boolean"
},
"triggerAfter": {
"type": "text"
}
}
},
"inventory-view": {
"dynamic": false,
"properties": {}
@ -4022,4 +4043,4 @@
"dynamic": false,
"properties": {}
}
}
}

View file

@ -82,6 +82,7 @@ pageLoadAssetSize:
inputControlVis: 172675
inspector: 18000
interactiveSetup: 80000
intercepts: 26158
inventory: 27430
kibanaOverview: 56279
kibanaReact: 74422
@ -117,6 +118,7 @@ pageLoadAssetSize:
presentationPanel: 11550
presentationUtil: 9000
productDocBase: 22500
productIntercept: 23846
profiling: 36694
remoteClusters: 51327
reporting: 58600

View file

@ -44,8 +44,8 @@ export class NotificationsService {
public setup({ uiSettings, analytics }: SetupDeps): NotificationsSetup {
const notificationSetup = {
toasts: this.toasts.setup({ uiSettings, analytics }),
coordinator: this.coordinator,
toasts: this.toasts.setup({ uiSettings, analytics }),
};
this.uiSettingsErrorSubscription = uiSettings.getUpdateErrors$().subscribe((error: Error) => {

View file

@ -11,4 +11,4 @@ export { registerCoreObjectTypes } from './registration';
// set minimum number of registered saved objects to ensure no object types are removed after 8.8
// declared in internal implementation exclicilty to prevent unintended changes.
export const SAVED_OBJECT_TYPES_COUNT = 130 as const;
export const SAVED_OBJECT_TYPES_COUNT = 132 as const;

View file

@ -129,6 +129,8 @@ describe('checking migration metadata changes on all registered SO types', () =>
"ingest-outputs": "6743521f501bd77b1523dbb1df48d7c47fdad529",
"ingest-package-policies": "6a80000fdf2544f2485b0c6a51ecc434b6a12987",
"ingest_manager_settings": "111a616eb72627c002029c19feb9e6c439a10505",
"intercept_interaction_record": "13587751af378409df5cadd08aeb0d3884b1645a",
"intercept_trigger_record": "9223039379bf9997781ad91df120eb360c3e6b77",
"inventory-view": "fd2b7fe713956f261018dded00d8f8c986417763",
"kql-telemetry": "93c1d16c1a0dfca9c8842062cf5ef8f62ae401ad",
"legacy-url-alias": "9b8cca3fbb2da46fd12823d3cd38fdf1c9f24bc8",

View file

@ -85,6 +85,8 @@ const previouslyRegisteredTypes = [
'guided-onboarding-guide-state',
'guided-onboarding-plugin-state',
'index-pattern',
'intercept_interaction_record',
'intercept_trigger_record',
'infrastructure-monitoring-log-view',
'infrastructure-ui-source',
'infra-custom-dashboards',

View file

@ -84,6 +84,8 @@ export default function () {
pattern: '[%date][%level][%logger] %message %meta',
},
})}`,
// disable product intercept for all ftr tests by default
'--xpack.intercepts.enabled=false',
],
},
};

View file

@ -42,6 +42,9 @@ 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',
],
},

View file

@ -1104,6 +1104,8 @@
"@kbn/interactive-setup-plugin/*": ["src/platform/plugins/private/interactive_setup/*"],
"@kbn/interactive-setup-test-endpoints-plugin": ["src/platform/test/interactive_setup_api_integration/plugins/test_endpoints"],
"@kbn/interactive-setup-test-endpoints-plugin/*": ["src/platform/test/interactive_setup_api_integration/plugins/test_endpoints/*"],
"@kbn/intercepts-plugin": ["x-pack/platform/plugins/private/intercepts"],
"@kbn/intercepts-plugin/*": ["x-pack/platform/plugins/private/intercepts/*"],
"@kbn/interpreter": ["src/platform/packages/shared/kbn-interpreter"],
"@kbn/interpreter/*": ["src/platform/packages/shared/kbn-interpreter/*"],
"@kbn/inventory-e2e": ["x-pack/solutions/observability/plugins/inventory/e2e"],
@ -1434,6 +1436,8 @@
"@kbn/product-doc-base-plugin/*": ["x-pack/platform/plugins/shared/ai_infra/product_doc_base/*"],
"@kbn/product-doc-common": ["x-pack/platform/packages/shared/ai-infra/product-doc-common"],
"@kbn/product-doc-common/*": ["x-pack/platform/packages/shared/ai-infra/product-doc-common/*"],
"@kbn/product-intercept-plugin": ["x-pack/platform/plugins/private/product_intercept"],
"@kbn/product-intercept-plugin/*": ["x-pack/platform/plugins/private/product_intercept/*"],
"@kbn/profiler-cli": ["x-pack/platform/packages/shared/kbn-profiler-cli"],
"@kbn/profiler-cli/*": ["x-pack/platform/packages/shared/kbn-profiler-cli/*"],
"@kbn/profiling-data-access-plugin": ["x-pack/solutions/observability/plugins/profiling_data_access"],

View file

@ -0,0 +1,60 @@
## Intercept plugin
Contains business logic and orchestration for displaying the intercept dialog suited to the needs of Kibana, and is made available so that other solution teams might leverage this to register and schedule an intercept of their choosing
Exposes the following public api;
All exposed APIs existing solely on the start contract;
On the server;
- `registerTriggerDefinition`: This method registers a new trigger, and accepts an id for said trigger, alongside a callback that should return a duration interval. The callback provides values that can be used to conditionally return said interval depending on the use case. When a falsy value is returned no action is taken, if the the id provide already exists and a different trigger interval is provided we update said trigger's interval in place, if the same interval is turned no action is taken. One might define a new trigger like so;
```ts
const TRIGGER_ID = 'some_trigger';
const isServerless = (function evaluateIfIsServerless() {/* ... */})()
registerTriggerDefinition(TRIGGER_ID, ({ }) => {
return isServerless ? '30d' : '180d';
});
```
On the client;
- `registerIntercept`: The method provides a mechanism to configure the intercept that would get displayed to the user, along sides some hooks to act on the feedback provided as the user journeys through the configured steps;
when calling this method, the value of id should match the ID that was registered on the server
```ts
registerIntercept({
id: TRIGGER_DEF_ID,
steps: [],
onProgress({ stepId, stepResponse, runId }) {
// step response received on the last completed step
// allows the developer to act on the data immediately if
// they so wish especially that users might close the
// intercept at any point
},
onFinish({ response, runId }) {
// response here will be an object that contains all the
// input received provided the user completed all steps
// for the intercept
},
onDismiss({ stepId, runId }) {
// callback called when a user dismisses the intercept
}
})
```
Invoking the `registerIntercept` method returns a cold observable, that when subscribed to will kick off computation for when the intercept should be displayed and queue the intercept at the time it should be displayed, said subscription simply returns the ID of the last run.
This plugin also exposes the following config
- `xpack.intercepts.enabled`: Expects boolean value, denotes if the intercept plugin service will run, disabling this plugin implies that any downstream dependent of this plugin will not function.

View file

@ -0,0 +1,30 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
import type { PluginConfigDescriptor, ExposedToBrowserDescriptor } from '@kbn/core/server';
/**
* Config used by plugin to determine if orchestration is invoked,
* and if the product intercept gets displayed on the client.
*/
export const configSchema = schema.object({
enabled: schema.boolean({
defaultValue: true,
}),
});
export type ServerConfigSchema = TypeOf<typeof configSchema>;
const browserConfigSchemaDescriptor: ExposedToBrowserDescriptor<ServerConfigSchema> = {
enabled: true,
};
export const config: PluginConfigDescriptor<ServerConfigSchema> = {
exposeToBrowser: browserConfigSchemaDescriptor,
schema: configSchema,
};

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export const TRIGGER_INFO_API_ROUTE = '/internal/product_intercept/trigger_info' as const;
export const TRIGGER_USER_INTERACTION_METADATA_API_ROUTE =
'/internal/api/intercept/user_interaction/{triggerId}' as const;

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
export type TriggerInfo = {
registeredAt: ReturnType<Date['toISOString']>;
triggerIntervalInMs: number;
recurrent: boolean;
} | null;

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/platform/plugins/private/intercepts'],
};

View file

@ -0,0 +1,16 @@
{
"type": "plugin",
"id": "@kbn/intercepts-plugin",
"owner": ["@elastic/appex-sharedux"],
"group": "platform",
"visibility": "shared",
"description": "This plugin provides implementation for system level conditions which are then used in soliciting product feedback from users.",
"plugin": {
"id": "intercepts",
"browser": true,
"server": true,
"requiredPlugins": ["cloud"],
"requiredBundles": [],
"configPath": ["xpack", "intercepts"]
}
}

View file

@ -0,0 +1,16 @@
<svg width="113" height="81" viewBox="0 0 113 81" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_1084)">
<path d="M109.006 0.500443H37.0027C35.071 0.500443 33.5096 2.06182 33.5096 3.99353V49.4438C33.5096 51.3755 35.071 52.9368 37.0027 52.9368H83.3337L99.8884 65.8883V68.6207H103.832L103.582 53.2271L108.996 52.9469C110.928 52.9469 112.49 51.3855 112.49 49.4538V3.99353C112.49 2.06182 110.928 0.500443 108.996 0.500443H109.006Z" fill="#153385" stroke="#101C3F" stroke-miterlimit="10"/>
<path d="M105.834 0.500443H33.8299C31.8982 0.500443 30.3368 2.06182 30.3368 3.99353V49.4438C30.3368 51.3755 31.8982 52.9368 33.8299 52.9368H80.1609L99.438 68.5206C99.7182 68.7408 100.129 68.5507 100.129 68.1903V52.9368H105.824C107.755 52.9368 109.317 51.3755 109.317 49.4438V3.99353C109.317 2.06182 107.755 0.500443 105.824 0.500443H105.834Z" fill="#0B64DD" stroke="#101C3F" stroke-miterlimit="10"/>
<path d="M50.7149 21.0386H23.3106C10.9597 21.0386 0.61054 30.8673 0.500443 43.2182C0.410363 52.9569 5.95527 61.2542 14.5529 64.457V79.8807C14.5529 80.0909 14.723 80.261 14.9332 80.261H19.7975C20.0077 80.261 20.1779 80.0909 20.1779 79.8807V76.6378L33.1393 65.8783H50.1744C62.3352 65.8783 72.6843 56.4299 73.1147 44.2792C73.5651 31.5279 63.3661 21.0386 50.7049 21.0386H50.7149Z" fill="#153385" stroke="#101C3F" stroke-miterlimit="10"/>
<path d="M55.4891 21.0386H27.6945C15.3136 21.0386 5.27467 31.0775 5.27467 43.4585C5.27467 53.097 11.3701 61.2842 19.8976 64.457V80.261L37.9236 65.8783H55.4891C67.8701 65.8783 77.9089 55.8394 77.9089 43.4585C77.9089 31.0775 67.8701 21.0386 55.4891 21.0386Z" fill="white" stroke="#101C3F" stroke-linejoin="round"/>
<path d="M25.2223 46.4211C27.3284 46.4211 29.0357 44.7138 29.0357 42.6077C29.0357 40.5016 27.3284 38.7943 25.2223 38.7943C23.1163 38.7943 21.4089 40.5016 21.4089 42.6077C21.4089 44.7138 23.1163 46.4211 25.2223 46.4211Z" fill="#101C3F"/>
<path d="M41.5968 46.4211C43.7029 46.4211 45.4102 44.7138 45.4102 42.6077C45.4102 40.5016 43.7029 38.7943 41.5968 38.7943C39.4907 38.7943 37.7834 40.5016 37.7834 42.6077C37.7834 44.7138 39.4907 46.4211 41.5968 46.4211Z" fill="#101C3F"/>
<path d="M57.9713 46.4211C60.0774 46.4211 61.7847 44.7138 61.7847 42.6077C61.7847 40.5016 60.0774 38.7943 57.9713 38.7943C55.8652 38.7943 54.1579 40.5016 54.1579 42.6077C54.1579 44.7138 55.8652 46.4211 57.9713 46.4211Z" fill="#101C3F"/>
</g>
<defs>
<clipPath id="clip0_1_1084">
<rect width="112.9" height="80.69" fill="white" transform="scale(1.00089)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PluginInitializerContext } from '@kbn/core/public';
import { InterceptPublicPlugin } from './plugin';
export type { InterceptsSetup, InterceptsStart } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new InterceptPublicPlugin(initializerContext);
}

View file

@ -0,0 +1,54 @@
/*
* 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 { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { InterceptPrompter } from './prompter';
import type { ServerConfigSchema } from '../common/config';
export class InterceptPublicPlugin implements Plugin {
private readonly prompter?: InterceptPrompter;
private interceptsTargetDomElement?: HTMLDivElement;
constructor(initializerContext: PluginInitializerContext) {
const { enabled } = initializerContext.config.get<ServerConfigSchema>();
if (enabled) {
this.prompter = new InterceptPrompter();
}
}
public setup(core: CoreSetup) {
this.prompter?.setup({
analytics: core.analytics,
notifications: core.notifications,
});
return {};
}
public start(core: CoreStart) {
this.interceptsTargetDomElement = document.createElement('div');
const prompterStart = this.prompter?.start({
http: core.http,
analytics: core.analytics,
rendering: core.rendering,
targetDomElement: this.interceptsTargetDomElement,
});
return {
registerIntercept: prompterStart?.registerIntercept.bind(prompterStart),
};
}
public stop() {
this.interceptsTargetDomElement?.remove();
}
}
export type InterceptsSetup = ReturnType<InterceptPublicPlugin['setup']>;
export type InterceptsStart = ReturnType<InterceptPublicPlugin['start']>;

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export type { Intercept } from './intercept_display_manager';
export { InterceptDisplayManager } from './intercept_display_manager';

View file

@ -0,0 +1,227 @@
/*
* 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 * as Rx from 'rxjs';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { InterceptDisplayManager, type Intercept } from './intercept_display_manager';
const staticAssetsHelperMock = httpServiceMock.createSetupContract().staticAssets;
const mockPerformanceMark = jest.fn(
(name) =>
({
name,
startTime: 0,
duration: 0,
entryType: 'mark',
detail: {},
toJSON: () => ({}),
} as PerformanceMark)
);
const mockPerformanceMeasure = jest.fn(
(name) =>
({
name,
startTime: 0,
duration: 0,
entryType: 'measure',
detail: {},
toJSON: () => ({}),
} as PerformanceMeasure)
);
describe('InterceptDisplayManager', () => {
beforeAll(() => {
window.performance.mark = mockPerformanceMark;
window.performance.measure = mockPerformanceMeasure;
});
afterEach(() => {
jest.clearAllMocks();
});
it('does not render the dialog shell when there is no intercept to display', () => {
const ackProductIntercept = jest.fn();
render(
<InterceptDisplayManager
ackIntercept={ackProductIntercept}
intercept$={Rx.EMPTY}
staticAssetsHelper={staticAssetsHelperMock}
/>
);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('renders the dialog shell when there is an intercept to display', () => {
const ackProductIntercept = jest.fn();
const interceptStep: Intercept['steps'][number] = {
id: 'hello',
title: 'Hello World',
content: () => <>{'This is a test'}</>,
};
const intercept$ = new Rx.BehaviorSubject<Intercept>({
id: '1',
runId: 1,
steps: [
{ ...interceptStep, id: 'start' },
interceptStep,
{ ...interceptStep, id: 'completion' },
],
onFinish: jest.fn(),
});
render(
<InterceptDisplayManager
ackIntercept={ackProductIntercept}
intercept$={intercept$.asObservable()}
staticAssetsHelper={staticAssetsHelperMock}
/>
);
expect(screen.queryByRole('dialog')).not.toBeNull();
expect(screen.getByTestId(`interceptStep-start`)).not.toBeNull();
expect(screen.getByText('Hello World')).not.toBeNull();
expect(screen.getByText('This is a test')).not.toBeNull();
});
it('closes the dialog and calls the provided ack function when the close button is clicked', async () => {
const user = userEvent.setup();
const ackProductIntercept = jest.fn();
const interceptStep: Intercept['steps'][number] = {
id: 'hello',
title: 'Hello World',
content: () => <>{'This is a test'}</>,
};
const intercept$ = new Rx.BehaviorSubject<Intercept>({
id: '1',
runId: 1,
steps: [
{ ...interceptStep, id: 'start' },
interceptStep,
{ ...interceptStep, id: 'completion' },
],
onFinish: jest.fn(),
});
render(
<InterceptDisplayManager
ackIntercept={ackProductIntercept}
intercept$={intercept$.asObservable()}
staticAssetsHelper={staticAssetsHelperMock}
/>
);
expect(screen.queryByRole('dialog')).not.toBeNull();
await user.click(screen.getByTestId('productInterceptDismiss'));
expect(ackProductIntercept).toHaveBeenCalledWith({
ackType: 'dismissed',
interceptId: '1',
runId: 1,
interactionDuration: expect.any(Number),
});
expect(screen.queryByRole('dialog')).toBeNull();
});
it('invokes the passed onProgress handler with the response the user provides as feedback', async () => {
const user = userEvent.setup();
const ackProductIntercept = jest.fn();
const interceptStep: Intercept['steps'][number] = {
id: 'hello',
title: 'Hello World',
content: () => <>{'This is a test'}</>,
};
const productIntercept: Intercept = {
id: '1',
runId: 1,
steps: [
{ ...interceptStep, id: 'start' },
{
...interceptStep,
content: function InterceptContentTest({ onValue }) {
return (
<form
data-test-subj="intercept-test-form"
onSubmit={(e) => {
e.preventDefault();
// @ts-expect-error -- the value we seek is defined, else our tests would fail
onValue((e.target as HTMLFormElement).elements.drone!.value);
}}
>
<fieldset>
<legend>Select a maintenance drone:</legend>
<div>
<input type="radio" id="huey" name="drone" value="huey" defaultChecked />
<label htmlFor="huey">Huey</label>
</div>
<div>
<input type="radio" id="dewey" name="drone" value="dewey" />
<label htmlFor="dewey">Dewey</label>
</div>
<div>
<input type="radio" id="louie" name="drone" value="louie" />
<label htmlFor="louie">Louie</label>
</div>
<div>
<button type="submit">Submit</button>
</div>
</fieldset>
</form>
);
},
},
{ ...interceptStep, id: 'completion' },
],
onProgress: jest.fn(),
onFinish: jest.fn(),
};
const intercept$ = new Rx.BehaviorSubject<Intercept>(productIntercept);
render(
<InterceptDisplayManager
ackIntercept={ackProductIntercept}
intercept$={intercept$.asObservable()}
staticAssetsHelper={staticAssetsHelperMock}
/>
);
expect(screen.queryByRole('dialog')).not.toBeNull();
// transition user to next step in intercept dialog
await user.click(screen.getByTestId('productInterceptProgressionButton'));
expect(screen.queryByTestId('intercept-test-form')).not.toBeNull();
await user.click(screen.getByText('Louie'));
await user.click(screen.getByText('Submit'));
await waitFor(() =>
expect(productIntercept.onProgress).toHaveBeenCalledWith({
runId: 1,
stepId: 'hello',
stepResponse: 'louie',
})
);
});
});

View file

@ -0,0 +1,290 @@
/*
* 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 * as Rx from 'rxjs';
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import {
EuiButton,
EuiButtonEmpty,
EuiPortal,
useEuiTheme,
EuiTitle,
EuiTourStepIndicator,
euiFlyoutSlideInRight,
euiCanAnimate,
EuiFlexGroup,
EuiFlexItem,
EuiSplitPanel,
EuiForm,
EuiButtonIcon,
} from '@elastic/eui';
import type { HttpStart } from '@kbn/core-http-browser';
import type { EuiTourStepStatus } from '@elastic/eui/src/components/tour/tour_step_indicator';
import { InterceptDialogApi } from '../../service/intercept_dialog_api';
export type Intercept = Rx.ObservedValueOf<ReturnType<InterceptDialogApi['get$']>>[number];
const INTERCEPT_ILLUSTRATION_WIDTH = 89; // Magic number was provided by Ryan
interface InterceptDialogManagerProps {
ackIntercept: (args: Parameters<InterceptDialogApi['ack']>[0] & Pick<Intercept, 'runId'>) => void;
/**
* Observable that emits the intercept to be displayed
*/
intercept$: Rx.Observable<Intercept>;
/**
* Helper to load static assets pertinent to the intercept plugin
*/
staticAssetsHelper: HttpStart['staticAssets'];
}
interface InterceptProgressIndicatorProps {
stepsTotal: number;
currentStep: number;
}
const InterceptProgressIndicator = React.memo(
({ stepsTotal, currentStep }: InterceptProgressIndicatorProps) => {
if (!stepsTotal) return null;
return (
<EuiFlexItem grow={false}>
<ul className="euiTourFooter__stepList">
{[...Array(stepsTotal).keys()].map((_, i) => {
let status: EuiTourStepStatus = 'complete';
if (currentStep === i) {
status = 'active';
} else if (currentStep <= i) {
status = 'incomplete';
}
return <EuiTourStepIndicator key={i} number={i + 1} status={status} />;
})}
</ul>
</EuiFlexItem>
);
}
);
export function InterceptDisplayManager({
ackIntercept,
intercept$,
staticAssetsHelper,
}: InterceptDialogManagerProps) {
const interceptRenderMark = useRef<PerformanceMark>();
const { euiTheme } = useEuiTheme();
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [currentIntercept, setCurrentIntercept] = useState<Intercept | null>(null);
const feedbackStore = useRef<Record<string, unknown>>({});
const startIllustrationStyle = useRef(css`
background: var(
--intercept-background,
url(${staticAssetsHelper.getPluginAssetHref('communication.svg')})
);
background-size: ${INTERCEPT_ILLUSTRATION_WIDTH}px 64px;
background-repeat: no-repeat;
background-position: top ${euiTheme.size.base} right ${euiTheme.size.base};
`);
useEffect(() => {
const subscription = intercept$.subscribe((intercept) => {
setCurrentStepIndex(0);
setCurrentIntercept(intercept);
interceptRenderMark.current = performance.mark(`intercept-${intercept.id}-RenderMark`);
});
return () => subscription.unsubscribe();
}, [intercept$]);
const currentInterceptStep = useMemo(() => {
return currentIntercept?.steps?.[currentStepIndex];
}, [currentIntercept, currentStepIndex]);
const nextStep = useCallback(
(isLastStep?: boolean) => {
setCurrentStepIndex((prevStepIndex) => {
if (isLastStep) {
currentIntercept?.onFinish?.({
response: feedbackStore.current,
runId: currentIntercept!.runId,
});
setCurrentStepIndex(0);
// this will cause the component to unmount
ackIntercept({
runId: currentIntercept!.runId,
interceptId: currentIntercept!.id,
ackType: 'completed',
interactionDuration: performance.measure('interceptCompleteMark', {
start: interceptRenderMark.current!.startTime,
end: performance.now(),
}).duration,
});
}
return Math.min(prevStepIndex + 1, currentIntercept!.steps.length);
});
},
[ackIntercept, currentIntercept]
);
const dismissProductIntercept = useCallback(() => {
const runId = currentIntercept!.runId;
ackIntercept({
interceptId: currentIntercept!.id,
runId,
ackType: 'dismissed',
interactionDuration: performance.measure('interceptDismissedMark', {
start: interceptRenderMark.current!.startTime,
end: performance.now(),
}).duration,
});
currentIntercept?.onDismiss?.({ runId, stepId: currentInterceptStep!.id });
setCurrentIntercept(null);
}, [ackIntercept, currentIntercept, currentInterceptStep]);
const onInputChange = useCallback(
(value: unknown) => {
feedbackStore.current[currentInterceptStep!.id] = value;
currentIntercept?.onProgress?.({
stepId: currentInterceptStep!.id,
stepResponse: value,
runId: currentIntercept!.runId,
});
nextStep();
},
[currentIntercept, currentInterceptStep, nextStep]
);
let isLastStep = false;
const isStartStep = currentStepIndex === 0;
return currentIntercept && currentInterceptStep ? (
<EuiPortal>
<EuiSplitPanel.Outer
grow
role="dialog"
css={css`
position: fixed;
inline-size: 400px;
max-block-size: auto;
z-index: ${euiTheme.levels.toast};
inset-inline-end: ${euiTheme.size.l};
inset-block-end: ${euiTheme.size.xxl};
${euiCanAnimate} {
animation: ${euiFlyoutSlideInRight} ${euiTheme.animation.normal}
${euiTheme.animation.resistance};
}
`}
data-test-subj={`intercept-${currentIntercept.id}`}
>
<EuiSplitPanel.Inner
css={css`
min-height: 112px;
position: relative;
${isStartStep && startIllustrationStyle.current};
`}
data-test-subj={`interceptStep-${currentInterceptStep.id}`}
>
<EuiFlexGroup
gutterSize="s"
direction="column"
css={css({
...(isStartStep
? {
width: `calc(100% - ${INTERCEPT_ILLUSTRATION_WIDTH}px - ${euiTheme.size.base})`,
}
: {}),
})}
>
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">
<h2>{currentInterceptStep!.title}</h2>
</EuiTitle>
</EuiFlexItem>
{currentStepIndex > 0 &&
!(isLastStep = currentStepIndex === currentIntercept.steps.length - 1) && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="cross"
aria-label="Close dialog"
onClick={dismissProductIntercept}
color="text"
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiForm fullWidth>
{React.createElement(currentInterceptStep!.content, {
onValue: onInputChange,
})}
</EuiForm>
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner
grow={false}
color="subdued"
css={css`
border-top: ${euiTheme.border.thin};
`}
>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<InterceptProgressIndicator
stepsTotal={currentIntercept.steps.length}
currentStep={currentStepIndex}
/>
</EuiFlexItem>
{(isStartStep || isLastStep) && (
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="xs">
{isStartStep && (
<EuiFlexItem>
<EuiButtonEmpty
size="s"
data-test-subj="productInterceptDismiss"
onClick={dismissProductIntercept}
color="text"
>
{i18n.translate('core.notifications.productIntercept.dismiss', {
defaultMessage: 'Not now',
})}
</EuiButtonEmpty>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiButton
size="s"
data-test-subj="productInterceptProgressionButton"
onClick={() => nextStep(isLastStep)}
>
{isLastStep
? i18n.translate('core.notifications.productIntercept.nextStep', {
defaultMessage: 'Close',
})
: i18n.translate('core.notifications.productIntercept.nextStep', {
defaultMessage: 'Next',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
</EuiPortal>
) : null;
}

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { InterceptPrompter } from './prompter';

View file

@ -0,0 +1,385 @@
/*
* 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 * as Rx from 'rxjs';
import React from 'react';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
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 { TRIGGER_INFO_API_ROUTE } from '../../common/constants';
import type { TriggerInfo } from '../../common/types';
describe('ProductInterceptPrompter', () => {
it('defines a setup method', () => {
const prompter = new InterceptPrompter();
expect(prompter).toHaveProperty('setup', expect.any(Function));
});
it('defines a start method', () => {
const prompter = new InterceptPrompter();
expect(prompter).toHaveProperty('start', expect.any(Function));
});
describe('#start', () => {
const http = httpServiceMock.createStartContract();
const rendering = renderingServiceMock.create();
const analytics = analyticsServiceMock.createAnalyticsServiceStart();
const interceptDialogServiceStartFnSpy = jest.spyOn(InterceptDialogService.prototype, 'start');
let prompter: InterceptPrompter;
beforeAll(() => {
prompter = new InterceptPrompter();
});
beforeEach(() => {
prompter.setup({
analytics: analyticsServiceMock.createAnalyticsServiceSetup(),
notifications: notificationServiceMock.createSetupContract(),
});
});
it('returns an object with a specific shape', () => {
expect(
prompter.start({
http,
rendering,
analytics,
targetDomElement: document.createElement('div'),
})
).toEqual({
registerIntercept: expect.any(Function),
});
});
describe('registerIntercept', () => {
let registerIntercept: ReturnType<InterceptPrompter['start']>['registerIntercept'];
const mockQueueInterceptFn = jest.fn();
const interceptSteps: Parameters<
ReturnType<InterceptPrompter['start']>['registerIntercept']
>[0]['steps'] = [
{
id: 'start' as const,
title: 'Hello',
content: () => React.createElement('p', null, 'Couple of questions for you sir'),
},
{
id: 'interest',
title: 'Are you interested?',
content: () => React.createElement('p', null, '...'),
},
{
id: 'completion' as const,
title: 'Goodbye',
content: () => React.createElement('p', null, 'Goodbye sir'),
},
];
const intercept: Parameters<ReturnType<InterceptPrompter['start']>['registerIntercept']>[0] =
{
id: 'test-intercept',
steps: interceptSteps,
onFinish: jest.fn(),
onDismiss: jest.fn(),
onProgress: jest.fn(),
};
beforeEach(() => {
jest.useFakeTimers();
interceptDialogServiceStartFnSpy.mockImplementation(() => {
return {
add: mockQueueInterceptFn,
};
});
({ registerIntercept } = prompter.start({
http,
rendering,
analytics,
targetDomElement: document.createElement('div'),
}));
});
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
});
it('invoking the registerIntercept method returns an observable', () => {
jest.spyOn(http, 'post').mockResolvedValue({
registeredAt: new Date().toISOString(),
triggerIntervalInMs: 1000,
});
const intercept$ = registerIntercept(intercept);
expect(intercept$).toBeInstanceOf(Rx.Observable);
});
it('subscribing to the returned observable makes a request to the trigger info api endpoint', async () => {
jest.spyOn(http, 'post').mockResolvedValue({
registeredAt: new Date().toISOString(),
triggerIntervalInMs: 1000,
});
const intercept$ = registerIntercept(intercept);
expect(intercept$).toBeInstanceOf(Rx.Observable);
const subscriptionHandler = jest.fn();
const subscription = intercept$.subscribe(subscriptionHandler);
jest.runAllTimers();
expect(http.post).toHaveBeenCalledWith(TRIGGER_INFO_API_ROUTE, {
body: JSON.stringify({ triggerId: intercept.id }),
});
expect(subscriptionHandler).not.toHaveBeenCalled();
expect(mockQueueInterceptFn).not.toHaveBeenCalled();
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,
};
const triggerRuns = 30;
const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3;
// return the configured trigger info
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(intercept);
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({
id: intercept.id,
runId: triggerRuns + 1,
})
);
subscription.unsubscribe();
});
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,
};
const triggerRuns = 30;
const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3;
// return the configured trigger info
jest.spyOn(http, 'post').mockResolvedValue(triggerInfo);
jest.spyOn(http, 'get').mockResolvedValue({ lastInteractedInterceptId: 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 }),
});
jest.advanceTimersByTime(timeInMsTillNextRun);
expect(mockQueueInterceptFn).not.toHaveBeenCalled();
subscription.unsubscribe();
});
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,
};
const triggerRuns = 30;
const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3;
// 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 });
// 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 }),
});
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();
});
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,
};
const triggerRuns = 30;
const timeInMsTillNextRun = triggerInfo.triggerIntervalInMs / 3;
// return the configured trigger info
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 _intercept = {
...intercept,
id: 'test-repeat-intercept',
};
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(_intercept);
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({
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();
});
});
});
});

View file

@ -0,0 +1,107 @@
/*
* 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 * as Rx from 'rxjs';
import type { CoreStart, CoreSetup } from '@kbn/core/public';
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';
type ProductInterceptPrompterSetupDeps = Pick<CoreSetup, 'analytics' | 'notifications'>;
type ProductInterceptPrompterStartDeps = Omit<
InterceptServiceStartDeps,
'persistInterceptRunId' | 'staticAssetsHelper'
> &
Pick<CoreStart, 'http'>;
export class InterceptPrompter {
private userInterceptRunPersistenceService = new UserInterceptRunPersistenceService();
private interceptDialogService = new InterceptDialogService();
private queueIntercept?: ReturnType<InterceptDialogService['start']>['add'];
setup({ analytics, notifications }: ProductInterceptPrompterSetupDeps) {
this.interceptDialogService.setup({ analytics, notifications });
return {};
}
start({ http, ...dialogServiceDeps }: ProductInterceptPrompterStartDeps) {
const { getUserTriggerData$, updateUserTriggerData } =
this.userInterceptRunPersistenceService.start(http);
({ add: this.queueIntercept } = this.interceptDialogService.start({
...dialogServiceDeps,
persistInterceptRunId: updateUserTriggerData,
staticAssetsHelper: http.staticAssets,
}));
return {
/**
* Configures the intercept journey that will be shown to the user, and returns an observable
* that manages displaying the intercept at the appropriate time based on the interval that's been pre-configured for the
* trigger ID matching the ID of this particular journey being configured.
*/
registerIntercept: this.registerIntercept.bind(this, http, getUserTriggerData$),
};
}
private registerIntercept(
http: CoreStart['http'],
getUserTriggerData$: ReturnType<
UserInterceptRunPersistenceService['start']
>['getUserTriggerData$'],
intercept: Intercept
) {
let nextRunId: number;
return Rx.from(
http.post<NonNullable<TriggerInfo>>(TRIGGER_INFO_API_ROUTE, {
body: JSON.stringify({
triggerId: intercept.id,
}),
})
)
.pipe(Rx.filter((response) => !!response))
.pipe(
Rx.mergeMap((response) => {
const now = 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
);
nextRunId = runs + 1;
// Calculate the time until the next run
const nextRun = nextRunId * response.triggerIntervalInMs - diff;
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;
})
);
})
)
.pipe(
Rx.tap((triggerData) => {
if (nextRunId !== triggerData.lastInteractedInterceptId) {
this.queueIntercept?.({ ...intercept, runId: nextRunId });
nextRunId++;
}
})
);
}
}

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
export { InterceptDialogService } from './intercept_dialog_service';
export type { InterceptServiceStartDeps } from './intercept_dialog_service';
export type {
InterceptDialogApi,
InterceptWithoutRunId as Intercept,
} from './intercept_dialog_api';

View file

@ -0,0 +1,103 @@
/*
* 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 { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { InterceptDialogApi } from './intercept_dialog_api';
describe('InterceptDialogApi', () => {
it('exposes the setup and start public methods', () => {
const intercept = new InterceptDialogApi();
expect(intercept).toHaveProperty('setup', expect.any(Function));
expect(intercept).toHaveProperty('start', expect.any(Function));
});
describe('start', () => {
const analytics = analyticsServiceMock.createAnalyticsServiceStart();
let interceptApi: ReturnType<InterceptDialogApi['start']>;
const intercept: Parameters<ReturnType<InterceptDialogApi['start']>['add']>[0] = {
id: 'test-intercept',
runId: 1234,
steps: [
{
id: 'start' as const,
title: 'Hello',
content: () => <>{'Couple of questions for you sir'}</>,
},
{
id: 'interest',
title: 'Are you interested?',
content: () => <>{'...'}</>,
},
{ id: 'completion' as const, title: 'Goodbye', content: () => <>{'Goodbye sir'}</> },
],
onFinish: jest.fn(),
};
beforeEach(() => {
const interceptDialog = new InterceptDialogApi();
interceptApi = interceptDialog.start({ analytics });
});
it('has specific properties', () => {
expect(interceptApi).toHaveProperty('add', expect.any(Function));
expect(interceptApi).toHaveProperty('ack', expect.any(Function));
expect(interceptApi).toHaveProperty('get$', expect.any(Function));
});
it('invoking the add method adds an intercept', () => {
const nextHandlerFn = jest.fn();
const sub = interceptApi.get$().subscribe(nextHandlerFn);
interceptApi.add(intercept);
expect(nextHandlerFn).toHaveBeenCalledTimes(2); // called because of the initial value and the new intercept
expect(nextHandlerFn).toHaveBeenLastCalledWith([intercept]);
sub.unsubscribe();
});
it('invoking the add method multiple times with an intercept matching the same id is idempotent', () => {
const nextHandlerFn = jest.fn();
const sub = interceptApi.get$().subscribe(nextHandlerFn);
Array.from({ length: 5 }).forEach(() => {
interceptApi.add(intercept);
});
expect(nextHandlerFn).toHaveBeenCalledTimes(2); // called because of the initial value and the new intercept
expect(nextHandlerFn).toHaveBeenLastCalledWith([intercept]);
sub.unsubscribe();
});
it('invoking the ack method with the id of an existing intercept removes said intercept', () => {
const nextHandlerFn = jest.fn();
const sub = interceptApi.get$().subscribe(nextHandlerFn);
// add intercept we'd like to perform an ack on
interceptApi.add(intercept);
expect(nextHandlerFn).toHaveBeenCalledTimes(2); // called because of the initial value and the new intercept
expect(nextHandlerFn).toHaveBeenLastCalledWith([intercept]);
interceptApi.ack({ interceptId: intercept.id, ackType: 'dismissed', interactionDuration: 0 });
expect(nextHandlerFn).toHaveBeenCalledTimes(3); // called because of the initial value, the new intercept, and the ack
expect(nextHandlerFn).toHaveBeenLastCalledWith([]);
sub.unsubscribe();
});
});
});

View file

@ -0,0 +1,145 @@
/*
* 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 type { FC } from 'react';
import type { EuiTourStepProps } from '@elastic/eui';
import type { AnalyticsServiceStart, AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
import * as Rx from 'rxjs';
import { InterceptTelemetry } from './telemetry';
interface InterceptSteps extends Pick<EuiTourStepProps, 'title'> {
id: string;
/**
* expects a react component that will be rendered in the dialog, and expects a callback to be called with the value
* of the step when the user is done with the step.
*/
content: FC<{ onValue: (value: unknown) => void }>;
}
interface StartingInterceptStep extends InterceptSteps {
id: 'start';
}
interface CompletionInterceptStep extends InterceptSteps {
id: 'completion';
}
interface InterceptProgressEvent {
runId: Intercept['runId'];
stepId: string;
stepResponse: unknown;
}
interface InterceptCompletionEvent {
runId: Intercept['runId'];
response: Record<string, unknown>;
}
interface InterceptDismissalEvent {
runId: Intercept['runId'];
stepId: string;
}
/**
* @internal
*/
export interface Intercept {
/**
* Unique identifier for the intercept, value provided must match the Id used when registering the trigger condition for said intercept.
*/
id: string;
runId: number;
steps: [StartingInterceptStep, ...InterceptSteps[], CompletionInterceptStep];
/**
* Provides the response of the user interaction with the dialog for a particular step. Progress will not fire for the start or completion steps.
*/
onProgress?: (evt: InterceptProgressEvent) => void;
/**
* Provides the response of the users interaction within the dialog as a object with keys corresponding to the id of the steps.
*/
onFinish: (evt: InterceptCompletionEvent) => void;
onDismiss?: (evt: InterceptDismissalEvent) => void;
}
/**
* @public
*/
export type InterceptWithoutRunId = Omit<Intercept, 'runId'>;
interface InterceptDialogApiStartDeps {
analytics: AnalyticsServiceStart;
}
interface InterceptDialogApiSetupDeps {
analytics: AnalyticsServiceSetup;
}
export class InterceptDialogApi {
private readonly telemetry = new InterceptTelemetry();
private productIntercepts$ = new Rx.BehaviorSubject<Intercept[]>([]);
private eventReporter?: ReturnType<InterceptTelemetry['start']>;
setup({ analytics }: InterceptDialogApiSetupDeps) {
this.telemetry.setup({ analytics });
return {};
}
start({ analytics }: InterceptDialogApiStartDeps) {
this.eventReporter = this.telemetry.start({ analytics });
return {
add: this.add.bind(this),
ack: this.ack.bind(this),
get$: this.get$.bind(this),
};
}
private get$() {
return this.productIntercepts$.asObservable();
}
private add(productIntercept: Intercept): string {
const existingIntercepts = this.productIntercepts$.getValue();
if (existingIntercepts.some((intercept) => intercept.id === productIntercept.id)) {
this.eventReporter?.reportInterceptOverload({ interceptId: productIntercept.id });
} else {
// order is important so we can operate on a FIFO basis
this.productIntercepts$.next([productIntercept, ...existingIntercepts]);
this.eventReporter?.reportInterceptRegistration({ interceptId: productIntercept.id });
}
return productIntercept.id;
}
/**
* @description expected to be called when a user is determined to have acknowledged the intercept for which the id is provided
*/
private ack({
interactionDuration,
interceptId,
ackType,
}: {
ackType: 'dismissed' | 'completed';
} & Omit<
Parameters<NonNullable<typeof this.eventReporter>['reportInterceptInteraction']>[0],
'interactionType'
>): void {
this.productIntercepts$.next(
this.productIntercepts$.getValue().filter((intercept) => intercept.id !== interceptId)
);
this.eventReporter?.reportInterceptInteraction({
interactionType: ackType,
interactionDuration,
interceptId,
});
}
}

View file

@ -0,0 +1,57 @@
/*
* 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 { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import { analyticsServiceMock } from '@kbn/core-analytics-browser-mocks';
import { renderingServiceMock } from '@kbn/core-rendering-browser-mocks';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { InterceptDialogService } from './intercept_dialog_service';
const staticAssetsHelperMock = httpServiceMock.createSetupContract().staticAssets;
describe('InterceptDialogService', () => {
it('exposes a setup and start method', () => {
const interceptDialogService = new InterceptDialogService();
expect(interceptDialogService).toHaveProperty('setup', expect.any(Function));
expect(interceptDialogService).toHaveProperty('start', expect.any(Function));
expect(interceptDialogService).toHaveProperty('stop', expect.any(Function));
});
describe('#start', () => {
let start: ReturnType<InterceptDialogService['start']>;
const interceptDialogService = new InterceptDialogService();
beforeAll(() => {
interceptDialogService.setup({
analytics: analyticsServiceMock.createAnalyticsServiceSetup(),
notifications: notificationServiceMock.createSetupContract(),
});
});
beforeEach(() => {
start = interceptDialogService.start({
analytics: analyticsServiceMock.createAnalyticsServiceStart(),
rendering: renderingServiceMock.create(),
targetDomElement: document.createElement('div'),
persistInterceptRunId: jest.fn(),
staticAssetsHelper: staticAssetsHelperMock,
});
});
afterEach(() => {
interceptDialogService.stop();
});
it('exposes an expected set of properties', () => {
expect(start).toStrictEqual({
add: expect.any(Function),
});
});
});
});

View file

@ -0,0 +1,119 @@
/*
* 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 * as Rx from 'rxjs';
import React, { ComponentProps } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import type { HttpStart } from '@kbn/core-http-browser';
import type { AnalyticsServiceStart, AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
import type { RenderingService } from '@kbn/core-rendering-browser';
import type { NotificationsSetup } from '@kbn/core-notifications-browser';
import { InterceptDialogApi } from './intercept_dialog_api';
import { UserInterceptRunPersistenceService } from './user_intercept_run_persistence_service';
import { InterceptDisplayManager } from '../component/intercept_display_manager';
interface InterceptServiceSetupDeps {
analytics: AnalyticsServiceSetup;
notifications: NotificationsSetup;
}
export interface InterceptServiceStartDeps {
analytics: AnalyticsServiceStart;
rendering: RenderingService;
targetDomElement: HTMLElement;
persistInterceptRunId: ReturnType<
UserInterceptRunPersistenceService['start']
>['updateUserTriggerData'];
staticAssetsHelper: HttpStart['staticAssets'];
}
export class InterceptDialogService {
private readonly api = new InterceptDialogApi();
private targetDomElement?: HTMLElement;
private notificationsCoordinator?: ReturnType<NotificationsSetup['coordinator']>;
setup({ analytics, notifications }: InterceptServiceSetupDeps) {
this.api.setup({ analytics });
this.notificationsCoordinator = notifications.coordinator(InterceptDialogService.name);
return {};
}
public start({
targetDomElement,
rendering,
analytics,
staticAssetsHelper,
persistInterceptRunId: persistInterceptRunInteraction,
}: InterceptServiceStartDeps) {
const { ack, add, get$ } = this.api.start({ analytics });
this.targetDomElement = targetDomElement;
if (!this.notificationsCoordinator) {
throw new Error('Notifications coordinator is not initialized');
}
// leverage the notifications coordinator to ensure that we are not showing
// multiple intercepts at the same time, and that we are not showing
// intercepts when the user is interacting with other notifications
// (e.g. toast notifications)
const intercept$ = this.notificationsCoordinator
.optInToCoordination(get$(), ({ locked }) => !locked)
.pipe(
Rx.mergeMap((x) => x),
// since the backing store for product intercepts accepts all queued intercepts,
// we might receive a list of intercepts that should be attended to,
// hence we attempt to present them serially to the user, without interrupting other queued notification types
Rx.delayWhen(() =>
this.notificationsCoordinator!.lock$.pipe(
Rx.filter(({ controller }) => controller === InterceptDialogService.name)
)
)
);
const ackIntercept = (
...args: Parameters<ComponentProps<typeof InterceptDisplayManager>['ackIntercept']>
) => {
const [{ runId, ...ackArgs }] = args;
ack(ackArgs);
persistInterceptRunInteraction(ackArgs.interceptId, runId);
// we release the coordination lock on processing the user's acknowledgement of the product intercept,
// so that any other pending notification can be shown
this.notificationsCoordinator?.releaseLock();
};
render(
rendering.addContext(
<InterceptDisplayManager
{...{
intercept$,
ackIntercept,
staticAssetsHelper,
}}
/>
),
this.targetDomElement
);
return {
add,
};
}
stop() {
if (this.targetDomElement) {
unmountComponentAtNode(this.targetDomElement);
}
}
}
export type InterceptDialogServiceStart = ReturnType<InterceptDialogApi['start']>;

View file

@ -0,0 +1,77 @@
/*
* 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 { type RootSchema, type EventTypeOpts } from '@elastic/ebt/client';
export enum EventMetric {
INTERCEPT_TERMINATION_INTERACTION = 'intercept_termination_interaction',
INTERCEPT_REGISTRATION = 'intercept_registration',
INTERCEPT_OVERLOAD = 'intercept_overload',
}
export enum EventFieldType {
INTERACTION_TYPE = 'interaction_type',
INTERCEPT_ID = 'intercept_id',
INTERACTION_DURATION = 'interaction_duration',
}
const fields: Record<EventFieldType, RootSchema<unknown>> = {
[EventFieldType.INTERACTION_TYPE]: {
[EventFieldType.INTERACTION_TYPE]: {
type: 'keyword',
_meta: {
description: 'The type of interaction that occurred with the intercept',
optional: false,
},
},
},
[EventFieldType.INTERCEPT_ID]: {
[EventFieldType.INTERCEPT_ID]: {
type: 'keyword',
_meta: {
description: 'ID of the intercept',
optional: false,
},
},
},
[EventFieldType.INTERACTION_DURATION]: {
[EventFieldType.INTERACTION_DURATION]: {
type: 'long',
_meta: {
description: 'Duration of the interaction in milliseconds',
optional: false,
},
},
},
};
/**
* @description defines all the event types that can be reported by the intercept dialog,
* with the mapping that values provided will be ingested as within EBT
*/
export const eventTypes: Array<EventTypeOpts<Record<string, unknown>>> = [
{
eventType: EventMetric.INTERCEPT_TERMINATION_INTERACTION,
schema: {
...fields[EventFieldType.INTERACTION_TYPE],
...fields[EventFieldType.INTERCEPT_ID],
...fields[EventFieldType.INTERACTION_DURATION],
},
},
{
eventType: EventMetric.INTERCEPT_REGISTRATION,
schema: {
...fields[EventFieldType.INTERCEPT_ID],
},
},
{
eventType: EventMetric.INTERCEPT_OVERLOAD,
schema: {
...fields[EventFieldType.INTERCEPT_ID],
},
},
];

View file

@ -0,0 +1,59 @@
/*
* 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 type { AnalyticsServiceStart, AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
import { EventMetric, EventFieldType, eventTypes } from './event_definitions';
export class InterceptTelemetry {
private reportEvent?: AnalyticsServiceStart['reportEvent'];
public setup({ analytics }: { analytics: AnalyticsServiceSetup }) {
eventTypes.forEach((eventType) => {
analytics.registerEventType(eventType);
});
return {};
}
public start({ analytics }: { analytics: AnalyticsServiceStart }) {
this.reportEvent = analytics.reportEvent;
return {
reportInterceptRegistration: this.reportInterceptRegistration.bind(this),
reportInterceptOverload: this.reportInterceptOverload.bind(this),
reportInterceptInteraction: this.reportInterceptInteraction.bind(this),
};
}
private reportInterceptRegistration({ interceptId }: { interceptId: string }) {
this.reportEvent?.(EventMetric.INTERCEPT_REGISTRATION, {
[EventFieldType.INTERCEPT_ID]: interceptId,
});
}
private reportInterceptOverload({ interceptId }: { interceptId: string }) {
this.reportEvent?.(EventMetric.INTERCEPT_OVERLOAD, {
[EventFieldType.INTERCEPT_ID]: interceptId,
});
}
private reportInterceptInteraction({
interactionDuration,
interactionType,
interceptId,
}: {
interactionDuration: number;
interactionType: string;
interceptId: string;
}) {
this.reportEvent?.(EventMetric.INTERCEPT_TERMINATION_INTERACTION, {
[EventFieldType.INTERACTION_TYPE]: interactionType,
[EventFieldType.INTERCEPT_ID]: interceptId,
[EventFieldType.INTERACTION_DURATION]: interactionDuration,
});
}
}

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { InterceptTelemetry } from './event_reporter';

View file

@ -0,0 +1,41 @@
/*
* 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 * as Rx from 'rxjs';
import type { CoreStart } from '@kbn/core/public';
import { TRIGGER_USER_INTERACTION_METADATA_API_ROUTE } from '../../../common/constants';
/**
* @description provides utils to read and update information about the last interaction with a given intercept trigger.
*/
export class UserInterceptRunPersistenceService {
start(http: CoreStart['http']) {
return {
getUserTriggerData$: this.userInterceptRunId.bind(this, http),
updateUserTriggerData: this.persistInterceptRunInteraction.bind(this, http),
};
}
userInterceptRunId(http: CoreStart['http'], triggerId: string) {
return Rx.from(
http.get<{ lastInteractedInterceptId: number }>(
TRIGGER_USER_INTERACTION_METADATA_API_ROUTE.replace('{triggerId}', triggerId)
)
);
}
persistInterceptRunInteraction(http: CoreStart['http'], triggerId: string, runId: number) {
return http.post(
TRIGGER_USER_INTERACTION_METADATA_API_ROUTE.replace('{triggerId}', triggerId),
{
body: JSON.stringify({
lastInteractedInterceptId: runId,
}),
}
);
}
}

View file

@ -0,0 +1,16 @@
/*
* 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 type { PluginInitializerContext } from '@kbn/core/server';
export { config } from '../common/config';
export type { InterceptSetup, InterceptStart } from './plugin';
export const plugin = async (initContext: PluginInitializerContext) => {
const { InterceptsServerPlugin } = await import('./plugin');
return new InterceptsServerPlugin(initContext);
};

View file

@ -0,0 +1,143 @@
/*
* 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 crypto from 'crypto';
import {
RequestHandlerContext,
type CoreSetup,
type CoreStart,
type Logger,
type IRouter,
type IContextProvider,
} from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { parseIntervalAsMillisecond } from '@kbn/task-manager-plugin/server/lib/intervals';
import { InterceptTriggerService } from './services/intercept_trigger';
import { InterceptUserInteractionService } from './services/intercept_user_interaction';
import { TRIGGER_INFO_API_ROUTE } from '../common/constants';
import type { TriggerInfo } from '../common/types';
interface InterceptTriggerRouteContext extends RequestHandlerContext {
triggerInfo: Promise<TriggerInfo>;
}
interface InterceptTriggerCoreSetup {
kibanaVersion: string;
}
export class InterceptsTriggerOrchestrator {
private logger?: Logger;
private interceptTriggerService = new InterceptTriggerService();
private interceptUserInteractionService = new InterceptUserInteractionService();
setup(core: CoreSetup, logger: Logger, { kibanaVersion }: InterceptTriggerCoreSetup) {
this.logger = logger;
const { fetchRegisteredTask } = this.interceptTriggerService.setup(core, this.logger, {
kibanaVersion,
});
this.interceptUserInteractionService.setup(core, this.logger);
core.http.registerRouteHandlerContext<InterceptTriggerRouteContext, 'triggerInfo'>(
'triggerInfo',
this.routerHandlerContext.bind(this, fetchRegisteredTask)
);
const router = core.http.createRouter<InterceptTriggerRouteContext>();
router.post.apply(router, this.getRouteConfig());
}
start(core: CoreStart) {
const { registerTriggerDefinition } = this.interceptTriggerService.start(core);
this.interceptUserInteractionService.start(core);
return {
registerTriggerDefinition,
};
}
/**
* @description this method provides trigger information as context for the route handler
*/
private async routerHandlerContext(
fetchRegisteredTask: ReturnType<InterceptTriggerService['setup']>['fetchRegisteredTask'],
...args: Parameters<IContextProvider<InterceptTriggerRouteContext, 'triggerInfo'>>
) {
const [, request] = args;
// @ts-expect-error -- the context is not typed
const triggerId = request.body?.triggerId;
if (!triggerId) {
return null;
}
let triggerInfo: InterceptTriggerRouteContext['triggerInfo'] extends Promise<infer T>
? T
: never = null;
let registeredTriggerDefinition;
if ((registeredTriggerDefinition = await fetchRegisteredTask(triggerId))) {
triggerInfo = {
registeredAt: registeredTriggerDefinition.firstRegisteredAt,
triggerIntervalInMs: parseIntervalAsMillisecond(registeredTriggerDefinition.triggerAfter),
recurrent: registeredTriggerDefinition.recurrent,
};
}
return triggerInfo;
}
private getRouteConfig(): Parameters<IRouter<InterceptTriggerRouteContext>['post']> {
return [
{
path: TRIGGER_INFO_API_ROUTE,
validate: {
body: schema.object({
triggerId: schema.string(),
}),
},
security: {
authz: {
enabled: false,
reason: 'route is public and provides information about the intercept trigger',
},
},
},
async (context, request, response) => {
const resolvedTriggerInfo = await context.triggerInfo;
if (!resolvedTriggerInfo) {
return response.noContent();
}
// use the trigger interval as the etag
const responseETag = crypto
.createHash('sha256')
.update(Buffer.from(String(resolvedTriggerInfo.triggerIntervalInMs)))
.digest('hex');
if (request.headers['if-none-match'] === responseETag) {
return response.notModified({});
}
return response.ok({
headers: {
etag: responseETag,
// cache the response for the duration of the trigger interval
'cache-control': `max-age=${resolvedTriggerInfo.triggerIntervalInMs}, must-revalidate`,
},
body: resolvedTriggerInfo,
});
},
];
}
}

View file

@ -0,0 +1,54 @@
/*
* 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 {
Plugin,
type CoreSetup,
type CoreStart,
type PluginInitializerContext,
type Logger,
} from '@kbn/core/server';
import { InterceptsTriggerOrchestrator } from './orchestrator';
import type { ServerConfigSchema } from '../common/config';
export class InterceptsServerPlugin implements Plugin<object, object, object, never> {
private readonly logger: Logger;
private readonly config: ServerConfigSchema;
private readonly interceptsOrchestrator?: InterceptsTriggerOrchestrator;
constructor(private initContext: PluginInitializerContext<unknown>) {
this.logger = initContext.logger.get();
this.config = initContext.config.get<ServerConfigSchema>();
if (this.config.enabled) {
this.interceptsOrchestrator = new InterceptsTriggerOrchestrator();
}
}
public setup(core: CoreSetup) {
this.interceptsOrchestrator?.setup(core, this.logger, {
kibanaVersion: this.initContext.env.packageInfo.version,
});
return {};
}
public start(core: CoreStart) {
const interceptOrchestratorStart = this.interceptsOrchestrator?.start(core);
return {
registerTriggerDefinition: interceptOrchestratorStart?.registerTriggerDefinition.bind(
interceptOrchestratorStart
),
};
}
public stop() {}
}
export type InterceptSetup = ReturnType<InterceptsServerPlugin['setup']>;
export type InterceptStart = ReturnType<InterceptsServerPlugin['start']>;

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export type { InterceptTriggerRecord } from './intercept_trigger_record';
export { interceptTriggerRecordSavedObject } from './intercept_trigger_record';
export type { InterceptInteractionUserRecordAttributes } from './intercept_user_interaction_record';
export { interceptInteractionUserRecordSavedObject } from './intercept_user_interaction_record';

View file

@ -0,0 +1,68 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { SavedObjectsType, SavedObjectsFieldMapping } from '@kbn/core/server';
import type { InferObjectSchema } from './types';
const interceptTriggerV1 = schema.object({
firstRegisteredAt: schema.string(),
/**
* The interval at which the intercept should be displayed to the user.
*/
triggerAfter: schema.string(),
/**
* The version of kibana where this intercept trigger was installed.
*/
installedOn: schema.string(),
/**
* Flag to denote if the trigger should run in perpetuity or not. set to false for a trigger that should run only once.
*/
recurrent: schema.boolean(),
});
export type InterceptTriggerRecord = InferObjectSchema<typeof interceptTriggerV1>;
type InterceptTriggerSavedObjectProperties = Record<
keyof InterceptTriggerRecord,
SavedObjectsFieldMapping
>;
const interceptTriggerProperties: InterceptTriggerSavedObjectProperties = {
firstRegisteredAt: {
type: 'date',
},
triggerAfter: {
type: 'text',
},
installedOn: {
type: 'keyword',
},
recurrent: {
type: 'boolean',
},
};
export const interceptTriggerRecordSavedObject: SavedObjectsType<InterceptTriggerRecord> = {
name: 'intercept_trigger_record',
hidden: true,
hiddenFromHttpApis: true,
namespaceType: 'agnostic',
mappings: {
dynamic: false,
properties: interceptTriggerProperties,
},
modelVersions: {
1: {
changes: [],
schemas: {
forwardCompatibility: interceptTriggerV1.extends({}, { unknowns: 'ignore' }),
create: interceptTriggerV1,
},
},
},
};

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import type { SavedObjectsType } from '@kbn/core/server';
import type { InferObjectSchema } from './types';
const interceptInteractionV1 = schema.object({
userId: schema.string(),
triggerId: schema.string(),
metadata: schema.object(
{
lastInteractedInterceptId: schema.number(),
},
{ unknowns: 'allow' }
),
});
export type InterceptInteractionUserRecordAttributes = InferObjectSchema<
typeof interceptInteractionV1
>;
export const interceptInteractionUserRecordSavedObject: SavedObjectsType = {
name: 'intercept_interaction_record',
hidden: true,
hiddenFromHttpApis: true,
namespaceType: 'agnostic',
mappings: {
dynamic: false,
properties: {},
},
modelVersions: {
1: {
changes: [],
schemas: {
forwardCompatibility: interceptInteractionV1.extends({}, { unknowns: 'ignore' }),
create: interceptInteractionV1,
},
},
},
};

View file

@ -0,0 +1,18 @@
/*
* 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 type { ObjectType, Type } from '@kbn/config-schema';
export type InferObjectSchema<S> = {
[Property in keyof (S extends ObjectType<infer O> ? O : never)]: (S extends ObjectType<infer O>
? O
: never)[Property] extends Type<infer T>
? T extends ObjectType
? InferObjectSchema<T>
: T
: never;
};

View file

@ -0,0 +1,126 @@
/*
* 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 { coreMock } from '@kbn/core/server/mocks';
import { InterceptTriggerService } from './intercept_trigger';
import { interceptTriggerRecordSavedObject } from '../saved_objects';
import { ISavedObjectsRepository } from '@kbn/core/server';
describe('InterceptTriggerService', () => {
describe('#setup', () => {
it('invoking setup registers the backing saved object', () => {
const interceptTrigger = new InterceptTriggerService();
const coreSetupMock = coreMock.createSetup();
interceptTrigger.setup(coreSetupMock, {} as any, {
kibanaVersion: '8.0.0',
});
expect(coreSetupMock.savedObjects.registerType).toHaveBeenCalledWith(
interceptTriggerRecordSavedObject
);
});
});
describe('#start', () => {
it('should return a specific of properties', () => {
const interceptTrigger = new InterceptTriggerService();
const coreStartMock = coreMock.createStart();
const interceptTriggerStartContract = interceptTrigger.start(coreStartMock);
expect(interceptTriggerStartContract).toHaveProperty(
'registerTriggerDefinition',
expect.any(Function)
);
});
describe('registerTriggerDefinition', () => {
it('would cause a creation invocation when a trigger with the same ID has not been registered', async () => {
const interceptTrigger = new InterceptTriggerService();
const coreSetupMock = coreMock.createSetup();
const coreStartMock = coreMock.createStart();
const createSavedObjectFnMock = jest.fn(() => Promise.resolve());
coreStartMock.savedObjects.createInternalRepository.mockReturnValue({
create: createSavedObjectFnMock,
get: jest.fn(() => Promise.resolve({ attributes: null })),
} as unknown as ISavedObjectsRepository);
interceptTrigger.setup(coreSetupMock, {} as any, {
kibanaVersion: '8.0.0',
});
const { registerTriggerDefinition } = interceptTrigger.start(coreStartMock);
const triggerId = 'trigger-id';
await registerTriggerDefinition(triggerId, () => ({
triggerAfter: '30d',
}));
expect(createSavedObjectFnMock).toHaveBeenCalledWith(
interceptTriggerRecordSavedObject.name,
{
firstRegisteredAt: expect.any(String),
recurrent: expect.any(Boolean),
triggerAfter: expect.any(String),
installedOn: expect.any(String),
},
{ id: triggerId }
);
});
it('would cause a update invocation when a trigger with the same ID is already registered', async () => {
const interceptTrigger = new InterceptTriggerService();
const coreSetupMock = coreMock.createSetup();
const coreStartMock = coreMock.createStart();
const updateSavedObjectFnMock = jest.fn(() => Promise.resolve());
coreStartMock.savedObjects.createInternalRepository.mockReturnValue({
update: updateSavedObjectFnMock,
get: jest.fn((...args) =>
Promise.resolve({
attributes: {
id: args[1],
// simulate an existing trigger with configured value
triggerAfter: '30d',
},
})
),
} as unknown as ISavedObjectsRepository);
interceptTrigger.setup(coreSetupMock, {} as any, {
kibanaVersion: '8.0.0',
});
const { registerTriggerDefinition } = interceptTrigger.start(coreStartMock);
const triggerId = 'trigger-id';
await registerTriggerDefinition(triggerId, () => ({
// provide a new value for the triggerAfter
triggerAfter: '28d',
}));
expect(updateSavedObjectFnMock).toHaveBeenCalledWith(
interceptTriggerRecordSavedObject.name,
triggerId,
{
triggerAfter: expect.any(String),
}
);
});
});
});
});

View file

@ -0,0 +1,116 @@
/*
* 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 { type CoreSetup, type CoreStart, type Logger } from '@kbn/core/server';
import type { ISavedObjectsRepository } from '@kbn/core-saved-objects-api-server';
import { interceptTriggerRecordSavedObject, type InterceptTriggerRecord } from '../saved_objects';
interface InterceptTriggerServiceSetupDeps {
kibanaVersion: string;
}
export interface InterceptRegistrationCallbackArgs {
existingTriggerDefinition?: InterceptTriggerRecord | null;
}
export class InterceptTriggerService {
private logger?: Logger;
private savedObjectsClient?: ISavedObjectsRepository;
private kibanaVersion?: string;
private savedObjectRef = interceptTriggerRecordSavedObject;
setup(core: CoreSetup, logger: Logger, { kibanaVersion }: InterceptTriggerServiceSetupDeps) {
this.logger = logger;
this.kibanaVersion = kibanaVersion;
core.savedObjects.registerType(this.savedObjectRef);
return {
fetchRegisteredTask: this.fetchRegisteredTask.bind(this),
};
}
start(core: CoreStart) {
this.savedObjectsClient = core.savedObjects.createInternalRepository([
this.savedObjectRef.name,
]);
return {
registerTriggerDefinition: this.registerTriggerDefinition.bind(this),
};
}
private async fetchRegisteredTask(triggerId: string) {
let result;
try {
result = await this.savedObjectsClient?.get<InterceptTriggerRecord>(
interceptTriggerRecordSavedObject.name,
triggerId
);
} catch (err) {
this.logger?.error(err);
}
return result?.attributes ?? null;
}
private async registerTriggerDefinition(
triggerId: string,
cb: (args: InterceptRegistrationCallbackArgs) => {
triggerAfter: string | null;
isRecurrent?: boolean;
}
) {
const existingTriggerDefinition = await this.fetchRegisteredTask(triggerId);
const { triggerAfter, isRecurrent } = cb({
existingTriggerDefinition,
});
if (!triggerAfter) {
this.logger?.error('Trigger interval is not defined');
return;
}
if (!existingTriggerDefinition) {
await this.savedObjectsClient
?.create<InterceptTriggerRecord>(
this.savedObjectRef.name,
{
firstRegisteredAt: new Date().toISOString(),
triggerAfter,
installedOn: this.kibanaVersion!,
recurrent: isRecurrent ?? true,
},
{ id: triggerId }
)
.catch((err) => {
// TODO: handle error properly
this.logger?.error(err.message);
});
return;
} else if (
// only support updating the trigger interval for existing trigger definitions
existingTriggerDefinition &&
existingTriggerDefinition.triggerAfter !== triggerAfter
) {
await this.savedObjectsClient
?.update<InterceptTriggerRecord>(this.savedObjectRef.name, triggerId, {
triggerAfter,
})
.catch((err) => {
// TODO: handle error properly
this.logger?.error(err.message);
});
return;
}
// Nothing to do if the trigger interval is the same
}
}

View file

@ -0,0 +1,171 @@
/*
* 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 assert from 'assert';
import {
type CoreSetup,
type CoreStart,
type Logger,
RequestHandlerContext,
SavedObjectsErrorHelpers,
} from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import type { ISavedObjectsRepository, SavedObject } from '@kbn/core-saved-objects-api-server';
import {
interceptInteractionUserRecordSavedObject,
type InterceptInteractionUserRecordAttributes,
} from '../saved_objects';
import { TRIGGER_USER_INTERACTION_METADATA_API_ROUTE } from '../../common/constants';
export class InterceptUserInteractionService {
private savedObjectsClient?: ISavedObjectsRepository;
private savedObjectRef = interceptInteractionUserRecordSavedObject;
setup(core: CoreSetup, logger: Logger) {
core.savedObjects.registerType(this.savedObjectRef);
const router = core.http.createRouter<RequestHandlerContext>();
router.get(
{
path: TRIGGER_USER_INTERACTION_METADATA_API_ROUTE,
validate: {
params: schema.object({
triggerId: schema.string(),
}),
},
security: {
authz: {
enabled: false,
reason:
'route is public and provides information about the next product intercept trigger',
},
},
},
async (ctx, request, response) => {
const coreRequestHandlerCtx = await ctx.core;
const userId = coreRequestHandlerCtx.security.authc.getCurrentUser()?.profile_uid;
if (!userId) {
return response.forbidden();
}
const result = await this.getUserInteractionSavedObject(userId, request.params.triggerId);
return response.ok({
body: result?.attributes.metadata,
});
}
);
router.post(
{
path: TRIGGER_USER_INTERACTION_METADATA_API_ROUTE,
validate: {
params: schema.object({
triggerId: schema.string(),
}),
body: schema.object({
lastInteractedInterceptId: schema.number(),
}),
},
security: {
authz: {
enabled: false,
reason: 'This route delegates authorization to SO client.',
},
authc: {
enabled: true,
},
},
},
async (ctx, request, response) => {
const coreRequestHandlerCtx = await ctx.core;
const userId = coreRequestHandlerCtx.security.authc.getCurrentUser()?.profile_uid;
if (!userId) {
return response.forbidden();
}
await this.recordUserInteractionForTrigger(userId, request.params.triggerId, request.body);
return response.created();
}
);
}
public start(core: CoreStart) {
this.savedObjectsClient = core.savedObjects.createInternalRepository([
this.savedObjectRef.name,
]);
}
// returns an id scoped to the current user
private getSavedObjectId = (triggerId: string, userId: string) => `${triggerId}:${userId}`;
public async getUserInteractionSavedObject(
userId: string,
triggerId: string
): Promise<SavedObject<InterceptInteractionUserRecordAttributes> | null> {
assert.ok(this.savedObjectsClient, 'savedObjectsClient is not initialized');
try {
const userInteractionSavedObject =
await this.savedObjectsClient.get<InterceptInteractionUserRecordAttributes>(
this.savedObjectRef.name,
this.getSavedObjectId(triggerId, userId)
);
return userInteractionSavedObject;
} catch (e) {
// If the saved object is not found, return null
if (SavedObjectsErrorHelpers.isNotFoundError(e)) {
return null;
}
throw e;
}
}
public async recordUserInteractionForTrigger(
userId: string,
triggerId: string,
data: InterceptInteractionUserRecordAttributes['metadata']
) {
assert.ok(this.savedObjectsClient, 'savedObjectsClient is not initialized');
let interactionTriggerSavedObject = await this.getUserInteractionSavedObject(userId, triggerId);
const docId = this.getSavedObjectId(triggerId, userId);
if (!interactionTriggerSavedObject) {
interactionTriggerSavedObject = await this.savedObjectsClient.create(
this.savedObjectRef.name,
{ userId, triggerId, metadata: data },
{ id: docId }
);
} else {
interactionTriggerSavedObject =
(await this.savedObjectsClient.update<InterceptInteractionUserRecordAttributes>(
this.savedObjectRef.name,
docId,
{
...interactionTriggerSavedObject.attributes,
metadata: {
...interactionTriggerSavedObject.attributes.metadata,
...data,
},
},
{
version: interactionTriggerSavedObject.version,
}
)) as SavedObject<InterceptInteractionUserRecordAttributes>;
}
return interactionTriggerSavedObject?.attributes;
}
}

View file

@ -0,0 +1,31 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"common/**/*",
"public/**/*",
"public/**/*.json",
"server/**/*",
"test_helpers/**/*",
"storybook/**/*",
"../../../../../typings/**/*"
],
"kbn_references": [
"@kbn/core",
"@kbn/core-analytics-browser",
"@kbn/core-saved-objects-api-server",
"@kbn/config-schema",
"@kbn/i18n",
"@kbn/task-manager-plugin",
"@kbn/core-http-browser-mocks",
"@kbn/core-notifications-browser-mocks",
"@kbn/core-analytics-browser-mocks",
"@kbn/core-rendering-browser-mocks",
"@kbn/core-rendering-browser",
"@kbn/core-notifications-browser",
"@kbn/core-http-browser"
],
"exclude": ["target/**/*"]
}

View file

@ -0,0 +1,9 @@
## Product intercept plugin
This is a standalone plugin that leverages the intercept plugin to display product intercept used to gather information that is turn used to compute CSAT about user's experience of Kibana.
This plugin exposes no public APIs, but however exposes the following config
- `xpack.product_intercept.enabled`: Expects a boolean value, determines if the product intercept would be allowed to run given that the intercept plugin is enabled.
- `xpack.product_intercept.interval`: Expects a limited subset of duration string; (d,m,h,s) , denotes the cadence at which a user would be prompted to provide feedback about kibana

View file

@ -0,0 +1,43 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
import type { PluginConfigDescriptor, ExposedToBrowserDescriptor } from '@kbn/core/server';
/**
* Config used by plugin to determine if orchestration is invoked,
* and if the product intercept gets displayed on the client.
*/
export const configSchema = schema.object({
/**
* Whether the product intercept orchestration is enabled.
* It's worth noting that if the intercept plugin is disabled this setting will have no effect.
*/
enabled: schema.boolean({
defaultValue: false,
}),
interval: schema.string({
defaultValue: '30m',
validate(value) {
if (!/^[0-9]+(d|h|m|s)$/.test(value)) {
return 'must be a supported duration string';
}
},
}),
});
export type ServerConfigSchema = TypeOf<typeof configSchema>;
const browserConfigSchemaDescriptor: ExposedToBrowserDescriptor<ServerConfigSchema> = {
interval: false,
enabled: false,
};
export const config: PluginConfigDescriptor<ServerConfigSchema> = {
exposeToBrowser: browserConfigSchemaDescriptor,
schema: configSchema,
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export const TRIGGER_DEF_ID = 'productInterceptTrigger' as const;

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/platform/plugins/private/product_intercept'],
};

View file

@ -0,0 +1,16 @@
{
"type": "plugin",
"id": "@kbn/product-intercept-plugin",
"owner": ["@elastic/appex-sharedux"],
"group": "platform",
"visibility": "private",
"description": "This plugin leverages the intercepts plugin to in soliciting app-wide product feedback from users.",
"plugin": {
"id": "productIntercept",
"browser": true,
"server": true,
"requiredPlugins": ["intercepts"],
"requiredBundles": [],
"configPath": ["xpack", "product_intercept"]
}
}

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { NPSScoreInput } from './nps_score_input';

View file

@ -0,0 +1,71 @@
/*
* 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, { useState } from 'react';
import {
EuiButtonGroup,
EuiButtonGroupProps,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
} from '@elastic/eui';
interface NPSScoreInputProps {
/**
* default value here is 5
*/
upperBound?: number;
lowerBoundHelpText?: string;
upperBoundHelpText?: string;
onChange?: EuiButtonGroupProps['onChange'];
}
export function NPSScoreInput({
onChange,
lowerBoundHelpText,
upperBoundHelpText,
upperBound = 5,
}: NPSScoreInputProps) {
const options: EuiButtonGroupProps['options'] = Array.from({ length: upperBound }, (_, i) => {
const optionValue = i + 1;
return {
id: `nps-${optionValue}`,
label: optionValue,
value: optionValue,
};
});
const [selectedOption, setSelectedOption] = useState('');
return (
<EuiFormRow
helpText={
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<span>{lowerBoundHelpText}</span>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span>{upperBoundHelpText}</span>
</EuiFlexItem>
</EuiFlexGroup>
}
>
<EuiButtonGroup
legend="Survey about user satisfaction"
type="single"
options={options}
idSelected={selectedOption}
onChange={(id, value) => {
setSelectedOption(id);
onChange?.(value);
}}
isFullWidth
/>
</EuiFormRow>
);
}

View file

@ -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 { ProductInterceptPublicPlugin } from './plugin';
/**
* @internal
*/
export function plugin() {
return new ProductInterceptPublicPlugin();
}

View file

@ -0,0 +1,161 @@
/*
* 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 { 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 { InterceptsStart } from '@kbn/intercepts-plugin/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { NPSScoreInput } from './components';
import { PromptTelemetry } from './telemetry';
import { TRIGGER_DEF_ID } from '../common/constants';
interface ProductInterceptPluginStartDeps {
intercepts: InterceptsStart;
}
export class ProductInterceptPublicPlugin implements Plugin {
private readonly telemetry = new PromptTelemetry();
private interceptSubscription?: Subscription;
setup(core: CoreSetup) {
return this.telemetry.setup({ analytics: core.analytics });
}
start(core: CoreStart, { intercepts }: 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.',
})
),
},
{
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();
return {};
}
stop() {
this.interceptSubscription?.unsubscribe();
}
}

View file

@ -0,0 +1,98 @@
/*
* 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 { type RootSchema, type EventTypeOpts } from '@elastic/ebt/client';
export enum EventMetric {
PRODUCT_INTERCEPT_TERMINATION_INTERACTION = 'product_intercept_termination_interaction',
PRODUCT_INTERCEPT_PROGRESS_INTERACTION = 'product_intercept_interaction_progress',
PRODUCT_INTERCEPT_TRIGGER_FETCH_ERROR = 'product_intercept_trigger_fetch_error',
}
export enum EventFieldType {
INTERACTION_TYPE = 'interaction_type',
INTERCEPT_RUN_ID = 'interaction_run_id',
INTERACTION_METRIC = 'interaction_metric',
INTERACTION_METRIC_VALUE = 'interaction_metric_value',
TRIGGER_FETCH_ERROR_MESSAGE = 'trigger_fetch_error_message',
}
const fields: Record<EventFieldType, RootSchema<unknown>> = {
[EventFieldType.INTERCEPT_RUN_ID]: {
[EventFieldType.INTERCEPT_RUN_ID]: {
type: 'keyword',
_meta: {
description: 'The id of the product intercept run',
optional: false,
},
},
},
[EventFieldType.INTERACTION_TYPE]: {
[EventFieldType.INTERACTION_TYPE]: {
type: 'keyword',
_meta: {
description: 'The type of interaction that occurred with the intercept',
optional: false,
},
},
},
[EventFieldType.INTERACTION_METRIC]: {
[EventFieldType.INTERACTION_METRIC]: {
type: 'keyword',
_meta: {
description: 'The interaction metric id of of the product intercept',
optional: false,
},
},
},
[EventFieldType.INTERACTION_METRIC_VALUE]: {
[EventFieldType.INTERACTION_METRIC_VALUE]: {
type: 'long',
_meta: {
description: 'The value for the interaction metric id of of the product intercept',
optional: false,
},
},
},
[EventFieldType.TRIGGER_FETCH_ERROR_MESSAGE]: {
[EventFieldType.TRIGGER_FETCH_ERROR_MESSAGE]: {
type: 'text',
_meta: {
description: 'The error message from the trigger fetch',
optional: false,
},
},
},
};
/**
* @description defines all the event types that can be reported by the product intercept dialog,
* with the mapping that values provided will be ingested as within EBT
*/
export const eventTypes: Array<EventTypeOpts<Record<string, unknown>>> = [
{
eventType: EventMetric.PRODUCT_INTERCEPT_TERMINATION_INTERACTION,
schema: {
...fields[EventFieldType.INTERACTION_TYPE],
...fields[EventFieldType.INTERCEPT_RUN_ID],
},
},
{
eventType: EventMetric.PRODUCT_INTERCEPT_PROGRESS_INTERACTION,
schema: {
...fields[EventFieldType.INTERACTION_METRIC],
...fields[EventFieldType.INTERACTION_METRIC_VALUE],
...fields[EventFieldType.INTERCEPT_RUN_ID],
},
},
{
eventType: EventMetric.PRODUCT_INTERCEPT_TRIGGER_FETCH_ERROR,
schema: {
...fields[EventFieldType.TRIGGER_FETCH_ERROR_MESSAGE],
},
},
];

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { AnalyticsServiceStart, AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
import { EventMetric, EventFieldType, eventTypes } from './event_definitions';
export class PromptTelemetry {
private reportEvent?: AnalyticsServiceStart['reportEvent'];
public setup({ analytics }: { analytics: AnalyticsServiceSetup }) {
eventTypes.forEach((eventType) => {
analytics.registerEventType(eventType);
});
return {};
}
public start({ analytics }: { analytics: AnalyticsServiceStart }) {
this.reportEvent = analytics.reportEvent;
return {
reportInterceptInteraction: this.reportInterceptTermination,
reportInterceptInteractionProgress: this.reportInterceptInteractionProgress,
reportTriggerFetchError: this.reportTriggerFetchError,
};
}
private reportInterceptTermination({
interactionType,
interceptRunId,
}: {
interactionType: 'dismissal' | 'completion';
interceptRunId: number;
}) {
this.reportEvent?.(EventMetric.PRODUCT_INTERCEPT_TERMINATION_INTERACTION, {
[EventFieldType.INTERACTION_TYPE]: interactionType,
[EventFieldType.INTERCEPT_RUN_ID]: String(interceptRunId),
});
}
private reportInterceptInteractionProgress({
interceptRunId,
metricId,
value,
}: {
interceptRunId: number;
metricId: string;
value: number;
}) {
this.reportEvent?.(EventMetric.PRODUCT_INTERCEPT_PROGRESS_INTERACTION, {
[EventFieldType.INTERACTION_METRIC]: metricId,
[EventFieldType.INTERACTION_METRIC_VALUE]: value,
[EventFieldType.INTERCEPT_RUN_ID]: String(interceptRunId),
});
}
private reportTriggerFetchError({ errorMessage }: { errorMessage: string }) {
this.reportEvent?.(EventMetric.PRODUCT_INTERCEPT_TRIGGER_FETCH_ERROR, {
[EventFieldType.TRIGGER_FETCH_ERROR_MESSAGE]: errorMessage,
});
}
}

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { PromptTelemetry } from './event_reporter';

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { PluginInitializerContext } from '@kbn/core/server';
export { config } from '../common/config';
export const plugin = async (initContext: PluginInitializerContext) => {
const { ProductInterceptServerPlugin } = await import('./plugin');
return new ProductInterceptServerPlugin(initContext);
};

View file

@ -0,0 +1,55 @@
/*
* 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 {
Plugin,
type CoreSetup,
type CoreStart,
type PluginInitializerContext,
type Logger,
} from '@kbn/core/server';
import type { InterceptSetup, InterceptStart } from '@kbn/intercepts-plugin/server';
import { TRIGGER_DEF_ID } from '../common/constants';
import { ServerConfigSchema } from '../common/config';
interface ProductInterceptServerPluginSetup {
intercepts: InterceptSetup;
}
interface ProductInterceptServerPluginStart {
intercepts: InterceptStart;
}
/**
* @internal
*/
export class ProductInterceptServerPlugin
implements Plugin<object, object, ProductInterceptServerPluginSetup, never>
{
private readonly logger: Logger;
private readonly config: ServerConfigSchema;
constructor(initContext: PluginInitializerContext<unknown>) {
this.logger = initContext.logger.get();
this.config = initContext.config.get<ServerConfigSchema>();
}
setup(core: CoreSetup, {}: ProductInterceptServerPluginSetup) {
return {};
}
start(core: CoreStart, { intercepts }: ProductInterceptServerPluginStart) {
if (this.config.enabled) {
void intercepts.registerTriggerDefinition?.(TRIGGER_DEF_ID, () => {
this.logger.debug('Registering kibana product trigger definition');
return { triggerAfter: this.config.interval };
});
}
return {};
}
}

View file

@ -0,0 +1,24 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"common/**/*",
"public/**/*",
"public/**/*.json",
"server/**/*",
"test_helpers/**/*",
"storybook/**/*",
"../../../../../typings/**/*"
],
"kbn_references": [
"@kbn/core",
"@kbn/core-analytics-browser",
"@kbn/config-schema",
"@kbn/i18n",
"@kbn/intercepts-plugin",
"@kbn/i18n-react"
],
"exclude": ["target/**/*"]
}

View file

@ -181,6 +181,8 @@ 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',
],
},

View file

@ -5904,6 +5904,10 @@
version "0.0.0"
uid ""
"@kbn/intercepts-plugin@link:x-pack/platform/plugins/private/intercepts":
version "0.0.0"
uid ""
"@kbn/interpreter@link:src/platform/packages/shared/kbn-interpreter":
version "0.0.0"
uid ""
@ -6564,6 +6568,10 @@
version "0.0.0"
uid ""
"@kbn/product-intercept-plugin@link:x-pack/platform/plugins/private/product_intercept":
version "0.0.0"
uid ""
"@kbn/profiler-cli@link:x-pack/platform/packages/shared/kbn-profiler-cli":
version "0.0.0"
uid ""