A/B Testing via LaunchDarkly (#139212)

Co-authored-by: Luke Elmers <lukeelmers@gmail.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Alejandro Fernández Haro 2022-09-20 13:36:17 +02:00 committed by GitHub
parent 01daf31d04
commit 8c4e8b5e66
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2088 additions and 60 deletions

1
.github/CODEOWNERS vendored
View file

@ -265,6 +265,7 @@ x-pack/examples/files_example @elastic/kibana-app-services
/x-pack/plugins/licensing/ @elastic/kibana-core
/x-pack/plugins/global_search/ @elastic/kibana-core
/x-pack/plugins/cloud/ @elastic/kibana-core
/x-pack/plugins/cloud_integrations/ @elastic/kibana-core
/x-pack/plugins/saved_objects_tagging/ @elastic/kibana-core
/x-pack/test/saved_objects_field_count/ @elastic/kibana-core
/x-pack/test/saved_object_tagging/ @elastic/kibana-core

View file

@ -424,6 +424,10 @@ The plugin exposes the static DefaultEditorController class to consume.
|The cloud plugin adds Cloud-specific features to Kibana.
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_integrations/cloud_experiments/README.mdx[cloudExperiments]
|The Cloud Experiments Service provides the necessary APIs to implement A/B testing scenarios, fetching the variations in configuration and reporting back metrics to track conversion rates of the experiments.
|{kib-repo}blob/{branch}/x-pack/plugins/cloud_security_posture/README.md[cloudSecurityPosture]
|Cloud Posture automates the identification and remediation of risks across cloud infrastructures

View file

@ -503,6 +503,8 @@
"jsonwebtoken": "^8.3.0",
"jsts": "^1.6.2",
"kea": "^2.4.2",
"launchdarkly-js-client-sdk": "^2.22.1",
"launchdarkly-node-server-sdk": "^6.4.2",
"load-json-file": "^6.2.0",
"lodash": "^4.17.21",
"lru-cache": "^4.1.5",

View file

@ -10,6 +10,7 @@ pageLoadAssetSize:
cases: 144442
charts: 55000
cloud: 21076
cloudExperiments: 59358
cloudSecurityPosture: 19109
console: 46091
controls: 40000

View file

@ -89,6 +89,7 @@ export const PROJECTS = [
'src/plugins/chart_expressions/*/tsconfig.json',
'src/plugins/vis_types/*/tsconfig.json',
'x-pack/plugins/*/tsconfig.json',
'x-pack/plugins/cloud_integrations/*/tsconfig.json',
'examples/*/tsconfig.json',
'x-pack/examples/*/tsconfig.json',
'test/analytics/fixtures/plugins/*/tsconfig.json',

View file

@ -313,6 +313,8 @@
"@kbn/canvas-plugin/*": ["x-pack/plugins/canvas/*"],
"@kbn/cases-plugin": ["x-pack/plugins/cases"],
"@kbn/cases-plugin/*": ["x-pack/plugins/cases/*"],
"@kbn/cloud-experiments-plugin": ["x-pack/plugins/cloud_integrations/cloud_experiments"],
"@kbn/cloud-experiments-plugin/*": ["x-pack/plugins/cloud_integrations/cloud_experiments/*"],
"@kbn/cloud-security-posture-plugin": ["x-pack/plugins/cloud_security_posture"],
"@kbn/cloud-security-posture-plugin/*": ["x-pack/plugins/cloud_security_posture/*"],
"@kbn/cloud-plugin": ["x-pack/plugins/cloud"],

View file

@ -7,7 +7,7 @@
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "cloud"],
"optionalPlugins": ["usageCollection", "home", "security"],
"optionalPlugins": ["cloudExperiments", "usageCollection", "home", "security"],
"server": true,
"ui": true
}

View file

