[kbn] Subscription tracking (cont.) (#157392)

## Summary

(this is the continuation of
https://github.com/elastic/kibana/pull/143910, which I started before my
parental leave and which is impossible to rebase)

With the introduction of more features that are part of licenses, we're
also adding more upsells to Kibana. These upsells advertise the feature,
they explain which license is required in order to use said feature and
they will link the client to the subscription page. Take the upsell for
more insights in the alert flyout as an example:

<img width="1584" alt="Screenshot 2022-10-18 at 16 39 52"
src="https://user-images.githubusercontent.com/68591/197629708-17978c8b-595e-4797-b80a-59c799896509.png">

Upsells come in all different shapes. Somtimes they're just links,
sometimes full pages and sometimes interactive popups. They are also
used across all solutions in Kibana.

There is currently no specific tracking in place for these types of
elements yet. Meaning we don't know how many people interact with them,
how many custerms see them and how well they perform in terms of
conversions.

It is technically already possible to analyze clicks on these elements
as part of the regular Kibana click tracking but it would require
setting up queries with lots of `data-test-subj` and `url` filters for
the right click events. Even if we wanted to set up tracking dashboards
with that data, we would still not know how often upsells are seen which
is necessary to calculate their click-through-rate. That rate can give
an indicator if an upsell performs well or if we might want to improve
it in the future.

For that reason, I'm proposing a dedicated set of tracking methods to
capture `impressions` and `clicks` for upsells. No conversion tracking
as of yet, but I will get back to that later.

This PR introduces the `@kbn/subscription-tracking` package. It
leverages the `@kbn/analytics-client` package to send dedicated
subscription tracking events.

It comes with a set of React components that automatically track
impressions and click events. Consumers of those components only need to
specify a `subscription context` that gives more details on the type of
feature that is advertised and the location of the upsell.

```typescript
import { SubscriptionLink } from '@kbn/subscription-tracking';
import type { SubscriptionContextData } from '@kbn/subscription-tracking';

const subscriptionContext: SubscriptionContextData = {
  feature: 'threat-intelligence',
  source: 'security__threat-intelligence',
};

export const Paywall = () => {
  return (
    <div>
      <SubscriptionLink subscriptionContext={subscriptionContext}>
        Upgrade to Platinum to get this feature
      </SubscriptionLink>
    </div>
  )
}
```

The example above uses a `SubscriptionLink` which is a wrapper of
`EuiLink` . So it behaves just like a normal link. Alternatively,
upsells can also use a `SubscriptionButton` or `SubscriptionButtonEmpty`
which wrap `EuiButton` and `EuiButtonEmpty` respectively.

When the link is mounted, it will send off an `impression` event with
the given `subscriptionContext`. That piece of metadata consists of an
identifier of the advertised feature (in this case
`threat-intelligence`) and the `source` of the impression (in this case
the `threat-intelligence` page in the `security` solution). `source`
follows the following format:
`{solution-identifier}__location-identifier`.

There are no special rules for how to name these identifiers but it's
good practise to make sure that `feature` has the same value for all
upsells advertising the same feature (e.g. use enums for features to
prevent spelling mistakes).

Upon interaction with the upsell link/button, a special `click` event is
sent, which, again, contains the same subscription context.

If you want to use the `subscription-tracking` elements in your
solution, you have to set up a `SubscriptionTrackingProvider` in your
plugin setup and register the tracking events on startup. This PR
contains an example for this setup for the security plugin and some of
its sub-plugins.

## Next steps
- There are currently no dedicated tracking dashboards for these events
which I am planning to set up in the future.
- Since I only had a week's worth of time, I did not manage to add
conversion tracking. The addition of those events might be a lot harder
as well since the current license flow does not integrate seamlessly
into Kibana
- All upsells currently link to the license management page which
currently does not inform customers about our license and cloud
offering. It seems to me like a weak link in the subscription funnel and
it would be great to improve on that page.
- potential improvement: Send `impression` event when the element
becomes visible in the viewport instead of when the element is mounted

### Checklist

- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jan Monschke 2023-09-18 18:27:16 +02:00 committed by GitHub
parent d8c112e9b7
commit a6c25b15aa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 801 additions and 133 deletions

1
.github/CODEOWNERS vendored
View file

@ -723,6 +723,7 @@ test/server_integration/plugins/status_plugin_b @elastic/kibana-core
packages/kbn-std @elastic/kibana-core
packages/kbn-stdio-dev-helpers @elastic/kibana-operations
packages/kbn-storybook @elastic/kibana-operations
packages/kbn-subscription-tracking @elastic/security-threat-hunting-investigations
x-pack/plugins/synthetics @elastic/uptime
x-pack/test/alerting_api_integration/common/plugins/task_manager_fixture @elastic/response-ops
x-pack/test/plugin_api_perf/plugins/task_manager_performance @elastic/response-ops

View file

@ -722,6 +722,7 @@
"@kbn/status-plugin-a-plugin": "link:test/server_integration/plugins/status_plugin_a",
"@kbn/status-plugin-b-plugin": "link:test/server_integration/plugins/status_plugin_b",
"@kbn/std": "link:packages/kbn-std",
"@kbn/subscription-tracking": "link:packages/kbn-subscription-tracking",
"@kbn/synthetics-plugin": "link:x-pack/plugins/synthetics",
"@kbn/task-manager-fixture-plugin": "link:x-pack/test/alerting_api_integration/common/plugins/task_manager_fixture",
"@kbn/task-manager-performance-plugin": "link:x-pack/test/plugin_api_perf/plugins/task_manager_performance",

View file

@ -2,14 +2,9 @@
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
"types": ["jest", "node"]
},
"include": [
"**/*.ts"
],
"include": ["**/*.ts"],
"kbn_references": [
"@kbn/logging",
"@kbn/analytics-client",
@ -20,7 +15,5 @@
"@kbn/core-base-browser-mocks",
"@kbn/core-injected-metadata-browser-mocks"
],
"exclude": [
"target/**/*",
]
"exclude": ["target/**/*"]
}

