[LaunchDarkly] Add Deployment Metadata (#143002)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Alejandro Fernández Haro 2022-10-17 17:41:42 +02:00 committed by GitHub
parent 36f330ec9c
commit 46ccdc9ee0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1459 additions and 270 deletions

View file

@ -170,12 +170,16 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.cloud.base_url (string)',
'xpack.cloud.cname (string)',
'xpack.cloud.deployment_url (string)',
'xpack.cloud.is_elastic_staff_owned (boolean)',
'xpack.cloud.trial_end_date (string)',
'xpack.cloud_integrations.chat.chatURL (string)',
// No PII. This is an escape patch to override LaunchDarkly's flag resolution mechanism for testing or quick fix.
'xpack.cloud_integrations.experiments.flag_overrides (record)',
// Commented because it's inside a schema conditional, and the test is not able to resolve it. But it's shared.
// Added here for documentation purposes.
// 'xpack.cloud_integrations.experiments.launch_darkly.client_id (string)',
// 'xpack.cloud_integrations.experiments.launch_darkly.client_log_level (string)',
'xpack.cloud_integrations.experiments.metadata_refresh_interval (duration)',
'xpack.cloud_integrations.full_story.org_id (any)',
// No PII. Just the list of event types we want to forward to FullStory.
'xpack.cloud_integrations.full_story.eventTypesAllowlist (array)',

View file

@ -52,4 +52,14 @@ This is the path to the Cloud Account and Billing page. The value is already pre
This value is the same as `baseUrl` on ESS but can be customized on ECE.
**Example:** `cloud.elastic.co` (on ESS)
**Example:** `cloud.elastic.co` (on ESS)
### `trial_end_date`
The end date for the Elastic Cloud trial. Only available on Elastic Cloud.
**Example:** `2020-10-14T10:40:22Z`
### `is_elastic_staff_owned`
`true` if the deployment is owned by an Elastician. Only available on Elastic Cloud.

View file

@ -6,7 +6,7 @@
*/
import { firstValueFrom } from 'rxjs';
import { registerCloudDeploymentIdAnalyticsContext } from './register_cloud_deployment_id_analytics_context';
import { registerCloudDeploymentMetadataAnalyticsContext } from './register_cloud_deployment_id_analytics_context';
describe('registerCloudDeploymentIdAnalyticsContext', () => {
let analytics: { registerContextProvider: jest.Mock };
@ -17,14 +17,16 @@ describe('registerCloudDeploymentIdAnalyticsContext', () => {
});
test('it does not register the context provider if cloudId not provided', () => {
registerCloudDeploymentIdAnalyticsContext(analytics);
registerCloudDeploymentMetadataAnalyticsContext(analytics, {});
expect(analytics.registerContextProvider).not.toHaveBeenCalled();
});
test('it registers the context provider and emits the cloudId', async () => {
registerCloudDeploymentIdAnalyticsContext(analytics, 'cloud_id');
registerCloudDeploymentMetadataAnalyticsContext(analytics, { id: 'cloud_id' });
expect(analytics.registerContextProvider).toHaveBeenCalledTimes(1);
const [{ context$ }] = analytics.registerContextProvider.mock.calls[0];
await expect(firstValueFrom(context$)).resolves.toEqual({ cloudId: 'cloud_id' });
await expect(firstValueFrom(context$)).resolves.toEqual({
cloudId: 'cloud_id',
});
});
});

View file

@ -8,21 +8,44 @@
import type { AnalyticsClient } from '@kbn/analytics-client';
import { of } from 'rxjs';
export function registerCloudDeploymentIdAnalyticsContext(
export interface CloudDeploymentMetadata {
id?: string;
trial_end_date?: string;
is_elastic_staff_owned?: boolean;
}
export function registerCloudDeploymentMetadataAnalyticsContext(
analytics: Pick<AnalyticsClient, 'registerContextProvider'>,
cloudId?: string
cloudMetadata: CloudDeploymentMetadata
) {
if (!cloudId) {
if (!cloudMetadata.id) {
return;
}
const {
id: cloudId,
trial_end_date: cloudTrialEndDate,
is_elastic_staff_owned: cloudIsElasticStaffOwned,
} = cloudMetadata;
analytics.registerContextProvider({
name: 'Cloud Deployment ID',
context$: of({ cloudId }),
name: 'Cloud Deployment Metadata',
context$: of({ cloudId, cloudTrialEndDate, cloudIsElasticStaffOwned }),
schema: {
cloudId: {
type: 'keyword',
_meta: { description: 'The Cloud Deployment ID' },
},
cloudTrialEndDate: {
type: 'date',
_meta: { description: 'When the Elastic Cloud trial ends/ended', optional: true },
},
cloudIsElasticStaffOwned: {
type: 'boolean',
_meta: {
description: '`true` if the owner of the deployment is an Elastician',
optional: true,
},
},
},
});
}

View file

@ -18,6 +18,8 @@ function createSetupMock() {
deploymentUrl: 'deployment-url',
profileUrl: 'profile-url',
organizationUrl: 'organization-url',
isElasticStaffOwned: true,
trialEndDate: new Date('2020-10-01T14:13:12Z'),
registerCloudService: jest.fn(),
};
}

View file

@ -8,7 +8,7 @@
import React, { FC } from 'react';
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context';
import { registerCloudDeploymentMetadataAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
import { ELASTIC_SUPPORT_LINK, CLOUD_SNAPSHOTS_PATH } from '../common/constants';
import { getFullCloudUrl } from './utils';
@ -20,11 +20,8 @@ export interface CloudConfigType {
profile_url?: string;
deployment_url?: string;
organization_url?: string;
full_story: {
enabled: boolean;
org_id?: string;
eventTypesAllowlist?: string[];
};
trial_end_date?: string;
is_elastic_staff_owned?: boolean;
}
export interface CloudStart {
@ -55,14 +52,50 @@ export interface CloudStart {
}
export interface CloudSetup {
/**
* Cloud ID. Undefined if not running on Cloud.
*/
cloudId?: string;
/**
* This value is the same as `baseUrl` on ESS but can be customized on ECE.
*/
cname?: string;
/**
* This is the URL of the Cloud interface.
*/
baseUrl?: string;
/**
* The full URL to the deployment management page on Elastic Cloud. Undefined if not running on Cloud.
*/
deploymentUrl?: string;
/**
* The full URL to the user profile page on Elastic Cloud. Undefined if not running on Cloud.
*/
profileUrl?: string;
/**
* The full URL to the organization management page on Elastic Cloud. Undefined if not running on Cloud.
*/
organizationUrl?: string;
/**
* This is the path to the Snapshots page for the deployment to which the Kibana instance belongs. The value is already prepended with `deploymentUrl`.
*/
snapshotsUrl?: string;
/**
* `true` when Kibana is running on Elastic Cloud.
*/
isCloudEnabled: boolean;
/**
* When the Cloud Trial ends/ended for the organization that owns this deployment. Only available when running on Elastic Cloud.
*/
trialEndDate?: Date;
/**
* `true` if the Elastic Cloud organization that owns this deployment is owned by an Elastician. Only available when running on Elastic Cloud.
*/
isElasticStaffOwned?: boolean;
/**
* Registers CloudServiceProviders so start's `CloudContextProvider` hooks them.
* @param contextProvider The React component from the Service Provider.
*/
registerCloudService: (contextProvider: FC) => void;
}
@ -84,15 +117,23 @@ export class CloudPlugin implements Plugin<CloudSetup> {
}
public setup(core: CoreSetup): CloudSetup {
registerCloudDeploymentIdAnalyticsContext(core.analytics, this.config.id);
registerCloudDeploymentMetadataAnalyticsContext(core.analytics, this.config);
const { id, cname, base_url: baseUrl } = this.config;
const {
id,
cname,
base_url: baseUrl,
trial_end_date: trialEndDate,
is_elastic_staff_owned: isElasticStaffOwned,
} = this.config;
return {
cloudId: id,
cname,
baseUrl,
...this.getCloudUrls(),
trialEndDate: trialEndDate ? new Date(trialEndDate) : undefined,
isElasticStaffOwned,
isCloudEnabled: this.isCloudEnabled,
registerCloudService: (contextProvider) => {
this.contextProviders.push(contextProvider);

View file

@ -5,31 +5,51 @@
* 2.0.
*/
import {
createCollectorFetchContextMock,
usageCollectionPluginMock,
} from '@kbn/usage-collection-plugin/server/mocks';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { createCloudUsageCollector } from './cloud_usage_collector';
import { createCollectorFetchContextMock } from '@kbn/usage-collection-plugin/server/mocks';
const mockUsageCollection = () => ({
makeUsageCollector: jest.fn().mockImplementation((args: any) => ({ ...args })),
});
const getMockConfigs = (isCloudEnabled: boolean) => ({ isCloudEnabled });
import { CollectorFetchContext } from '@kbn/usage-collection-plugin/server';
describe('createCloudUsageCollector', () => {
let usageCollection: UsageCollectionSetup;
let collectorFetchContext: jest.Mocked<CollectorFetchContext>;
beforeEach(() => {
usageCollection = usageCollectionPluginMock.createSetupContract();
collectorFetchContext = createCollectorFetchContextMock();
});
it('calls `makeUsageCollector`', () => {
const mockConfigs = getMockConfigs(false);
const usageCollection = mockUsageCollection();
createCloudUsageCollector(usageCollection as any, mockConfigs);
createCloudUsageCollector(usageCollection, { isCloudEnabled: false });
expect(usageCollection.makeUsageCollector).toBeCalledTimes(1);
});
describe('Fetched Usage data', () => {
it('return isCloudEnabled boolean', async () => {
const mockConfigs = getMockConfigs(true);
const usageCollection = mockUsageCollection() as any;
const collector = createCloudUsageCollector(usageCollection, mockConfigs);
const collectorFetchContext = createCollectorFetchContextMock();
const collector = createCloudUsageCollector(usageCollection, { isCloudEnabled: true });
expect((await collector.fetch(collectorFetchContext)).isCloudEnabled).toBe(true); // Adding the await because the fetch can be a Promise or a synchronous method and TS complains in the test if not awaited
expect(await collector.fetch(collectorFetchContext)).toStrictEqual({
isCloudEnabled: true,
isElasticStaffOwned: undefined,
trialEndDate: undefined,
});
});
it('return inTrial boolean if trialEndDateIsProvided', async () => {
const collector = createCloudUsageCollector(usageCollection, {
isCloudEnabled: true,
trialEndDate: '2020-10-01T14:30:16Z',
});
expect(await collector.fetch(collectorFetchContext)).toStrictEqual({
isCloudEnabled: true,
isElasticStaffOwned: undefined,
trialEndDate: '2020-10-01T14:30:16Z',
inTrial: false,
});
});
});
});

View file

@ -9,23 +9,35 @@ import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
interface Config {
isCloudEnabled: boolean;
trialEndDate?: string;
isElasticStaffOwned?: boolean;
}
interface CloudUsage {
isCloudEnabled: boolean;
trialEndDate?: string;
inTrial?: boolean;
isElasticStaffOwned?: boolean;
}
export function createCloudUsageCollector(usageCollection: UsageCollectionSetup, config: Config) {
const { isCloudEnabled } = config;
const { isCloudEnabled, trialEndDate, isElasticStaffOwned } = config;
const trialEndDateMs = trialEndDate ? new Date(trialEndDate).getTime() : undefined;
return usageCollection.makeUsageCollector<CloudUsage>({
type: 'cloud',
isReady: () => true,
schema: {
isCloudEnabled: { type: 'boolean' },
trialEndDate: { type: 'date' },
inTrial: { type: 'boolean' },
isElasticStaffOwned: { type: 'boolean' },
},
fetch: () => {
return {
isCloudEnabled,
isElasticStaffOwned,
trialEndDate,
...(trialEndDateMs ? { inTrial: Date.now() <= trialEndDateMs } : {}),
};
},
});

View file

@ -26,6 +26,8 @@ const configSchema = schema.object({
id: schema.maybe(schema.string()),
organization_url: schema.maybe(schema.string()),
profile_url: schema.maybe(schema.string()),
trial_end_date: schema.maybe(schema.string()),
is_elastic_staff_owned: schema.maybe(schema.boolean()),
});
export type CloudConfigType = TypeOf<typeof configSchema>;
@ -38,6 +40,8 @@ export const config: PluginConfigDescriptor<CloudConfigType> = {
id: true,
organization_url: true,
profile_url: true,
trial_end_date: true,
is_elastic_staff_owned: true,
},
schema: configSchema,
};

View file

@ -13,6 +13,8 @@ function createSetupMock(): jest.Mocked<CloudSetup> {
instanceSizeMb: 1234,
deploymentId: 'deployment-id',
isCloudEnabled: true,
isElasticStaffOwned: true,
trialEndDate: new Date('2020-10-01T14:13:12Z'),
apm: {
url: undefined,
secretToken: undefined,

View file

@ -7,7 +7,7 @@
import type { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/server';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context';
import { registerCloudDeploymentMetadataAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context';
import type { CloudConfigType } from './config';
import { registerCloudUsageCollector } from './collectors';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
@ -18,11 +18,37 @@ interface PluginsSetup {
usageCollection?: UsageCollectionSetup;
}
/**
* Setup contract
*/
export interface CloudSetup {
/**
* The deployment's Cloud ID. Only available when running on Elastic Cloud.
*/
cloudId?: string;
/**
* The deployment's ID. Only available when running on Elastic Cloud.
*/
deploymentId?: string;
/**
* `true` when running on Elastic Cloud.
*/
isCloudEnabled: boolean;
/**
* The size of the instance in which Kibana is running. Only available when running on Elastic Cloud.
*/
instanceSizeMb?: number;
/**
* When the Cloud Trial ends/ended for the organization that owns this deployment. Only available when running on Elastic Cloud.
*/
trialEndDate?: Date;
/**
* `true` if the Elastic Cloud organization that owns this deployment is owned by an Elastician. Only available when running on Elastic Cloud.
*/
isElasticStaffOwned?: boolean;
/**
* APM configuration keys.
*/
apm: {
url?: string;
secretToken?: string;
@ -38,14 +64,20 @@ export class CloudPlugin implements Plugin<CloudSetup> {
public setup(core: CoreSetup, { usageCollection }: PluginsSetup): CloudSetup {
const isCloudEnabled = getIsCloudEnabled(this.config.id);
registerCloudDeploymentIdAnalyticsContext(core.analytics, this.config.id);
registerCloudUsageCollector(usageCollection, { isCloudEnabled });
registerCloudDeploymentMetadataAnalyticsContext(core.analytics, this.config);
registerCloudUsageCollector(usageCollection, {
isCloudEnabled,
trialEndDate: this.config.trial_end_date,
isElasticStaffOwned: this.config.is_elastic_staff_owned,
});
return {
cloudId: this.config.id,
instanceSizeMb: readInstanceSizeMb(),
deploymentId: parseDeploymentIdFromDeploymentUrl(this.config.deployment_url),
isCloudEnabled,
trialEndDate: this.config.trial_end_date ? new Date(this.config.trial_end_date) : undefined,
isElasticStaffOwned: this.config.is_elastic_staff_owned,
apm: {
url: this.config.apm?.url,
secretToken: this.config.apm?.secret_token,

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 { MetadataService } from './metadata_service';

View file

@ -0,0 +1,97 @@
/*
* 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 moment from 'moment';
import { fakeSchedulers } from 'rxjs-marbles/jest';
import { firstValueFrom } from 'rxjs';
import { MetadataService } from './metadata_service';
jest.mock('rxjs', () => {
const RxJs = jest.requireActual('rxjs');
return {
...RxJs,
debounceTime: () => RxJs.identity, // Remove the delaying effect of debounceTime
};
});
describe('MetadataService', () => {
jest.useFakeTimers();
let metadataService: MetadataService;
beforeEach(() => {
metadataService = new MetadataService({ metadata_refresh_interval: moment.duration(1, 's') });
});
afterEach(() => {
jest.clearAllTimers();
jest.clearAllMocks();
});
describe('setup', () => {
test('emits the initial metadata', async () => {
const initialMetadata = { userId: 'fake-user-id', kibanaVersion: 'version' };
metadataService.setup(initialMetadata);
await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual(
initialMetadata
);
});
test(
'emits in_trial when trial_end_date is provided',
fakeSchedulers(async (advance) => {
const initialMetadata = {
userId: 'fake-user-id',
kibanaVersion: 'version',
trial_end_date: new Date(0).toISOString(),
};
metadataService.setup(initialMetadata);
// Still equals initialMetadata
await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual(
initialMetadata
);
// After scheduler kicks in...
advance(1); // The timer kicks in first on 0 (but let's give us 1ms so the trial is expired)
await new Promise((resolve) => process.nextTick(resolve)); // The timer triggers a promise, so we need to skip to the next tick
await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual({
...initialMetadata,
in_trial: false,
});
})
);
});
describe('start', () => {
const initialMetadata = { userId: 'fake-user-id', kibanaVersion: 'version' };
beforeEach(() => {
metadataService.setup(initialMetadata);
});
test(
'emits has_data after resolving the `hasUserDataView`',
fakeSchedulers(async (advance) => {
metadataService.start({ hasDataFetcher: async () => ({ has_data: true }) });
// Still equals initialMetadata
await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual(
initialMetadata
);
// After scheduler kicks in...
advance(1); // The timer kicks in first on 0 (but let's give us 1ms so the trial is expired)
await new Promise((resolve) => process.nextTick(resolve)); // The timer triggers a promise, so we need to skip to the next tick
await expect(firstValueFrom(metadataService.userMetadata$)).resolves.toStrictEqual({
...initialMetadata,
has_data: true,
});
})
);
});
});

View file

@ -0,0 +1,113 @@
/*
* 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 {
BehaviorSubject,
debounceTime,
distinct,
exhaustMap,
filter,
type Observable,
shareReplay,
Subject,
takeUntil,
takeWhile,
timer,
} from 'rxjs';
import { type Duration } from 'moment';
export interface MetadataServiceStartContract {
hasDataFetcher: () => Promise<{ has_data: boolean }>;
}
export interface UserMetadata extends Record<string, string | boolean | number | undefined> {
// Static values
userId: string;
kibanaVersion: string;
trial_end_date?: string;
is_elastic_staff_owned?: boolean;
// Dynamic/calculated values
in_trial?: boolean;
has_data?: boolean;
}
export interface MetadataServiceConfig {
metadata_refresh_interval: Duration;
}
export class MetadataService {
private readonly _userMetadata$ = new BehaviorSubject<UserMetadata | undefined>(undefined);
private readonly stop$ = new Subject<void>();
constructor(private readonly config: MetadataServiceConfig) {}
public setup(initialUserMetadata: UserMetadata) {
this._userMetadata$.next(initialUserMetadata);
// Calculate `in_trial` based on the `trial_end_date`.
// Elastic Cloud allows customers to end their trials earlier or even extend it in some cases, but this is a good compromise for now.
const trialEndDate = initialUserMetadata.trial_end_date;
if (trialEndDate) {
this.scheduleUntil(
() => ({ in_trial: Date.now() <= new Date(trialEndDate).getTime() }),
// Stop recalculating in_trial when the user is no-longer in trial
(metadata) => metadata.in_trial === false
);
}
}
public get userMetadata$(): Observable<UserMetadata> {
return this._userMetadata$.pipe(
filter(Boolean), // Ensure we don't return undefined
debounceTime(100), // Swallows multiple emissions that may occur during bootstrap
distinct((meta) => [meta.in_trial, meta.has_data].join('-')), // Checks if any of the dynamic fields have changed
shareReplay(1)
);
}
public start({ hasDataFetcher }: MetadataServiceStartContract) {
// If no initial metadata (setup was not called) => it should not schedule any metadata extension
if (!this._userMetadata$.value) return;
this.scheduleUntil(
async () => hasDataFetcher(),
// Stop checking the moment the user has any data
(metadata) => metadata.has_data === true
);
}
public stop() {
this.stop$.next();
this._userMetadata$.complete();
}
/**
* Schedules a timer that calls `fn` to update the {@link UserMetadata} until `untilFn` returns true.
* @param fn Method to calculate the dynamic metadata.
* @param untilFn Method that returns true when the scheduler should stop calling fn (potentially because the dynamic value is not expected to change anymore).
* @private
*/
private scheduleUntil(
fn: () => Partial<UserMetadata> | Promise<Partial<UserMetadata>>,
untilFn: (value: UserMetadata) => boolean
) {
timer(0, this.config.metadata_refresh_interval.asMilliseconds())
.pipe(
takeUntil(this.stop$),
exhaustMap(async () => {
this._userMetadata$.next({
...this._userMetadata$.value!, // We are running the schedules after the initial user metadata is set
...(await fn()),
});
}),
takeWhile(() => {
return !untilFn(this._userMetadata$.value!);
})
)
.subscribe();
}
}

View file

@ -10,6 +10,6 @@
"server": true,
"ui": true,
"configPath": ["xpack", "cloud_integrations", "experiments"],
"requiredPlugins": ["cloud"],
"requiredPlugins": ["cloud", "dataViews"],
"optionalPlugins": ["usageCollection"]
}

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 {
LaunchDarklyClient,
type LaunchDarklyUserMetadata,
type LaunchDarklyClientConfig,
} from './launch_darkly_client';

View file

@ -9,16 +9,19 @@ import type { LDClient } from 'launchdarkly-js-client-sdk';
export function createLaunchDarklyClientMock(): jest.Mocked<LDClient> {
return {
identify: jest.fn(),
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,
}));
export const launchDarklyLibraryMock = {
initialize: jest.fn(),
basicLogger: jest.fn(),
};
jest.doMock('launchdarkly-js-client-sdk', () => launchDarklyLibraryMock);

View file

@ -0,0 +1,168 @@
/*
* 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 { ldClientMock, launchDarklyLibraryMock } from './launch_darkly_client.test.mock';
import { LaunchDarklyClient, type LaunchDarklyClientConfig } from './launch_darkly_client';
describe('LaunchDarklyClient - browser', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const config: LaunchDarklyClientConfig = {
client_id: 'fake-client-id',
client_log_level: 'debug',
};
describe('Public APIs', () => {
let client: LaunchDarklyClient;
const testUserMetadata = { userId: 'fake-user-id', kibanaVersion: 'version' };
beforeEach(() => {
client = new LaunchDarklyClient(config, 'version');
});
describe('updateUserMetadata', () => {
test("calls the client's initialize method with all the possible values", async () => {
expect(client).toHaveProperty('launchDarklyClient', undefined);
launchDarklyLibraryMock.initialize.mockReturnValue(ldClientMock);
const topFields = {
name: 'First Last',
firstName: 'First',
lastName: 'Last',
email: 'first.last@boring.co',
avatar: 'fake-blue-avatar',
ip: 'my-weird-ip',
country: 'distributed',
};
const extraFields = {
other_field: 'my other custom field',
kibanaVersion: 'version',
};
await client.updateUserMetadata({ userId: 'fake-user-id', ...topFields, ...extraFields });
expect(launchDarklyLibraryMock.initialize).toHaveBeenCalledWith(
'fake-client-id',
{
key: 'fake-user-id',
...topFields,
custom: extraFields,
},
{
application: { id: 'kibana-browser', version: 'version' },
logger: undefined,
}
);
expect(client).toHaveProperty('launchDarklyClient', ldClientMock);
});
test('sets a minimum amount of info', async () => {
expect(client).toHaveProperty('launchDarklyClient', undefined);
await client.updateUserMetadata({ userId: 'fake-user-id', kibanaVersion: 'version' });
expect(launchDarklyLibraryMock.initialize).toHaveBeenCalledWith(
'fake-client-id',
{
key: 'fake-user-id',
custom: { kibanaVersion: 'version' },
},
{
application: { id: 'kibana-browser', version: 'version' },
logger: undefined,
}
);
});
test('calls identify if an update comes after initializing the client', async () => {
expect(client).toHaveProperty('launchDarklyClient', undefined);
launchDarklyLibraryMock.initialize.mockReturnValue(ldClientMock);
await client.updateUserMetadata({ userId: 'fake-user-id', kibanaVersion: 'version' });
expect(launchDarklyLibraryMock.initialize).toHaveBeenCalledWith(
'fake-client-id',
{
key: 'fake-user-id',
custom: { kibanaVersion: 'version' },
},
{
application: { id: 'kibana-browser', version: 'version' },
logger: undefined,
}
);
expect(ldClientMock.identify).not.toHaveBeenCalled();
expect(client).toHaveProperty('launchDarklyClient', ldClientMock);
// Update user metadata a 2nd time
await client.updateUserMetadata({ userId: 'fake-user-id', kibanaVersion: 'version' });
expect(ldClientMock.identify).toHaveBeenCalledWith({
key: 'fake-user-id',
custom: { kibanaVersion: 'version' },
});
});
});
describe('getVariation', () => {
test('returns the default value if the user has not been defined', async () => {
await expect(client.getVariation('my-feature-flag', 123)).resolves.toStrictEqual(123);
expect(ldClientMock.variation).toHaveBeenCalledTimes(0);
});
test('calls the LaunchDarkly client when the user has been defined', async () => {
ldClientMock.variation.mockResolvedValue(1234);
await client.updateUserMetadata(testUserMetadata);
await expect(client.getVariation('my-feature-flag', 123)).resolves.toStrictEqual(1234);
expect(ldClientMock.variation).toHaveBeenCalledTimes(1);
expect(ldClientMock.variation).toHaveBeenCalledWith('my-feature-flag', 123);
});
});
describe('reportMetric', () => {
test('does not call track if the user has not been defined', () => {
client.reportMetric('my-feature-flag', {}, 123);
expect(ldClientMock.track).toHaveBeenCalledTimes(0);
});
test('calls the LaunchDarkly client when the user has been defined', async () => {
await client.updateUserMetadata(testUserMetadata);
client.reportMetric('my-feature-flag', {}, 123);
expect(ldClientMock.track).toHaveBeenCalledTimes(1);
expect(ldClientMock.track).toHaveBeenCalledWith('my-feature-flag', {}, 123);
});
});
describe('stop', () => {
test('flushes the events', async () => {
await client.updateUserMetadata(testUserMetadata);
ldClientMock.flush.mockResolvedValue();
expect(() => client.stop()).not.toThrow();
expect(ldClientMock.flush).toHaveBeenCalledTimes(1);
await new Promise((resolve) => process.nextTick(resolve)); // wait for the flush resolution
});
test('handles errors when flushing events', async () => {
await client.updateUserMetadata(testUserMetadata);
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
const err = new Error('Something went terribly wrong');
ldClientMock.flush.mockRejectedValue(err);
expect(() => client.stop()).not.toThrow();
expect(ldClientMock.flush).toHaveBeenCalledTimes(1);
await new Promise((resolve) => process.nextTick(resolve)); // wait for the flush resolution
expect(consoleWarnSpy).toHaveBeenCalledWith(err);
});
});
});
});

View file

@ -0,0 +1,79 @@
/*
* 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, type LDUser, type LDLogLevel } from 'launchdarkly-js-client-sdk';
export interface LaunchDarklyClientConfig {
client_id: string;
client_log_level: LDLogLevel;
}
export interface LaunchDarklyUserMetadata
extends Record<string, string | boolean | number | undefined> {
userId: string;
// We are not collecting any of the above, but this is to match the LDUser first-level definition
name?: string;
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
ip?: string;
country?: string;
}
export class LaunchDarklyClient {
private launchDarklyClient?: LDClient;
constructor(
private readonly ldConfig: LaunchDarklyClientConfig,
private readonly kibanaVersion: string
) {}
public async updateUserMetadata(userMetadata: LaunchDarklyUserMetadata) {
const { userId, name, firstName, lastName, email, avatar, ip, country, ...custom } =
userMetadata;
const launchDarklyUser: LDUser = {
key: userId,
name,
firstName,
lastName,
email,
avatar,
ip,
country,
// This casting is needed because LDUser does not allow `Record<string, undefined>`
custom: custom as Record<string, string | boolean | number>,
};
if (this.launchDarklyClient) {
await this.launchDarklyClient.identify(launchDarklyUser);
} else {
const { initialize, basicLogger } = await import('launchdarkly-js-client-sdk');
this.launchDarklyClient = initialize(this.ldConfig.client_id, launchDarklyUser, {
application: { id: 'kibana-browser', version: this.kibanaVersion },
logger: basicLogger({ level: this.ldConfig.client_log_level }),
});
}
}
public async getVariation<Data>(configKey: string, defaultValue: Data): Promise<Data> {
if (!this.launchDarklyClient) return defaultValue; // Skip any action if no LD User is defined
await this.launchDarklyClient.waitForInitialization();
return await this.launchDarklyClient.variation(configKey, defaultValue);
}
public reportMetric(metricName: string, meta?: unknown, value?: number): void {
if (!this.launchDarklyClient) return; // Skip any action if no LD User is defined
this.launchDarklyClient.track(metricName, meta, value);
}
public stop() {
this.launchDarklyClient
?.flush()
// eslint-disable-next-line no-console
.catch((err) => console.warn(err));
}
}

View file

@ -5,17 +5,30 @@
* 2.0.
*/
import { duration } from 'moment';
import { coreMock } from '@kbn/core/public/mocks';
import { cloudMock } from '@kbn/cloud-plugin/public/mocks';
import { ldClientMock } from './plugin.test.mock';
import { CloudExperimentsPlugin } from './plugin';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { CloudExperimentsPluginStart } from '../common';
import { FEATURE_FLAG_NAMES } from '../common/constants';
import { CloudExperimentsPlugin } from './plugin';
import { LaunchDarklyClient } from './launch_darkly_client';
import { MetadataService } from '../common/metadata_service';
jest.mock('./launch_darkly_client');
function getLaunchDarklyClientInstanceMock() {
const launchDarklyClientInstanceMock = (
LaunchDarklyClient as jest.MockedClass<typeof LaunchDarklyClient>
).mock.instances[0] as jest.Mocked<LaunchDarklyClient>;
return launchDarklyClientInstanceMock;
}
describe('Cloud Experiments public plugin', () => {
jest.spyOn(console, 'debug').mockImplementation(); // silence console.debug logs
beforeEach(() => {
jest.resetAllMocks();
afterEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
@ -29,6 +42,7 @@ describe('Cloud Experiments public plugin', () => {
expect(plugin).toHaveProperty('stop');
expect(plugin).toHaveProperty('flagOverrides', undefined);
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
expect(plugin).toHaveProperty('metadataService', expect.any(MetadataService));
});
test('fails if launch_darkly is not provided in the config and it is a non-dev environment', () => {
@ -49,17 +63,34 @@ describe('Cloud Experiments public plugin', () => {
const plugin = new CloudExperimentsPlugin(initializerContext);
expect(plugin).toHaveProperty('flagOverrides', { my_flag: '1234' });
});
test('it initializes the LaunchDarkly client', () => {
const initializerContext = coreMock.createPluginInitializerContext({
launch_darkly: { client_id: 'sdk-1234' },
});
const plugin = new CloudExperimentsPlugin(initializerContext);
expect(LaunchDarklyClient).toHaveBeenCalledTimes(1);
expect(plugin).toHaveProperty('launchDarklyClient', expect.any(LaunchDarklyClient));
});
});
describe('setup', () => {
let plugin: CloudExperimentsPlugin;
let metadataServiceSetupSpy: jest.SpyInstance;
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext({
launch_darkly: { client_id: '1234' },
flag_overrides: { my_flag: '1234' },
metadata_refresh_interval: duration(1, 'h'),
});
plugin = new CloudExperimentsPlugin(initializerContext);
// eslint-disable-next-line dot-notation
metadataServiceSetupSpy = jest.spyOn(plugin['metadataService'], 'setup');
});
afterEach(() => {
plugin.stop();
});
test('returns no contract', () => {
@ -74,37 +105,41 @@ describe('Cloud Experiments public plugin', () => {
test('it skips creating the client if no client id provided in the config', () => {
const initializerContext = coreMock.createPluginInitializerContext({
flag_overrides: { my_flag: '1234' },
metadata_refresh_interval: duration(1, 'h'),
});
const customPlugin = new CloudExperimentsPlugin(initializerContext);
expect(customPlugin).toHaveProperty('launchDarklyClient', undefined);
customPlugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
expect(customPlugin).toHaveProperty('launchDarklyClient', undefined);
});
test('it skips creating the client if cloud is not enabled', () => {
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
test('it skips identifying the user if cloud is not enabled', () => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: false },
});
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
expect(metadataServiceSetupSpy).not.toHaveBeenCalled();
});
test('it initializes the LaunchDarkly client', async () => {
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
// await the lazy import
await new Promise((resolve) => process.nextTick(resolve));
expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock);
expect(metadataServiceSetupSpy).toHaveBeenCalledWith({
is_elastic_staff_owned: true,
kibanaVersion: 'version',
trial_end_date: '2020-10-01T14:13:12.000Z',
userId: '1c2412b751f056aef6e340efa5637d137442d489a4b1e3117071e7c87f8523f2',
});
});
});
});
describe('start', () => {
let plugin: CloudExperimentsPlugin;
let launchDarklyInstanceMock: jest.Mocked<LaunchDarklyClient>;
const firstKnownFlag = Object.keys(FEATURE_FLAG_NAMES)[0] as keyof typeof FEATURE_FLAG_NAMES;
@ -114,11 +149,19 @@ describe('Cloud Experiments public plugin', () => {
flag_overrides: { [firstKnownFlag]: '1234' },
});
plugin = new CloudExperimentsPlugin(initializerContext);
launchDarklyInstanceMock = getLaunchDarklyClientInstanceMock();
});
afterEach(() => {
plugin.stop();
});
test('returns the contract', () => {
plugin.setup(coreMock.createSetup(), { cloud: cloudMock.createSetup() });
const startContract = plugin.start(coreMock.createStart());
const startContract = plugin.start(coreMock.createStart(), {
cloud: cloudMock.createStart(),
dataViews: dataViewPluginMocks.createStartContract(),
});
expect(startContract).toStrictEqual(
expect.objectContaining({
getVariation: expect.any(Function),
@ -127,24 +170,46 @@ describe('Cloud Experiments public plugin', () => {
);
});
test('triggers a userMetadataUpdate for `has_data`', async () => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
const dataViews = dataViewPluginMocks.createStartContract();
plugin.start(coreMock.createStart(), { cloud: cloudMock.createStart(), dataViews });
// After scheduler kicks in...
await new Promise((resolve) => setTimeout(resolve, 200));
// Using a timeout of 0ms to let the `timer` kick in.
// For some reason, fakeSchedulers is not working on browser-side tests :shrug:
expect(launchDarklyInstanceMock.updateUserMetadata).toHaveBeenCalledWith(
expect.objectContaining({
has_data: true,
})
);
});
describe('getVariation', () => {
describe('with the user identified', () => {
let startContract: CloudExperimentsPluginStart;
describe('with the client created', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
startContract = plugin.start(coreMock.createStart(), {
cloud: cloudMock.createStart(),
dataViews: dataViewPluginMocks.createStartContract(),
});
});
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');
launchDarklyInstanceMock.getVariation.mockResolvedValue('12345');
await expect(
startContract.getVariation(
// @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES
@ -152,29 +217,37 @@ describe('Cloud Experiments public plugin', () => {
123
)
).resolves.toStrictEqual('12345');
expect(ldClientMock.variation).toHaveBeenCalledWith(
expect(launchDarklyInstanceMock.getVariation).toHaveBeenCalledWith(
undefined, // it couldn't find it in FEATURE_FLAG_NAMES
123
);
});
});
describe('with the user not identified', () => {
describe('with the client not created', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: false },
const initializerContext = coreMock.createPluginInitializerContext({
flag_overrides: { [firstKnownFlag]: '1234' },
metadata_refresh_interval: duration(1, 'h'),
});
const customPlugin = new CloudExperimentsPlugin(initializerContext);
customPlugin.setup(coreMock.createSetup(), {
cloud: cloudMock.createSetup(),
});
expect(customPlugin).toHaveProperty('launchDarklyClient', undefined);
startContract = customPlugin.start(coreMock.createStart(), {
cloud: cloudMock.createStart(),
dataViews: dataViewPluginMocks.createStartContract(),
});
});
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
@ -182,28 +255,32 @@ describe('Cloud Experiments public plugin', () => {
123
)
).resolves.toStrictEqual(123);
expect(ldClientMock.variation).not.toHaveBeenCalled();
expect(launchDarklyInstanceMock.getVariation).not.toHaveBeenCalled();
});
});
});
describe('reportMetric', () => {
describe('with the user identified', () => {
let startContract: CloudExperimentsPluginStart;
describe('with the client created', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
startContract = plugin.start(coreMock.createStart(), {
cloud: cloudMock.createStart(),
dataViews: dataViewPluginMocks.createStartContract(),
});
});
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(
expect(launchDarklyInstanceMock.reportMetric).toHaveBeenCalledWith(
undefined, // it couldn't find it in METRIC_NAMES
{},
1
@ -211,22 +288,31 @@ describe('Cloud Experiments public plugin', () => {
});
});
describe('with the user not identified', () => {
describe('with the client not created', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: false },
const initializerContext = coreMock.createPluginInitializerContext({
flag_overrides: { [firstKnownFlag]: '1234' },
metadata_refresh_interval: duration(1, 'h'),
});
const customPlugin = new CloudExperimentsPlugin(initializerContext);
customPlugin.setup(coreMock.createSetup(), {
cloud: cloudMock.createSetup(),
});
expect(customPlugin).toHaveProperty('launchDarklyClient', undefined);
startContract = customPlugin.start(coreMock.createStart(), {
cloud: cloudMock.createStart(),
dataViews: dataViewPluginMocks.createStartContract(),
});
});
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();
expect(launchDarklyInstanceMock.reportMetric).not.toHaveBeenCalled();
});
});
});
@ -234,33 +320,28 @@ describe('Cloud Experiments public plugin', () => {
describe('stop', () => {
let plugin: CloudExperimentsPlugin;
let launchDarklyInstanceMock: jest.Mocked<LaunchDarklyClient>;
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext({
launch_darkly: { client_id: '1234' },
flag_overrides: { my_flag: '1234' },
metadata_refresh_interval: duration(1, 'h'),
});
plugin = new CloudExperimentsPlugin(initializerContext);
launchDarklyInstanceMock = getLaunchDarklyClientInstanceMock();
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
plugin.start(coreMock.createStart());
plugin.start(coreMock.createStart(), {
cloud: cloudMock.createStart(),
dataViews: dataViewPluginMocks.createStartContract(),
});
});
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);
expect(launchDarklyInstanceMock.stop).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -6,29 +6,38 @@
*/
import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import type { LDClient } from 'launchdarkly-js-client-sdk';
import { get, has } from 'lodash';
import { duration } from 'moment';
import { concatMap } from 'rxjs';
import { Sha256 } from '@kbn/crypto-browser';
import type { CloudSetup } from '@kbn/cloud-plugin/public';
import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { LaunchDarklyClient, type LaunchDarklyClientConfig } from './launch_darkly_client';
import type {
CloudExperimentsFeatureFlagNames,
CloudExperimentsMetric,
CloudExperimentsPluginStart,
} from '../common';
import { MetadataService } from '../common/metadata_service';
import { FEATURE_FLAG_NAMES, METRIC_NAMES } from '../common/constants';
interface CloudExperimentsPluginSetupDeps {
cloud: CloudSetup;
}
interface CloudExperimentsPluginStartDeps {
cloud: CloudStart;
dataViews: DataViewsPublicPluginStart;
}
/**
* Browser-side implementation of the Cloud Experiments plugin
*/
export class CloudExperimentsPlugin
implements Plugin<void, CloudExperimentsPluginStart, CloudExperimentsPluginSetupDeps>
{
private launchDarklyClient?: LDClient;
private readonly clientId?: string;
private readonly metadataService: MetadataService;
private readonly launchDarklyClient?: LaunchDarklyClient;
private readonly kibanaVersion: string;
private readonly flagOverrides?: Record<string, unknown>;
private readonly isDev: boolean;
@ -38,22 +47,28 @@ export class CloudExperimentsPlugin
this.isDev = initializerContext.env.mode.dev;
this.kibanaVersion = initializerContext.env.packageInfo.version;
const config = initializerContext.config.get<{
launch_darkly?: { client_id: string };
launch_darkly?: LaunchDarklyClientConfig;
flag_overrides?: Record<string, unknown>;
metadata_refresh_interval: string;
}>();
this.metadataService = new MetadataService({
metadata_refresh_interval: duration(config.metadata_refresh_interval),
});
if (config.flag_overrides) {
this.flagOverrides = config.flag_overrides;
}
const ldConfig = config.launch_darkly;
if (!ldConfig && !initializerContext.env.mode.dev) {
if (!ldConfig?.client_id && !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;
if (ldConfig?.client_id) {
this.launchDarklyClient = new LaunchDarklyClient(ldConfig, this.kibanaVersion);
}
}
@ -63,26 +78,13 @@ export class CloudExperimentsPlugin
* @param deps {@link CloudExperimentsPluginSetupDeps}
*/
public setup(core: CoreSetup, deps: CloudExperimentsPluginSetupDeps) {
if (deps.cloud.isCloudEnabled && deps.cloud.cloudId && this.clientId) {
import('launchdarkly-js-client-sdk').then(
(LaunchDarkly) => {
this.launchDarklyClient = LaunchDarkly.initialize(
this.clientId!,
{
// We use the Hashed Cloud Deployment ID as the userId in the Cloud Experiments
key: sha256(deps.cloud.cloudId!),
custom: {
kibanaVersion: this.kibanaVersion,
},
},
{ application: { id: 'kibana-browser', version: this.kibanaVersion } }
);
},
(err) => {
// eslint-disable-next-line no-console
console.debug(`Error setting up LaunchDarkly: ${err.toString()}`);
}
);
if (deps.cloud.isCloudEnabled && deps.cloud.cloudId && this.launchDarklyClient) {
this.metadataService.setup({
userId: sha256(deps.cloud.cloudId),
kibanaVersion: this.kibanaVersion,
trial_end_date: deps.cloud.trialEndDate?.toISOString(),
is_elastic_staff_owned: deps.cloud.isElasticStaffOwned,
});
}
}
@ -90,7 +92,26 @@ export class CloudExperimentsPlugin
* Returns the contract {@link CloudExperimentsPluginStart}
* @param core {@link CoreStart}
*/
public start(core: CoreStart): CloudExperimentsPluginStart {
public start(
core: CoreStart,
{ cloud, dataViews }: CloudExperimentsPluginStartDeps
): CloudExperimentsPluginStart {
if (cloud.isCloudEnabled) {
this.metadataService.start({
hasDataFetcher: async () => ({ has_data: await dataViews.hasData.hasUserDataView() }),
});
// We only subscribe to the user metadata updates if Cloud is enabled.
// This way, since the user is not identified, it cannot retrieve Feature Flags from LaunchDarkly when not running on Cloud.
this.metadataService.userMetadata$
.pipe(
// Using concatMap to ensure we call the promised update in an orderly manner to avoid concurrency issues
concatMap(
async (userMetadata) => await this.launchDarklyClient!.updateUserMetadata(userMetadata)
)
)
.subscribe(); // This subscription will stop on when the metadataService stops because it completes the Observable
}
return {
getVariation: this.getVariation,
reportMetric: this.reportMetric,
@ -101,10 +122,8 @@ export class CloudExperimentsPlugin
* Cleans up and flush the sending queues.
*/
public stop() {
this.launchDarklyClient
?.flush()
// eslint-disable-next-line no-console
.catch((err) => console.warn(err));
this.launchDarklyClient?.stop();
this.metadataService.stop();
}
private getVariation = async <Data>(
@ -112,18 +131,23 @@ export class CloudExperimentsPlugin
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);
// Skip any action if no LD Client is defined
if (!this.launchDarklyClient) {
return defaultValue;
}
return await this.launchDarklyClient.getVariation(configKey, defaultValue);
};
private reportMetric = <Data>({ name, meta, value }: CloudExperimentsMetric<Data>): void => {
const metricName = METRIC_NAMES[name];
this.launchDarklyClient?.track(metricName, meta, value);
this.launchDarklyClient?.reportMetric(metricName, meta, value);
if (this.isDev) {
// eslint-disable-next-line no-console
console.debug(`Reported experimentation metric ${metricName}`, {

View file

@ -5,13 +5,17 @@
* 2.0.
*/
import moment from 'moment';
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 });
expect(config.schema.validate({}, ctx)).toStrictEqual({
enabled: false,
metadata_refresh_interval: moment.duration(1, 'h'),
});
});
test('it should allow any additional config', () => {
@ -26,7 +30,11 @@ describe('cloudExperiments config', () => {
'my-plugin.my-feature-flag': 1234,
},
};
expect(config.schema.validate(cfg, ctx)).toStrictEqual(cfg);
expect(config.schema.validate(cfg, ctx)).toStrictEqual({
...cfg,
// Additional default fields
metadata_refresh_interval: moment.duration(1, 'h'),
});
});
test('it should allow any additional config (missing flag_overrides)', () => {
@ -38,7 +46,10 @@ describe('cloudExperiments config', () => {
client_log_level: 'none',
},
};
expect(config.schema.validate(cfg, ctx)).toStrictEqual(cfg);
expect(config.schema.validate(cfg, ctx)).toStrictEqual({
...cfg,
metadata_refresh_interval: moment.duration(1, 'h'),
});
});
test('it should allow any additional config (missing launch_darkly)', () => {
@ -48,7 +59,10 @@ describe('cloudExperiments config', () => {
'my-plugin.my-feature-flag': 1234,
},
};
expect(config.schema.validate(cfg, ctx)).toStrictEqual(cfg);
expect(config.schema.validate(cfg, ctx)).toStrictEqual({
...cfg,
metadata_refresh_interval: moment.duration(1, 'h'),
});
});
});
@ -61,11 +75,15 @@ describe('cloudExperiments config', () => {
).toStrictEqual({
enabled: true,
flag_overrides: { my_flag: 1 },
metadata_refresh_interval: moment.duration(1, 'h'),
});
});
test('in dev mode, it allows `launch_darkly` and `flag_overrides` to be empty', () => {
expect(config.schema.validate({ enabled: true }, ctx)).toStrictEqual({ enabled: true });
expect(config.schema.validate({ enabled: true }, ctx)).toStrictEqual({
enabled: true,
metadata_refresh_interval: moment.duration(1, 'h'),
});
});
});
@ -98,6 +116,7 @@ describe('cloudExperiments config', () => {
client_id: '1234',
client_log_level: 'none',
},
metadata_refresh_interval: moment.duration(1, 'h'),
});
});
@ -126,6 +145,7 @@ describe('cloudExperiments config', () => {
flag_overrides: {
my_flag: 123,
},
metadata_refresh_interval: moment.duration(1, 'h'),
});
});
});

View file

@ -37,6 +37,7 @@ const configSchema = schema.object({
schema.maybe(launchDarklySchema)
),
flag_overrides: schema.maybe(schema.recordOf(schema.string(), schema.any())),
metadata_refresh_interval: schema.duration({ defaultValue: '1h' }),
});
export type CloudExperimentsConfigType = TypeOf<typeof configSchema>;
@ -45,8 +46,10 @@ export const config: PluginConfigDescriptor<CloudExperimentsConfigType> = {
exposeToBrowser: {
launch_darkly: {
client_id: true,
client_log_level: true,
},
flag_overrides: true,
metadata_refresh_interval: true,
},
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 { LaunchDarklyClient, type LaunchDarklyUserMetadata } from './launch_darkly_client';

View file

@ -13,7 +13,6 @@ export function createLaunchDarklyClientMock(): jest.Mocked<LDClient> {
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.
}

View file

@ -0,0 +1,211 @@
/*
* 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 { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
import { ldClientMock } from './launch_darkly_client.test.mock';
import LaunchDarkly from 'launchdarkly-node-server-sdk';
import { LaunchDarklyClient, type LaunchDarklyClientConfig } from './launch_darkly_client';
describe('LaunchDarklyClient - server', () => {
beforeEach(() => {
jest.resetAllMocks();
});
const config: LaunchDarklyClientConfig = {
sdk_key: 'fake-sdk-key',
client_id: 'fake-client-id',
client_log_level: 'debug',
kibana_version: 'version',
};
describe('constructor', () => {
let launchDarklyInitSpy: jest.SpyInstance;
beforeEach(() => {
launchDarklyInitSpy = jest.spyOn(LaunchDarkly, 'init');
});
afterEach(() => {
launchDarklyInitSpy.mockRestore();
});
test('it initializes the LaunchDarkly client', async () => {
const logger = loggerMock.create();
ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock);
const client = new LaunchDarklyClient(config, logger);
expect(launchDarklyInitSpy).toHaveBeenCalledWith('fake-sdk-key', {
application: { id: 'kibana-server', version: 'version' },
logger: undefined, // The method basicLogger is mocked without a return value
stream: false,
});
expect(client).toHaveProperty('launchDarklyClient', ldClientMock);
await new Promise((resolve) => process.nextTick(resolve)); // wait for the waitForInitialization resolution
expect(logger.debug).toHaveBeenCalledWith('LaunchDarkly is initialized!');
});
test('it initializes the LaunchDarkly client... and handles failure', async () => {
const logger = loggerMock.create();
ldClientMock.waitForInitialization.mockRejectedValue(
new Error('Something went terribly wrong')
);
const client = new LaunchDarklyClient(config, logger);
expect(launchDarklyInitSpy).toHaveBeenCalledWith('fake-sdk-key', {
application: { id: 'kibana-server', version: 'version' },
logger: undefined, // The method basicLogger is mocked without a return value
stream: false,
});
expect(client).toHaveProperty('launchDarklyClient', ldClientMock);
await new Promise((resolve) => process.nextTick(resolve)); // wait for the waitForInitialization resolution
expect(logger.warn).toHaveBeenCalledWith(
'Error initializing LaunchDarkly: Error: Something went terribly wrong'
);
});
});
describe('Public APIs', () => {
let client: LaunchDarklyClient;
let logger: MockedLogger;
const testUserMetadata = { userId: 'fake-user-id', kibanaVersion: 'version' };
beforeEach(() => {
logger = loggerMock.create();
ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock);
client = new LaunchDarklyClient(config, logger);
});
describe('updateUserMetadata', () => {
test('sets the top-level properties at the root (renaming userId to key) and the rest under `custom`', () => {
expect(client).toHaveProperty('launchDarklyUser', undefined);
const topFields = {
name: 'First Last',
firstName: 'First',
lastName: 'Last',
email: 'first.last@boring.co',
avatar: 'fake-blue-avatar',
ip: 'my-weird-ip',
country: 'distributed',
};
const extraFields = {
other_field: 'my other custom field',
kibanaVersion: 'version',
};
client.updateUserMetadata({ userId: 'fake-user-id', ...topFields, ...extraFields });
expect(client).toHaveProperty('launchDarklyUser', {
key: 'fake-user-id',
...topFields,
custom: extraFields,
});
});
test('sets a minimum amount of info', () => {
expect(client).toHaveProperty('launchDarklyUser', undefined);
client.updateUserMetadata({ userId: 'fake-user-id', kibanaVersion: 'version' });
expect(client).toHaveProperty('launchDarklyUser', {
key: 'fake-user-id',
custom: { kibanaVersion: 'version' },
});
});
});
describe('getVariation', () => {
test('returns the default value if the user has not been defined', async () => {
await expect(client.getVariation('my-feature-flag', 123)).resolves.toStrictEqual(123);
expect(ldClientMock.variation).toHaveBeenCalledTimes(0);
});
test('calls the LaunchDarkly client when the user has been defined', async () => {
ldClientMock.variation.mockResolvedValue(1234);
client.updateUserMetadata(testUserMetadata);
await expect(client.getVariation('my-feature-flag', 123)).resolves.toStrictEqual(1234);
expect(ldClientMock.variation).toHaveBeenCalledTimes(1);
expect(ldClientMock.variation).toHaveBeenCalledWith(
'my-feature-flag',
{ key: 'fake-user-id', custom: { kibanaVersion: 'version' } },
123
);
});
});
describe('reportMetric', () => {
test('does not call track if the user has not been defined', () => {
client.reportMetric('my-feature-flag', {}, 123);
expect(ldClientMock.track).toHaveBeenCalledTimes(0);
});
test('calls the LaunchDarkly client when the user has been defined', () => {
client.updateUserMetadata(testUserMetadata);
client.reportMetric('my-feature-flag', {}, 123);
expect(ldClientMock.track).toHaveBeenCalledTimes(1);
expect(ldClientMock.track).toHaveBeenCalledWith(
'my-feature-flag',
{ key: 'fake-user-id', custom: { kibanaVersion: 'version' } },
{},
123
);
});
});
describe('getAllFlags', () => {
test('returns the non-initialized state if the user has not been defined', async () => {
await expect(client.getAllFlags()).resolves.toStrictEqual({
initialized: false,
flagNames: [],
flags: {},
});
expect(ldClientMock.allFlagsState).toHaveBeenCalledTimes(0);
});
test('calls the LaunchDarkly client when the user has been defined', async () => {
ldClientMock.allFlagsState.mockResolvedValue({
valid: true,
allValues: jest.fn().mockReturnValue({ my_flag: '1234' }),
getFlagValue: jest.fn(),
getFlagReason: jest.fn(),
toJSON: jest.fn(),
});
client.updateUserMetadata(testUserMetadata);
await expect(client.getAllFlags()).resolves.toStrictEqual({
initialized: true,
flagNames: ['my_flag'],
flags: { my_flag: '1234' },
});
expect(ldClientMock.allFlagsState).toHaveBeenCalledTimes(1);
expect(ldClientMock.allFlagsState).toHaveBeenCalledWith({
key: 'fake-user-id',
custom: { kibanaVersion: 'version' },
});
});
});
describe('stop', () => {
test('flushes the events', async () => {
ldClientMock.flush.mockResolvedValue();
expect(() => client.stop()).not.toThrow();
expect(ldClientMock.flush).toHaveBeenCalledTimes(1);
await new Promise((resolve) => process.nextTick(resolve)); // wait for the flush resolution
expect(logger.error).not.toHaveBeenCalled();
});
test('handles errors when flushing events', async () => {
const err = new Error('Something went terribly wrong');
ldClientMock.flush.mockRejectedValue(err);
expect(() => client.stop()).not.toThrow();
expect(ldClientMock.flush).toHaveBeenCalledTimes(1);
await new Promise((resolve) => process.nextTick(resolve)); // wait for the flush resolution
expect(logger.error).toHaveBeenCalledWith(err);
});
});
});
});

View file

@ -0,0 +1,104 @@
/*
* 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 LaunchDarkly, {
type LDClient,
type LDFlagSet,
type LDLogLevel,
type LDUser,
} from 'launchdarkly-node-server-sdk';
import type { Logger } from '@kbn/core/server';
export interface LaunchDarklyClientConfig {
sdk_key: string;
client_id: string;
client_log_level: LDLogLevel;
kibana_version: string;
}
export interface LaunchDarklyUserMetadata
extends Record<string, string | boolean | number | undefined> {
userId: string;
// We are not collecting any of the above, but this is to match the LDUser first-level definition
name?: string;
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
ip?: string;
country?: string;
}
export interface LaunchDarklyGetAllFlags {
initialized: boolean;
flags: LDFlagSet;
flagNames: string[];
}
export class LaunchDarklyClient {
private readonly launchDarklyClient: LDClient;
private launchDarklyUser?: LDUser;
constructor(ldConfig: LaunchDarklyClientConfig, private readonly logger: Logger) {
this.launchDarklyClient = LaunchDarkly.init(ldConfig.sdk_key, {
application: { id: `kibana-server`, version: ldConfig.kibana_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 updateUserMetadata(userMetadata: LaunchDarklyUserMetadata) {
const { userId, name, firstName, lastName, email, avatar, ip, country, ...custom } =
userMetadata;
this.launchDarklyUser = {
key: userId,
name,
firstName,
lastName,
email,
avatar,
ip,
country,
// This casting is needed because LDUser does not allow `Record<string, undefined>`
custom: custom as Record<string, string | boolean | number>,
};
}
public async getVariation<Data>(configKey: string, defaultValue: Data): Promise<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);
}
public reportMetric(metricName: string, meta?: unknown, value?: number): void {
if (!this.launchDarklyUser) return; // Skip any action if no LD User is defined
this.launchDarklyClient.track(metricName, this.launchDarklyUser, meta, value);
}
public async getAllFlags(): Promise<LaunchDarklyGetAllFlags> {
if (!this.launchDarklyUser) 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 this.launchDarklyClient.allFlagsState(this.launchDarklyUser);
const flags = flagsState.allValues();
return {
initialized: flagsState.valid,
flags,
flagNames: Object.keys(flags),
};
}
public stop() {
this.launchDarklyClient?.flush().catch((err) => this.logger.error(err));
}
}

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 { PublicMethodsOf } from '@kbn/utility-types';
import { LaunchDarklyClient } from './launch_darkly_client';
function createLaunchDarklyClientMock(): jest.Mocked<LaunchDarklyClient> {
const launchDarklyClientMock: jest.Mocked<PublicMethodsOf<LaunchDarklyClient>> = {
updateUserMetadata: jest.fn(),
getVariation: jest.fn(),
getAllFlags: jest.fn(),
reportMetric: jest.fn(),
stop: jest.fn(),
};
return launchDarklyClientMock as jest.Mocked<LaunchDarklyClient>;
}
export const launchDarklyClientMocks = {
launchDarklyClientMock: createLaunchDarklyClientMock(),
createLaunchDarklyClient: createLaunchDarklyClientMock,
};

View file

@ -5,24 +5,28 @@
* 2.0.
*/
import { fakeSchedulers } from 'rxjs-marbles/jest';
import { coreMock } from '@kbn/core/server/mocks';
import { cloudMock } from '@kbn/cloud-plugin/server/mocks';
import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/server/mocks';
import { ldClientMock } from './plugin.test.mock';
import {
createIndexPatternsStartMock,
dataViewsService,
} from '@kbn/data-views-plugin/server/mocks';
import { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server';
import { config } from './config';
import { CloudExperimentsPlugin } from './plugin';
import { FEATURE_FLAG_NAMES } from '../common/constants';
import { LaunchDarklyClient } from './launch_darkly_client';
jest.mock('./launch_darkly_client');
describe('Cloud Experiments server plugin', () => {
beforeEach(() => {
jest.resetAllMocks();
});
jest.useFakeTimers();
const ldUser = {
key: '1c2412b751f056aef6e340efa5637d137442d489a4b1e3117071e7c87f8523f2',
custom: {
kibanaVersion: coreMock.createPluginInitializerContext().env.packageInfo.version,
},
};
afterEach(() => {
jest.clearAllTimers();
jest.clearAllMocks();
});
describe('constructor', () => {
test('successfully creates a new plugin if provided an empty configuration', () => {
@ -48,9 +52,9 @@ describe('Cloud Experiments server plugin', () => {
const initializerContext = coreMock.createPluginInitializerContext({
launch_darkly: { sdk_key: 'sdk-1234' },
});
ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock);
const plugin = new CloudExperimentsPlugin(initializerContext);
expect(plugin).toHaveProperty('launchDarklyClient', ldClientMock);
expect(LaunchDarklyClient).toHaveBeenCalledTimes(1);
expect(plugin).toHaveProperty('launchDarklyClient', expect.any(LaunchDarklyClient));
});
test('it initializes the flagOverrides property', () => {
@ -67,14 +71,22 @@ describe('Cloud Experiments server plugin', () => {
let plugin: CloudExperimentsPlugin;
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext({
launch_darkly: { sdk_key: 'sdk-1234' },
flag_overrides: { my_flag: '1234' },
});
ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock);
const initializerContext = coreMock.createPluginInitializerContext(
config.schema.validate(
{
launch_darkly: { sdk_key: 'sdk-1234', client_id: 'fake-client-id' },
flag_overrides: { my_flag: '1234' },
},
{ dev: true }
)
);
plugin = new CloudExperimentsPlugin(initializerContext);
});
afterEach(() => {
plugin.stop();
});
test('returns the contract', () => {
expect(
plugin.setup(coreMock.createSetup(), {
@ -93,35 +105,58 @@ describe('Cloud Experiments server plugin', () => {
expect(usageCollection.registerCollector).toHaveBeenCalledTimes(1);
});
describe('identifyUser', () => {
test('sets launchDarklyUser and calls identify', () => {
expect(plugin).toHaveProperty('launchDarklyUser', undefined);
test(
'updates the user metadata on setup',
fakeSchedulers((advance) => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
expect(plugin).toHaveProperty('launchDarklyUser', ldUser);
expect(ldClientMock.identify).toHaveBeenCalledWith(ldUser);
});
});
const launchDarklyInstanceMock = (
LaunchDarklyClient as jest.MockedClass<typeof LaunchDarklyClient>
).mock.instances[0];
advance(100); // Remove the debounceTime effect
expect(launchDarklyInstanceMock.updateUserMetadata).toHaveBeenCalledWith({
userId: '1c2412b751f056aef6e340efa5637d137442d489a4b1e3117071e7c87f8523f2',
kibanaVersion: coreMock.createPluginInitializerContext().env.packageInfo.version,
is_elastic_staff_owned: true,
trial_end_date: expect.any(String),
});
})
);
});
describe('start', () => {
let plugin: CloudExperimentsPlugin;
let dataViews: jest.Mocked<DataViewsServerPluginStart>;
let launchDarklyInstanceMock: jest.Mocked<LaunchDarklyClient>;
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);
jest.useRealTimers();
const initializerContext = coreMock.createPluginInitializerContext(
config.schema.validate(
{
launch_darkly: { sdk_key: 'sdk-1234', client_id: 'fake-client-id' },
flag_overrides: { [firstKnownFlag]: '1234' },
},
{ dev: true }
)
);
plugin = new CloudExperimentsPlugin(initializerContext);
dataViews = createIndexPatternsStartMock();
launchDarklyInstanceMock = (LaunchDarklyClient as jest.MockedClass<typeof LaunchDarklyClient>)
.mock.instances[0] as jest.Mocked<LaunchDarklyClient>;
});
afterEach(() => {
plugin.stop();
jest.useFakeTimers();
});
test('returns the contract', () => {
plugin.setup(coreMock.createSetup(), { cloud: cloudMock.createSetup() });
const startContract = plugin.start(coreMock.createStart());
const startContract = plugin.start(coreMock.createStart(), { dataViews });
expect(startContract).toStrictEqual(
expect.objectContaining({
getVariation: expect.any(Function),
@ -130,8 +165,25 @@ describe('Cloud Experiments server plugin', () => {
);
});
test('triggers a userMetadataUpdate for `has_data`', async () => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
dataViews.dataViewsServiceFactory.mockResolvedValue(dataViewsService);
dataViewsService.hasUserDataView.mockResolvedValue(true);
plugin.start(coreMock.createStart(), { dataViews });
// After scheduler kicks in...
await new Promise((resolve) => setTimeout(resolve, 200)); // Waiting for scheduler and debounceTime to complete (don't know why fakeScheduler didn't work here).
expect(launchDarklyInstanceMock.updateUserMetadata).toHaveBeenCalledWith(
expect.objectContaining({
has_data: true,
})
);
});
describe('getVariation', () => {
describe('with the user identified', () => {
describe('with the client created', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
@ -139,15 +191,15 @@ describe('Cloud Experiments server plugin', () => {
});
test('uses the flag overrides to respond early', async () => {
const startContract = plugin.start(coreMock.createStart());
const startContract = plugin.start(coreMock.createStart(), { dataViews });
await expect(startContract.getVariation(firstKnownFlag, 123)).resolves.toStrictEqual(
'1234'
);
});
test('calls the client', async () => {
const startContract = plugin.start(coreMock.createStart());
ldClientMock.variation.mockResolvedValue('12345');
const startContract = plugin.start(coreMock.createStart(), { dataViews });
launchDarklyInstanceMock.getVariation.mockResolvedValue('12345');
await expect(
startContract.getVariation(
// @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES
@ -155,30 +207,38 @@ describe('Cloud Experiments server plugin', () => {
123
)
).resolves.toStrictEqual('12345');
expect(ldClientMock.variation).toHaveBeenCalledWith(
expect(launchDarklyInstanceMock.getVariation).toHaveBeenCalledWith(
undefined, // it couldn't find it in FEATURE_FLAG_NAMES
ldUser,
123
);
});
});
describe('with the user not identified', () => {
describe('with the client not created (missing LD settings)', () => {
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext(
config.schema.validate(
{
flag_overrides: { [firstKnownFlag]: '1234' },
},
{ dev: true }
)
);
plugin = new CloudExperimentsPlugin(initializerContext);
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: false },
});
});
test('uses the flag overrides to respond early', async () => {
const startContract = plugin.start(coreMock.createStart());
const startContract = plugin.start(coreMock.createStart(), { dataViews });
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());
const startContract = plugin.start(coreMock.createStart(), { dataViews });
await expect(
startContract.getVariation(
// @ts-expect-error We only allow existing flags in FEATURE_FLAG_NAMES
@ -186,52 +246,59 @@ describe('Cloud Experiments server plugin', () => {
123
)
).resolves.toStrictEqual(123);
expect(ldClientMock.variation).not.toHaveBeenCalled();
});
});
});
describe('reportMetric', () => {
describe('with the user identified', () => {
describe('with the client created', () => {
beforeEach(() => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
});
test('calls the track API', () => {
const startContract = plugin.start(coreMock.createStart());
test('calls LaunchDarklyClient.reportMetric', () => {
const startContract = plugin.start(coreMock.createStart(), { dataViews });
startContract.reportMetric({
// @ts-expect-error We only allow existing flags in METRIC_NAMES
name: 'my-flag',
meta: {},
value: 1,
});
expect(ldClientMock.track).toHaveBeenCalledWith(
expect(launchDarklyInstanceMock.reportMetric).toHaveBeenCalledWith(
undefined, // it couldn't find it in METRIC_NAMES
ldUser,
{},
1
);
});
});
describe('with the user not identified', () => {
describe('with the client not created (missing LD settings)', () => {
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext(
config.schema.validate(
{
flag_overrides: { [firstKnownFlag]: '1234' },
},
{ dev: true }
)
);
plugin = new CloudExperimentsPlugin(initializerContext);
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: false },
});
});
test('calls the track API', () => {
const startContract = plugin.start(coreMock.createStart());
test('does not call LaunchDarklyClient.reportMetric because the client is not there', () => {
const startContract = plugin.start(coreMock.createStart(), { dataViews });
startContract.reportMetric({
// @ts-expect-error We only allow existing flags in METRIC_NAMES
name: 'my-flag',
meta: {},
value: 1,
});
expect(ldClientMock.track).not.toHaveBeenCalled();
expect(plugin).toHaveProperty('launchDarklyClient', undefined);
});
});
});
@ -241,28 +308,36 @@ describe('Cloud Experiments server plugin', () => {
let plugin: CloudExperimentsPlugin;
beforeEach(() => {
const initializerContext = coreMock.createPluginInitializerContext({
launch_darkly: { sdk_key: 'sdk-1234' },
flag_overrides: { my_flag: '1234' },
});
ldClientMock.waitForInitialization.mockResolvedValue(ldClientMock);
const initializerContext = coreMock.createPluginInitializerContext(
config.schema.validate(
{
launch_darkly: { sdk_key: 'sdk-1234', client_id: 'fake-client-id' },
flag_overrides: { my_flag: '1234' },
},
{ dev: true }
)
);
plugin = new CloudExperimentsPlugin(initializerContext);
const dataViews = createIndexPatternsStartMock();
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
plugin.start(coreMock.createStart());
plugin.start(coreMock.createStart(), { dataViews });
});
test('flushes the events', () => {
ldClientMock.flush.mockResolvedValue();
expect(() => plugin.stop()).not.toThrow();
expect(ldClientMock.flush).toHaveBeenCalledTimes(1);
test('stops the LaunchDarkly client', () => {
plugin.stop();
const launchDarklyInstanceMock = (
LaunchDarklyClient as jest.MockedClass<typeof LaunchDarklyClient>
).mock.instances[0] as jest.Mocked<LaunchDarklyClient>;
expect(launchDarklyInstanceMock.stop).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);
test('stops the Metadata Service', () => {
// eslint-disable-next-line dot-notation
const metadataServiceStopSpy = jest.spyOn(plugin['metadataService'], 'stop');
plugin.stop();
expect(metadataServiceStopSpy).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -13,11 +13,14 @@ import type {
Logger,
} from '@kbn/core/server';
import { get, has } from 'lodash';
import LaunchDarkly, { type LDClient, type LDUser } from 'launchdarkly-node-server-sdk';
import { createSHA256Hash } from '@kbn/crypto';
import type { LogMeta } from '@kbn/logging';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import type { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server/types';
import { filter, map } from 'rxjs';
import { MetadataService } from '../common/metadata_service';
import { LaunchDarklyClient } from './launch_darkly_client';
import { registerUsageCollector } from './usage';
import type { CloudExperimentsConfigType } from './config';
import type {
@ -32,17 +35,26 @@ interface CloudExperimentsPluginSetupDeps {
usageCollection?: UsageCollectionSetup;
}
interface CloudExperimentsPluginStartDeps {
dataViews: DataViewsServerPluginStart;
}
export class CloudExperimentsPlugin
implements Plugin<void, CloudExperimentsPluginStart, CloudExperimentsPluginSetupDeps>
{
private readonly logger: Logger;
private readonly launchDarklyClient?: LDClient;
private readonly launchDarklyClient?: LaunchDarklyClient;
private readonly flagOverrides?: Record<string, unknown>;
private launchDarklyUser: LDUser | undefined;
private readonly metadataService: MetadataService;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.logger = initializerContext.logger.get();
const config = initializerContext.config.get<CloudExperimentsConfigType>();
this.metadataService = new MetadataService({
metadata_refresh_interval: config.metadata_refresh_interval,
});
if (config.flag_overrides) {
this.flagOverrides = config.flag_overrides;
}
@ -55,17 +67,12 @@ export class CloudExperimentsPlugin
);
}
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}`)
this.launchDarklyClient = new LaunchDarklyClient(
{
...ldConfig,
kibana_version: initializerContext.env.packageInfo.version,
},
this.logger.get('launch_darkly')
);
}
}
@ -74,24 +81,33 @@ export class CloudExperimentsPlugin
if (deps.usageCollection) {
registerUsageCollector(deps.usageCollection, () => ({
launchDarklyClient: this.launchDarklyClient,
launchDarklyUser: this.launchDarklyUser,
}));
}
if (deps.cloud.isCloudEnabled && deps.cloud.cloudId) {
this.launchDarklyUser = {
this.metadataService.setup({
// We use the Cloud ID as the userId in the Cloud Experiments
key: createSHA256Hash(deps.cloud.cloudId),
custom: {
// This list of deployment metadata will likely grow in future versions
kibanaVersion: this.initializerContext.env.packageInfo.version,
},
};
this.launchDarklyClient?.identify(this.launchDarklyUser);
userId: createSHA256Hash(deps.cloud.cloudId),
kibanaVersion: this.initializerContext.env.packageInfo.version,
trial_end_date: deps.cloud.trialEndDate?.toISOString(),
is_elastic_staff_owned: deps.cloud.isElasticStaffOwned,
});
// We only subscribe to the user metadata updates if Cloud is enabled.
// This way, since the user is not identified, it cannot retrieve Feature Flags from LaunchDarkly when not running on Cloud.
this.metadataService.userMetadata$
.pipe(
filter(Boolean), // Filter out undefined
map((userMetadata) => this.launchDarklyClient?.updateUserMetadata(userMetadata))
)
.subscribe(); // This subscription will stop on when the metadataService stops because it completes the Observable
}
}
public start(core: CoreStart) {
public start(core: CoreStart, deps: CloudExperimentsPluginStartDeps) {
this.metadataService.start({
hasDataFetcher: async () => await this.addHasDataMetadata(core, deps.dataViews),
});
return {
getVariation: this.getVariation,
reportMetric: this.reportMetric,
@ -99,7 +115,8 @@ export class CloudExperimentsPlugin
}
public stop() {
this.launchDarklyClient?.flush().catch((err) => this.logger.error(err));
this.launchDarklyClient?.stop();
this.metadataService.stop();
}
private getVariation = async <Data>(
@ -111,15 +128,13 @@ export class CloudExperimentsPlugin
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);
if (!this.launchDarklyClient) return defaultValue;
return await this.launchDarklyClient.getVariation(configKey, 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.launchDarklyClient?.reportMetric(metricName, meta, value);
this.logger.debug<{ experimentationMetric: CloudExperimentsMetric<Data> } & LogMeta>(
`Reported experimentation metric ${metricName}`,
{
@ -127,4 +142,19 @@ export class CloudExperimentsPlugin
}
);
};
private async addHasDataMetadata(
core: CoreStart,
dataViews: DataViewsServerPluginStart
): Promise<{ has_data: boolean }> {
const dataViewsService = await dataViews.dataViewsServiceFactory(
core.savedObjects.createInternalRepository(),
core.elasticsearch.client.asInternalUser,
void 0, // No Kibana Request to scope the check
true // Ignore capabilities checks
);
return {
has_data: await dataViewsService.hasUserDataView(),
};
}
}

View file

@ -15,7 +15,7 @@ import {
type LaunchDarklyEntitiesGetter,
type Usage,
} from './register_usage_collector';
import { createLaunchDarklyClientMock } from '../plugin.test.mock';
import { launchDarklyClientMocks } from '../launch_darkly_client/mocks';
describe('cloudExperiments usage collector', () => {
let collector: Collector<Usage>;
@ -34,27 +34,7 @@ describe('cloudExperiments usage collector', () => {
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: {},
@ -63,21 +43,16 @@ describe('cloudExperiments usage collector', () => {
});
test('should return all the flags returned by the client', async () => {
const launchDarklyClient = createLaunchDarklyClientMock();
getLaunchDarklyEntitiesMock.mockReturnValueOnce({
launchDarklyClient,
launchDarklyUser: { key: 'test' },
});
const launchDarklyClient = launchDarklyClientMocks.createLaunchDarklyClient();
getLaunchDarklyEntitiesMock.mockReturnValueOnce({ launchDarklyClient });
launchDarklyClient.allFlagsState.mockResolvedValueOnce({
valid: true,
getFlagValue: jest.fn(),
getFlagReason: jest.fn(),
toJSON: jest.fn(),
allValues: jest.fn().mockReturnValueOnce({
launchDarklyClient.getAllFlags.mockResolvedValueOnce({
initialized: true,
flags: {
'my-plugin.my-feature-flag': true,
'my-plugin.my-other-feature-flag': 22,
}),
},
flagNames: ['my-plugin.my-feature-flag', 'my-plugin.my-other-feature-flag'],
});
await expect(collector.fetch(createCollectorFetchContextMock())).resolves.toStrictEqual({

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import type { LDClient, LDUser } from 'launchdarkly-node-server-sdk';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import type { LaunchDarklyClient } from '../launch_darkly_client';
export interface Usage {
initialized: boolean;
@ -15,8 +15,7 @@ export interface Usage {
}
export type LaunchDarklyEntitiesGetter = () => {
launchDarklyUser?: LDUser;
launchDarklyClient?: LDClient;
launchDarklyClient?: LaunchDarklyClient;
};
export function registerUsageCollector(
@ -53,17 +52,9 @@ export function registerUsageCollector(
},
},
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),
};
const { launchDarklyClient } = getLaunchDarklyEntities();
if (!launchDarklyClient) return { initialized: false, flagNames: [], flags: {} };
return await launchDarklyClient.getAllFlags();
},
})
);

View file

@ -15,6 +15,7 @@
],
"references": [
{ "path": "../../../../src/core/tsconfig.json" },
{ "path": "../../../../src/plugins/data_views/tsconfig.json" },
{ "path": "../../../../src/plugins/usage_collection/tsconfig.json" },
{ "path": "../../cloud/tsconfig.json" },
]

View file

@ -4970,6 +4970,15 @@
"properties": {
"isCloudEnabled": {
"type": "boolean"
},
"trialEndDate": {
"type": "date"
},
"inTrial": {
"type": "boolean"
},
"isElasticStaffOwned": {
"type": "boolean"
}
}
},