@ -12,6 +12,21 @@ import { coreMock } from '@kbn/core/public/mocks';
import { homePluginMock } from '@kbn/home-plugin/public/mocks';
import { securityMock } from '@kbn/security-plugin/public/mocks';
import { CloudPlugin, type CloudConfigType } from './plugin';
import { CloudExperimentsPluginSetup } from '@kbn/cloud-experiments-plugin/common';
import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks';
const baseConfig = {
base_url: 'https://cloud.elastic.co',
deployment_url: '/abc123',
profile_url: '/user/settings/',
organization_url: '/account/',
full_story: {
enabled: false,
},
chat: {
enabled: false,
},
};
describe('Cloud Plugin', () => {
describe('#setup', () => {
@ -22,17 +37,8 @@ describe('Cloud Plugin', () => {
const setupPlugin = async ({ config = {} }: { config?: Partial<CloudConfigType> }) => {
const initContext = coreMock.createPluginInitializerContext({
...baseConfig,
id: 'cloudId',
base_url: 'https://cloud.elastic.co',
deployment_url: '/abc123',
profile_url: '/profile/alice',
organization_url: '/org/myOrg',
full_story: {
enabled: false,
},
chat: {
enabled: false,
},
...config,
});
@ -92,16 +98,7 @@ describe('Cloud Plugin', () => {
currentUserProps?: Record<string, any> | Error;
}) => {
const initContext = coreMock.createPluginInitializerContext({
base_url: 'https://cloud.elastic.co',
deployment_url: '/abc123',
profile_url: '/profile/alice',
organization_url: '/org/myOrg',
full_story: {
enabled: false,
},
chat: {
enabled: false,
},
...baseConfig,
...config,
});
@ -249,17 +246,8 @@ describe('Cloud Plugin', () => {
failHttp?: boolean;
}) => {
const initContext = coreMock.createPluginInitializerContext({
...baseConfig,
id: isCloudEnabled ? 'cloud-id' : null,
base_url: 'https://cloud.elastic.co',
deployment_url: '/abc123',
profile_url: '/profile/alice',
organization_url: '/org/myOrg',
full_story: {
enabled: false,
},
chat: {
enabled: false,
},
...config,
});
@ -322,18 +310,9 @@ describe('Cloud Plugin', () => {
describe('interface', () => {
const setupPlugin = () => {
const initContext = coreMock.createPluginInitializerContext({
...baseConfig,
id: 'cloudId',
cname: 'cloud.elastic.co',
base_url: 'https://cloud.elastic.co',
deployment_url: '/abc123',
profile_url: '/user/settings/',
organization_url: '/account/',
chat: {
enabled: false,
},
full_story: {
enabled: false,
},
});
const plugin = new CloudPlugin(initContext);
@ -383,6 +362,50 @@ describe('Cloud Plugin', () => {
expect(setup.cname).toBe('cloud.elastic.co');
});
});
describe('Set up cloudExperiments', () => {
describe('when cloud ID is not provided in the config', () => {
let cloudExperiments: jest.Mocked<CloudExperimentsPluginSetup>;
beforeEach(() => {
const plugin = new CloudPlugin(coreMock.createPluginInitializerContext(baseConfig));
cloudExperiments = cloudExperimentsMock.createSetupMock();
plugin.setup(coreMock.createSetup(), { cloudExperiments });
});
test('does not call cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser).not.toHaveBeenCalled();
});
});
describe('when cloud ID is provided in the config', () => {
let cloudExperiments: jest.Mocked<CloudExperimentsPluginSetup>;
beforeEach(() => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext({ ...baseConfig, id: 'cloud test' })
);
cloudExperiments = cloudExperimentsMock.createSetupMock();
plugin.setup(coreMock.createSetup(), { cloudExperiments });
});
test('calls cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser).toHaveBeenCalledTimes(1);
});
test('the cloud ID is hashed when calling cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser.mock.calls[0][0]).toEqual(
'1acb4a1cc1c3d672a8d826055d897c2623ceb1d4fb07e46d97986751a36b06cf'
);
});
test('specifies the Kibana version when calling cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser.mock.calls[0][1]).toEqual(
expect.objectContaining({
kibanaVersion: 'version',
})
);
});
});
});
});
describe('#start', () => {

View file

@ -22,6 +22,7 @@ import { BehaviorSubject, catchError, from, map, of } from 'rxjs';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import { HomePublicPluginSetup } from '@kbn/home-plugin/public';
import { Sha256 } from '@kbn/crypto-browser';
import type { CloudExperimentsPluginSetup } from '@kbn/cloud-experiments-plugin/common';
import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
import {
@ -58,6 +59,7 @@ export interface CloudConfigType {
interface CloudSetupDependencies {
home?: HomePublicPluginSetup;
security?: Pick<SecurityPluginSetup, 'authc'>;
cloudExperiments?: CloudExperimentsPluginSetup;
}
interface CloudStartDependencies {
@ -93,15 +95,15 @@ interface SetupChatDeps extends Pick<CloudSetupDependencies, 'security'> {
export class CloudPlugin implements Plugin<CloudSetup> {
private readonly config: CloudConfigType;
private isCloudEnabled: boolean;
private readonly isCloudEnabled: boolean;
private chatConfig$ = new BehaviorSubject<ChatConfig>({ enabled: false });
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<CloudConfigType>();
this.isCloudEnabled = false;
this.isCloudEnabled = getIsCloudEnabled(this.config.id);
}
public setup(core: CoreSetup, { home, security }: CloudSetupDependencies) {
public setup(core: CoreSetup, { cloudExperiments, home, security }: CloudSetupDependencies) {
this.setupTelemetryContext(core.analytics, security, this.config.id);
this.setupFullStory({ analytics: core.analytics, basePath: core.http.basePath }).catch((e) =>
@ -118,7 +120,12 @@ export class CloudPlugin implements Plugin<CloudSetup> {
base_url: baseUrl,
} = this.config;
this.isCloudEnabled = getIsCloudEnabled(id);
if (this.isCloudEnabled && id) {
// We use the Hashed Cloud Deployment ID as the userId in the Cloud Experiments
cloudExperiments?.identifyUser(sha256(id), {
kibanaVersion: this.initializerContext.env.packageInfo.version,
});
}
this.setupChat({ http: core.http, security }).catch((e) =>
// eslint-disable-next-line no-console

View file

@ -10,6 +10,8 @@ import { CloudPlugin } from './plugin';
import { config } from './config';
import { securityMock } from '@kbn/security-plugin/server/mocks';
import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks';
import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks';
import { CloudExperimentsPluginSetup } from '@kbn/cloud-experiments-plugin/common';
describe('Cloud Plugin', () => {
describe('#setup', () => {
@ -66,5 +68,51 @@ describe('Cloud Plugin', () => {
expect(securityDependencyMock.setIsElasticCloudDeployment).toHaveBeenCalledTimes(1);
});
});
describe('Set up cloudExperiments', () => {
describe('when cloud ID is not provided in the config', () => {
let cloudExperiments: jest.Mocked<CloudExperimentsPluginSetup>;
beforeEach(() => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext(config.schema.validate({}))
);
cloudExperiments = cloudExperimentsMock.createSetupMock();
plugin.setup(coreMock.createSetup(), { cloudExperiments });
});
test('does not call cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser).not.toHaveBeenCalled();
});
});
describe('when cloud ID is provided in the config', () => {
let cloudExperiments: jest.Mocked<CloudExperimentsPluginSetup>;
beforeEach(() => {
const plugin = new CloudPlugin(
coreMock.createPluginInitializerContext(config.schema.validate({ id: 'cloud test' }))
);
cloudExperiments = cloudExperimentsMock.createSetupMock();
plugin.setup(coreMock.createSetup(), { cloudExperiments });
});
test('calls cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser).toHaveBeenCalledTimes(1);
});
test('the cloud ID is hashed when calling cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser.mock.calls[0][0]).toEqual(
'1acb4a1cc1c3d672a8d826055d897c2623ceb1d4fb07e46d97986751a36b06cf'
);
});
test('specifies the Kibana version when calling cloudExperiments.identifyUser', async () => {
expect(cloudExperiments.identifyUser.mock.calls[0][1]).toEqual(
expect.objectContaining({
kibanaVersion: 'version',
})
);
});
});
});
});
});

View file

@ -8,6 +8,8 @@
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { CoreSetup, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server';
import type { SecurityPluginSetup } from '@kbn/security-plugin/server';
import type { CloudExperimentsPluginSetup } from '@kbn/cloud-experiments-plugin/common';
import { createSHA256Hash } from '@kbn/crypto';
import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context';
import { CloudConfigType } from './config';
import { registerCloudUsageCollector } from './collectors';
@ -20,6 +22,7 @@ import { readInstanceSizeMb } from './env';
interface PluginsSetup {
usageCollection?: UsageCollectionSetup;
security?: SecurityPluginSetup;
cloudExperiments?: CloudExperimentsPluginSetup;
}
export interface CloudSetup {
@ -44,7 +47,10 @@ export class CloudPlugin implements Plugin<CloudSetup> {
this.isDev = this.context.env.mode.dev;
}
public setup(core: CoreSetup, { usageCollection, security }: PluginsSetup): CloudSetup {
public setup(
core: CoreSetup,
{ cloudExperiments, usageCollection, security }: PluginsSetup
): CloudSetup {
this.logger.debug('Setting up Cloud plugin');
const isCloudEnabled = getIsCloudEnabled(this.config.id);
registerCloudDeploymentIdAnalyticsContext(core.analytics, this.config.id);
@ -54,6 +60,13 @@ export class CloudPlugin implements Plugin<CloudSetup> {
security?.setIsElasticCloudDeployment();
}
if (isCloudEnabled && this.config.id) {
// We use the Cloud ID as the userId in the Cloud Experiments
cloudExperiments?.identifyUser(createSHA256Hash(this.config.id), {
kibanaVersion: this.context.env.packageInfo.version,
});
}
if (this.config.full_story.enabled) {
registerFullstoryRoute({
httpResources: core.http.resources,

View file

@ -17,6 +17,7 @@
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../../../src/plugins/usage_collection/tsconfig.json" },
{ "path": "../../../src/plugins/home/tsconfig.json" },
{ "path": "../cloud_integrations/cloud_experiments/tsconfig.json" },
{ "path": "../security/tsconfig.json" },
]
}

View file

@ -0,0 +1,179 @@
---
id: kibCloudExperimentsPlugin
slug: /kibana-dev-docs/key-concepts/cloud-experiments-plugin
title: Cloud Experiments service
description: The Cloud Experiments Service provides the necessary APIs to implement A/B testing scenarios, fetching the variations in configuration and reporting back metrics to track conversion rates of the experiments.
date: 2022-09-07
tags: ['kibana', 'dev', 'contributor', 'api docs', 'cloud', 'a/b testing', 'experiments']
---
# Kibana Cloud Experiments Service
The Cloud Experiments Service provides the necessary APIs to implement A/B testing scenarios, fetching the variations in configuration and reporting back metrics to track conversion rates of the experiments.
The `cloudExperiments` plugin is disabled by default and only enabled on Elastic Cloud deployments.
## Public API
If you are developing a feature that needs to use a feature flag, or you are implementing an A/B-testing scenario, this is how you should fetch the value of your feature flags (for either server and browser side code):
First, you should declare the optional dependency on this plugin. Do not list it in your `requiredPlugins`, as this plugin is disabled by default and only enabled in Cloud deployments. Adding it to your `requiredPlugins` will cause Kibana to refuse to start by default.
```json
// plugin/kibana.json
{
"id": "myPlugin",
"optionalPlugins": ["cloudExperiments"]
}
```
Please, be aware that your plugin will run even when the `cloudExperiment` plugin is disabled. Make sure to declare it as an optional dependency in your plugin's TypeScript contract to remind you that it might not always be available.
### Fetching the value of the feature flags
First, make sure that your feature flag is listed in [`FEATURE_FLAG_NAMES`](./common/constants.ts).
Then, you can fetch the value of your feature flag by using the API `cloudExperiments.getVariation` as follows:
```ts
import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/(public|server)';
import type {
CloudExperimentsPluginSetup,
CloudExperimentsPluginStart
} from '@kbn/cloud-experiments-plugin/common';
interface SetupDeps {
cloudExperiments?: CloudExperimentsPluginSetup;
}
interface StartDeps {
cloudExperiments?: CloudExperimentsPluginStart;
}
export class MyPlugin implements Plugin<void, void, SetupDeps, StartDeps> {
public setup(core: CoreSetup, deps: SetupDeps) {
this.doSomethingBasedOnFeatureFlag(deps.cloudExperiments);
}
public start(core: CoreStart, deps: StartDeps) {
this.doSomethingBasedOnFeatureFlag(deps.cloudExperiments);
}
private async doSomethingBasedOnFeatureFlag(cloudExperiments?: CloudExperimentsPluginStart) {
let myConfig = 'default config';
if (cloudExperiments) {
myConfig = await cloudExperiments.getVariation(
'my-plugin.my-feature-flag', // The key 'my-plugin.my-feature-flag' should exist in FEATURE_FLAG_NAMES
'default config'
);
}
// do something with the final value of myConfig...
}
}
```
Since the `getVariation` API returns a promise, when using it in a React component, you may want to use the hook `useEffect`.
```tsx
import React, { useEffect, useState } from 'react';
import type {
CloudExperimentsFeatureFlagNames,
CloudExperimentsPluginStart
} from '@kbn/cloud-experiments-plugin/common';
interface Props {
cloudExperiments?: CloudExperimentsPluginStart;
}
const useVariation = <Data>(
cloudExperiments: CloudExperimentsPluginStart | undefined,
featureFlagName: CloudExperimentsFeatureFlagNames,
defaultValue: Data,
setter: (value: Data) => void
) => {
useEffect(() => {
(async function loadVariation() {
const variationUrl = await cloudExperiments?.getVariation(featureFlagName, defaultValue);
if (variationUrl) {
setter(variationUrl);
}
})();
}, [cloudExperiments, featureFlagName, defaultValue, setter]);
};
export const MyReactComponent: React.FC<Props> = ({ cloudExperiments }: Props) => {
const [myConfig, setMyConfig] = useState('default config');
useVariation(
cloudExperiments,
'my-plugin.my-feature-flag', // The key 'my-plugin.my-feature-flag' should exist in FEATURE_FLAG_NAMES
'default config',
setMyConfig
);
// use myConfig in the component...
}
```
### Reporting metrics
Experiments require feedback to analyze which variation to the feature flag is the most successful. For this reason, we need to report some metrics defined in the success criteria of the experiment (check back with your PM if they are unclear).
Our A/B testing provider allows some high-level analysis of the experiment based on the metrics. It also has some limitations about how it handles some type of metrics like number of objects or size of indices. For this reason, you might want to consider shipping the metrics via our usual telemetry channels (`core.analytics` for event-based metrics, or <DocLink id="kibUsageCollectionPlugin" />).
However, if our A/B testing provider's analysis tool is good enough for your use case, you can use the api `reportMetric` as follows.
First, make sure to add the metric name in [`METRIC_NAMES`](./common/constants.ts). Then you can use it like below:
```ts
import type { CoreStart, Plugin } from '@kbn/core/(public|server)';
import type {
CloudExperimentsPluginSetup,
CloudExperimentsPluginStart
} from '@kbn/cloud-experiments-plugin/common';
interface SetupDeps {
cloudExperiments?: CloudExperimentsPluginSetup;
}
interface StartDeps {
cloudExperiments?: CloudExperimentsPluginStart;
}
export class MyPlugin implements Plugin<void, void, SetupDeps, StartDeps> {
public start(core: CoreStart, deps: StartDeps) {
// whenever we need to report any metrics:
// the user performed some action,
// or a metric hit a threshold we want to communicate about
deps.cloudExperiments?.reportMetric({
name: 'Something happened', // The key 'Something happened' should exist in METRIC_NAMES
value: 22, // (optional) in case the metric requires a numeric metric
meta: { // Optional metadata.
hadSomething: true,
userType: 'type 1',
otherNumericField: 1,
}
})
}
}
```
### Testing
To test your code locally when developing the A/B scenarios, this plugin accepts a custom config to skip the A/B provider calls and return the values. Use the following `kibana.dev.yml` configuration as an example:
```yml
xpack.cloud_integrations.experiments.enabled: true
xpack.cloud_integrations.experiments.flag_overrides:
"my-plugin.my-feature-flag": "my custom value"
```
### How is my user identified?
The user is automatically identified during the `setup` phase. It currently uses a hash of the deployment ID, meaning all users accessing the same deployment will get the same values for the `getVariation` requests unless the A/B provider is explicitly configured to randomize it.
If you are curious of the data provided to the `identify` call, you can see that in the [`cloud` plugin](../../cloud).
---
## Development
See the [kibana contributing guide](https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md) for instructions setting up your development environment.

View file

@ -0,0 +1,25 @@
/*
* 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 { FEATURE_FLAG_NAMES, METRIC_NAMES } from './constants';
function removeDuplicates(obj: Record<string, string>) {
return [...new Set(Object.values(obj))];
}
describe('constants', () => {
describe('FEATURE_FLAG_NAMES', () => {
test('the values should not include duplicates', () => {
expect(Object.values(FEATURE_FLAG_NAMES)).toStrictEqual(removeDuplicates(FEATURE_FLAG_NAMES));
});
});
describe('METRIC_NAMES', () => {
test('the values should not include duplicates', () => {
expect(Object.values(METRIC_NAMES)).toStrictEqual(removeDuplicates(METRIC_NAMES));
});
});
});

View file

@ -0,0 +1,36 @@
/*
* 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.
*/
/**
* List of feature flag names used in Kibana.
*
* Feel free to add/remove entries if needed.
*
* As a convention, the key and the value have the same string.
*
* @remarks Kept centralized in this place to serve as a repository
* to help devs understand if there is someone else already using it.
*/
export enum FEATURE_FLAG_NAMES {
/**
* Used in the Security Solutions onboarding page.
* It resolves the URL that the button "Add Integrations" will point to.
*/
'security-solutions.add-integrations-url' = 'security-solutions.add-integrations-url',
}
/**
* List of LaunchDarkly metric names used in Kibana.
*
* Feel free to add/remove entries if needed.
*
* As a convention, the key and the value have the same string.
*
* @remarks Kept centralized in this place to serve as a repository
* to help devs understand if there is someone else already using it.
*/
export enum METRIC_NAMES {}

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.
*/
export type {
CloudExperimentsMetric,
CloudExperimentsMetricNames,
CloudExperimentsPluginStart,
CloudExperimentsPluginSetup,
CloudExperimentsFeatureFlagNames,
} from './types';

View file

@ -0,0 +1,26 @@
/*
* 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 { CloudExperimentsPluginSetup, CloudExperimentsPluginStart } from './types';
function createStartMock(): jest.Mocked<CloudExperimentsPluginStart> {
return {
getVariation: jest.fn(),
reportMetric: jest.fn(),
};
}
function createSetupMock(): jest.Mocked<CloudExperimentsPluginSetup> {
return {
identifyUser: jest.fn(),
};
}
export const cloudExperimentsMock = {
createSetupMock,
createStartMock,
};

View file

@ -0,0 +1,92 @@
/*
* 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 { FEATURE_FLAG_NAMES, METRIC_NAMES } from './constants';
/**
* The contract of the setup lifecycle method.
*
* @public
*/
export interface CloudExperimentsPluginSetup {
/**
* Identifies the user in the A/B testing service.
* For now, we only rely on the user ID. In the future, we may request further details for more targeted experiments.
* @param userId The unique identifier of the user in the experiment.
* @param userMetadata Additional attributes to the user. Take care to ensure these values do not contain PII.
*
* @deprecated This API will become internal as soon as we reduce the dependency graph of the `cloud` plugin,
* and this plugin depends on it to fetch the data.
*/
identifyUser: (
userId: string,
userMetadata?: Record<string, string | boolean | number | Array<string | boolean | number>>
) => void;
}
/**
* The names of the feature flags declared in Kibana.
* Valid keys are defined in {@link FEATURE_FLAG_NAMES}. When using a new feature flag, add the name to the list.
*
* @public
*/
export type CloudExperimentsFeatureFlagNames = keyof typeof FEATURE_FLAG_NAMES;
/**
* The contract of the start lifecycle method
*
* @public
*/
export interface CloudExperimentsPluginStart {
/**
* Fetch the configuration assigned to variation `configKey`. If nothing is found, fallback to `defaultValue`.
* @param featureFlagName The name of the key to find the config variation. {@link CloudExperimentsFeatureFlagNames}.
* @param defaultValue The fallback value in case no variation is found.
*
* @public
*/
getVariation: <Data>(
featureFlagName: CloudExperimentsFeatureFlagNames,
defaultValue: Data
) => Promise<Data>;
/**
* Report metrics back to the A/B testing service to measure the conversion rate for each variation in the experiment.
* @param metric {@link CloudExperimentsMetric}
*
* @public
*/
reportMetric: <Data>(metric: CloudExperimentsMetric<Data>) => void;
}
/**
* The names of the metrics declared in Kibana.
* Valid keys are defined in {@link METRIC_NAMES}. When reporting a new metric, add the name to the list.
*
* @public
*/
export type CloudExperimentsMetricNames = keyof typeof METRIC_NAMES;
/**
* Definition of the metric to report back to the A/B testing service to measure the conversions.
*
* @public
*/
export interface CloudExperimentsMetric<Data> {
/**
* The name of the metric {@link CloudExperimentsMetricNames}
*/
name: CloudExperimentsMetricNames;
/**
* Any optional data to enrich the context of the metric. Or if the conversion is based on a non-numeric value.
*/
meta?: Data;
/**
* The numeric value of the metric. Bear in mind that they are averaged by the underlying solution.
* Typical values to report here are time-to-action, number of panels in a loaded dashboard, and page load time.
*/
value?: number;
}

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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../',
roots: ['<rootDir>/x-pack/plugins/cloud_integrations/cloud_experiments'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/cloud_integrations/cloud_experiments',
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/x-pack/plugins/cloud_integrations/cloud_experiments/{common,public,server}/**/*.{ts,tsx}',
],
};

View file

@ -0,0 +1,15 @@
{
"id": "cloudExperiments",
"version": "1.0.0",
"kibanaVersion": "kibana",
"owner": {
"name": "Kibana Core",
"githubTeam": "@elastic/kibana-core"
},
"description": "Provides the necessary APIs to implement A/B testing scenarios, fetching the variations in configuration and reporting back metrics to track conversion rates of the experiments.",
"server": true,
"ui": true,
"configPath": ["xpack", "cloud_integrations", "experiments"],
"requiredPlugins": [],
"optionalPlugins": ["usageCollection"]
}

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.
*/
import type { PluginInitializerContext } from '@kbn/core/public';
import { CloudExperimentsPlugin } from './plugin';
export function plugin(core: PluginInitializerContext) {
return new CloudExperimentsPlugin(core);
}

View file

@ -0,0 +1,24 @@
/*
* 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 { LDClient } from 'launchdarkly-js-client-sdk';
export function createLaunchDarklyClientMock(): jest.Mocked<LDClient> {
return {
waitForInitialization: jest.fn(),
variation: jest.fn(),
track: jest.fn(),
identify: jest.fn(),
flush: jest.fn(),
} as unknown as jest.Mocked<LDClient>; // Using casting because we only use these APIs. No need to declare everything.
}
export const ldClientMock = createLaunchDarklyClientMock();
jest.doMock('launchdarkly-js-client-sdk', () => ({
initialize: () => ldClientMock,
}));

View file

@ -0,0 +1,274 @@
/*
* 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/public/mocks';
import { ldClientMock } from './plugin.test.mock';
import { CloudExperimentsPlugin } from './plugin';
import { FEATURE_FLAG_NAMES } from '../common/constants';
describe('Cloud Experiments public plugin', () => {
jest.spyOn(console, 'debug').mockImplementation(); // silence console.debug logs
beforeEach(() => {
jest.resetAllMocks();
});
describe('constructor', () => {
test('successfully creates a new plugin if provided an empty configuration', () => {
const initializerContext = coreMock.createPluginInitializerContext();
// @ts-expect-error it's defined as readonly but the mock is not.
initializerContext.env.mode.dev = true; // ensure it's true
const plugin = new CloudExperimentsPlugin(initializerContext);
expect(plugin).toHaveProperty('setup');
expect(plugin).toHaveProperty('start');
expect(plugin).toHaveProperty('stop');
expect(plugin).toHaveProperty('flagOverrides', undefined);
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
});
test('fails if launch_darkly is not provided in the config and it is a non-dev environment', () => {
const initializerContext = coreMock.createPluginInitializerContext();
// @ts-expect-error it's defined as readonly but the mock is not.
initializerContext.env.mode.dev = false;
expect(() => new CloudExperimentsPlugin(initializerContext)).toThrowError(
'xpack.cloud_integrations.experiments.launch_darkly configuration should exist'
);
});
test('it initializes the flagOverrides property', () => {
const initializerContext = coreMock.createPluginInitializerContext({
flag_overrides: { my_flag: '1234' },
});
// @ts-expect-error it's defined as readonly but the mock is not.
initializerContext.env.mode.dev = true; // ensure it's true
const plugin = new CloudExperimentsPlugin(initializerContext);
expect(plugin).toHaveProperty('flagOverrides', { my_flag: '1234' });
});
});
describe('setup', () => {
let plugin: CloudExperimentsPlugin;
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext({
launch_darkly: { client_id: '1234' },
flag_overrides: { my_flag: '1234' },
});
plugin = new CloudExperimentsPlugin(initializerContext);
});
test('returns the contract', () => {
const setupContract = plugin.setup(coreMock.createSetup());
expect(setupContract).toStrictEqual(
expect.objectContaining({
identifyUser: expect.any(Function),
})
);
});
describe('identifyUser', () => {
test('it skips creating the client if no client id provided in the config', () => {
const initializerContext = coreMock.createPluginInitializerContext({
flag_overrides: { my_flag: '1234' },
});
const customPlugin = new CloudExperimentsPlugin(initializerContext);
const setupContract = customPlugin.setup(coreMock.createSetup());
expect(customPlugin).toHaveProperty('launchDarklyClient', undefined);
setupContract.identifyUser('user-id', {});
expect(customPlugin).toHaveProperty('launchDarklyClient', undefined);
});
test('it initializes the LaunchDarkly client', () => {
const setupContract = plugin.setup(coreMock.createSetup());
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
setupContract.identifyUser('user-id', {});
expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock);
expect(ldClientMock.identify).not.toHaveBeenCalled();
});
test('it calls identify if the client already exists', () => {
const setupContract = plugin.setup(coreMock.createSetup());
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
setupContract.identifyUser('user-id', {});
expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock);
expect(ldClientMock.identify).not.toHaveBeenCalled();
ldClientMock.identify.mockResolvedValue({}); // ensure it's a promise
setupContract.identifyUser('user-id', {});
expect(ldClientMock.identify).toHaveBeenCalledTimes(1);
});
test('it handles identify rejections', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
const setupContract = plugin.setup(coreMock.createSetup());
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
setupContract.identifyUser('user-id', {});
expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock);
expect(ldClientMock.identify).not.toHaveBeenCalled();
const error = new Error('Something went terribly wrong');
ldClientMock.identify.mockRejectedValue(error);
setupContract.identifyUser('user-id', {});
expect(ldClientMock.identify).toHaveBeenCalledTimes(1);
await new Promise((resolve) => process.nextTick(resolve));
expect(consoleWarnSpy).toHaveBeenCalledWith(error);
});
});
});
describe('start', () => {
let plugin: CloudExperimentsPlugin;
const firstKnownFlag = Object.keys(FEATURE_FLAG_NAMES)[0] as keyof typeof FEATURE_FLAG_NAMES;
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext({
launch_darkly: { client_id: '1234' },
flag_overrides: { [firstKnownFlag]: '1234' },
});
plugin = new CloudExperimentsPlugin(initializerContext);
});
test('returns the contract', () => {
plugin.setup(coreMock.createSetup());
const startContract = plugin.start(coreMock.createStart());
expect(startContract).toStrictEqual(
expect.objectContaining({
getVariation: expect.any(Function),
reportMetric: expect.any(Function),
})
);
});
describe('getVariation', () => {
describe('with the user identified', () => {
beforeEach(() => {
const setupContract = plugin.setup(coreMock.createSetup());
setupContract.identifyUser('user-id', {});
});
test('uses the flag overrides to respond early', async () => {
const startContract = plugin.start(coreMock.createStart());
await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual(
'1234'
);
});
test('calls the client', async () => {
const startContract = plugin.start(coreMock.createStart());
ldClientMock.variation.mockReturnValue('12345');
await expect(
startContract.getVariation(
// @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES
'some-random-flag',
123
)
).resolves.toStrictEqual('12345');
expect(ldClientMock.variation).toHaveBeenCalledWith(
undefined, // it couldn't find it in FEATURE_FLAG_NAMES
123
);
});
});
describe('with the user not identified', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup());
});
test('uses the flag overrides to respond early', async () => {
const startContract = plugin.start(coreMock.createStart());
await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual(
'1234'
);
});
test('returns the default value without calling the client', async () => {
const startContract = plugin.start(coreMock.createStart());
await expect(
startContract.getVariation(
// @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES
'some-random-flag',
123
)
).resolves.toStrictEqual(123);
expect(ldClientMock.variation).not.toHaveBeenCalled();
});
});
});
describe('reportMetric', () => {
describe('with the user identified', () => {
beforeEach(() => {
const setupContract = plugin.setup(coreMock.createSetup());
setupContract.identifyUser('user-id', {});
});
test('calls the track API', () => {
const startContract = plugin.start(coreMock.createStart());
startContract.reportMetric({
// @ts-expect-error We only allow existing flags in METRIC_NAMES
name: 'my-flag',
meta: {},
value: 1,
});
expect(ldClientMock.track).toHaveBeenCalledWith(
undefined, // it couldn't find it in METRIC_NAMES
{},
1
);
});
});
describe('with the user not identified', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup());
});
test('calls the track API', () => {
const startContract = plugin.start(coreMock.createStart());
startContract.reportMetric({
// @ts-expect-error We only allow existing flags in METRIC_NAMES
name: 'my-flag',
meta: {},
value: 1,
});
expect(ldClientMock.track).not.toHaveBeenCalled();
});
});
});
});
describe('stop', () => {
let plugin: CloudExperimentsPlugin;
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext({
launch_darkly: { client_id: '1234' },
flag_overrides: { my_flag: '1234' },
});
plugin = new CloudExperimentsPlugin(initializerContext);
const setupContract = plugin.setup(coreMock.createSetup());
setupContract.identifyUser('user-id', {});
plugin.start(coreMock.createStart());
});
test('flushes the events on stop', () => {
ldClientMock.flush.mockResolvedValue();
expect(() => plugin.stop()).not.toThrow();
expect(ldClientMock.flush).toHaveBeenCalledTimes(1);
});
test('handles errors when flushing events', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
const error = new Error('Something went terribly wrong');
ldClientMock.flush.mockRejectedValue(error);
expect(() => plugin.stop()).not.toThrow();
expect(ldClientMock.flush).toHaveBeenCalledTimes(1);
await new Promise((resolve) => process.nextTick(resolve));
expect(consoleWarnSpy).toHaveBeenCalledWith(error);
});
});
});

View file

@ -0,0 +1,127 @@
/*
* 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, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import LaunchDarkly, { type LDClient } from 'launchdarkly-js-client-sdk';
import { get, has } from 'lodash';
import type {
CloudExperimentsFeatureFlagNames,
CloudExperimentsMetric,
CloudExperimentsPluginSetup,
CloudExperimentsPluginStart,
} from '../common';
import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants';
/**
* Browser-side implementation of the Cloud Experiments plugin
*/
export class CloudExperimentsPlugin
implements Plugin<CloudExperimentsPluginSetup, CloudExperimentsPluginStart>
{
private launchDarklyClient?: LDClient;
private readonly clientId?: string;
private readonly kibanaVersion: string;
private readonly flagOverrides?: Record<string, unknown>;
private readonly isDev: boolean;
/** Constructor of the plugin **/
constructor(initializerContext: PluginInitializerContext) {
this.isDev = initializerContext.env.mode.dev;
this.kibanaVersion = initializerContext.env.packageInfo.version;
const config = initializerContext.config.get<{
launch_darkly?: { client_id: string };
flag_overrides?: Record<string, unknown>;
}>();
if (config.flag_overrides) {
this.flagOverrides = config.flag_overrides;
}
const ldConfig = config.launch_darkly;
if (!ldConfig && !initializerContext.env.mode.dev) {
// If the plugin is enabled, and it's in prod mode, launch_darkly must exist
// (config-schema should enforce it, but just in case).
throw new Error(
'xpack.cloud_integrations.experiments.launch_darkly configuration should exist'
);
}
if (ldConfig) {
this.clientId = ldConfig.client_id;
}
}
/**
* Returns the contract {@link CloudExperimentsPluginSetup}
* @param core {@link CoreSetup}
*/
public setup(core: CoreSetup): CloudExperimentsPluginSetup {
return {
identifyUser: (userId, userMetadata) => {
if (!this.clientId) return; // Only applies in dev mode.
if (!this.launchDarklyClient) {
// If the client has not been initialized, create it with the user data..
this.launchDarklyClient = LaunchDarkly.initialize(
this.clientId,
{ key: userId, custom: userMetadata },
{ application: { id: 'kibana-browser', version: this.kibanaVersion } }
);
} else {
// Otherwise, call the `identify` method.
this.launchDarklyClient
.identify({ key: userId, custom: userMetadata })
// eslint-disable-next-line no-console
.catch((err) => console.warn(err));
}
},
};
}
/**
* Returns the contract {@link CloudExperimentsPluginStart}
* @param core {@link CoreStart}
*/
public start(core: CoreStart): CloudExperimentsPluginStart {
return {
getVariation: this.getVariation,
reportMetric: this.reportMetric,
};
}
/**
* Cleans up and flush the sending queues.
*/
public stop() {
this.launchDarklyClient
?.flush()
// eslint-disable-next-line no-console
.catch((err) => console.warn(err));
}
private getVariation = async <Data>(
featureFlagName: CloudExperimentsFeatureFlagNames,
defaultValue: Data
): Promise<Data> => {
const configKey = FEATURE_FLAG_NAMES[featureFlagName];
// Apply overrides if they exist without asking LaunchDarkly.
if (this.flagOverrides && has(this.flagOverrides, configKey)) {
return get(this.flagOverrides, configKey, defaultValue) as Data;
}
if (!this.launchDarklyClient) return defaultValue; // Skip any action if no LD User is defined
await this.launchDarklyClient.waitForInitialization();
return this.launchDarklyClient.variation(configKey, defaultValue);
};
private reportMetric = <Data>({ name, meta, value }: CloudExperimentsMetric<Data>): void => {
const metricName = METRIC_NAMES[name];
this.launchDarklyClient?.track(metricName, meta, value);
if (this.isDev) {
// eslint-disable-next-line no-console
console.debug(`Reported experimentation metric ${metricName}`, {
experimentationMetric: { name, meta, value },
});
}
};
}

View file

@ -0,0 +1,133 @@
/*
* 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 { config } from './config';
describe('cloudExperiments config', () => {
describe.each([true, false])('when disabled (dev: %p)', (dev) => {
const ctx = { dev };
test('should default to `enabled:false` and the rest empty', () => {
expect(config.schema.validate({}, ctx)).toStrictEqual({ enabled: false });
});
test('it should allow any additional config', () => {
const cfg = {
enabled: false,
launch_darkly: {
sdk_key: 'sdk-1234',
client_id: '1234',
client_log_level: 'none',
},
flag_overrides: {
'my-plugin.my-feature-flag': 1234,
},
};
expect(config.schema.validate(cfg, ctx)).toStrictEqual(cfg);
});
test('it should allow any additional config (missing flag_overrides)', () => {
const cfg = {
enabled: false,
launch_darkly: {
sdk_key: 'sdk-1234',
client_id: '1234',
client_log_level: 'none',
},
};
expect(config.schema.validate(cfg, ctx)).toStrictEqual(cfg);
});
test('it should allow any additional config (missing launch_darkly)', () => {
const cfg = {
enabled: false,
flag_overrides: {
'my-plugin.my-feature-flag': 1234,
},
};
expect(config.schema.validate(cfg, ctx)).toStrictEqual(cfg);
});
});
describe('when enabled', () => {
describe('in dev mode', () => {
const ctx = { dev: true };
test('in dev mode, it allows `launch_darkly` to be empty', () => {
expect(
config.schema.validate({ enabled: true, flag_overrides: { my_flag: 1 } }, ctx)
).toStrictEqual({
enabled: true,
flag_overrides: { my_flag: 1 },
});
});
test('in dev mode, it allows `launch_darkly` and `flag_overrides` to be empty', () => {
expect(config.schema.validate({ enabled: true }, ctx)).toStrictEqual({ enabled: true });
});
});
describe('in prod (non-dev mode)', () => {
const ctx = { dev: false };
test('it enforces `launch_darkly` config if not in dev-mode', () => {
expect(() =>
config.schema.validate({ enabled: true }, ctx)
).toThrowErrorMatchingInlineSnapshot(
`"[launch_darkly.sdk_key]: expected value of type [string] but got [undefined]"`
);
});
test('in prod mode, it allows `flag_overrides` to be empty', () => {
expect(
config.schema.validate(
{
enabled: true,
launch_darkly: {
sdk_key: 'sdk-1234',
client_id: '1234',
},
},
ctx
)
).toStrictEqual({
enabled: true,
launch_darkly: {
sdk_key: 'sdk-1234',
client_id: '1234',
client_log_level: 'none',
},
});
});
test('in prod mode, it allows `flag_overrides` to be provided', () => {
expect(
config.schema.validate(
{
enabled: true,
launch_darkly: {
sdk_key: 'sdk-1234',
client_id: '1234',
},
flag_overrides: {
my_flag: 123,
},
},
ctx
)
).toStrictEqual({
enabled: true,
launch_darkly: {
sdk_key: 'sdk-1234',
client_id: '1234',
client_log_level: 'none',
},
flag_overrides: {
my_flag: 123,
},
});
});
});
});
});

View file

@ -0,0 +1,52 @@
/*
* 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 { PluginConfigDescriptor } from '@kbn/core/server';
const launchDarklySchema = schema.object({
sdk_key: schema.string({ minLength: 1 }),
client_id: schema.string({ minLength: 1 }),
client_log_level: schema.oneOf(
[
schema.literal('none'),
schema.literal('error'),
schema.literal('warn'),
schema.literal('info'),
schema.literal('debug'),
],
{ defaultValue: 'none' }
),
});
const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
launch_darkly: schema.conditional(
schema.siblingRef('enabled'),
true,
schema.conditional(
schema.contextRef('dev'),
schema.literal(true), // this is still optional when running on dev because devs might use the `flag_overrides`
schema.maybe(launchDarklySchema),
launchDarklySchema
),
schema.maybe(launchDarklySchema)
),
flag_overrides: schema.maybe(schema.recordOf(schema.string(), schema.any())),
});
export type CloudExperimentsConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<CloudExperimentsConfigType> = {
exposeToBrowser: {
launch_darkly: {
client_id: true,
},
flag_overrides: true,
},
schema: configSchema,
};

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 type { PluginInitializerContext } from '@kbn/core/server';
import { CloudExperimentsPlugin } from './plugin';
export { config } from './config';
export function plugin(initializerContext: PluginInitializerContext) {
return new CloudExperimentsPlugin(initializerContext);
}

View file

@ -0,0 +1,26 @@
/*
* 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 { LDClient } from 'launchdarkly-node-server-sdk';
export function createLaunchDarklyClientMock(): jest.Mocked<LDClient> {
return {
waitForInitialization: jest.fn(),
variation: jest.fn(),
allFlagsState: jest.fn(),
track: jest.fn(),
identify: jest.fn(),
flush: jest.fn(),
} as unknown as jest.Mocked<LDClient>; // Using casting because we only use these APIs. No need to declare everything.
}
export const ldClientMock = createLaunchDarklyClientMock();
jest.doMock('launchdarkly-node-server-sdk', () => ({
init: () => ldClientMock,
basicLogger: jest.fn(),
}));

View file

@ -0,0 +1,250 @@
/*
* 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 { ldClientMock } from './plugin.test.mock';
import { CloudExperimentsPlugin } from './plugin';
import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks';
import { FEATURE_FLAG_NAMES } from '../common/constants';
describe('Cloud Experiments server plugin', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('constructor', () => {
test('successfully creates a new plugin if provided an empty configuration', () => {
const initializerContext = coreMock.createPluginInitializerContext();
initializerContext.env.mode.dev = true; // ensure it's true
const plugin = new CloudExperimentsPlugin(initializerContext);
expect(plugin).toHaveProperty('setup');
expect(plugin).toHaveProperty('start');
expect(plugin).toHaveProperty('stop');
expect(plugin).toHaveProperty('flagOverrides', undefined);
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
});
test('fails if launch_darkly is not provided in the config and it is a non-dev environment', () => {
const initializerContext = coreMock.createPluginInitializerContext();
initializerContext.env.mode.dev = false;
expect(() => new CloudExperimentsPlugin(initializerContext)).toThrowError(
'xpack.cloud_integrations.experiments.launch_darkly configuration should exist'
);
});
test('it initializes the LaunchDarkly client', () => {
const initializerContext = coreMock.createPluginInitializerContext({
launch_darkly: { sdk_key: 'sdk-1234' },
});
ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock);
const plugin = new CloudExperimentsPlugin(initializerContext);
expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock);
});
test('it initializes the flagOverrides property', () => {
const initializerContext = coreMock.createPluginInitializerContext({
flag_overrides: { my_flag: '1234' },
});
initializerContext.env.mode.dev = true; // ensure it's true
const plugin = new CloudExperimentsPlugin(initializerContext);
expect(plugin).toHaveProperty('flagOverrides', { my_flag: '1234' });
});
});
describe('setup', () => {
let plugin: CloudExperimentsPlugin;
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext({
launch_darkly: { sdk_key: 'sdk-1234' },
flag_overrides: { my_flag: '1234' },
});
ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock);
plugin = new CloudExperimentsPlugin(initializerContext);
});
test('returns the contract', () => {
const setupContract = plugin.setup(coreMock.createSetup(), {});
expect(setupContract).toStrictEqual(
expect.objectContaining({
identifyUser: expect.any(Function),
})
);
});
test('registers the usage collector when available', () => {
const usageCollection = usageCollectionPluginMock.createSetupContract();
plugin.setup(coreMock.createSetup(), { usageCollection });
expect(usageCollection.makeUsageCollector).toHaveBeenCalledTimes(1);
expect(usageCollection.registerCollector).toHaveBeenCalledTimes(1);
});
describe('identifyUser', () => {
test('sets launchDarklyUser and calls identify', () => {
expect(plugin).toHaveProperty('launchDarklyUser', undefined);
const setupContract = plugin.setup(coreMock.createSetup(), {});
setupContract.identifyUser('user-id', {});
const ldUser = { key: 'user-id', custom: {} };
expect(plugin).toHaveProperty('launchDarklyUser', ldUser);
expect(ldClientMock.identify).toHaveBeenCalledWith(ldUser);
});
});
});
describe('start', () => {
let plugin: CloudExperimentsPlugin;
const firstKnownFlag = Object.keys(FEATURE_FLAG_NAMES)[0] as keyof typeof FEATURE_FLAG_NAMES;
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext({
launch_darkly: { sdk_key: 'sdk-1234' },
flag_overrides: { [firstKnownFlag]: '1234' },
});
ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock);
plugin = new CloudExperimentsPlugin(initializerContext);
});
test('returns the contract', () => {
plugin.setup(coreMock.createSetup(), {});
const startContract = plugin.start(coreMock.createStart());
expect(startContract).toStrictEqual(
expect.objectContaining({
getVariation: expect.any(Function),
reportMetric: expect.any(Function),
})
);
});
describe('getVariation', () => {
describe('with the user identified', () => {
beforeEach(() => {
const setupContract = plugin.setup(coreMock.createSetup(), {});
setupContract.identifyUser('user-id', {});
});
test('uses the flag overrides to respond early', async () => {
const startContract = plugin.start(coreMock.createStart());
await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual(
'1234'
);
});
test('calls the client', async () => {
const startContract = plugin.start(coreMock.createStart());
ldClientMock.variation.mockResolvedValue('12345');
await expect(
startContract.getVariation(
// @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES
'some-random-flag',
123
)
).resolves.toStrictEqual('12345');
expect(ldClientMock.variation).toHaveBeenCalledWith(
undefined, // it couldn't find it in FEATURE_FLAG_NAMES
{ key: 'user-id', custom: {} },
123
);
});
});
describe('with the user not identified', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup(), {});
});
test('uses the flag overrides to respond early', async () => {
const startContract = plugin.start(coreMock.createStart());
await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual(
'1234'
);
});
test('returns the default value without calling the client', async () => {
const startContract = plugin.start(coreMock.createStart());
await expect(
startContract.getVariation(
// @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES
'some-random-flag',
123
)
).resolves.toStrictEqual(123);
expect(ldClientMock.variation).not.toHaveBeenCalled();
});
});
});
describe('reportMetric', () => {
describe('with the user identified', () => {
beforeEach(() => {
const setupContract = plugin.setup(coreMock.createSetup(), {});
setupContract.identifyUser('user-id', {});
});
test('calls the track API', () => {
const startContract = plugin.start(coreMock.createStart());
startContract.reportMetric({
// @ts-expect-error We only allow existing flags in METRIC_NAMES
name: 'my-flag',
meta: {},
value: 1,
});
expect(ldClientMock.track).toHaveBeenCalledWith(
undefined, // it couldn't find it in METRIC_NAMES
{ key: 'user-id', custom: {} },
{},
1
);
});
});
describe('with the user not identified', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup(), {});
});
test('calls the track API', () => {
const startContract = plugin.start(coreMock.createStart());
startContract.reportMetric({
// @ts-expect-error We only allow existing flags in METRIC_NAMES
name: 'my-flag',
meta: {},
value: 1,
});
expect(ldClientMock.track).not.toHaveBeenCalled();
});
});
});
});
describe('stop', () => {
let plugin: CloudExperimentsPlugin;
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext({
launch_darkly: { sdk_key: 'sdk-1234' },
flag_overrides: { my_flag: '1234' },
});
ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock);
plugin = new CloudExperimentsPlugin(initializerContext);
plugin.setup(coreMock.createSetup(), {});
plugin.start(coreMock.createStart());
});
test('flushes the events', () => {
ldClientMock.flush.mockResolvedValue();
expect(() => plugin.stop()).not.toThrow();
expect(ldClientMock.flush).toHaveBeenCalledTimes(1);
});
test('handles errors when flushing events', () => {
ldClientMock.flush.mockRejectedValue(new Error('Something went terribly wrong'));
expect(() => plugin.stop()).not.toThrow();
expect(ldClientMock.flush).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,131 @@
/*
* 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,
CoreSetup,
CoreStart,
Plugin,
Logger,
} from '@kbn/core/server';
import { get, has } from 'lodash';
import LaunchDarkly, { type LDClient, type LDUser } from 'launchdarkly-node-server-sdk';
import type { LogMeta } from '@kbn/logging';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { registerUsageCollector } from './usage';
import type { CloudExperimentsConfigType } from './config';
import type {
CloudExperimentsFeatureFlagNames,
CloudExperimentsMetric,
CloudExperimentsPluginSetup,
CloudExperimentsPluginStart,
} from '../common';
import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants';
interface CloudExperimentsPluginSetupDeps {
usageCollection?: UsageCollectionSetup;
}
export class CloudExperimentsPlugin
implements
Plugin<
CloudExperimentsPluginSetup,
CloudExperimentsPluginStart,
CloudExperimentsPluginSetupDeps
>
{
private readonly logger: Logger;
private readonly launchDarklyClient?: LDClient;
private readonly flagOverrides?: Record<string, unknown>;
private launchDarklyUser: LDUser | undefined;
constructor(initializerContext: PluginInitializerContext) {
this.logger = initializerContext.logger.get();
const config = initializerContext.config.get<CloudExperimentsConfigType>();
if (config.flag_overrides) {
this.flagOverrides = config.flag_overrides;
}
const ldConfig = config.launch_darkly; // If the plugin is enabled and no flag_overrides are provided (dev mode only), launch_darkly must exist
if (!ldConfig && !initializerContext.env.mode.dev) {
// If the plugin is enabled, and it's in prod mode, launch_darkly must exist
// (config-schema should enforce it, but just in case).
throw new Error(
'xpack.cloud_integrations.experiments.launch_darkly configuration should exist'
);
}
if (ldConfig) {
this.launchDarklyClient = LaunchDarkly.init(ldConfig.sdk_key, {
application: { id: `kibana-server`, version: initializerContext.env.packageInfo.version },
logger: LaunchDarkly.basicLogger({ level: ldConfig.client_log_level }),
// For some reason, the stream API does not work in Kibana. `.waitForInitialization()` hangs forever (doesn't throw, neither logs any errors).
// Using polling for now until we resolve that issue.
// Relevant issue: https://github.com/launchdarkly/node-server-sdk/issues/132
stream: false,
});
this.launchDarklyClient.waitForInitialization().then(
() => this.logger.debug('LaunchDarkly is initialized!'),
(err) => this.logger.warn(`Error initializing LaunchDarkly: ${err}`)
);
}
}
public setup(
core: CoreSetup,
deps: CloudExperimentsPluginSetupDeps
): CloudExperimentsPluginSetup {
if (deps.usageCollection) {
registerUsageCollector(deps.usageCollection, () => ({
launchDarklyClient: this.launchDarklyClient,
launchDarklyUser: this.launchDarklyUser,
}));
}
return {
identifyUser: (userId, userMetadata) => {
this.launchDarklyUser = { key: userId, custom: userMetadata };
this.launchDarklyClient?.identify(this.launchDarklyUser!);
},
};
}
public start(core: CoreStart) {
return {
getVariation: this.getVariation,
reportMetric: this.reportMetric,
};
}
public stop() {
this.launchDarklyClient?.flush().catch((err) => this.logger.error(err));
}
private getVariation = async <Data>(
featureFlagName: CloudExperimentsFeatureFlagNames,
defaultValue: Data
): Promise<Data> => {
const configKey = FEATURE_FLAG_NAMES[featureFlagName];
// Apply overrides if they exist without asking LaunchDarkly.
if (this.flagOverrides && has(this.flagOverrides, configKey)) {
return get(this.flagOverrides, configKey, defaultValue) as Data;
}
if (!this.launchDarklyUser) return defaultValue; // Skip any action if no LD User is defined
await this.launchDarklyClient?.waitForInitialization();
return await this.launchDarklyClient?.variation(configKey, this.launchDarklyUser, defaultValue);
};
private reportMetric = <Data>({ name, meta, value }: CloudExperimentsMetric<Data>): void => {
const metricName = METRIC_NAMES[name];
if (!this.launchDarklyUser) return; // Skip any action if no LD User is defined
this.launchDarklyClient?.track(metricName, this.launchDarklyUser, meta, value);
this.logger.debug<{ experimentationMetric: CloudExperimentsMetric<Data> } & LogMeta>(
`Reported experimentation metric ${metricName}`,
{
experimentationMetric: { name, meta, value },
}
);
};
}

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 { registerUsageCollector } from './register_usage_collector';

View file

@ -0,0 +1,92 @@
/*
* 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 {
usageCollectionPluginMock,
createCollectorFetchContextMock,
} from '@kbn/usage-collection-plugin/server/mocks';
import type { Collector } from '@kbn/usage-collection-plugin/server/mocks';
import {
registerUsageCollector,
type LaunchDarklyEntitiesGetter,
type Usage,
} from './register_usage_collector';
import { createLaunchDarklyClientMock } from '../plugin.test.mock';
describe('cloudExperiments usage collector', () => {
let collector: Collector<Usage>;
const getLaunchDarklyEntitiesMock: jest.MockedFunction<LaunchDarklyEntitiesGetter> = jest
.fn()
.mockImplementation(() => ({}));
beforeEach(() => {
const usageCollectionSetupMock = usageCollectionPluginMock.createSetupContract();
registerUsageCollector(usageCollectionSetupMock, getLaunchDarklyEntitiesMock);
collector = usageCollectionSetupMock.registerCollector.mock
.calls[0][0] as unknown as Collector<Usage>;
});
test('isReady should always be true', () => {
expect(collector.isReady()).toStrictEqual(true);
});
test('should return initialized false and empty values when the user and the client are not initialized', async () => {
await expect(collector.fetch(createCollectorFetchContextMock())).resolves.toStrictEqual({
flagNames: [],
flags: {},
initialized: false,
});
});
test('should return initialized false and empty values when the user is not initialized', async () => {
getLaunchDarklyEntitiesMock.mockReturnValueOnce({
launchDarklyClient: createLaunchDarklyClientMock(),
});
await expect(collector.fetch(createCollectorFetchContextMock())).resolves.toStrictEqual({
flagNames: [],
flags: {},
initialized: false,
});
});
test('should return initialized false and empty values when the client is not initialized', async () => {
getLaunchDarklyEntitiesMock.mockReturnValueOnce({ launchDarklyUser: { key: 'test' } });
await expect(collector.fetch(createCollectorFetchContextMock())).resolves.toStrictEqual({
flagNames: [],
flags: {},
initialized: false,
});
});
test('should return all the flags returned by the client', async () => {
const launchDarklyClient = createLaunchDarklyClientMock();
getLaunchDarklyEntitiesMock.mockReturnValueOnce({
launchDarklyClient,
launchDarklyUser: { key: 'test' },
});
launchDarklyClient.allFlagsState.mockResolvedValueOnce({
valid: true,
getFlagValue: jest.fn(),
getFlagReason: jest.fn(),
toJSON: jest.fn(),
allValues: jest.fn().mockReturnValueOnce({
'my-plugin.my-feature-flag': true,
'my-plugin.my-other-feature-flag': 22,
}),
});
await expect(collector.fetch(createCollectorFetchContextMock())).resolves.toStrictEqual({
flagNames: ['my-plugin.my-feature-flag', 'my-plugin.my-other-feature-flag'],
flags: {
'my-plugin.my-feature-flag': true,
'my-plugin.my-other-feature-flag': 22,
},
initialized: true,
});
});
});

View file

@ -0,0 +1,70 @@
/*
* 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 { LDClient, LDUser } from 'launchdarkly-node-server-sdk';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
export interface Usage {
initialized: boolean;
flags: Record<string, string>;
flagNames: string[];
}
export type LaunchDarklyEntitiesGetter = () => {
launchDarklyUser?: LDUser;
launchDarklyClient?: LDClient;
};
export function registerUsageCollector(
usageCollection: UsageCollectionSetup,
getLaunchDarklyEntities: LaunchDarklyEntitiesGetter
) {
usageCollection.registerCollector(
usageCollection.makeUsageCollector<Usage>({
type: 'cloudExperiments',
isReady: () => true,
schema: {
initialized: {
type: 'boolean',
_meta: {
description:
'Whether the A/B testing client is correctly initialized (identify has been called)',
},
},
// We'll likely map "flags" as `flattened`, so "flagNames" helps out to discover the key names
flags: {
DYNAMIC_KEY: {
type: 'keyword',
_meta: { description: 'Flags received by the client' },
},
},
flagNames: {
type: 'array',
items: {
type: 'keyword',
_meta: {
description: 'Names of the flags received by the client',
},
},
},
},
fetch: async () => {
const { launchDarklyUser, launchDarklyClient } = getLaunchDarklyEntities();
if (!launchDarklyUser || !launchDarklyClient)
return { initialized: false, flagNames: [], flags: {} };
// According to the docs, this method does not send analytics back to LaunchDarkly, so it does not provide false results
const flagsState = await launchDarklyClient.allFlagsState(launchDarklyUser);
const flags = flagsState.allValues();
return {
initialized: flagsState.valid,
flags,
flagNames: Object.keys(flags),
};
},
})
);
}

View file

@ -0,0 +1,20 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./target/types",
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
},
"include": [
".storybook/**/*",
"common/**/*",
"public/**/*",
"server/**/*",
"../../../typings/**/*"
],
"references": [
{ "path": "../../../../src/core/tsconfig.json" },
{ "path": "../../../../src/plugins/usage_collection/tsconfig.json" },
]
}

View file

@ -34,6 +34,7 @@
"unifiedSearch"
],
"optionalPlugins": [
"cloudExperiments",
"encryptedSavedObjects",
"fleet",
"ml",

View file

@ -7,8 +7,9 @@
import React from 'react';
import { render } from '@testing-library/react';
import { useLocation } from 'react-router-dom';
import { useVariationMock } from '../../../common/components/utils.mocks';
import { GlobalHeader } from '.';
import { SecurityPageName } from '../../../../common/constants';
import { ADD_DATA_PATH, SecurityPageName } from '../../../../common/constants';
import {
createSecuritySolutionStorageMock,
mockGlobalState,
@ -31,7 +32,7 @@ jest.mock('../../../common/lib/kibana', () => {
return {
...originalModule,
useKibana: jest.fn().mockReturnValue({
services: { theme: { theme$: {} }, http: { basePath: { prepend: jest.fn() } } },
services: { theme: { theme$: {} }, http: { basePath: { prepend: jest.fn((href) => href) } } },
}),
useUiSetting$: jest.fn().mockReturnValue([]),
};
@ -68,6 +69,10 @@ describe('global header', () => {
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
beforeEach(() => {
useVariationMock.mockReset();
});
it('has add data link', () => {
(useLocation as jest.Mock).mockReturnValue([
{ pageName: SecurityPageName.overview, detailName: undefined },
@ -80,6 +85,39 @@ describe('global header', () => {
expect(getByText('Add integrations')).toBeInTheDocument();
});
it('points to the default Add data URL', () => {
(useLocation as jest.Mock).mockReturnValue([
{ pageName: SecurityPageName.overview, detailName: undefined },
]);
const { queryByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
</TestProviders>
);
const link = queryByTestId('add-data');
expect(link?.getAttribute('href')).toBe(ADD_DATA_PATH);
});
it('points to the resolved Add data URL by useVariation', () => {
(useLocation as jest.Mock).mockReturnValue([
{ pageName: SecurityPageName.overview, detailName: undefined },
]);
const customResolvedUrl = '/test/url';
useVariationMock.mockImplementationOnce(
(cloudExperiments, featureFlagName, defaultValue, setter) => {
setter(customResolvedUrl);
}
);
const { queryByTestId } = render(
<TestProviders store={store}>
<GlobalHeader setHeaderActionMenu={mockSetHeaderActionMenu} />
</TestProviders>
);
const link = queryByTestId('add-data');
expect(link?.getAttribute('href')).toBe(customResolvedUrl);
});
it.each(sourcererPaths)('shows sourcerer on %s page', (pathname) => {
(useLocation as jest.Mock).mockReturnValue({ pathname });

View file

@ -10,13 +10,14 @@ import {
EuiHeaderSection,
EuiHeaderSectionItem,
} from '@elastic/eui';
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import { i18n } from '@kbn/i18n';
import type { AppMountParameters } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { useVariation } from '../../../common/components/utils';
import { MlPopover } from '../../../common/components/ml_popover/ml_popover';
import { useKibana } from '../../../common/lib/kibana';
import { ADD_DATA_PATH } from '../../../../common/constants';
@ -45,6 +46,7 @@ export const GlobalHeader = React.memo(
http: {
basePath: { prepend },
},
cloudExperiments,
} = useKibana().services;
const { pathname } = useLocation();
@ -56,7 +58,15 @@ export const GlobalHeader = React.memo(
const sourcererScope = getScopeFromPath(pathname);
const showSourcerer = showSourcererByPath(pathname);
const href = useMemo(() => prepend(ADD_DATA_PATH), [prepend]);
const [addIntegrationsUrl, setAddIntegrationsUrl] = useState(ADD_DATA_PATH);
useVariation(
cloudExperiments,
'security-solutions.add-integrations-url',
ADD_DATA_PATH,
setAddIntegrationsUrl
);
const href = useMemo(() => prepend(addIntegrationsUrl), [prepend, addIntegrationsUrl]);
useEffect(() => {
setHeaderActionMenu((element) => {

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 React from 'react';
import { render } from '@testing-library/react';
import { useVariationMock } from '../utils.mocks';
import { TestProviders } from '../../mock';
import { LandingCards } from '.';
import { ADD_DATA_PATH } from '../../../../common/constants';
describe('LandingCards component', () => {
beforeEach(() => {
useVariationMock.mockReset();
});
it('has add data links', () => {
const { getAllByText } = render(
<TestProviders>
<LandingCards />
</TestProviders>
);
expect(getAllByText('Add security integrations')).toHaveLength(2);
});
describe.each(['header', 'footer'])('URLs at the %s', (place) => {
it('points to the default Add data URL', () => {
const { queryByTestId } = render(
<TestProviders>
<LandingCards />
</TestProviders>
);
const link = queryByTestId(`add-integrations-${place}`);
expect(link?.getAttribute('href')).toBe(ADD_DATA_PATH);
});
it('points to the resolved Add data URL by useVariation', () => {
const customResolvedUrl = '/test/url';
useVariationMock.mockImplementationOnce(
(cloudExperiments, featureFlagName, defaultValue, setter) => {
setter(customResolvedUrl);
}
);
const { queryByTestId } = render(
<TestProviders>
<LandingCards />
</TestProviders>
);
const link = queryByTestId(`add-integrations-${place}`);
expect(link?.getAttribute('href')).toBe(customResolvedUrl);
});
});
});

View file

@ -5,9 +5,10 @@
* 2.0.
*/
import React, { memo, useMemo } from 'react';
import React, { memo, useMemo, useState } from 'react';
import { EuiButton, EuiCard, EuiFlexGroup, EuiFlexItem, EuiPageHeader } from '@elastic/eui';
import styled from 'styled-components';
import { useVariation } from '../utils';
import * as i18n from './translations';
import endpointSvg from '../../images/endpoint1.svg';
import cloudSvg from '../../images/cloud1.svg';
@ -60,9 +61,18 @@ export const LandingCards = memo(() => {
http: {
basePath: { prepend },
},
cloudExperiments,
} = useKibana().services;
const href = useMemo(() => prepend(ADD_DATA_PATH), [prepend]);
const [addIntegrationsUrl, setAddIntegrationsUrl] = useState(ADD_DATA_PATH);
useVariation(
cloudExperiments,
'security-solutions.add-integrations-url',
ADD_DATA_PATH,
setAddIntegrationsUrl
);
const href = useMemo(() => prepend(addIntegrationsUrl), [prepend, addIntegrationsUrl]);
return (
<EuiFlexGroup data-test-subj="siem-landing-page" direction="column" gutterSize="l">
<EuiFlexItem>

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 { useVariation } from './utils';
export const useVariationMock: jest.MockedFunction<typeof useVariation> = jest.fn();
jest.doMock('./utils', () => {
const actualUtils = jest.requireActual('./utils');
return {
...actualUtils,
useVariation: useVariationMock,
};
});

View file

@ -0,0 +1,40 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks';
import { useVariation } from './utils';
describe('useVariation', () => {
test('it should call the setter if cloudExperiments is enabled', async () => {
const cloudExperiments = cloudExperimentsMock.createStartMock();
cloudExperiments.getVariation.mockResolvedValue('resolved value');
const setter = jest.fn();
const { result } = renderHook(() =>
useVariation(
cloudExperiments,
'security-solutions.add-integrations-url',
'my default value',
setter
)
);
await new Promise((resolve) => process.nextTick(resolve));
expect(result.error).toBe(undefined);
expect(setter).toHaveBeenCalledTimes(1);
expect(setter).toHaveBeenCalledWith('resolved value');
});
test('it should not call the setter if cloudExperiments is not enabled', async () => {
const setter = jest.fn();
const { result } = renderHook(() =>
useVariation(undefined, 'security-solutions.add-integrations-url', 'my default value', setter)
);
await new Promise((resolve) => process.nextTick(resolve));
expect(result.error).toBe(undefined);
expect(setter).not.toHaveBeenCalled();
});
});

View file

@ -6,10 +6,14 @@
*/
import { throttle } from 'lodash/fp';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import useResizeObserver from 'use-resize-observer/polyfilled';
import { niceTimeFormatByDay, timeFormatter } from '@elastic/charts';
import moment from 'moment-timezone';
import type {
CloudExperimentsFeatureFlagNames,
CloudExperimentsPluginStart,
} from '@kbn/cloud-experiments-plugin/common';
export const getDaysDiff = (minDate: moment.Moment, maxDate: moment.Moment) => {
const diff = maxDate.diff(minDate, 'days');
@ -35,3 +39,26 @@ export const useThrottledResizeObserver = (wait = 100) => {
return { ref, ...size };
};
/**
* Retrieves the variation of the feature flag if the cloudExperiments plugin is enabled.
* @param cloudExperiments {@link CloudExperimentsPluginStart}
* @param featureFlagName The name of the feature flag {@link CloudExperimentsFeatureFlagNames}
* @param defaultValue The default value in case it cannot retrieve the feature flag
* @param setter The setter from {@link useState} to update the value.
*/
export const useVariation = <Data>(
cloudExperiments: CloudExperimentsPluginStart | undefined,
featureFlagName: CloudExperimentsFeatureFlagNames,
defaultValue: Data,
setter: (value: Data) => void
) => {
useEffect(() => {
(async function loadVariation() {
const variationUrl = await cloudExperiments?.getVariation(featureFlagName, defaultValue);
if (variationUrl) {
setter(variationUrl);
}
})();
}, [cloudExperiments, featureFlagName, defaultValue, setter]);
};

View file

@ -43,6 +43,7 @@ import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
import { noCasesPermissions } from '../../../cases_test_utils';
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
import { mockApm } from '../apm/service.mock';
import { cloudExperimentsMock } from '@kbn/cloud-experiments-plugin/common/mocks';
const mockUiSettings: Record<string, unknown> = {
[DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' },
@ -104,6 +105,7 @@ export const createStartServicesMock = (
const cases = mockCasesContract();
cases.helpers.getUICapabilities.mockReturnValue(noCasesPermissions());
const triggersActionsUi = triggersActionsUiMock.createStart();
const cloudExperiments = cloudExperimentsMock.createStartMock();
return {
...core,
@ -170,6 +172,7 @@ export const createStartServicesMock = (
OsqueryResults: jest.fn().mockReturnValue(null),
},
triggersActionsUi,
cloudExperiments,
} as unknown as StartServices;
};

View file

@ -40,6 +40,7 @@ import type {
SavedObjectTaggingOssPluginStart,
} from '@kbn/saved-objects-tagging-oss-plugin/public';
import type { ThreatIntelligencePluginStart } from '@kbn/threat-intelligence-plugin/public';
import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common';
import type { ResolverPluginSetup } from './resolver/types';
import type { Inspect } from '../common/search_strategy';
import type { Detections } from './detections';
@ -91,6 +92,7 @@ export interface StartPlugins {
security: SecurityPluginStart;
cloudSecurityPosture: CspClientPluginStart;
threatIntelligence: ThreatIntelligencePluginStart;
cloudExperiments?: CloudExperimentsPluginStart;
}
export interface StartPluginsDependencies extends StartPlugins {

View file

@ -36,6 +36,7 @@ import type {
import type { TelemetryPluginStart, TelemetryPluginSetup } from '@kbn/telemetry-plugin/server';
import type { OsqueryPluginSetup } from '@kbn/osquery-plugin/server';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import type { CloudExperimentsPluginStart } from '@kbn/cloud-experiments-plugin/common';
export interface SecuritySolutionPluginSetupDependencies {
alerting: AlertingPluginSetup;
@ -59,6 +60,7 @@ export interface SecuritySolutionPluginSetupDependencies {
export interface SecuritySolutionPluginStartDependencies {
alerting: AlertingPluginStart;
cases?: CasesPluginStart;
cloudExperiments?: CloudExperimentsPluginStart;
data: DataPluginStart;
eventLog: IEventLogClientService;
fleet?: FleetPluginStart;

View file

@ -32,6 +32,7 @@
{ "path": "../actions/tsconfig.json" },
{ "path": "../alerting/tsconfig.json" },
{ "path": "../cases/tsconfig.json" },
{ "path": "../cloud_integrations/cloud_experiments/tsconfig.json" },
{ "path": "../cloud_security_posture/tsconfig.json" },
{ "path": "../encrypted_saved_objects/tsconfig.json" },
{ "path": "../features/tsconfig.json" },

View file

@ -4888,6 +4888,35 @@
}
}
},
"cloudExperiments": {
"properties": {
"initialized": {
"type": "boolean",
"_meta": {
"description": "Whether the A/B testing client is correctly initialized (identify has been called)"
}
},
"flags": {
"properties": {
"DYNAMIC_KEY": {
"type": "keyword",
"_meta": {
"description": "Flags received by the client"
}
}
}
},
"flagNames": {
"type": "array",
"items": {
"type": "keyword",
"_meta": {
"description": "Names of the flags received by the client"
}
}
}
}
},
"discoverEnhanced": {
"properties": {
"exploreDataInChartActionEnabled": {

View file

@ -9908,7 +9908,7 @@ async@^1.4.2:
resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=
async@^3.2.0, async@^3.2.3:
async@^3.1.0, async@^3.2.0, async@^3.2.3:
version "3.2.4"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c"
integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==
@ -11333,16 +11333,16 @@ clone-stats@^1.0.0:
resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680"
integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA=
clone@2.x, clone@^2.1.1, clone@~2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
clone@^1.0.2, clone@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
clone@^2.1.1, clone@~2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
cloneable-readable@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.2.tgz#d591dee4a8f8bc15da43ce97dceeba13d43e2a65"
@ -18683,6 +18683,41 @@ latest-version@^5.1.0:
dependencies:
package-json "^6.3.0"
launchdarkly-eventsource@1.4.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/launchdarkly-eventsource/-/launchdarkly-eventsource-1.4.4.tgz#fa595af8602e487c61520787170376c6a1104459"
integrity sha512-GL+r2Y3WccJlhFyL2buNKel+9VaMnYpbE/FfCkOST5jSNSFodahlxtGyrE8o7R+Qhobyq0Ree4a7iafJDQi9VQ==
launchdarkly-js-client-sdk@^2.22.1:
version "2.22.1"
resolved "https://registry.yarnpkg.com/launchdarkly-js-client-sdk/-/launchdarkly-js-client-sdk-2.22.1.tgz#e6064c79bc575eea0aa4364be41754d54d89ae6a"
integrity sha512-EAdw7B8w4m/WZGmHHLj9gbYBP6lCqJs5TQDCM9kWJOnvHBz7DJIxOdqazNMDn5AzBxfvaMG7cpLms+Cur5LD5g==
dependencies:
escape-string-regexp "^1.0.5"
launchdarkly-js-sdk-common "3.6.0"
launchdarkly-js-sdk-common@3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/launchdarkly-js-sdk-common/-/launchdarkly-js-sdk-common-3.6.0.tgz#d146be5bbd86a019c4bedc52e66c37a1ffa7bb3d"
integrity sha512-wCdBoBiYXlP64jTrC0dOXY2B345LSJO/IvitbdW4kBKmJ1DkeufpqV0s5DBlwE0RLzDmaQx3mRTmcoNAIhIoaA==
dependencies:
base64-js "^1.3.0"
fast-deep-equal "^2.0.1"
uuid "^3.3.2"
launchdarkly-node-server-sdk@^6.4.2:
version "6.4.2"
resolved "https://registry.yarnpkg.com/launchdarkly-node-server-sdk/-/launchdarkly-node-server-sdk-6.4.2.tgz#10a4fea21762315a095a9377cb23dc8d6e714469"
integrity sha512-cZQ/FDpzrXu7rOl2re9+79tX/jOrj+kb1ikbqpk/jEgLvXUHGE7Xr+fsEIbQa80H1PkGwiyWbmnAl31THJfKew==
dependencies:
async "^3.1.0"
launchdarkly-eventsource "1.4.4"
lru-cache "^6.0.0"
node-cache "^5.1.0"
semver "^7.3.0"
tunnel "0.0.6"
uuid "^8.3.2"
lazy-ass@1.6.0, lazy-ass@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/lazy-ass/-/lazy-ass-1.6.0.tgz#7999655e8646c17f089fdd187d150d3324d54513"
@ -20294,6 +20329,13 @@ node-bitmap@0.0.1:
resolved "https://registry.yarnpkg.com/node-bitmap/-/node-bitmap-0.0.1.tgz#180eac7003e0c707618ef31368f62f84b2a69091"
integrity sha1-GA6scAPgxwdhjvMTaPYvhLKmkJE=
node-cache@^5.1.0:
version "5.1.2"
resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d"
integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==
dependencies:
clone "2.x"
node-dir@^0.1.10:
version "0.1.17"
resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5"
@ -24466,7 +24508,7 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semve
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@~7.3.2:
semver@^7.2.1, semver@^7.3.0, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@~7.3.2:
version "7.3.7"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
@ -26514,6 +26556,11 @@ tunnel-agent@^0.6.0:
dependencies:
safe-buffer "^5.0.1"
tunnel@0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==
tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"