View file

@ -0,0 +1,35 @@
# @kbn/subscription-tracking
This package leverages the `@kbn/analytics-client` package to send dedicated subscription tracking events.
It provides a set of React components that automatically track `impression` and `click` events. Consumers of those components need to specify a `subscription context` that gives more details on the type of feature that is advertised and the location of the upsell.
```typescript
import { SubscriptionLink } from '@kbn/subscription-tracking';
import type { SubscriptionContext } from '@kbn/subscription-tracking';
const subscriptionContext: SubscriptionContext = {
feature: 'threat-intelligence',
source: 'security__threat-intelligence',
};
export const Paywall = () => {
return (
<div>
<SubscriptionLink subscriptionContext={subscriptionContext}>
Upgrade to Platinum to get this feature
</SubscriptionLink>
</div>
);
};
```
The example above uses a `SubscriptionLink` which is a wrapper of `EuiLink` . So it behaves just like a normal link. Alternatively, upsells can also use a `SubscriptionButton` or `SubscriptionButtonEmpty` which wrap `EuiButton` and `EuiButtonEmpty` respectively.
When the link is mounted, it will send off an `impression` event with the given `subscriptionContext`. That piece of metadata consists of an identifier of the advertised feature (in this case `threat-intelligence`) and the `source` (aka location) of the impression (in this case the `threat-intelligence` page in the `security` solution). `source` follows the following format: `{solution-identifier}__location-identifier`.
There are no special rules for how to name these identifiers but it's good practise to make sure that `feature` has the same value for all upsells advertising the same feature (e.g. use enums for features to prevent spelling mistakes).
Upon interaction with the upsell link/button, a special `click` event is sent, which, again, contains the same subscription context.
If you want to use the `subscription-tracking` elements in your app, you have to set up a `SubscriptionTrackingProvider` in your plugin setup and register the tracking events on startup. Have a look at https://github.com/elastic/kibana/pull/143910 for an example of an integration.

View file

@ -0,0 +1,17 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './src/subscription_elements';
export {
SubscriptionTrackingContext,
SubscriptionTrackingProvider,
registerEvents,
} from './src/services';
export * from './types';

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-subscription-tracking'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/subscription-tracking",
"owner": "@elastic/security-threat-hunting-investigations"
}

View file

@ -0,0 +1,28 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { FC } from 'react';
import { analyticsClientMock } from '@kbn/analytics-client/src/mocks';
import { SubscriptionTrackingProvider } from './src/services';
const analyticsClientMockInst = analyticsClientMock.create();
/**
* Mock for the external services provider. Only use in tests!
*/
export const MockSubscriptionTrackingProvider: FC = ({ children }) => {
return (
<SubscriptionTrackingProvider
navigateToApp={jest.fn()}
analyticsClient={analyticsClientMockInst}
>
{children}
</SubscriptionTrackingProvider>
);
};

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/subscription-tracking",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { isValidContext } from './helpers';
describe('tracking', () => {
describe('isValidLocation', () => {
it('identifies correct contexts', () => {
expect(
isValidContext({
feature: 'test',
source: 'security__test',
})
).toBeTruthy();
});
it('identifies incorrect contexts', () => {
expect(
isValidContext({
feature: '',
source: 'security__',
})
).toBeFalsy();
expect(
isValidContext({
feature: 'test',
source: 'security__',
})
).toBeFalsy();
expect(
isValidContext({
feature: '',
source: 'security__test',
})
).toBeFalsy();
});
});
});

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { SubscriptionContextData } from '../types';
const sourceStringRegEx = /^(\w[\w\-_]*)__(\w[\w\-_]*)$/;
export function isValidContext(context: SubscriptionContextData): boolean {
return context.feature.length > 0 && sourceStringRegEx.test(context.source);
}

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { FC, useContext } from 'react';
import type { AnalyticsClient, EventTypeOpts } from '@kbn/analytics-client';
import { EVENT_NAMES, Services, SubscriptionContextData } from '../types';
export const SubscriptionTrackingContext = React.createContext<Services | null>(null);
/**
* External services provider
*/
export const SubscriptionTrackingProvider: FC<Services> = ({ children, ...services }) => {
return (
<SubscriptionTrackingContext.Provider value={services}>
{children}
</SubscriptionTrackingContext.Provider>
);
};
/**
* React hook for accessing pre-wired services.
*/
export function useServices() {
const context = useContext(SubscriptionTrackingContext);
if (!context) {
throw new Error(
'SubscriptionTrackingContext is missing. Ensure your component or React root is wrapped with SubscriptionTrackingProvider.'
);
}
return context;
}
const subscriptionContextSchema: EventTypeOpts<SubscriptionContextData>['schema'] = {
source: {
type: 'keyword',
_meta: {
description:
'A human-readable identifier describing the location of the beginning of the subscription flow',
},
},
feature: {
type: 'keyword',
_meta: {
description: 'A human-readable identifier describing the feature that is being promoted',
},
},
};
/**
* Registers the subscription-specific event types
*/
export function registerEvents(analyticsClient: Pick<AnalyticsClient, 'registerEventType'>) {
analyticsClient.registerEventType<SubscriptionContextData>({
eventType: EVENT_NAMES.IMPRESSION,
schema: subscriptionContextSchema,
});
analyticsClient.registerEventType<SubscriptionContextData>({
eventType: EVENT_NAMES.CLICK,
schema: subscriptionContextSchema,
});
}

View file

@ -0,0 +1,108 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import {
SubscriptionLink,
SubscriptionButton,
SubscriptionButtonEmpty,
} from './subscription_elements';
import { SubscriptionTrackingProvider } from './services';
import { EVENT_NAMES, Services, SubscriptionContextData } from '../types';
import { coolDownTimeMs, resetCoolDown } from './use_impression';
const testServices: Services = {
navigateToApp: jest.fn(),
analyticsClient: {
reportEvent: jest.fn(),
registerEventType: jest.fn(),
} as any,
};
const testContext: SubscriptionContextData = { feature: 'test', source: 'security__test' };
const WithProviders: React.FC = ({ children }) => (
<SubscriptionTrackingProvider
analyticsClient={testServices.analyticsClient}
navigateToApp={testServices.navigateToApp}
>
{children}
</SubscriptionTrackingProvider>
);
const renderWithProviders = (children: React.ReactElement) =>
render(children, { wrapper: WithProviders });
const reset = () => {
jest.resetAllMocks();
resetCoolDown();
};
describe('SubscriptionElements', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
[SubscriptionButton, SubscriptionLink, SubscriptionButtonEmpty].forEach((SubscriptionElement) => {
describe(SubscriptionElement.name, () => {
beforeEach(reset);
it('renders the children correctly', () => {
renderWithProviders(
<SubscriptionElement subscriptionContext={testContext}>Hello</SubscriptionElement>
);
expect(screen.getByText('Hello')).toBeTruthy();
});
it('fires an impression event when rendered', () => {
renderWithProviders(<SubscriptionElement subscriptionContext={testContext} />);
expect(testServices.analyticsClient.reportEvent).toHaveBeenCalledWith(
EVENT_NAMES.IMPRESSION,
testContext
);
});
it('fires an impression event when rendered (but only once)', () => {
const { unmount } = renderWithProviders(
<SubscriptionElement subscriptionContext={testContext} />
);
expect(testServices.analyticsClient.reportEvent).toHaveBeenCalledTimes(1);
unmount();
// does not create an impression again when remounted
const { unmount: unmountAgain } = renderWithProviders(
<SubscriptionElement subscriptionContext={testContext} />
);
unmountAgain();
expect(testServices.analyticsClient.reportEvent).toHaveBeenCalledTimes(1);
// only creates anew impression when the cooldown time has passed
jest.setSystemTime(Date.now() + coolDownTimeMs);
renderWithProviders(<SubscriptionElement subscriptionContext={testContext} />);
expect(testServices.analyticsClient.reportEvent).toHaveBeenCalledTimes(2);
});
it('tracks a click when clicked and navigates to page', () => {
renderWithProviders(
<SubscriptionElement subscriptionContext={testContext}>hello</SubscriptionElement>
);
screen.getByText('hello').click();
expect(testServices.analyticsClient.reportEvent).toHaveBeenCalledWith(
EVENT_NAMES.CLICK,
testContext
);
expect(testServices.navigateToApp).toHaveBeenCalled();
});
});
});
});

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiLink, EuiButton, EuiButtonEmpty } from '@elastic/eui';
import type { EuiLinkProps, EuiButtonEmptyProps, EuiButtonProps } from '@elastic/eui';
import { useGoToSubscription } from './use_go_to_subscription';
import { useImpression } from './use_impression';
import type { SubscriptionContextData } from '../types';
interface CommonProps {
/** The context information for this subscription element */
subscriptionContext: SubscriptionContextData;
}
export type SubscriptionLinkProps = EuiLinkProps & CommonProps;
/**
* Wrapper around `EuiLink` that provides subscription events
*/
export function SubscriptionLink({
subscriptionContext,
children,
...restProps
}: SubscriptionLinkProps) {
const goToSubscription = useGoToSubscription({ subscriptionContext });
useImpression(subscriptionContext);
return (
<EuiLink {...restProps} onClick={goToSubscription}>
{children}
</EuiLink>
);
}
export type SubscriptionButtonProps = EuiButtonProps & CommonProps;
/**
* Wrapper around `EuiButton` that provides subscription events
*/
export function SubscriptionButton({
subscriptionContext,
children,
...restProps
}: SubscriptionButtonProps) {
const goToSubscription = useGoToSubscription({ subscriptionContext });
useImpression(subscriptionContext);
return (
<EuiButton {...restProps} onClick={goToSubscription}>
{children}
</EuiButton>
);
}
export type SubscriptionButtonEmptyProps = EuiButtonEmptyProps & CommonProps;
/**
* Wrapper around `EuiButtonEmpty` that provides subscription events
*/
export function SubscriptionButtonEmpty({
subscriptionContext,
children,
...restProps
}: SubscriptionButtonEmptyProps) {
const goToSubscription = useGoToSubscription({ subscriptionContext });
useImpression(subscriptionContext);
return (
<EuiButtonEmpty {...restProps} onClick={goToSubscription}>
{children}
</EuiButtonEmpty>
);
}

View file

@ -0,0 +1,35 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useCallback } from 'react';
import { isValidContext } from './helpers';
import { useServices } from './services';
import { EVENT_NAMES, SubscriptionContextData } from '../types';
interface Options {
subscriptionContext: SubscriptionContextData;
}
/**
* Provides a navigation function that navigates to the subscription
* management page. When the function executes, a click event with the
* given context is emitted.
*/
export const useGoToSubscription = ({ subscriptionContext }: Options) => {
const { navigateToApp, analyticsClient } = useServices();
const goToSubscription = useCallback(() => {
if (isValidContext(subscriptionContext)) {
analyticsClient.reportEvent(EVENT_NAMES.CLICK, subscriptionContext);
} else {
// eslint-disable-next-line no-console
console.error('The provided subscription context is invalid', subscriptionContext);
}
navigateToApp('management', { path: 'stack/license_management' });
}, [analyticsClient, navigateToApp, subscriptionContext]);
return goToSubscription;
};

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useEffect } from 'react';
import { isValidContext } from './helpers';
import { useServices } from './services';
import { EVENT_NAMES, SubscriptionContextData } from '../types';
/**
* Sends an impression event with the given context.
*
* Note: impression events are throttled and will not fire more
* often than once every 30 seconds.
*/
export const useImpression = (context: SubscriptionContextData) => {
const { analyticsClient } = useServices();
useEffect(() => {
if (!isValidContext(context)) {
// eslint-disable-next-line no-console
console.error('The provided subscription context is invalid', context);
return;
}
if (!isCoolingDown(context)) {
analyticsClient.reportEvent(EVENT_NAMES.IMPRESSION, context);
coolDown(context);
}
}, [analyticsClient, context]);
};
/**
* Impressions from the same context should not fire more than once every 30 seconds.
* This prevents logging too many impressions in case a page is reloaded often or
* if the user is navigating back and forth rapidly.
*/
export const coolDownTimeMs = 30 * 1000;
let impressionCooldown = new WeakMap<SubscriptionContextData, number>();
function isCoolingDown(context: SubscriptionContextData) {
const previousLog = impressionCooldown.get(context);
// we logged before and we are in the cooldown period
return previousLog && Date.now() - previousLog < coolDownTimeMs;
}
function coolDown(context: SubscriptionContextData) {
impressionCooldown.set(context, Date.now());
}
export function resetCoolDown() {
impressionCooldown = new WeakMap<SubscriptionContextData, number>();
}

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": ["jest", "node", "react"]
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["target/**/*"],
"kbn_references": ["@kbn/analytics-client"]
}

View file

@ -0,0 +1,47 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { AnalyticsClient } from '@kbn/analytics-client';
enum SolutionIdentifier {
observability = 'observability',
security = 'security',
}
type LocationString = string;
type SourceIdentifier = `${SolutionIdentifier}__${LocationString}`;
/**
* A piece of metadata which consists of an identifier of the advertised feature and
* the `source` (e.g. location) of the subscription element.
*/
export interface SubscriptionContextData {
/**
* A human-readable identifier describing the location of the beginning of the
* subscription flow.
* Location identifiers are prefixed with a solution identifier, e.g. `security__`
*
* @example "security__host-overview" - the user is looking at an upsell button
* on the host overview page in the security solution
*/
source: SourceIdentifier;
/**
* A human-readable identifier describing the feature that is being promoted.
*
* @example "alerts-by-process-ancestry"
*/
feature: string;
}
export interface Services {
navigateToApp: (app: string, options: { path: string }) => void;
analyticsClient: Pick<AnalyticsClient, 'reportEvent'>;
}
export enum EVENT_NAMES {
CLICK = 'subscription__upsell__click',
IMPRESSION = 'subscription__upsell__impression',
}

View file

@ -1440,6 +1440,8 @@
"@kbn/stdio-dev-helpers/*": ["packages/kbn-stdio-dev-helpers/*"],
"@kbn/storybook": ["packages/kbn-storybook"],
"@kbn/storybook/*": ["packages/kbn-storybook/*"],
"@kbn/subscription-tracking": ["packages/kbn-subscription-tracking"],
"@kbn/subscription-tracking/*": ["packages/kbn-subscription-tracking/*"],
"@kbn/synthetics-plugin": ["x-pack/plugins/synthetics"],
"@kbn/synthetics-plugin/*": ["x-pack/plugins/synthetics/*"],
"@kbn/task-manager-fixture-plugin": ["x-pack/test/alerting_api_integration/common/plugins/task_manager_fixture"],

View file

@ -5,9 +5,16 @@
* 2.0.
*/
import { EuiEmptyPrompt, EuiPageSection, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import { EuiEmptyPrompt, EuiPageSection } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { SubscriptionLink } from '@kbn/subscription-tracking';
import type { SubscriptionContextData } from '@kbn/subscription-tracking';
const subscriptionContext: SubscriptionContextData = {
feature: 'cloud-security-posture',
source: 'security__cloud-security-posture',
};
export const SubscriptionNotAllowed = ({
licenseManagementLocator,
@ -34,12 +41,12 @@ export const SubscriptionNotAllowed = ({
defaultMessage="To use these cloud security features, you must {link}."
values={{
link: (
<EuiLink href={licenseManagementLocator}>
<SubscriptionLink subscriptionContext={subscriptionContext}>
<FormattedMessage
id="xpack.csp.subscriptionNotAllowed.promptLinkText"
defaultMessage="start a trial or upgrade your subscription"
/>
</EuiLink>
</SubscriptionLink>
),
}}
/>

View file

@ -8,6 +8,7 @@ import React, { lazy, Suspense } from 'react';
import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { SubscriptionTrackingProvider } from '@kbn/subscription-tracking';
import { CspLoadingState } from './components/csp_loading_state';
import type { CspRouterProps } from './application/csp_router';
import type {
@ -71,11 +72,16 @@ export class CspPlugin
const App = (props: CspRouterProps) => (
<KibanaContextProvider services={{ ...core, ...plugins }}>
<RedirectAppLinks coreStart={core}>
<div style={{ width: '100%', height: '100%' }}>
<SetupContext.Provider value={{ isCloudEnabled: this.isCloudEnabled }}>
<CspRouter {...props} />
</SetupContext.Provider>
</div>
<SubscriptionTrackingProvider
analyticsClient={core.analytics}
navigateToApp={core.application.navigateToApp}
>
<div style={{ width: '100%', height: '100%' }}>
<SetupContext.Provider value={{ isCloudEnabled: this.isCloudEnabled }}>
<CspRouter {...props} />
</SetupContext.Provider>
</div>
</SubscriptionTrackingProvider>
</RedirectAppLinks>
</KibanaContextProvider>
);

View file

@ -11,7 +11,7 @@ import { I18nProvider } from '@kbn/i18n-react';
// eslint-disable-next-line no-restricted-imports
import { Router } from 'react-router-dom';
import { Route, Routes } from '@kbn/shared-ux-router';
import { MockSubscriptionTrackingProvider } from '@kbn/subscription-tracking/mocks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { coreMock } from '@kbn/core/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
@ -48,9 +48,11 @@ export const TestProvider: React.FC<Partial<CspAppDeps>> = ({
<QueryClientProvider client={queryClient}>
<Router history={params.history}>
<I18nProvider>
<Routes>
<Route path="*" render={() => <>{children}</>} />
</Routes>
<MockSubscriptionTrackingProvider>
<Routes>
<Route path="*" render={() => <>{children}</>} />
</Routes>
</MockSubscriptionTrackingProvider>
</I18nProvider>
</Router>
</QueryClientProvider>

View file

@ -1,7 +1,7 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"outDir": "target/types"
},
"include": [
"common/**/*",
@ -49,9 +49,8 @@
"@kbn/core-saved-objects-server",
"@kbn/share-plugin",
"@kbn/core-http-server",
"@kbn/core-http-browser"
"@kbn/core-http-browser",
"@kbn/subscription-tracking"
],
"exclude": [
"target/**/*",
]
"exclude": ["target/**/*"]
}

View file

@ -16,6 +16,7 @@ import { License } from '../common/license';
import { licenseMock } from '../common/licensing.mock';
import { coreMock } from '@kbn/core/public/mocks';
import { HttpInterceptor } from '@kbn/core/public';
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-browser';
const coreStart = coreMock.createStart();
describe('licensing plugin', () => {
@ -442,5 +443,69 @@ describe('licensing plugin', () => {
expect(removeInterceptorMock).toHaveBeenCalledTimes(1);
});
it('registers the subscription upsell events', async () => {
const sessionStorage = coreMock.createStorage();
plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage);
const coreSetup = coreMock.createSetup();
await plugin.setup(coreSetup);
await plugin.stop();
expect(findRegisteredEventTypeByName('subscription__upsell__click', coreSetup.analytics))
.toMatchInlineSnapshot(`
Array [
Object {
"eventType": "subscription__upsell__click",
"schema": Object {
"feature": Object {
"_meta": Object {
"description": "A human-readable identifier describing the feature that is being promoted",
},
"type": "keyword",
},
"source": Object {
"_meta": Object {
"description": "A human-readable identifier describing the location of the beginning of the subscription flow",
},
"type": "keyword",
},
},
},
]
`);
expect(findRegisteredEventTypeByName('subscription__upsell__impression', coreSetup.analytics))
.toMatchInlineSnapshot(`
Array [
Object {
"eventType": "subscription__upsell__impression",
"schema": Object {
"feature": Object {
"_meta": Object {
"description": "A human-readable identifier describing the feature that is being promoted",
},
"type": "keyword",
},
"source": Object {
"_meta": Object {
"description": "A human-readable identifier describing the location of the beginning of the subscription flow",
},
"type": "keyword",
},
},
},
]
`);
});
});
});
function findRegisteredEventTypeByName(
eventTypeName: string,
analyticsClientMock: jest.Mocked<AnalyticsServiceSetup>
) {
return analyticsClientMock.registerEventType.mock.calls.find(
([{ eventType }]) => eventType === eventTypeName
)!;
}

View file

@ -8,6 +8,7 @@
import { Observable, Subject, Subscription } from 'rxjs';
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { registerEvents as registerSubscriptionTrackingEvents } from '@kbn/subscription-tracking';
import { ILicense } from '../common/types';
import { LicensingPluginSetup, LicensingPluginStart } from './types';
import { createLicenseUpdate } from '../common/license_update';
@ -84,6 +85,7 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
);
registerAnalyticsContextProvider(core.analytics, license$);
registerSubscriptionTrackingEvents(core.analytics);
this.internalSubscription = license$.subscribe((license) => {
if (license.isAvailable) {

View file

@ -2,13 +2,9 @@
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"isolatedModules": true,
"isolatedModules": true
},
"include": [
"public/**/*",
"server/**/*",
"common/**/*"
],
"include": ["public/**/*", "server/**/*", "common/**/*"],
"kbn_references": [
"@kbn/core",
"@kbn/kibana-react-plugin",
@ -18,8 +14,8 @@
"@kbn/std",
"@kbn/i18n",
"@kbn/analytics-client",
"@kbn/subscription-tracking",
"@kbn/core-analytics-browser"
],
"exclude": [
"target/**/*",
]
"exclude": ["target/**/*"]
}

View file

@ -7,6 +7,7 @@
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { SubscriptionTrackingProvider } from '@kbn/subscription-tracking';
import { SecurityApp } from './app';
import type { RenderAppProps } from './types';
import { AppRoutes } from './app_routes';
@ -21,6 +22,7 @@ export const renderApp = ({
usageCollection,
subPluginRoutes,
theme$,
subscriptionTrackingServices,
}: RenderAppProps): (() => void) => {
const ApplicationUsageTrackingProvider =
usageCollection?.components.ApplicationUsageTrackingProvider ?? React.Fragment;
@ -34,7 +36,12 @@ export const renderApp = ({
theme$={theme$}
>
<ApplicationUsageTrackingProvider>
<AppRoutes subPluginRoutes={subPluginRoutes} services={services} />
<SubscriptionTrackingProvider
analyticsClient={subscriptionTrackingServices.analyticsClient}
navigateToApp={subscriptionTrackingServices.navigateToApp}
>
<AppRoutes subPluginRoutes={subPluginRoutes} services={services} />
</SubscriptionTrackingProvider>
</ApplicationUsageTrackingProvider>
</SecurityApp>,
element

View file

@ -19,6 +19,7 @@ import type { RouteProps } from 'react-router-dom';
import type { AppMountParameters } from '@kbn/core/public';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import type { TableState } from '@kbn/securitysolution-data-table';
import type { Services as SubscriptionTrackingServices } from '@kbn/subscription-tracking';
import type { ExploreReducer, ExploreState } from '../explore';
import type { StartServices } from '../types';
@ -29,6 +30,7 @@ export interface RenderAppProps extends AppMountParameters {
services: StartServices;
store: Store<State, Action>;
subPluginRoutes: RouteProps[];
subscriptionTrackingServices: SubscriptionTrackingServices;
usageCollection?: UsageCollectionSetup;
}

View file

@ -5,12 +5,18 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiIcon, EuiText } from '@elastic/eui';
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { SubscriptionLink } from '@kbn/subscription-tracking';
import type { SubscriptionContextData } from '@kbn/subscription-tracking';
import { INSIGHTS_UPSELL } from './translations';
import { useNavigation } from '../../../lib/kibana';
const subscriptionContext: SubscriptionContextData = {
feature: 'alert-details-insights',
source: 'security__alert-details-flyout',
};
const UpsellContainer = euiStyled.div`
border: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
@ -23,15 +29,6 @@ const StyledIcon = euiStyled(EuiIcon)`
`;
export const RelatedAlertsUpsell = React.memo(() => {
const { getAppUrl, navigateTo } = useNavigation();
const subscriptionUrl = getAppUrl({
appId: 'management',
path: 'stack/license_management',
});
const goToSubscription = useCallback(() => {
navigateTo({ url: subscriptionUrl });
}, [navigateTo, subscriptionUrl]);
return (
<UpsellContainer>
<EuiFlexGroup alignItems="center" gutterSize="none">
@ -40,9 +37,13 @@ export const RelatedAlertsUpsell = React.memo(() => {
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<EuiLink color="subdued" onClick={goToSubscription} target="_blank">
<SubscriptionLink
color="subdued"
target="_blank"
subscriptionContext={subscriptionContext}
>
{INSIGHTS_UPSELL}
</EuiLink>
</SubscriptionLink>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -21,6 +21,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Action } from '@kbn/ui-actions-plugin/public';
import { CellActionsProvider } from '@kbn/cell-actions';
import { ExpandableFlyoutProvider } from '@kbn/expandable-flyout';
import { MockSubscriptionTrackingProvider } from '@kbn/subscription-tracking/mocks';
import { useKibana } from '../lib/kibana';
import { UpsellingProvider } from '../components/upselling_provider';
import { MockAssistantProvider } from './mock_assistant_provider';
@ -74,25 +75,27 @@ export const TestProvidersComponent: React.FC<Props> = ({
return (
<I18nProvider>
<MockKibanaContextProvider>
<UpsellingProviderMock>
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<MockAssistantProvider>
<QueryClientProvider client={queryClient}>
<ExpandableFlyoutProvider>
<ConsoleManager>
<CellActionsProvider
getTriggerCompatibleActions={() => Promise.resolve(cellActions)}
>
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
</CellActionsProvider>
</ConsoleManager>
</ExpandableFlyoutProvider>
</QueryClientProvider>
</MockAssistantProvider>
</ThemeProvider>
</ReduxStoreProvider>
</UpsellingProviderMock>
<MockSubscriptionTrackingProvider>
<UpsellingProviderMock>
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<MockAssistantProvider>
<QueryClientProvider client={queryClient}>
<ExpandableFlyoutProvider>
<ConsoleManager>
<CellActionsProvider
getTriggerCompatibleActions={() => Promise.resolve(cellActions)}
>
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
</CellActionsProvider>
</ConsoleManager>
</ExpandableFlyoutProvider>
</QueryClientProvider>
</MockAssistantProvider>
</ThemeProvider>
</ReduxStoreProvider>
</UpsellingProviderMock>
</MockSubscriptionTrackingProvider>
</MockKibanaContextProvider>
</I18nProvider>
);
@ -117,27 +120,29 @@ const TestProvidersWithPrivilegesComponent: React.FC<Props> = ({
return (
<I18nProvider>
<MockKibanaContextProvider>
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<MockAssistantProvider>
<UserPrivilegesProvider
kibanaCapabilities={
{
siem: { show: true, crud: true },
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: false },
[ASSISTANT_FEATURE_ID]: { 'ai-assistant': true },
} as unknown as Capabilities
}
>
<CellActionsProvider
getTriggerCompatibleActions={() => Promise.resolve(cellActions)}
<MockSubscriptionTrackingProvider>
<ReduxStoreProvider store={store}>
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
<MockAssistantProvider>
<UserPrivilegesProvider
kibanaCapabilities={
{
siem: { show: true, crud: true },
[CASES_FEATURE_ID]: { read_cases: true, crud_cases: false },
[ASSISTANT_FEATURE_ID]: { 'ai-assistant': true },
} as unknown as Capabilities
}
>
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
</CellActionsProvider>
</UserPrivilegesProvider>
</MockAssistantProvider>
</ThemeProvider>
</ReduxStoreProvider>
<CellActionsProvider
getTriggerCompatibleActions={() => Promise.resolve(cellActions)}
>
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
</CellActionsProvider>
</UserPrivilegesProvider>
</MockAssistantProvider>
</ThemeProvider>
</ReduxStoreProvider>
</MockSubscriptionTrackingProvider>
</MockKibanaContextProvider>
</I18nProvider>
);

View file

@ -225,6 +225,11 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
const services = await startServices(params);
await this.registerActions(store, params.history, services);
const subscriptionTrackingServices = {
analyticsClient: coreStart.analytics,
navigateToApp: coreStart.application.navigateToApp,
};
const { renderApp } = await this.lazyApplicationDependencies();
const { getSubPluginRoutesByCapabilities } = await this.lazyHelpersForRoutes();
@ -238,6 +243,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
coreStart.application.capabilities,
services
),
subscriptionTrackingServices,
});
},
});

View file

@ -172,6 +172,7 @@
"@kbn/core-lifecycle-browser",
"@kbn/security-solution-features",
"@kbn/handlebars",
"@kbn/content-management-plugin"
"@kbn/content-management-plugin",
"@kbn/subscription-tracking"
]
}

View file

@ -14,5 +14,5 @@ export default {
};
export function BasicPaywall() {
return <Paywall licenseManagementHref="https://elastic.co" />;
return <Paywall />;
}

View file

@ -6,24 +6,17 @@
*/
import React, { VFC } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
} from '@elastic/eui';
import { EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { SubscriptionButtonEmpty } from '@kbn/subscription-tracking';
import type { SubscriptionContextData } from '@kbn/subscription-tracking';
interface PaywallProps {
/**
* Can be obtained using `http.basePath.prepend('/app/management/stack/license_management')`
*/
licenseManagementHref: string;
}
const subscriptionContext: SubscriptionContextData = {
feature: 'threat-intelligence',
source: 'security__threat-intelligence',
};
export const Paywall: VFC<PaywallProps> = ({ licenseManagementHref }) => {
export const Paywall: VFC = () => {
return (
<EuiEmptyPrompt
icon={<EuiIcon type="logoSecurity" size="xl" />}
@ -59,12 +52,12 @@ export const Paywall: VFC<PaywallProps> = ({ licenseManagementHref }) => {
</EuiFlexItem>
<EuiFlexItem>
<div>
<EuiButtonEmpty href={licenseManagementHref}>
<SubscriptionButtonEmpty subscriptionContext={subscriptionContext}>
<FormattedMessage
id="xpack.threatIntelligence.paywall.trial"
defaultMessage="Start a free trial"
/>
</EuiButtonEmpty>
</SubscriptionButtonEmpty>
</div>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -6,16 +6,13 @@
*/
import React, { FC } from 'react';
import { Paywall } from '../components/paywall';
import { useKibana } from '../hooks/use_kibana';
import { useSecurityContext } from '../hooks/use_security_context';
import { SecuritySolutionPluginTemplateWrapper } from './security_solution_plugin_template_wrapper';
export const EnterpriseGuard: FC = ({ children }) => {
const { licenseService } = useSecurityContext();
const {
services: { http },
} = useKibana();
if (licenseService.isEnterprise()) {
return <>{children}</>;
@ -23,9 +20,7 @@ export const EnterpriseGuard: FC = ({ children }) => {
return (
<SecuritySolutionPluginTemplateWrapper isEmptyState>
<Paywall
licenseManagementHref={http.basePath.prepend('/app/management/stack/license_management')}
/>
<Paywall />
</SecuritySolutionPluginTemplateWrapper>
);
};

View file

@ -12,6 +12,7 @@ import { CoreStart, IUiSettingsClient } from '@kbn/core/public';
import { TimelinesUIStart } from '@kbn/timelines-plugin/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { MockSubscriptionTrackingProvider } from '@kbn/subscription-tracking/mocks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { SettingsStart } from '@kbn/core-ui-settings-browser';
import { mockIndicatorsFiltersContext } from './mock_indicators_filters_context';
@ -107,7 +108,9 @@ export const StoryProvidersComponent: VFC<StoryProvidersComponentProps> = ({
<SecuritySolutionContext.Provider value={securitySolutionContextMock}>
<IndicatorsFiltersContext.Provider value={mockIndicatorsFiltersContext}>
<KibanaReactContext.Provider>
<BlockListProvider>{children}</BlockListProvider>
<MockSubscriptionTrackingProvider>
<BlockListProvider>{children}</BlockListProvider>
</MockSubscriptionTrackingProvider>
</KibanaReactContext.Provider>
</IndicatorsFiltersContext.Provider>
</SecuritySolutionContext.Provider>

View file

@ -17,6 +17,7 @@ import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks
import { createTGridMocks } from '@kbn/timelines-plugin/public/mock';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { MockSubscriptionTrackingProvider } from '@kbn/subscription-tracking/mocks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { casesPluginMock } from '@kbn/cases-plugin/public/mocks';
@ -141,11 +142,13 @@ export const TestProvidersComponent: FC = ({ children }) => (
<EuiThemeProvider>
<SecuritySolutionContext.Provider value={mockSecurityContext}>
<KibanaContext.Provider value={{ services: mockedServices } as any}>
<I18nProvider>
<IndicatorsFiltersContext.Provider value={mockIndicatorsFiltersContext}>
{children}
</IndicatorsFiltersContext.Provider>
</I18nProvider>
<MockSubscriptionTrackingProvider>
<I18nProvider>
<IndicatorsFiltersContext.Provider value={mockIndicatorsFiltersContext}>
{children}
</IndicatorsFiltersContext.Provider>
</I18nProvider>
</MockSubscriptionTrackingProvider>
</KibanaContext.Provider>
</SecuritySolutionContext.Provider>
</EuiThemeProvider>

View file

@ -11,6 +11,7 @@ import { Provider as ReduxStoreProvider } from 'react-redux';
import React, { Suspense } from 'react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { ExternalReferenceAttachmentType } from '@kbn/cases-plugin/public/client/attachment_framework/types';
import { SubscriptionTrackingProvider } from '@kbn/subscription-tracking';
import { generateAttachmentType } from './modules/cases/utils/attachments';
import { KibanaContextProvider } from './hooks/use_kibana';
import {
@ -43,11 +44,16 @@ export const createApp =
<ReduxStoreProvider store={securitySolutionContext.securitySolutionStore}>
<SecuritySolutionContext.Provider value={securitySolutionContext}>
<KibanaContextProvider services={services}>
<EnterpriseGuard>
<Suspense fallback={<div />}>
<LazyIndicatorsPageWrapper />
</Suspense>
</EnterpriseGuard>
<SubscriptionTrackingProvider
analyticsClient={services.analytics}
navigateToApp={services.application.navigateToApp}
>
<EnterpriseGuard>
<Suspense fallback={<div />}>
<LazyIndicatorsPageWrapper />
</Suspense>
</EnterpriseGuard>
</SubscriptionTrackingProvider>
</KibanaContextProvider>
</SecuritySolutionContext.Provider>
</ReduxStoreProvider>

View file

@ -1,7 +1,7 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"outDir": "target/types"
},
"include": [
"common/**/*",
@ -32,9 +32,8 @@
"@kbn/utility-types",
"@kbn/ui-theme",
"@kbn/securitysolution-io-ts-list-types",
"@kbn/core-ui-settings-browser"
"@kbn/core-ui-settings-browser",
"@kbn/subscription-tracking"
],
"exclude": [
"target/**/*",
]
"exclude": ["target/**/*"]
}

View file

@ -5829,6 +5829,10 @@
version "0.0.0"
uid ""
"@kbn/subscription-tracking@link:packages/kbn-subscription-tracking":
version "0.0.0"
uid ""
"@kbn/synthetics-plugin@link:x-pack/plugins/synthetics":
version "0.0.0"
uid ""