[Telemetry] Check permissions when requesting telemetry (#126238)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Alejandro Fernández Haro 2022-03-07 21:29:18 +01:00 committed by GitHub
parent a24b1fc957
commit cb7088816a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 715 additions and 1262 deletions

View file

@ -11,7 +11,7 @@ import { createUsageCollectionSetupMock } from '../../../plugins/usage_collectio
const { makeUsageCollector } = createUsageCollectionSetupMock();
export const myCollector = makeUsageCollector<Usage, false>({
export const myCollector = makeUsageCollector<Usage>({
type: 'importing_from_export_collector',
isReady: () => true,
fetch() {

View file

@ -19,7 +19,7 @@ interface Usage {
* We should collect them when the schema is defined.
*/
export const myCollectorWithSchema = makeStatsCollector<Usage, false>({
export const myCollectorWithSchema = makeStatsCollector<Usage>({
type: 'my_stats_collector_with_schema',
isReady: () => true,
fetch() {

View file

@ -24,6 +24,7 @@
{ "path": "../navigation/tsconfig.json" },
{ "path": "../saved_objects_tagging_oss/tsconfig.json" },
{ "path": "../saved_objects/tsconfig.json" },
{ "path": "../screenshot_mode/tsconfig.json" },
{ "path": "../ui_actions/tsconfig.json" },
{ "path": "../charts/tsconfig.json" },
{ "path": "../discover/tsconfig.json" },

View file

@ -8,6 +8,6 @@
"server": true,
"ui": true,
"requiredPlugins": ["dataViews", "share", "urlForwarding"],
"optionalPlugins": ["usageCollection", "telemetry", "customIntegrations"],
"optionalPlugins": ["usageCollection", "customIntegrations"],
"requiredBundles": ["kibanaReact"]
}

View file

@ -374,202 +374,6 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to false when t
exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when there are no index patterns 1`] = `
<Welcome
onSkip={[Function]}
telemetry={
Object {
"telemetryConstants": Object {
"getPrivacyStatementUrl": [MockFunction],
},
"telemetryNotifications": TelemetryNotifications {
"http": Object {
"addLoadingCountSource": [MockFunction],
"anonymousPaths": Object {
"isAnonymous": [MockFunction],
"register": [MockFunction],
},
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"publicBaseUrl": undefined,
"remove": [Function],
"serverBasePath": "",
},
"delete": [MockFunction],
"externalUrl": Object {
"isInternalUrl": [MockFunction],
"validateUrl": [MockFunction],
},
"fetch": [MockFunction],
"get": [MockFunction],
"getLoadingCount$": [MockFunction],
"head": [MockFunction],
"intercept": [MockFunction],
"options": [MockFunction],
"patch": [MockFunction],
"post": [MockFunction],
"put": [MockFunction],
},
"onSetOptInClick": [Function],
"optInBannerId": undefined,
"optedInNoticeBannerId": undefined,
"overlays": Object {
"banners": Object {
"add": [MockFunction],
"get$": [MockFunction],
"getComponent": [MockFunction],
"remove": [MockFunction],
"replace": [MockFunction],
},
"openConfirm": [MockFunction],
"openFlyout": [MockFunction],
"openModal": [MockFunction],
},
"renderOptInBanner": [Function],
"renderOptedInNoticeBanner": [Function],
"setOptedInNoticeSeen": [Function],
"shouldShowOptInBanner": [Function],
"shouldShowOptedInNoticeBanner": [Function],
"telemetryService": TelemetryService {
"canSendTelemetry": [Function],
"currentKibanaVersion": "mockKibanaVersion",
"defaultConfig": Object {
"allowChangingOptInStatus": true,
"banner": true,
"enabled": true,
"optIn": true,
"sendUsageFrom": "browser",
"sendUsageTo": "staging",
"telemetryNotifyUserAboutOptInDefault": true,
"userCanChangeSettings": true,
},
"fetchExample": [Function],
"fetchLastReported": [Function],
"fetchTelemetry": [Function],
"getCanChangeOptInStatus": [Function],
"getIsOptedIn": [Function],
"getOptInStatusUrl": [Function],
"getTelemetryUrl": [Function],
"http": Object {
"addLoadingCountSource": [MockFunction],
"anonymousPaths": Object {
"isAnonymous": [MockFunction],
"register": [MockFunction],
},
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"publicBaseUrl": undefined,
"remove": [Function],
"serverBasePath": "",
},
"delete": [MockFunction],
"externalUrl": Object {
"isInternalUrl": [MockFunction],
"validateUrl": [MockFunction],
},
"fetch": [MockFunction],
"get": [MockFunction],
"getLoadingCount$": [MockFunction],
"head": [MockFunction],
"intercept": [MockFunction],
"options": [MockFunction],
"patch": [MockFunction],
"post": [MockFunction],
"put": [MockFunction],
},
"isScreenshotMode": false,
"notifications": Object {
"toasts": Object {
"add": [MockFunction],
"addDanger": [MockFunction],
"addError": [MockFunction],
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
"get$": [MockFunction],
"remove": [MockFunction],
},
},
"reportOptInStatus": [MockFunction],
"reportOptInStatusChange": true,
"setOptIn": [Function],
"setUserHasSeenNotice": [Function],
"updateLastReported": [Function],
"updatedConfig": undefined,
},
},
"telemetryService": TelemetryService {
"canSendTelemetry": [Function],
"currentKibanaVersion": "mockKibanaVersion",
"defaultConfig": Object {
"allowChangingOptInStatus": true,
"banner": true,
"enabled": true,
"optIn": true,
"sendUsageFrom": "browser",
"sendUsageTo": "staging",
"telemetryNotifyUserAboutOptInDefault": true,
"userCanChangeSettings": true,
},
"fetchExample": [Function],
"fetchLastReported": [Function],
"fetchTelemetry": [Function],
"getCanChangeOptInStatus": [Function],
"getIsOptedIn": [Function],
"getOptInStatusUrl": [Function],
"getTelemetryUrl": [Function],
"http": Object {
"addLoadingCountSource": [MockFunction],
"anonymousPaths": Object {
"isAnonymous": [MockFunction],
"register": [MockFunction],
},
"basePath": BasePath {
"basePath": "",
"get": [Function],
"prepend": [Function],
"publicBaseUrl": undefined,
"remove": [Function],
"serverBasePath": "",
},
"delete": [MockFunction],
"externalUrl": Object {
"isInternalUrl": [MockFunction],
"validateUrl": [MockFunction],
},
"fetch": [MockFunction],
"get": [MockFunction],
"getLoadingCount$": [MockFunction],
"head": [MockFunction],
"intercept": [MockFunction],
"options": [MockFunction],
"patch": [MockFunction],
"post": [MockFunction],
"put": [MockFunction],
},
"isScreenshotMode": false,
"notifications": Object {
"toasts": Object {
"add": [MockFunction],
"addDanger": [MockFunction],
"addError": [MockFunction],
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
"get$": [MockFunction],
"remove": [MockFunction],
},
},
"reportOptInStatus": [MockFunction],
"reportOptInStatusChange": true,
"setOptIn": [Function],
"setUserHasSeenNotice": [Function],
"updateLastReported": [Function],
"updatedConfig": undefined,
},
}
}
urlBasePath="goober"
/>
`;

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should render a Welcome screen with no telemetry disclaimer 1`] = `
exports[`should render a Welcome screen 1`] = `
<EuiPortal>
<div
className="homWelcome"
@ -61,385 +61,3 @@ exports[`should render a Welcome screen with no telemetry disclaimer 1`] = `
</div>
</EuiPortal>
`;
exports[`should render a Welcome screen with the telemetry disclaimer 1`] = `
<EuiPortal>
<div
className="homWelcome"
data-test-subj="homeWelcomeInterstitial"
>
<header
className="homWelcome__header"
>
<div
className="homWelcome__content eui-textCenter"
>
<EuiSpacer
size="xl"
/>
<span
className="homWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoElastic"
/>
</span>
<EuiTitle
className="homWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Elastic"
id="home.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiSpacer
size="m"
/>
</div>
</header>
<div
className="homWelcome__content homWelcome-body"
>
<EuiFlexGroup
gutterSize="l"
>
<EuiFlexItem>
<SampleDataCard
onConfirm={[Function]}
onDecline={[Function]}
urlBasePath="/"
/>
<EuiSpacer
size="s"
/>
<EuiTextColor
className="euiText--small"
color="subdued"
>
<FormattedMessage
defaultMessage="To learn about how usage data helps us manage and improve our products and services, see our "
id="home.dataManagementDisclaimerPrivacy"
values={Object {}}
/>
<EuiLink
rel="noopener"
target="_blank"
>
<FormattedMessage
defaultMessage="Privacy Statement."
id="home.dataManagementDisclaimerPrivacyLink"
values={Object {}}
/>
</EuiLink>
<FormattedMessage
defaultMessage=" To stop collection, "
id="home.dataManagementDisableCollection"
values={Object {}}
/>
<EuiLink
href="rootmanagement/kibana/settings"
>
<FormattedMessage
defaultMessage="disable usage data here."
id="home.dataManagementDisableCollectionLink"
values={Object {}}
/>
</EuiLink>
</EuiTextColor>
<EuiSpacer
size="xs"
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
</EuiPortal>
`;
exports[`should render a Welcome screen with the telemetry disclaimer when optIn is false 1`] = `
<EuiPortal>
<div
className="homWelcome"
data-test-subj="homeWelcomeInterstitial"
>
<header
className="homWelcome__header"
>
<div
className="homWelcome__content eui-textCenter"
>
<EuiSpacer
size="xl"
/>
<span
className="homWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoElastic"
/>
</span>
<EuiTitle
className="homWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Elastic"
id="home.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiSpacer
size="m"
/>
</div>
</header>
<div
className="homWelcome__content homWelcome-body"
>
<EuiFlexGroup
gutterSize="l"
>
<EuiFlexItem>
<SampleDataCard
onConfirm={[Function]}
onDecline={[Function]}
urlBasePath="/"
/>
<EuiSpacer
size="s"
/>
<EuiTextColor
className="euiText--small"
color="subdued"
>
<FormattedMessage
defaultMessage="To learn about how usage data helps us manage and improve our products and services, see our "
id="home.dataManagementDisclaimerPrivacy"
values={Object {}}
/>
<EuiLink
rel="noopener"
target="_blank"
>
<FormattedMessage
defaultMessage="Privacy Statement."
id="home.dataManagementDisclaimerPrivacyLink"
values={Object {}}
/>
</EuiLink>
<FormattedMessage
defaultMessage=" To start collection, "
id="home.dataManagementEnableCollection"
values={Object {}}
/>
<EuiLink
href="rootmanagement/kibana/settings"
>
<FormattedMessage
defaultMessage="enable usage data here."
id="home.dataManagementEnableCollectionLink"
values={Object {}}
/>
</EuiLink>
</EuiTextColor>
<EuiSpacer
size="xs"
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
</EuiPortal>
`;
exports[`should render a Welcome screen with the telemetry disclaimer when optIn is true 1`] = `
<EuiPortal>
<div
className="homWelcome"
data-test-subj="homeWelcomeInterstitial"
>
<header
className="homWelcome__header"
>
<div
className="homWelcome__content eui-textCenter"
>
<EuiSpacer
size="xl"
/>
<span
className="homWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoElastic"
/>
</span>
<EuiTitle
className="homWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Elastic"
id="home.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiSpacer
size="m"
/>
</div>
</header>
<div
className="homWelcome__content homWelcome-body"
>
<EuiFlexGroup
gutterSize="l"
>
<EuiFlexItem>
<SampleDataCard
onConfirm={[Function]}
onDecline={[Function]}
urlBasePath="/"
/>
<EuiSpacer
size="s"
/>
<EuiTextColor
className="euiText--small"
color="subdued"
>
<FormattedMessage
defaultMessage="To learn about how usage data helps us manage and improve our products and services, see our "
id="home.dataManagementDisclaimerPrivacy"
values={Object {}}
/>
<EuiLink
rel="noopener"
target="_blank"
>
<FormattedMessage
defaultMessage="Privacy Statement."
id="home.dataManagementDisclaimerPrivacyLink"
values={Object {}}
/>
</EuiLink>
<FormattedMessage
defaultMessage=" To stop collection, "
id="home.dataManagementDisableCollection"
values={Object {}}
/>
<EuiLink
href="rootmanagement/kibana/settings"
>
<FormattedMessage
defaultMessage="disable usage data here."
id="home.dataManagementDisableCollectionLink"
values={Object {}}
/>
</EuiLink>
</EuiTextColor>
<EuiSpacer
size="xs"
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
</EuiPortal>
`;
exports[`should render a Welcome screen without the opt in/out link when user cannot change optIn status 1`] = `
<EuiPortal>
<div
className="homWelcome"
data-test-subj="homeWelcomeInterstitial"
>
<header
className="homWelcome__header"
>
<div
className="homWelcome__content eui-textCenter"
>
<EuiSpacer
size="xl"
/>
<span
className="homWelcome__logo"
>
<EuiIcon
size="xxl"
type="logoElastic"
/>
</span>
<EuiTitle
className="homWelcome__title"
size="l"
>
<h1>
<FormattedMessage
defaultMessage="Welcome to Elastic"
id="home.welcomeTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
<EuiSpacer
size="m"
/>
</div>
</header>
<div
className="homWelcome__content homWelcome-body"
>
<EuiFlexGroup
gutterSize="l"
>
<EuiFlexItem>
<SampleDataCard
onConfirm={[Function]}
onDecline={[Function]}
urlBasePath="/"
/>
<EuiSpacer
size="s"
/>
<EuiTextColor
className="euiText--small"
color="subdued"
>
<FormattedMessage
defaultMessage="To learn about how usage data helps us manage and improve our products and services, see our "
id="home.dataManagementDisclaimerPrivacy"
values={Object {}}
/>
<EuiLink
rel="noopener"
target="_blank"
>
<FormattedMessage
defaultMessage="Privacy Statement."
id="home.dataManagementDisclaimerPrivacyLink"
values={Object {}}
/>
</EuiLink>
</EuiTextColor>
<EuiSpacer
size="xs"
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
</EuiPortal>
`;

View file

@ -12,7 +12,6 @@ import type { HomeProps } from './home';
import { Home } from './home';
import { FeatureCatalogueCategory } from '../../services';
import { telemetryPluginMock } from '../../../../telemetry/public/mocks';
import { Welcome } from './welcome';
let mockHasIntegrationsPermission = true;
@ -57,7 +56,6 @@ describe('home', () => {
setItem: jest.fn(),
},
urlBasePath: 'goober',
telemetry: telemetryPluginMock.createStartContract(),
addBasePath(url) {
return `base_path/${url}`;
},

View file

@ -10,7 +10,6 @@ import React, { Component } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { METRIC_TYPE } from '@kbn/analytics';
import { i18n } from '@kbn/i18n';
import type { TelemetryPluginStart } from 'src/plugins/telemetry/public';
import { KibanaPageTemplate, OverviewPageFooter } from '../../../../kibana_react/public';
import { HOME_APP_BASE_PATH } from '../../../common/constants';
import type { FeatureCatalogueEntry, FeatureCatalogueSolution } from '../../services';
@ -29,7 +28,6 @@ export interface HomeProps {
solutions: FeatureCatalogueSolution[];
localStorage: Storage;
urlBasePath: string;
telemetry: TelemetryPluginStart;
hasUserDataView: () => Promise<boolean>;
}
@ -175,13 +173,7 @@ export class Home extends Component<HomeProps, State> {
}
private renderWelcome() {
return (
<Welcome
onSkip={() => this.skipWelcome()}
urlBasePath={this.props.urlBasePath}
telemetry={this.props.telemetry}
/>
);
return <Welcome onSkip={() => this.skipWelcome()} urlBasePath={this.props.urlBasePath} />;
}
public render() {

View file

@ -26,7 +26,6 @@ export function HomeApp({ directories, solutions }) {
getBasePath,
addBasePath,
environmentService,
telemetry,
dataViewsService,
} = getServices();
const environment = environmentService.getEnvironment();
@ -75,7 +74,6 @@ export function HomeApp({ directories, solutions }) {
solutions={solutions}
localStorage={localStorage}
urlBasePath={getBasePath()}
telemetry={telemetry}
hasUserDataView={() => dataViewsService.hasUserDataView()}
/>
</Route>

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.
*/
import { welcomeServiceMock } from '../../services/welcome/welcome_service.mocks';
jest.doMock('../kibana_services', () => ({
getServices: () => ({
addBasePath: (path: string) => `root${path}`,
trackUiMetric: () => {},
welcomeService: welcomeServiceMock.create(),
}),
}));

View file

@ -8,58 +8,11 @@
import React from 'react';
import { shallow } from 'enzyme';
import './welcome.test.mocks';
import { Welcome } from './welcome';
import { telemetryPluginMock } from '../../../../telemetry/public/mocks';
jest.mock('../kibana_services', () => ({
getServices: () => ({
addBasePath: (path: string) => `root${path}`,
trackUiMetric: () => {},
}),
}));
test('should render a Welcome screen with the telemetry disclaimer', () => {
const telemetry = telemetryPluginMock.createStartContract();
const component = shallow(<Welcome urlBasePath="/" onSkip={() => {}} telemetry={telemetry} />);
expect(component).toMatchSnapshot();
});
test('should render a Welcome screen with the telemetry disclaimer when optIn is true', () => {
const telemetry = telemetryPluginMock.createStartContract();
telemetry.telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true);
const component = shallow(<Welcome urlBasePath="/" onSkip={() => {}} telemetry={telemetry} />);
expect(component).toMatchSnapshot();
});
test('should render a Welcome screen with the telemetry disclaimer when optIn is false', () => {
const telemetry = telemetryPluginMock.createStartContract();
telemetry.telemetryService.getIsOptedIn = jest.fn().mockReturnValue(false);
const component = shallow(<Welcome urlBasePath="/" onSkip={() => {}} telemetry={telemetry} />);
expect(component).toMatchSnapshot();
});
test('should render a Welcome screen with no telemetry disclaimer', () => {
test('should render a Welcome screen', () => {
const component = shallow(<Welcome urlBasePath="/" onSkip={() => {}} />);
expect(component).toMatchSnapshot();
});
test('should render a Welcome screen without the opt in/out link when user cannot change optIn status', () => {
const telemetry = telemetryPluginMock.createStartContract();
telemetry.telemetryService.getCanChangeOptInStatus = jest.fn().mockReturnValue(false);
const component = shallow(<Welcome urlBasePath="/" onSkip={() => {}} telemetry={telemetry} />);
expect(component).toMatchSnapshot();
});
test('fires opt-in seen when mounted', () => {
const telemetry = telemetryPluginMock.createStartContract();
const mockSetOptedInNoticeSeen = jest.fn();
telemetry.telemetryNotifications.setOptedInNoticeSeen = mockSetOptedInNoticeSeen;
shallow(<Welcome urlBasePath="/" onSkip={() => {}} telemetry={telemetry} />);
expect(mockSetOptedInNoticeSeen).toHaveBeenCalled();
});

View file

@ -12,27 +12,17 @@
* in Elasticsearch.
*/
import React, { Fragment } from 'react';
import {
EuiLink,
EuiTextColor,
EuiTitle,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiPortal,
} from '@elastic/eui';
import React from 'react';
import { EuiTitle, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPortal } from '@elastic/eui';
import { METRIC_TYPE } from '@kbn/analytics';
import { FormattedMessage } from '@kbn/i18n-react';
import { getServices } from '../kibana_services';
import { TelemetryPluginStart } from '../../../../telemetry/public';
import { SampleDataCard } from './sample_data';
interface Props {
urlBasePath: string;
onSkip: () => void;
telemetry?: TelemetryPluginStart;
}
/**
@ -47,7 +37,7 @@ export class Welcome extends React.Component<Props> {
}
};
private redirecToAddData() {
private redirectToAddData() {
this.services.application.navigateToApp('integrations', { path: '/browse' });
}
@ -58,68 +48,23 @@ export class Welcome extends React.Component<Props> {
private onSampleDataConfirm = () => {
this.services.trackUiMetric(METRIC_TYPE.CLICK, 'sampleDataConfirm');
this.redirecToAddData();
this.redirectToAddData();
};
componentDidMount() {
const { telemetry } = this.props;
const { welcomeService } = this.services;
this.services.trackUiMetric(METRIC_TYPE.LOADED, 'welcomeScreenMount');
if (telemetry?.telemetryService.userCanChangeSettings) {
telemetry.telemetryNotifications.setOptedInNoticeSeen();
}
document.addEventListener('keydown', this.hideOnEsc);
welcomeService.onRendered();
}
componentWillUnmount() {
document.removeEventListener('keydown', this.hideOnEsc);
}
private renderTelemetryEnabledOrDisabledText = () => {
const { telemetry } = this.props;
if (
!telemetry ||
!telemetry.telemetryService.userCanChangeSettings ||
!telemetry.telemetryService.getCanChangeOptInStatus()
) {
return null;
}
const isOptedIn = telemetry.telemetryService.getIsOptedIn();
if (isOptedIn) {
return (
<Fragment>
<FormattedMessage
id="home.dataManagementDisableCollection"
defaultMessage=" To stop collection, "
/>
<EuiLink href={this.services.addBasePath('management/kibana/settings')}>
<FormattedMessage
id="home.dataManagementDisableCollectionLink"
defaultMessage="disable usage data here."
/>
</EuiLink>
</Fragment>
);
} else {
return (
<Fragment>
<FormattedMessage
id="home.dataManagementEnableCollection"
defaultMessage=" To start collection, "
/>
<EuiLink href={this.services.addBasePath('management/kibana/settings')}>
<FormattedMessage
id="home.dataManagementEnableCollectionLink"
defaultMessage="enable usage data here."
/>
</EuiLink>
</Fragment>
);
}
};
render() {
const { urlBasePath, telemetry } = this.props;
const { urlBasePath } = this.props;
const { welcomeService } = this.services;
return (
<EuiPortal>
<div className="homWelcome" data-test-subj="homeWelcomeInterstitial">
@ -146,28 +91,7 @@ export class Welcome extends React.Component<Props> {
onDecline={this.onSampleDataDecline}
/>
<EuiSpacer size="s" />
{!!telemetry && (
<Fragment>
<EuiTextColor className="euiText--small" color="subdued">
<FormattedMessage
id="home.dataManagementDisclaimerPrivacy"
defaultMessage="To learn about how usage data helps us manage and improve our products and services, see our "
/>
<EuiLink
href={telemetry.telemetryConstants.getPrivacyStatementUrl()}
target="_blank"
rel="noopener"
>
<FormattedMessage
id="home.dataManagementDisclaimerPrivacyLink"
defaultMessage="Privacy Statement."
/>
</EuiLink>
{this.renderTelemetryEnabledOrDisabledText()}
</EuiTextColor>
<EuiSpacer size="xs" />
</Fragment>
)}
{welcomeService.renderTelemetryNotice()}
</EuiFlexItem>
</EuiFlexGroup>
</div>

View file

@ -17,7 +17,6 @@ import {
ApplicationStart,
} from 'kibana/public';
import { UiCounterMetricType } from '@kbn/analytics';
import { TelemetryPluginStart } from '../../../telemetry/public';
import { UrlForwardingStart } from '../../../url_forwarding/public';
import { DataViewsContract } from '../../../data_views/public';
import { TutorialService } from '../services/tutorials';
@ -26,6 +25,7 @@ import { FeatureCatalogueRegistry } from '../services/feature_catalogue';
import { EnvironmentService } from '../services/environment';
import { ConfigSchema } from '../../config';
import { SharePluginSetup } from '../../../share/public';
import type { WelcomeService } from '../services/welcome';
export interface HomeKibanaServices {
dataViewsService: DataViewsContract;
@ -46,9 +46,9 @@ export interface HomeKibanaServices {
docLinks: DocLinksStart;
addBasePath: (url: string) => string;
environmentService: EnvironmentService;
telemetry?: TelemetryPluginStart;
tutorialService: TutorialService;
addDataService: AddDataService;
welcomeService: WelcomeService;
}
let services: HomeKibanaServices | null = null;

View file

@ -27,6 +27,8 @@ export type {
TutorialVariables,
TutorialDirectoryHeaderLinkComponent,
TutorialModuleNoticeComponent,
WelcomeRenderTelemetryNotice,
WelcomeServiceSetup,
} from './services';
export { INSTRUCTION_VARIANT, getDisplayText } from '../common/instruction_variant';

View file

@ -8,16 +8,17 @@
import { featureCatalogueRegistryMock } from './services/feature_catalogue/feature_catalogue_registry.mock';
import { environmentServiceMock } from './services/environment/environment.mock';
import { configSchema } from '../config';
import { tutorialServiceMock } from './services/tutorials/tutorial_service.mock';
import { addDataServiceMock } from './services/add_data/add_data_service.mock';
import { HomePublicPluginSetup } from './plugin';
import { welcomeServiceMock } from './services/welcome/welcome_service.mocks';
const createSetupContract = () => ({
const createSetupContract = (): jest.Mocked<HomePublicPluginSetup> => ({
featureCatalogue: featureCatalogueRegistryMock.createSetup(),
environment: environmentServiceMock.createSetup(),
tutorials: tutorialServiceMock.createSetup(),
addData: addDataServiceMock.createSetup(),
config: configSchema.validate({}),
welcomeScreen: welcomeServiceMock.createSetup(),
});
export const homePluginMock = {

View file

@ -10,14 +10,17 @@ import { featureCatalogueRegistryMock } from './services/feature_catalogue/featu
import { environmentServiceMock } from './services/environment/environment.mock';
import { tutorialServiceMock } from './services/tutorials/tutorial_service.mock';
import { addDataServiceMock } from './services/add_data/add_data_service.mock';
import { welcomeServiceMock } from './services/welcome/welcome_service.mocks';
export const registryMock = featureCatalogueRegistryMock.create();
export const environmentMock = environmentServiceMock.create();
export const tutorialMock = tutorialServiceMock.create();
export const addDataMock = addDataServiceMock.create();
export const welcomeMock = welcomeServiceMock.create();
jest.doMock('./services', () => ({
FeatureCatalogueRegistry: jest.fn(() => registryMock),
EnvironmentService: jest.fn(() => environmentMock),
TutorialService: jest.fn(() => tutorialMock),
AddDataService: jest.fn(() => addDataMock),
WelcomeService: jest.fn(() => welcomeMock),
}));

View file

@ -79,5 +79,18 @@ describe('HomePublicPlugin', () => {
expect(setup).toHaveProperty('tutorials');
expect(setup.tutorials).toHaveProperty('setVariable');
});
test('wires up and returns welcome service', async () => {
const setup = await new HomePublicPlugin(mockInitializerContext).setup(
coreMock.createSetup() as any,
{
share: mockShare,
urlForwarding: urlForwardingPluginMock.createSetupContract(),
}
);
expect(setup).toHaveProperty('welcomeScreen');
expect(setup.welcomeScreen).toHaveProperty('registerOnRendered');
expect(setup.welcomeScreen).toHaveProperty('registerTelemetryNoticeRenderer');
});
});
});

View file

@ -25,11 +25,12 @@ import {
TutorialServiceSetup,
AddDataService,
AddDataServiceSetup,
WelcomeService,
WelcomeServiceSetup,
} from './services';
import { ConfigSchema } from '../config';
import { setServices } from './application/kibana_services';
import { DataViewsPublicPluginStart } from '../../data_views/public';
import { TelemetryPluginStart } from '../../telemetry/public';
import { UsageCollectionSetup } from '../../usage_collection/public';
import { UrlForwardingSetup, UrlForwardingStart } from '../../url_forwarding/public';
import { AppNavLinkStatus } from '../../../core/public';
@ -38,7 +39,6 @@ import { SharePluginSetup } from '../../share/public';
export interface HomePluginStartDependencies {
dataViews: DataViewsPublicPluginStart;
telemetry?: TelemetryPluginStart;
urlForwarding: UrlForwardingStart;
}
@ -61,6 +61,7 @@ export class HomePublicPlugin
private readonly environmentService = new EnvironmentService();
private readonly tutorialService = new TutorialService();
private readonly addDataService = new AddDataService();
private readonly welcomeService = new WelcomeService();
constructor(private readonly initializerContext: PluginInitializerContext<ConfigSchema>) {}
@ -76,7 +77,7 @@ export class HomePublicPlugin
const trackUiMetric = usageCollection
? usageCollection.reportUiCounter.bind(usageCollection, 'Kibana_home')
: () => {};
const [coreStart, { telemetry, dataViews, urlForwarding: urlForwardingStart }] =
const [coreStart, { dataViews, urlForwarding: urlForwardingStart }] =
await core.getStartServices();
setServices({
share,
@ -89,7 +90,6 @@ export class HomePublicPlugin
savedObjectsClient: coreStart.savedObjects.client,
chrome: coreStart.chrome,
application: coreStart.application,
telemetry,
uiSettings: core.uiSettings,
addBasePath: core.http.basePath.prepend,
getBasePath: core.http.basePath.get,
@ -100,6 +100,7 @@ export class HomePublicPlugin
tutorialService: this.tutorialService,
addDataService: this.addDataService,
featureCatalogue: this.featuresCatalogueRegistry,
welcomeService: this.welcomeService,
});
coreStart.chrome.docTitle.change(
i18n.translate('home.pageTitle', { defaultMessage: 'Home' })
@ -132,6 +133,7 @@ export class HomePublicPlugin
environment: { ...this.environmentService.setup() },
tutorials: { ...this.tutorialService.setup() },
addData: { ...this.addDataService.setup() },
welcomeScreen: { ...this.welcomeService.setup() },
};
}
@ -159,12 +161,12 @@ export interface HomePublicPluginSetup {
tutorials: TutorialServiceSetup;
addData: AddDataServiceSetup;
featureCatalogue: FeatureCatalogueSetup;
welcomeScreen: WelcomeServiceSetup;
/**
* The environment service is only available for a transition period and will
* be replaced by display specific extension points.
* @deprecated
*/
environment: EnvironmentSetup;
}

View file

@ -28,3 +28,6 @@ export type {
export { AddDataService } from './add_data';
export type { AddDataServiceSetup, AddDataTab } from './add_data';
export { WelcomeService } from './welcome';
export type { WelcomeServiceSetup, WelcomeRenderTelemetryNotice } from './welcome';

View file

@ -0,0 +1,10 @@
/*
* 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 type { WelcomeServiceSetup, WelcomeRenderTelemetryNotice } from './welcome_service';
export { WelcomeService } from './welcome_service';

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 { PublicMethodsOf } from '@kbn/utility-types';
import { WelcomeService, WelcomeServiceSetup } from './welcome_service';
const createSetupMock = (): jest.Mocked<WelcomeServiceSetup> => {
const welcomeService = new WelcomeService();
const welcomeServiceSetup = welcomeService.setup();
return {
registerTelemetryNoticeRenderer: jest
.fn()
.mockImplementation(welcomeServiceSetup.registerTelemetryNoticeRenderer),
registerOnRendered: jest.fn().mockImplementation(welcomeServiceSetup.registerOnRendered),
};
};
const createMock = (): jest.Mocked<PublicMethodsOf<WelcomeService>> => {
const welcomeService = new WelcomeService();
return {
setup: jest.fn().mockImplementation(welcomeService.setup),
onRendered: jest.fn().mockImplementation(welcomeService.onRendered),
renderTelemetryNotice: jest.fn().mockImplementation(welcomeService.renderTelemetryNotice),
};
};
export const welcomeServiceMock = {
createSetup: createSetupMock,
create: createMock,
};

View file

@ -0,0 +1,86 @@
/*
* 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 { WelcomeService, WelcomeServiceSetup } from './welcome_service';
describe('WelcomeService', () => {
let welcomeService: WelcomeService;
let welcomeServiceSetup: WelcomeServiceSetup;
beforeEach(() => {
welcomeService = new WelcomeService();
welcomeServiceSetup = welcomeService.setup();
});
describe('onRendered', () => {
test('it should register an onRendered listener', () => {
const onRendered = jest.fn();
welcomeServiceSetup.registerOnRendered(onRendered);
welcomeService.onRendered();
expect(onRendered).toHaveBeenCalledTimes(1);
});
test('it should handle onRendered errors', () => {
const onRendered = jest.fn().mockImplementation(() => {
throw new Error('Something went terribly wrong');
});
welcomeServiceSetup.registerOnRendered(onRendered);
expect(() => welcomeService.onRendered()).not.toThrow();
expect(onRendered).toHaveBeenCalledTimes(1);
});
test('it should allow registering multiple onRendered listeners', () => {
const onRendered = jest.fn();
const onRendered2 = jest.fn();
welcomeServiceSetup.registerOnRendered(onRendered);
welcomeServiceSetup.registerOnRendered(onRendered2);
welcomeService.onRendered();
expect(onRendered).toHaveBeenCalledTimes(1);
expect(onRendered2).toHaveBeenCalledTimes(1);
});
test('if the same handler is registered twice, it is called twice', () => {
const onRendered = jest.fn();
welcomeServiceSetup.registerOnRendered(onRendered);
welcomeServiceSetup.registerOnRendered(onRendered);
welcomeService.onRendered();
expect(onRendered).toHaveBeenCalledTimes(2);
});
});
describe('renderTelemetryNotice', () => {
test('it should register a renderer', () => {
const renderer = jest.fn().mockReturnValue('rendered text');
welcomeServiceSetup.registerTelemetryNoticeRenderer(renderer);
expect(welcomeService.renderTelemetryNotice()).toEqual('rendered text');
});
test('it should fail to register a 2nd renderer and still use the first registered renderer', () => {
const renderer = jest.fn().mockReturnValue('rendered text');
const renderer2 = jest.fn().mockReturnValue('other text');
welcomeServiceSetup.registerTelemetryNoticeRenderer(renderer);
expect(() => welcomeServiceSetup.registerTelemetryNoticeRenderer(renderer2)).toThrowError(
'Only one renderTelemetryNotice handler can be registered'
);
expect(welcomeService.renderTelemetryNotice()).toEqual('rendered text');
});
test('it should handle errors in the renderer', () => {
const renderer = jest.fn().mockImplementation(() => {
throw new Error('Something went terribly wrong');
});
welcomeServiceSetup.registerTelemetryNoticeRenderer(renderer);
expect(welcomeService.renderTelemetryNotice()).toEqual(null);
});
});
});

View file

@ -0,0 +1,63 @@
/*
* 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 type WelcomeRenderTelemetryNotice = () => null | JSX.Element;
export interface WelcomeServiceSetup {
/**
* Register listeners to be called when the Welcome component is mounted.
* It can be called multiple times to register multiple listeners.
*/
registerOnRendered: (onRendered: () => void) => void;
/**
* Register a renderer of the telemetry notice to be shown below the Welcome page.
*/
registerTelemetryNoticeRenderer: (renderTelemetryNotice: WelcomeRenderTelemetryNotice) => void;
}
export class WelcomeService {
private readonly onRenderedHandlers: Array<() => void> = [];
private renderTelemetryNoticeHandler?: WelcomeRenderTelemetryNotice;
public setup = (): WelcomeServiceSetup => {
return {
registerOnRendered: (onRendered) => {
this.onRenderedHandlers.push(onRendered);
},
registerTelemetryNoticeRenderer: (renderTelemetryNotice) => {
if (this.renderTelemetryNoticeHandler) {
throw new Error('Only one renderTelemetryNotice handler can be registered');
}
this.renderTelemetryNoticeHandler = renderTelemetryNotice;
},
};
};
public onRendered = () => {
this.onRenderedHandlers.forEach((onRendered) => {
try {
onRendered();
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
}
});
};
public renderTelemetryNotice = () => {
if (this.renderTelemetryNoticeHandler) {
try {
return this.renderTelemetryNoticeHandler();
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
}
}
return null;
};
}

View file

@ -15,7 +15,6 @@
{ "path": "../kibana_react/tsconfig.json" },
{ "path": "../share/tsconfig.json" },
{ "path": "../url_forwarding/tsconfig.json" },
{ "path": "../usage_collection/tsconfig.json" },
{ "path": "../telemetry/tsconfig.json" }
{ "path": "../usage_collection/tsconfig.json" }
]
}

View file

@ -8,6 +8,7 @@
"server": true,
"ui": true,
"requiredPlugins": ["telemetryCollectionManager", "usageCollection", "screenshotMode"],
"optionalPlugins": ["home", "security"],
"extraPublicDirs": ["common/constants"],
"requiredBundles": ["kibanaUtils", "kibanaReact"]
}

View file

@ -31,6 +31,8 @@ import {
} from '../common/telemetry_config';
import { getNotifyUserAboutOptInDefault } from '../common/telemetry_config/get_telemetry_notify_user_about_optin_default';
import { PRIVACY_STATEMENT_URL } from '../common/constants';
import { HomePublicPluginSetup } from '../../home/public';
import { renderWelcomeTelemetryNotice } from './render_welcome_telemetry_notice';
/**
* Publicly exposed APIs from the Telemetry Service
@ -82,6 +84,7 @@ export interface TelemetryPluginStart {
interface TelemetryPluginSetupDependencies {
screenshotMode: ScreenshotModePluginSetup;
home?: HomePublicPluginSetup;
}
/**
@ -121,7 +124,7 @@ export class TelemetryPlugin implements Plugin<TelemetryPluginSetup, TelemetryPl
public setup(
{ http, notifications }: CoreSetup,
{ screenshotMode }: TelemetryPluginSetupDependencies
{ screenshotMode, home }: TelemetryPluginSetupDependencies
): TelemetryPluginSetup {
const config = this.config;
const currentKibanaVersion = this.currentKibanaVersion;
@ -135,6 +138,18 @@ export class TelemetryPlugin implements Plugin<TelemetryPluginSetup, TelemetryPl
this.telemetrySender = new TelemetrySender(this.telemetryService);
if (home) {
home.welcomeScreen.registerOnRendered(() => {
if (this.telemetryService?.userCanChangeSettings) {
this.telemetryNotifications?.setOptedInNoticeSeen();
}
});
home.welcomeScreen.registerTelemetryNoticeRenderer(() =>
renderWelcomeTelemetryNotice(this.telemetryService!, http.basePath.prepend)
);
}
return {
telemetryService: this.getTelemetryServicePublicApis(),
};

View file

@ -0,0 +1,32 @@
/*
* 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 { mountWithIntl } from '@kbn/test-jest-helpers';
import { renderWelcomeTelemetryNotice } from './render_welcome_telemetry_notice';
import { mockTelemetryService } from './mocks';
describe('renderWelcomeTelemetryNotice', () => {
test('it should show the opt-out message', () => {
const telemetryService = mockTelemetryService();
const component = mountWithIntl(renderWelcomeTelemetryNotice(telemetryService, (url) => url));
expect(component.exists('[id="telemetry.dataManagementDisableCollectionLink"]')).toBe(true);
});
test('it should show the opt-in message', () => {
const telemetryService = mockTelemetryService({ config: { optIn: false } });
const component = mountWithIntl(renderWelcomeTelemetryNotice(telemetryService, (url) => url));
expect(component.exists('[id="telemetry.dataManagementEnableCollectionLink"]')).toBe(true);
});
test('it should not show opt-in/out options if user cannot change the settings', () => {
const telemetryService = mockTelemetryService({ config: { allowChangingOptInStatus: false } });
const component = mountWithIntl(renderWelcomeTelemetryNotice(telemetryService, (url) => url));
expect(component.exists('[id="telemetry.dataManagementDisableCollectionLink"]')).toBe(false);
expect(component.exists('[id="telemetry.dataManagementEnableCollectionLink"]')).toBe(false);
});
});

View file

@ -0,0 +1,80 @@
/*
* 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, EuiSpacer, EuiTextColor } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import type { TelemetryService } from './services';
import { PRIVACY_STATEMENT_URL } from '../common/constants';
export function renderWelcomeTelemetryNotice(
telemetryService: TelemetryService,
addBasePath: (url: string) => string
) {
return (
<>
<EuiTextColor className="euiText--small" color="subdued">
<FormattedMessage
id="telemetry.dataManagementDisclaimerPrivacy"
defaultMessage="To learn about how usage data helps us manage and improve our products and services, see our "
/>
<EuiLink href={PRIVACY_STATEMENT_URL} target="_blank" rel="noopener">
<FormattedMessage
id="telemetry.dataManagementDisclaimerPrivacyLink"
defaultMessage="Privacy Statement."
/>
</EuiLink>
{renderTelemetryEnabledOrDisabledText(telemetryService, addBasePath)}
</EuiTextColor>
<EuiSpacer size="xs" />
</>
);
}
function renderTelemetryEnabledOrDisabledText(
telemetryService: TelemetryService,
addBasePath: (url: string) => string
) {
if (!telemetryService.userCanChangeSettings || !telemetryService.getCanChangeOptInStatus()) {
return null;
}
const isOptedIn = telemetryService.getIsOptedIn();
if (isOptedIn) {
return (
<>
<FormattedMessage
id="telemetry.dataManagementDisableCollection"
defaultMessage=" To stop collection, "
/>
<EuiLink href={addBasePath('management/kibana/settings')}>
<FormattedMessage
id="telemetry.dataManagementDisableCollectionLink"
defaultMessage="disable usage data here."
/>
</EuiLink>
</>
);
} else {
return (
<>
<FormattedMessage
id="telemetry.dataManagementEnableCollection"
defaultMessage=" To start collection, "
/>
<EuiLink href={addBasePath('management/kibana/settings')}>
<FormattedMessage
id="telemetry.dataManagementEnableCollectionLink"
defaultMessage="enable usage data here."
/>
</EuiLink>
</>
);
}
}

View file

@ -23,6 +23,7 @@ import type {
Plugin,
Logger,
} from 'src/core/server';
import type { SecurityPluginStart } from '../../../../x-pack/plugins/security/server';
import { SavedObjectsClient } from '../../../core/server';
import { registerRoutes } from './routes';
import { registerCollection } from './telemetry_collection';
@ -42,6 +43,7 @@ interface TelemetryPluginsDepsSetup {
interface TelemetryPluginsDepsStart {
telemetryCollectionManager: TelemetryCollectionManagerPluginStart;
security?: SecurityPluginStart;
}
/**
@ -90,6 +92,8 @@ export class TelemetryPlugin implements Plugin<TelemetryPluginSetup, TelemetryPl
*/
private savedObjectsInternalClient$ = new ReplaySubject<SavedObjectsClient>(1);
private security?: SecurityPluginStart;
constructor(initializerContext: PluginInitializerContext<TelemetryConfigType>) {
this.logger = initializerContext.logger.get();
this.isDev = initializerContext.env.mode.dev;
@ -119,6 +123,7 @@ export class TelemetryPlugin implements Plugin<TelemetryPluginSetup, TelemetryPl
router,
telemetryCollectionManager,
savedObjectsInternalClient$: this.savedObjectsInternalClient$,
getSecurity: () => this.security,
});
this.registerMappings((opts) => savedObjects.registerType(opts));
@ -137,11 +142,17 @@ export class TelemetryPlugin implements Plugin<TelemetryPluginSetup, TelemetryPl
};
}
public start(core: CoreStart, { telemetryCollectionManager }: TelemetryPluginsDepsStart) {
public start(
core: CoreStart,
{ telemetryCollectionManager, security }: TelemetryPluginsDepsStart
) {
const { savedObjects } = core;
const savedObjectsInternalRepository = savedObjects.createInternalRepository();
this.savedObjectsInternalRepository = savedObjectsInternalRepository;
this.savedObjectsInternalClient$.next(new SavedObjectsClient(savedObjectsInternalRepository));
this.security = security;
this.startFetcher(core, telemetryCollectionManager);
return {

View file

@ -10,7 +10,7 @@ import type { Observable } from 'rxjs';
import type { IRouter, Logger, SavedObjectsClient } from 'kibana/server';
import type { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server';
import { registerTelemetryOptInRoutes } from './telemetry_opt_in';
import { registerTelemetryUsageStatsRoutes } from './telemetry_usage_stats';
import { registerTelemetryUsageStatsRoutes, SecurityGetter } from './telemetry_usage_stats';
import { registerTelemetryOptInStatsRoutes } from './telemetry_opt_in_stats';
import { registerTelemetryUserHasSeenNotice } from './telemetry_user_has_seen_notice';
import type { TelemetryConfigType } from '../config';
@ -24,12 +24,14 @@ interface RegisterRoutesParams {
router: IRouter;
telemetryCollectionManager: TelemetryCollectionManagerPluginSetup;
savedObjectsInternalClient$: Observable<SavedObjectsClient>;
getSecurity: SecurityGetter;
}
export function registerRoutes(options: RegisterRoutesParams) {
const { isDev, telemetryCollectionManager, router, savedObjectsInternalClient$ } = options;
const { isDev, telemetryCollectionManager, router, savedObjectsInternalClient$, getSecurity } =
options;
registerTelemetryOptInRoutes(options);
registerTelemetryUsageStatsRoutes(router, telemetryCollectionManager, isDev);
registerTelemetryUsageStatsRoutes(router, telemetryCollectionManager, isDev, getSecurity);
registerTelemetryOptInStatsRoutes(router, telemetryCollectionManager);
registerTelemetryUserHasSeenNotice(router);
registerTelemetryLastReported(router, savedObjectsInternalClient$);

View file

@ -75,7 +75,6 @@ export function registerTelemetryOptInStatsRoutes(
const statsGetterConfig: StatsGetterConfig = {
unencrypted,
request: req,
};
const optInStatus = await telemetryCollectionManager.getOptInStats(

View file

@ -8,7 +8,8 @@
import { registerTelemetryUsageStatsRoutes } from './telemetry_usage_stats';
import { coreMock, httpServerMock } from 'src/core/server/mocks';
import type { RequestHandlerContext, IRouter } from 'kibana/server';
import type { RequestHandlerContext, IRouter } from 'src/core/server';
import { securityMock } from '../../../../../x-pack/plugins/security/server/mocks';
import { telemetryCollectionManagerPluginMock } from '../../../telemetry_collection_manager/server/mocks';
async function runRequest(
@ -35,13 +36,18 @@ describe('registerTelemetryUsageStatsRoutes', () => {
};
const telemetryCollectionManager = telemetryCollectionManagerPluginMock.createSetupContract();
const mockCoreSetup = coreMock.createSetup();
const mockRouter = mockCoreSetup.http.createRouter();
const mockStats = [{ clusterUuid: 'text', stats: 'enc_str' }];
telemetryCollectionManager.getStats.mockResolvedValue(mockStats);
const getSecurity = jest.fn();
let mockRouter: IRouter;
beforeEach(() => {
mockRouter = mockCoreSetup.http.createRouter();
});
describe('clusters/_stats POST route', () => {
it('registers _stats POST route and accepts body configs', () => {
registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true);
registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true, getSecurity);
expect(mockRouter.post).toBeCalledTimes(1);
const [routeConfig, handler] = (mockRouter.post as jest.Mock).mock.calls[0];
expect(routeConfig.path).toMatchInlineSnapshot(`"/api/telemetry/v2/clusters/_stats"`);
@ -50,11 +56,10 @@ describe('registerTelemetryUsageStatsRoutes', () => {
});
it('responds with encrypted stats with no cache refresh by default', async () => {
registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true);
registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true, getSecurity);
const { mockRequest, mockResponse } = await runRequest(mockRouter);
const { mockResponse } = await runRequest(mockRouter);
expect(telemetryCollectionManager.getStats).toBeCalledWith({
request: mockRequest,
unencrypted: undefined,
refreshCache: undefined,
});
@ -63,39 +68,99 @@ describe('registerTelemetryUsageStatsRoutes', () => {
});
it('when unencrypted is set getStats is called with unencrypted and refreshCache', async () => {
registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true);
registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true, getSecurity);
const { mockRequest } = await runRequest(mockRouter, { unencrypted: true });
await runRequest(mockRouter, { unencrypted: true });
expect(telemetryCollectionManager.getStats).toBeCalledWith({
request: mockRequest,
unencrypted: true,
refreshCache: true,
});
});
it('calls getStats with refreshCache when set in body', async () => {
registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true);
const { mockRequest } = await runRequest(mockRouter, { refreshCache: true });
registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true, getSecurity);
await runRequest(mockRouter, { refreshCache: true });
expect(telemetryCollectionManager.getStats).toBeCalledWith({
request: mockRequest,
unencrypted: undefined,
refreshCache: true,
});
});
it('calls getStats with refreshCache:true even if set to false in body when unencrypted is set to true', async () => {
registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true);
const { mockRequest } = await runRequest(mockRouter, {
registerTelemetryUsageStatsRoutes(mockRouter, telemetryCollectionManager, true, getSecurity);
await runRequest(mockRouter, {
refreshCache: false,
unencrypted: true,
});
expect(telemetryCollectionManager.getStats).toBeCalledWith({
request: mockRequest,
unencrypted: true,
refreshCache: true,
});
});
it('returns 403 when the user does not have enough permissions to request unencrypted telemetry', async () => {
const getSecurityMock = jest.fn().mockImplementation(() => {
const securityStartMock = securityMock.createStart();
securityStartMock.authz.checkPrivilegesWithRequest.mockReturnValue({
globally: () => ({ hasAllRequested: false }),
});
return securityStartMock;
});
registerTelemetryUsageStatsRoutes(
mockRouter,
telemetryCollectionManager,
true,
getSecurityMock
);
const { mockResponse } = await runRequest(mockRouter, {
refreshCache: false,
unencrypted: true,
});
expect(mockResponse.forbidden).toBeCalled();
});
it('returns 200 when the user has enough permissions to request unencrypted telemetry', async () => {
const getSecurityMock = jest.fn().mockImplementation(() => {
const securityStartMock = securityMock.createStart();
securityStartMock.authz.checkPrivilegesWithRequest.mockReturnValue({
globally: () => ({ hasAllRequested: true }),
});
return securityStartMock;
});
registerTelemetryUsageStatsRoutes(
mockRouter,
telemetryCollectionManager,
true,
getSecurityMock
);
const { mockResponse } = await runRequest(mockRouter, {
refreshCache: false,
unencrypted: true,
});
expect(mockResponse.ok).toBeCalled();
});
it('returns 200 when the user does not have enough permissions to request unencrypted telemetry but it requests encrypted', async () => {
const getSecurityMock = jest.fn().mockImplementation(() => {
const securityStartMock = securityMock.createStart();
securityStartMock.authz.checkPrivilegesWithRequest.mockReturnValue({
globally: () => ({ hasAllRequested: false }),
});
return securityStartMock;
});
registerTelemetryUsageStatsRoutes(
mockRouter,
telemetryCollectionManager,
true,
getSecurityMock
);
const { mockResponse } = await runRequest(mockRouter, {
refreshCache: false,
unencrypted: false,
});
expect(mockResponse.ok).toBeCalled();
});
it.todo('always returns an empty array on errors on encrypted payload');
it.todo('returns the actual request error object when in development mode');
it.todo('returns forbidden on unencrypted and ES returns 403 in getStats');

View file

@ -12,11 +12,15 @@ import {
TelemetryCollectionManagerPluginSetup,
StatsGetterConfig,
} from 'src/plugins/telemetry_collection_manager/server';
import type { SecurityPluginStart } from '../../../../../x-pack/plugins/security/server';
export type SecurityGetter = () => SecurityPluginStart | undefined;
export function registerTelemetryUsageStatsRoutes(
router: IRouter,
telemetryCollectionManager: TelemetryCollectionManagerPluginSetup,
isDev: boolean
isDev: boolean,
getSecurity: SecurityGetter
) {
router.post(
{
@ -31,9 +35,22 @@ export function registerTelemetryUsageStatsRoutes(
async (context, req, res) => {
const { unencrypted, refreshCache } = req.body;
const security = getSecurity();
if (security && unencrypted) {
// Normally we would use `options: { tags: ['access:decryptedTelemetry'] }` in the route definition to check authorization for an
// API action, however, we want to check this conditionally based on the `unencrypted` parameter. In this case we need to use the
// security API directly to check privileges for this action. Note that the 'decryptedTelemetry' API privilege string is only
// granted to users that have "Global All" or "Global Read" privileges in Kibana.
const { checkPrivilegesWithRequest, actions } = security.authz;
const privileges = { kibana: actions.api.get('decryptedTelemetry') };
const { hasAllRequested } = await checkPrivilegesWithRequest(req).globally(privileges);
if (!hasAllRequested) {
return res.forbidden();
}
}
try {
const statsConfig: StatsGetterConfig = {
request: req,
unencrypted,
refreshCache: unencrypted || refreshCache,
};

View file

@ -7,10 +7,9 @@
*/
import { omit } from 'lodash';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server';
import { StatsCollectionContext } from 'src/plugins/telemetry_collection_manager/server';
import { ElasticsearchClient } from 'src/core/server';
import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server';
import type { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import type { StatsCollectionContext } from 'src/plugins/telemetry_collection_manager/server';
export interface KibanaUsageStats {
kibana: {
@ -71,9 +70,8 @@ export function handleKibanaStats(
export async function getKibana(
usageCollection: UsageCollectionSetup,
asInternalUser: ElasticsearchClient,
soClient: SavedObjectsClientContract,
kibanaRequest: KibanaRequest | undefined // intentionally `| undefined` to enforce providing the parameter
soClient: SavedObjectsClientContract
): Promise<KibanaUsageStats> {
const usage = await usageCollection.bulkFetch(asInternalUser, soClient, kibanaRequest);
const usage = await usageCollection.bulkFetch(asInternalUser, soClient);
return usageCollection.toObject<KibanaUsageStats>(usage);
}

View file

@ -14,7 +14,7 @@ import {
usageCollectionPluginMock,
createCollectorFetchContextMock,
} from '../../../usage_collection/server/mocks';
import { elasticsearchServiceMock, httpServerMock } from '../../../../../src/core/server/mocks';
import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks';
import { StatsCollectionConfig } from '../../../telemetry_collection_manager/server';
function mockUsageCollection(kibanaUsage = {}) {
@ -74,7 +74,6 @@ function mockStatsCollectionConfig(
...createCollectorFetchContextMock(),
esClient: mockGetLocalStats(clusterInfo, clusterStats),
usageCollection: mockUsageCollection(kibana),
kibanaRequest: httpServerMock.createKibanaRequest(),
refreshCache: false,
};
}

View file

@ -65,7 +65,7 @@ export const getLocalStats: StatsGetter<TelemetryLocalStats> = async (
config,
context
) => {
const { usageCollection, esClient, soClient, kibanaRequest } = config;
const { usageCollection, esClient, soClient } = config;
return await Promise.all(
clustersDetails.map(async (clustersDetail) => {
@ -73,7 +73,7 @@ export const getLocalStats: StatsGetter<TelemetryLocalStats> = async (
getClusterInfo(esClient), // cluster info
getClusterStats(esClient), // cluster stats (not to be confused with cluster _state_)
getNodesUsage(esClient), // nodes_usage info
getKibana(usageCollection, esClient, soClient, kibanaRequest),
getKibana(usageCollection, esClient, soClient),
getDataTelemetry(esClient),
]);
return handleLocalStats(

View file

@ -17,10 +17,12 @@
],
"references": [
{ "path": "../../core/tsconfig.json" },
{ "path": "../../plugins/home/tsconfig.json" },
{ "path": "../../plugins/kibana_react/tsconfig.json" },
{ "path": "../../plugins/kibana_utils/tsconfig.json" },
{ "path": "../../plugins/screenshot_mode/tsconfig.json" },
{ "path": "../../plugins/telemetry_collection_manager/tsconfig.json" },
{ "path": "../../plugins/usage_collection/tsconfig.json" }
{ "path": "../../plugins/usage_collection/tsconfig.json" },
{ "path": "../../../x-pack/plugins/security/tsconfig.json" }
]
}

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { coreMock, httpServerMock } from '../../../core/server/mocks';
import { coreMock } from '../../../core/server/mocks';
import { usageCollectionPluginMock } from '../../usage_collection/server/mocks';
import { TelemetryCollectionManagerPlugin } from './plugin';
import type { BasicStatsPayload, CollectionStrategyConfig, StatsGetterConfig } from './types';
@ -217,19 +217,17 @@ describe('Telemetry Collection Manager', () => {
});
});
describe('unencrypted: true', () => {
const mockRequest = httpServerMock.createKibanaRequest();
const config: StatsGetterConfig = {
unencrypted: true,
request: mockRequest,
};
describe('getStats', () => {
test('getStats returns empty because clusterDetails returns empty, and the soClient is not an instance of the TelemetrySavedObjectsClient', async () => {
test('getStats returns empty because clusterDetails returns empty, and the soClient is an instance of the TelemetrySavedObjectsClient', async () => {
collectionStrategy.clusterDetailsGetter.mockResolvedValue([]);
await expect(setupApi.getStats(config)).resolves.toStrictEqual([]);
expect(
collectionStrategy.clusterDetailsGetter.mock.calls[0][0].soClient
).not.toBeInstanceOf(TelemetrySavedObjectsClient);
).toBeInstanceOf(TelemetrySavedObjectsClient);
});
test('returns encrypted payload (assumes opted-in when no explicitly opted-out)', async () => {
collectionStrategy.clusterDetailsGetter.mockResolvedValue([
@ -249,7 +247,7 @@ describe('Telemetry Collection Manager', () => {
expect(
collectionStrategy.clusterDetailsGetter.mock.calls[0][0].soClient
).not.toBeInstanceOf(TelemetrySavedObjectsClient);
).toBeInstanceOf(TelemetrySavedObjectsClient);
});
it('calls getStats with config { refreshCache: true } even if set to false', async () => {
@ -267,7 +265,6 @@ describe('Telemetry Collection Manager', () => {
expect(getStatsCollectionConfig).toReturnWith(
expect.objectContaining({
refreshCache: true,
kibanaRequest: mockRequest,
})
);
@ -281,7 +278,7 @@ describe('Telemetry Collection Manager', () => {
await expect(setupApi.getOptInStats(true, config)).resolves.toStrictEqual([]);
expect(
collectionStrategy.clusterDetailsGetter.mock.calls[0][0].soClient
).not.toBeInstanceOf(TelemetrySavedObjectsClient);
).toBeInstanceOf(TelemetrySavedObjectsClient);
});
test('returns results for opt-in true', async () => {
@ -296,7 +293,7 @@ describe('Telemetry Collection Manager', () => {
]);
expect(
collectionStrategy.clusterDetailsGetter.mock.calls[0][0].soClient
).not.toBeInstanceOf(TelemetrySavedObjectsClient);
).toBeInstanceOf(TelemetrySavedObjectsClient);
});
test('returns results for opt-in false', async () => {
@ -311,7 +308,7 @@ describe('Telemetry Collection Manager', () => {
]);
expect(
collectionStrategy.clusterDetailsGetter.mock.calls[0][0].soClient
).not.toBeInstanceOf(TelemetrySavedObjectsClient);
).toBeInstanceOf(TelemetrySavedObjectsClient);
});
});
});

View file

@ -126,11 +126,10 @@ export class TelemetryCollectionManagerPlugin
const esClient = this.getElasticsearchClient(config);
const soClient = this.getSavedObjectsClient(config);
// Provide the kibanaRequest so opted-in plugins can scope their custom clients only if the request is not encrypted
const kibanaRequest = config.unencrypted ? config.request : void 0;
const refreshCache = config.unencrypted ? true : !!config.refreshCache;
if (esClient && soClient) {
return { usageCollection, esClient, soClient, kibanaRequest, refreshCache };
return { usageCollection, esClient, soClient, refreshCache };
}
}
@ -142,9 +141,7 @@ export class TelemetryCollectionManagerPlugin
* @private
*/
private getElasticsearchClient(config: StatsGetterConfig): ElasticsearchClient | undefined {
return config.unencrypted
? this.elasticsearchClient?.asScoped(config.request).asCurrentUser
: this.elasticsearchClient?.asInternalUser;
return this.elasticsearchClient?.asInternalUser;
}
/**
@ -155,11 +152,7 @@ export class TelemetryCollectionManagerPlugin
* @private
*/
private getSavedObjectsClient(config: StatsGetterConfig): SavedObjectsClientContract | undefined {
if (config.unencrypted) {
// Intentionally using the scoped client here to make use of all the security wrappers.
// It also returns spaces-scoped telemetry.
return this.savedObjectsService?.getScopedClient(config.request);
} else if (this.savedObjectsService) {
if (this.savedObjectsService) {
// Wrapping the internalRepository with the `TelemetrySavedObjectsClient`
// to ensure some best practices when collecting "all the telemetry"
// (i.e.: `.find` requests should query all spaces)

View file

@ -6,14 +6,9 @@
* Side Public License, v 1.
*/
import {
ElasticsearchClient,
Logger,
KibanaRequest,
SavedObjectsClientContract,
} from 'src/core/server';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { TelemetryCollectionManagerPlugin } from './plugin';
import type { ElasticsearchClient, Logger, SavedObjectsClientContract } from 'src/core/server';
import type { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import type { TelemetryCollectionManagerPlugin } from './plugin';
export interface TelemetryCollectionManagerPluginSetup {
setCollectionStrategy: <T extends BasicStatsPayload>(
@ -36,7 +31,6 @@ export interface TelemetryOptInStats {
export interface BaseStatsGetterConfig {
unencrypted: boolean;
refreshCache?: boolean;
request?: KibanaRequest;
}
export interface EncryptedStatsGetterConfig extends BaseStatsGetterConfig {
@ -45,7 +39,6 @@ export interface EncryptedStatsGetterConfig extends BaseStatsGetterConfig {
export interface UnencryptedStatsGetterConfig extends BaseStatsGetterConfig {
unencrypted: true;
request: KibanaRequest;
}
export interface ClusterDetails {
@ -56,7 +49,6 @@ export interface StatsCollectionConfig {
usageCollection: UsageCollectionSetup;
esClient: ElasticsearchClient;
soClient: SavedObjectsClientContract;
kibanaRequest: KibanaRequest | undefined; // intentionally `| undefined` to enforce providing the parameter
refreshCache: boolean;
}

View file

@ -297,8 +297,7 @@ Some background:
- `isReady` (added in v7.2.0 and v6.8.4) is a way for a usage collector to announce that some async process must finish first before it can return data in the `fetch` method (e.g. a client needs to ne initialized, or the task manager needs to run a task first). If any collector reports that it is not ready when we call its `fetch` method, we reset a flag to try again and, after a set amount of time, collect data from those collectors that are ready and skip any that are not. This means that if a collector returns `true` for `isReady` and it actually isn't ready to return data, there won't be telemetry data from that collector in that telemetry report (usually once per day). You should consider what it means if your collector doesn't return data in the first few documents when Kibana starts or, if we should wait for any other reason (e.g. the task manager needs to run your task first). If you need to tell telemetry collection to wait, you should implement this function with custom logic. If your `fetch` method can run without the need of any previous dependencies, then you can return true for `isReady` as shown in the example below.
- The `fetch` method needs to support multiple contexts in which it is called. For example, when a user requests the example of what we collect in the **Kibana>Advanced Settings>Usage data** section, the clients provided in the context of the function (`CollectorFetchContext`) are scoped to that user's privileges. The reason is to avoid exposing via telemetry any data that user should not have access to (i.e.: if the user does not have access to certain indices, they shouldn't be allowed to see the number of documents that exists in it). In this case, the `fetch` method receives the clients `esClient` and `soClient` scoped to the user who performed the HTTP API request. Alternatively, when requesting the usage data to be reported to the Remote Telemetry Service, the clients are scoped to the internal Kibana user (`kibana_system`). Please, mind it might have lower-level access than the default super-admin `elastic` test user.
In some scenarios, your collector might need to maintain its own client. An example of that is the `monitoring` plugin, that maintains a connection to the Remote Monitoring Cluster to push its monitoring data. If that's the case, your plugin can opt-in to receive the additional `kibanaRequest` parameter by adding `extendFetchContext.kibanaRequest: true` to the collector's config: it will be appended to the context of the `fetch` method only if the request needs to be scoped to a user other than Kibana Internal, so beware that your collector will need to work for both scenarios (especially for the scenario when `kibanaRequest` is missing).
- The clients provided to the `fetch` method are scoped to the internal Kibana user (`kibana_system`).
Note: there will be many cases where you won't need to use the `esClient` or `soClient` function that gets passed in to your `fetch` method at all. Your feature might have an accumulating value in server memory, or read something from the OS.

View file

@ -7,20 +7,14 @@
*/
import type { Logger } from 'src/core/server';
import type {
CollectorFetchMethod,
CollectorOptions,
CollectorOptionsFetchExtendedContext,
ICollector,
} from './types';
import type { CollectorFetchMethod, CollectorOptions, ICollector } from './types';
export class Collector<TFetchReturn, ExtraOptions extends object = {}>
implements ICollector<TFetchReturn, ExtraOptions>
{
public readonly extendFetchContext: CollectorOptionsFetchExtendedContext<boolean>;
public readonly type: CollectorOptions<TFetchReturn, boolean>['type'];
public readonly fetch: CollectorFetchMethod<boolean, TFetchReturn, ExtraOptions>;
public readonly isReady: CollectorOptions<TFetchReturn, boolean>['isReady'];
public readonly type: CollectorOptions<TFetchReturn>['type'];
public readonly fetch: CollectorFetchMethod<TFetchReturn, ExtraOptions>;
public readonly isReady: CollectorOptions<TFetchReturn>['isReady'];
/**
* @private Constructor of a Collector. It should be called via the CollectorSet factory methods: `makeStatsCollector` and `makeUsageCollector`
* @param log {@link Logger}
@ -28,15 +22,7 @@ export class Collector<TFetchReturn, ExtraOptions extends object = {}>
*/
constructor(
public readonly log: Logger,
{
type,
fetch,
isReady,
extendFetchContext = {},
...options
}: // Any does not affect here, but needs to be set so it doesn't affect anything else down the line
// eslint-disable-next-line @typescript-eslint/no-explicit-any
CollectorOptions<TFetchReturn, any, ExtraOptions>
{ type, fetch, isReady, ...options }: CollectorOptions<TFetchReturn, ExtraOptions>
) {
if (type === undefined) {
throw new Error('Collector must be instantiated with a options.type string property');
@ -50,6 +36,5 @@ export class Collector<TFetchReturn, ExtraOptions extends object = {}>
this.type = type;
this.fetch = fetch;
this.isReady = typeof isReady === 'function' ? isReady : () => true;
this.extendFetchContext = extendFetchContext;
}
}

View file

@ -15,7 +15,6 @@ import {
elasticsearchServiceMock,
loggingSystemMock,
savedObjectsClientMock,
httpServerMock,
executionContextServiceMock,
} from '../../../../core/server/mocks';
import type { ExecutionContextSetup, Logger } from 'src/core/server';
@ -39,7 +38,6 @@ describe('CollectorSet', () => {
});
const mockEsClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
const mockSoClient = savedObjectsClientMock.create();
const req = void 0; // No need to instantiate any KibanaRequest in these tests
it('should throw an error if non-Collector type of object is registered', () => {
const collectors = new CollectorSet(collectorSetConfig);
@ -88,7 +86,7 @@ describe('CollectorSet', () => {
})
);
const result = await collectors.bulkFetch(mockEsClient, mockSoClient, req);
const result = await collectors.bulkFetch(mockEsClient, mockSoClient);
expect(logger.debug).toHaveBeenCalledTimes(2);
expect(logger.debug).toHaveBeenCalledWith('Getting ready collectors');
expect(logger.debug).toHaveBeenCalledWith('Fetching data from MY_TEST_COLLECTOR collector');
@ -121,7 +119,7 @@ describe('CollectorSet', () => {
let result;
try {
result = await collectors.bulkFetch(mockEsClient, mockSoClient, req);
result = await collectors.bulkFetch(mockEsClient, mockSoClient);
} catch (err) {
// Do nothing
}
@ -150,7 +148,7 @@ describe('CollectorSet', () => {
})
);
const result = await collectors.bulkFetch(mockEsClient, mockSoClient, req);
const result = await collectors.bulkFetch(mockEsClient, mockSoClient);
expect(result).toStrictEqual([
{
type: 'MY_TEST_COLLECTOR',
@ -178,7 +176,7 @@ describe('CollectorSet', () => {
})
);
const result = await collectors.bulkFetch(mockEsClient, mockSoClient, req);
const result = await collectors.bulkFetch(mockEsClient, mockSoClient);
expect(result).toStrictEqual([
{
type: 'MY_TEST_COLLECTOR',
@ -269,50 +267,6 @@ describe('CollectorSet', () => {
collectorSet = new CollectorSet(collectorSetConfig);
});
test('TS should hide kibanaRequest when not opted-in', () => {
collectorSet.makeStatsCollector({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
// @ts-expect-error
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
});
});
test('TS should hide kibanaRequest when not opted-in (explicit false)', () => {
collectorSet.makeStatsCollector({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
// @ts-expect-error
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
extendFetchContext: {
kibanaRequest: false,
},
});
});
test('TS should allow using kibanaRequest when opted-in (explicit true)', () => {
collectorSet.makeStatsCollector({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
extendFetchContext: {
kibanaRequest: true,
},
});
});
test('fetch can use the logger (TS allows it)', () => {
const collector = collectorSet.makeStatsCollector({
type: 'MY_TEST_COLLECTOR',
@ -339,188 +293,6 @@ describe('CollectorSet', () => {
collectorSet = new CollectorSet(collectorSetConfig);
});
describe('TS validations', () => {
describe('when types are inferred', () => {
test('TS should hide kibanaRequest when not opted-in', () => {
collectorSet.makeUsageCollector({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
// @ts-expect-error
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
});
});
test('TS should hide kibanaRequest when not opted-in (explicit false)', () => {
collectorSet.makeUsageCollector({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
// @ts-expect-error
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
extendFetchContext: {
kibanaRequest: false,
},
});
});
test('TS should allow using kibanaRequest when opted-in (explicit true)', () => {
collectorSet.makeUsageCollector({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
extendFetchContext: {
kibanaRequest: true,
},
});
});
});
describe('when types are explicit', () => {
test('TS should hide `kibanaRequest` from ctx when undefined or false', () => {
collectorSet.makeUsageCollector<{ test: number }>({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
// @ts-expect-error
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
});
collectorSet.makeUsageCollector<{ test: number }, false>({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
// @ts-expect-error
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
extendFetchContext: {
kibanaRequest: false,
},
});
collectorSet.makeUsageCollector<{ test: number }, false>({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
// @ts-expect-error
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
});
});
test('TS should not allow `true` when types declare false', () => {
// false is the default when at least 1 type is specified
collectorSet.makeUsageCollector<{ test: number }>({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
// @ts-expect-error
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
extendFetchContext: {
// @ts-expect-error
kibanaRequest: true,
},
});
collectorSet.makeUsageCollector<{ test: number }, false>({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
// @ts-expect-error
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
extendFetchContext: {
// @ts-expect-error
kibanaRequest: true,
},
});
});
test('TS should allow `true` when types explicitly declare `true` and do not allow `false` or undefined', () => {
// false is the default when at least 1 type is specified
collectorSet.makeUsageCollector<{ test: number }, true>({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
extendFetchContext: {
kibanaRequest: true,
},
});
collectorSet.makeUsageCollector<{ test: number }, true>({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
extendFetchContext: {
// @ts-expect-error
kibanaRequest: false,
},
});
collectorSet.makeUsageCollector<{ test: number }, true>({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
extendFetchContext: {
// @ts-expect-error
kibanaRequest: undefined,
},
});
collectorSet.makeUsageCollector<{ test: number }, true>({
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
// @ts-expect-error
extendFetchContext: {},
});
collectorSet.makeUsageCollector<{ test: number }, true>(
// @ts-expect-error
{
type: 'MY_TEST_COLLECTOR',
isReady: () => true,
schema: { test: { type: 'long' } },
fetch: (ctx) => {
const { kibanaRequest } = ctx;
return { test: kibanaRequest ? 1 : 0 };
},
}
);
});
});
});
test('fetch can use the logger (TS allows it)', () => {
const collector = collectorSet.makeUsageCollector({
type: 'MY_TEST_COLLECTOR',
@ -777,31 +549,5 @@ describe('CollectorSet', () => {
expect.any(Function)
);
});
it('adds extra context to collectors with extendFetchContext config', async () => {
const mockReadyFetch = jest.fn().mockResolvedValue({});
collectorSet.registerCollector(
collectorSet.makeUsageCollector({
type: 'ready_col',
isReady: () => true,
schema: {},
fetch: mockReadyFetch,
extendFetchContext: { kibanaRequest: true },
})
);
const mockEsClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
const mockSoClient = savedObjectsClientMock.create();
const request = httpServerMock.createKibanaRequest();
const results = await collectorSet.bulkFetch(mockEsClient, mockSoClient, request);
expect(mockReadyFetch).toBeCalledTimes(1);
expect(mockReadyFetch).toBeCalledWith({
esClient: mockEsClient,
soClient: mockSoClient,
kibanaRequest: request,
});
expect(results).toHaveLength(2);
});
});
});

View file

@ -11,7 +11,6 @@ import type {
Logger,
ElasticsearchClient,
SavedObjectsClientContract,
KibanaRequest,
KibanaExecutionContext,
ExecutionContextSetup,
} from 'src/core/server';
@ -64,12 +63,8 @@ export class CollectorSet {
* Instantiates a stats collector with the definition provided in the options
* @param options Definition of the collector {@link CollectorOptions}
*/
public makeStatsCollector = <
TFetchReturn,
WithKibanaRequest extends boolean,
ExtraOptions extends object = {}
>(
options: CollectorOptions<TFetchReturn, WithKibanaRequest, ExtraOptions>
public makeStatsCollector = <TFetchReturn, ExtraOptions extends object = {}>(
options: CollectorOptions<TFetchReturn, ExtraOptions>
) => {
return new Collector<TFetchReturn, ExtraOptions>(this.logger, options);
};
@ -78,15 +73,8 @@ export class CollectorSet {
* Instantiates an usage collector with the definition provided in the options
* @param options Definition of the collector {@link CollectorOptions}
*/
public makeUsageCollector = <
TFetchReturn,
// TODO: Right now, users will need to explicitly claim `true` for TS to allow `kibanaRequest` usage.
// If we improve `telemetry-check-tools` so plugins do not need to specify TFetchReturn,
// we'll be able to remove the type defaults and TS will successfully infer the config value as provided in JS.
WithKibanaRequest extends boolean = false,
ExtraOptions extends object = {}
>(
options: UsageCollectorOptions<TFetchReturn, WithKibanaRequest, ExtraOptions>
public makeUsageCollector = <TFetchReturn, ExtraOptions extends object = {}>(
options: UsageCollectorOptions<TFetchReturn, ExtraOptions>
) => {
return new UsageCollector<TFetchReturn, ExtraOptions>(this.logger, options);
};
@ -191,7 +179,6 @@ export class CollectorSet {
public bulkFetch = async (
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
kibanaRequest: KibanaRequest | undefined, // intentionally `| undefined` to enforce providing the parameter
collectors: Map<string, AnyCollector> = this.collectors
) => {
this.logger.debug(`Getting ready collectors`);
@ -209,11 +196,7 @@ export class CollectorSet {
readyCollectors.map(async (collector) => {
this.logger.debug(`Fetching data from ${collector.type} collector`);
try {
const context = {
esClient,
soClient,
...(collector.extendFetchContext.kibanaRequest && { kibanaRequest }),
};
const context = { esClient, soClient };
const executionContext: KibanaExecutionContext = {
type: 'usage_collection',
name: 'collector.fetch',
@ -254,16 +237,10 @@ export class CollectorSet {
public bulkFetchUsage = async (
esClient: ElasticsearchClient,
savedObjectsClient: SavedObjectsClientContract,
kibanaRequest: KibanaRequest | undefined // intentionally `| undefined` to enforce providing the parameter
savedObjectsClient: SavedObjectsClientContract
) => {
const usageCollectors = this.getFilteredCollectorSet((c) => c instanceof UsageCollector);
return await this.bulkFetch(
esClient,
savedObjectsClient,
kibanaRequest,
usageCollectors.collectors
);
return await this.bulkFetch(esClient, savedObjectsClient, usageCollectors.collectors);
};
/**

View file

@ -17,7 +17,6 @@ export type {
CollectorOptions,
CollectorFetchContext,
CollectorFetchMethod,
CollectorOptionsFetchExtendedContext,
ICollector as Collector,
} from './types';
export type { UsageCollectorOptions } from './usage_collector';

View file

@ -6,12 +6,7 @@
* Side Public License, v 1.
*/
import type {
ElasticsearchClient,
KibanaRequest,
SavedObjectsClientContract,
Logger,
} from 'src/core/server';
import type { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'src/core/server';
/** Types matching number values **/
export type AllowedSchemaNumberTypes =
@ -73,7 +68,7 @@ export type MakeSchemaFrom<Base> = {
*
* @remark Bear in mind when testing your collector that your user has the same privileges as the Kibana Internal user to ensure the expected data is sent to the remote cluster.
*/
export type CollectorFetchContext<WithKibanaRequest extends boolean | undefined = false> = {
export interface CollectorFetchContext {
/**
* Request-scoped Elasticsearch client
* @remark Bear in mind when testing your collector that your user has the same privileges as the Kibana Internal user to ensure the expected data is sent to the remote cluster (more info: {@link CollectorFetchContext})
@ -84,58 +79,22 @@ export type CollectorFetchContext<WithKibanaRequest extends boolean | undefined
* @remark Bear in mind when testing your collector that your user has the same privileges as the Kibana Internal user to ensure the expected data is sent to the remote cluster (more info: {@link CollectorFetchContext})
*/
soClient: SavedObjectsClientContract;
} & (WithKibanaRequest extends true
? {
/**
* The KibanaRequest that can be used to scope the requests:
* It is provided only when your custom clients need to be scoped. If not available, you should use the Internal Client.
* More information about when scoping is needed: {@link CollectorFetchContext}
* @remark You should only use this if you implement your collector to deal with both scenarios: when provided and, especially, when not provided. When telemetry payload is sent to the remote service the `kibanaRequest` will not be provided.
*/
kibanaRequest?: KibanaRequest;
}
: {});
}
/**
* The fetch method has the context of the Collector itself
* (this has access to all the properties of the collector like the logger)
* and the the first parameter is {@link CollectorFetchContext}.
*/
export type CollectorFetchMethod<
WithKibanaRequest extends boolean | undefined,
TReturn,
ExtraOptions extends object = {}
> = (
export type CollectorFetchMethod<TReturn, ExtraOptions extends object = {}> = (
this: ICollector<TReturn> & ExtraOptions, // Specify the context of `this` for this.log and others to become available
context: CollectorFetchContext<WithKibanaRequest>
context: CollectorFetchContext
) => Promise<TReturn> | TReturn;
export interface ICollectorOptionsFetchExtendedContext<WithKibanaRequest extends boolean> {
/**
* Set to `true` if your `fetch` method requires the `KibanaRequest` object to be added in its context {@link CollectorFetchContextWithRequest}.
* @remark You should fully acknowledge that by using the `KibanaRequest` in your collector, you need to ensure it should specially work without it because it won't be provided when building the telemetry payload actually sent to the remote telemetry service.
*/
kibanaRequest?: WithKibanaRequest;
}
/**
* The options to extend the context provided to the `fetch` method.
* @remark Only to be used in very rare scenarios when this is really needed.
*/
export type CollectorOptionsFetchExtendedContext<WithKibanaRequest extends boolean> =
ICollectorOptionsFetchExtendedContext<WithKibanaRequest> &
(WithKibanaRequest extends true // If enforced to true via Types, the config must be expected
? Required<Pick<ICollectorOptionsFetchExtendedContext<WithKibanaRequest>, 'kibanaRequest'>>
: {});
/**
* Options to instantiate a collector
*/
export type CollectorOptions<
TFetchReturn = unknown,
WithKibanaRequest extends boolean = boolean,
ExtraOptions extends object = {}
> = {
export type CollectorOptions<TFetchReturn = unknown, ExtraOptions extends object = {}> = {
/**
* Unique string identifier for the collector
*/
@ -152,17 +111,8 @@ export type CollectorOptions<
* The method that will collect and return the data in the final format.
* @param collectorFetchContext {@link CollectorFetchContext}
*/
fetch: CollectorFetchMethod<WithKibanaRequest, TFetchReturn, ExtraOptions>;
} & ExtraOptions &
(WithKibanaRequest extends true // If enforced to true via Types, the config must be enforced
? {
/** {@link CollectorOptionsFetchExtendedContext} **/
extendFetchContext: CollectorOptionsFetchExtendedContext<WithKibanaRequest>;
}
: {
/** {@link CollectorOptionsFetchExtendedContext} **/
extendFetchContext?: CollectorOptionsFetchExtendedContext<WithKibanaRequest>;
});
fetch: CollectorFetchMethod<TFetchReturn, ExtraOptions>;
} & ExtraOptions;
/**
* Common interface for Usage and Stats Collectors
@ -170,13 +120,8 @@ export type CollectorOptions<
export interface ICollector<TFetchReturn, ExtraOptions extends object = {}> {
/** Logger **/
readonly log: Logger;
/**
* The options to extend the context provided to the `fetch` method: {@link CollectorOptionsFetchExtendedContext}.
* @remark Only to be used in very rare scenarios when this is really needed.
*/
readonly extendFetchContext: CollectorOptionsFetchExtendedContext<boolean>;
/** The registered type (aka name) of the collector **/
readonly type: CollectorOptions<TFetchReturn, boolean>['type'];
readonly type: CollectorOptions<TFetchReturn>['type'];
/**
* The actual logic that reports the Usage collection.
* It will be called on every collection request.
@ -188,9 +133,9 @@ export interface ICollector<TFetchReturn, ExtraOptions extends object = {}> {
* [type]: await fetch(context)
* }
*/
readonly fetch: CollectorFetchMethod<boolean, TFetchReturn, ExtraOptions>;
readonly fetch: CollectorFetchMethod<TFetchReturn, ExtraOptions>;
/**
* Should return `true` when it's safe to call the `fetch` method.
*/
readonly isReady: CollectorOptions<TFetchReturn, boolean>['isReady'];
readonly isReady: CollectorOptions<TFetchReturn>['isReady'];
}

View file

@ -15,10 +15,9 @@ import { Collector } from './collector';
*/
export type UsageCollectorOptions<
TFetchReturn = unknown,
WithKibanaRequest extends boolean = false,
ExtraOptions extends object = {}
> = CollectorOptions<TFetchReturn, WithKibanaRequest, ExtraOptions> &
Required<Pick<CollectorOptions<TFetchReturn, boolean>, 'schema'>>;
> = CollectorOptions<TFetchReturn, ExtraOptions> &
Required<Pick<CollectorOptions<TFetchReturn>, 'schema'>>;
/**
* @private Only used in fixtures as a type
@ -27,12 +26,7 @@ export class UsageCollector<TFetchReturn, ExtraOptions extends object = {}> exte
TFetchReturn,
ExtraOptions
> {
constructor(
log: Logger,
// Needed because it doesn't affect on anything here but being explicit creates a lot of pain down the line
// eslint-disable-next-line @typescript-eslint/no-explicit-any
collectorOptions: UsageCollectorOptions<TFetchReturn, any, ExtraOptions>
) {
constructor(log: Logger, collectorOptions: UsageCollectorOptions<TFetchReturn, ExtraOptions>) {
super(log, collectorOptions);
}
}

View file

@ -17,7 +17,6 @@ export type {
UsageCollectorOptions,
CollectorFetchContext,
CollectorFetchMethod,
CollectorOptionsFetchExtendedContext,
} from './collector';
export type {

View file

@ -9,7 +9,6 @@
import {
elasticsearchServiceMock,
executionContextServiceMock,
httpServerMock,
loggingSystemMock,
savedObjectsClientMock,
} from '../../../../src/core/server/mocks';
@ -45,25 +44,14 @@ export const createUsageCollectionSetupMock = () => {
return usageCollectionSetupMock;
};
export function createCollectorFetchContextMock(): jest.Mocked<CollectorFetchContext<false>> {
const collectorFetchClientsMock: jest.Mocked<CollectorFetchContext<false>> = {
export function createCollectorFetchContextMock(): jest.Mocked<CollectorFetchContext> {
const collectorFetchClientsMock: jest.Mocked<CollectorFetchContext> = {
esClient: elasticsearchServiceMock.createClusterClient().asInternalUser,
soClient: savedObjectsClientMock.create(),
};
return collectorFetchClientsMock;
}
export function createCollectorFetchContextWithKibanaMock(): jest.Mocked<
CollectorFetchContext<true>
> {
const collectorFetchClientsMock: jest.Mocked<CollectorFetchContext<true>> = {
esClient: elasticsearchServiceMock.createClusterClient().asInternalUser,
soClient: savedObjectsClientMock.create(),
kibanaRequest: httpServerMock.createKibanaRequest(),
};
return collectorFetchClientsMock;
}
export const usageCollectionPluginMock = {
createSetupContract: createUsageCollectionSetupMock,
};

View file

@ -15,7 +15,6 @@ import type {
Plugin,
ElasticsearchClient,
SavedObjectsClientContract,
KibanaRequest,
} from 'src/core/server';
import type { ConfigType } from './config';
import { CollectorSet } from './collector';
@ -39,12 +38,8 @@ export interface UsageCollectionSetup {
* Creates a usage collector to collect plugin telemetry data.
* registerCollector must be called to connect the created collector with the service.
*/
makeUsageCollector: <
TFetchReturn,
WithKibanaRequest extends boolean = false,
ExtraOptions extends object = {}
>(
options: UsageCollectorOptions<TFetchReturn, WithKibanaRequest, ExtraOptions>
makeUsageCollector: <TFetchReturn, ExtraOptions extends object = {}>(
options: UsageCollectorOptions<TFetchReturn, ExtraOptions>
) => Collector<TFetchReturn, ExtraOptions>;
/**
* Register a usage collector or a stats collector.
@ -66,7 +61,6 @@ export interface UsageCollectionSetup {
bulkFetch: <TFetchReturn, ExtraOptions extends object>(
esClient: ElasticsearchClient,
soClient: SavedObjectsClientContract,
kibanaRequest: KibanaRequest | undefined, // intentionally `| undefined` to enforce providing the parameter
collectors?: Map<string, Collector<TFetchReturn, ExtraOptions>>
) => Promise<Array<{ type: string; result: unknown }>>;
/**
@ -88,12 +82,8 @@ export interface UsageCollectionSetup {
* registerCollector must be called to connect the created collector with the service.
* @internal: telemetry and monitoring use
*/
makeStatsCollector: <
TFetchReturn,
WithKibanaRequest extends boolean,
ExtraOptions extends object = {}
>(
options: CollectorOptions<TFetchReturn, WithKibanaRequest, ExtraOptions>
makeStatsCollector: <TFetchReturn, ExtraOptions extends object = {}>(
options: CollectorOptions<TFetchReturn, ExtraOptions>
) => Collector<TFetchReturn, ExtraOptions>;
}

View file

@ -15,7 +15,6 @@ import { first } from 'rxjs/operators';
import {
ElasticsearchClient,
IRouter,
KibanaRequest,
MetricsServiceSetup,
SavedObjectsClientContract,
ServiceStatus,
@ -55,10 +54,9 @@ export function registerStatsRoute({
}) {
const getUsage = async (
esClient: ElasticsearchClient,
savedObjectsClient: SavedObjectsClientContract,
kibanaRequest: KibanaRequest
savedObjectsClient: SavedObjectsClientContract
): Promise<UsageObject> => {
const usage = await collectorSet.bulkFetchUsage(esClient, savedObjectsClient, kibanaRequest);
const usage = await collectorSet.bulkFetchUsage(esClient, savedObjectsClient);
return collectorSet.toObject(usage);
};
@ -97,7 +95,7 @@ export function registerStatsRoute({
const [usage, clusterUuid] = await Promise.all([
shouldGetUsage
? getUsage(asCurrentUser, savedObjectsClient, req)
? getUsage(asCurrentUser, savedObjectsClient)
: Promise.resolve<UsageObject>({}),
getClusterUuid(asCurrentUser),
]);

View file

@ -30,6 +30,7 @@
{ "path": "../home/tsconfig.json" },
{ "path": "../share/tsconfig.json" },
{ "path": "../presentation_util/tsconfig.json" },
{ "path": "../screenshot_mode/tsconfig.json" },
{ "path": "../../../x-pack/plugins/spaces/tsconfig.json" }
]
}

View file

@ -90,7 +90,6 @@ export function getSettingsCollector(
) {
return usageCollection.makeStatsCollector<
EmailSettingData | undefined,
false,
KibanaSettingsCollectorExtraOptions
>({
type: 'kibana_settings',

View file

@ -18,7 +18,7 @@ export function getMonitoringUsageCollector(
config: MonitoringConfig,
getClient: () => IClusterClient
) {
return usageCollection.makeUsageCollector<MonitoringUsage, true>({
return usageCollection.makeUsageCollector<MonitoringUsage>({
type: 'monitoring',
isReady: () => true,
schema: {
@ -95,13 +95,8 @@ export function getMonitoringUsageCollector(
},
},
},
extendFetchContext: {
kibanaRequest: true,
},
fetch: async ({ kibanaRequest }) => {
const callCluster = kibanaRequest
? getClient().asScoped(kibanaRequest).asCurrentUser
: getClient().asInternalUser;
fetch: async () => {
const callCluster = getClient().asInternalUser;
const usageClusters: MonitoringClusterStackProductUsage[] = [];
const availableCcs = config.ui.ccs.enabled;
const clusters = await fetchClusters(callCluster);

View file

@ -34,13 +34,9 @@ export function registerMonitoringTelemetryCollection(
getClient: () => IClusterClient,
maxBucketSize: number
) {
const monitoringStatsCollector = usageCollection.makeStatsCollector<
MonitoringTelemetryUsage,
true
>({
const monitoringStatsCollector = usageCollection.makeStatsCollector<MonitoringTelemetryUsage>({
type: 'monitoringTelemetry',
isReady: () => true,
extendFetchContext: { kibanaRequest: true },
schema: {
stats: {
type: 'array',
@ -137,13 +133,13 @@ export function registerMonitoringTelemetryCollection(
},
},
},
fetch: async ({ kibanaRequest, esClient }) => {
fetch: async () => {
const timestamp = Date.now(); // Collect the telemetry from the monitoring indices for this moment.
// NOTE: Usually, the monitoring indices index stats for each product every 10s (by default).
// However, some data may be delayed up-to 24h because monitoring only collects extended Kibana stats in that interval
// to avoid overloading of the system when retrieving data from the collectors (that delay is dealt with in the Kibana Stats getter inside the `getAllStats` method).
// By 8.x, we expect to stop collecting the Kibana extended stats and keep only the monitoring-related metrics.
const callCluster = kibanaRequest ? esClient : getClient().asInternalUser;
const callCluster = getClient().asInternalUser;
const clusterDetails = await getClusterUuids(callCluster, timestamp, maxBucketSize);
const [licenses, stats] = await Promise.all([
getLicenses(clusterDetails, callCluster, maxBucketSize),

View file

@ -9,7 +9,7 @@ import { merge } from 'lodash';
import { loggingSystemMock } from 'src/core/server/mocks';
import {
Collector,
createCollectorFetchContextWithKibanaMock,
createCollectorFetchContextMock,
createUsageCollectionSetupMock,
} from 'src/plugins/usage_collection/server/mocks';
import { HealthStatus } from '../monitoring';
@ -26,7 +26,7 @@ describe('registerTaskManagerUsageCollector', () => {
it('should report telemetry on the ephemeral queue', async () => {
const monitoringStats$ = new Subject<MonitoredHealth>();
const usageCollectionMock = createUsageCollectionSetupMock();
const fetchContext = createCollectorFetchContextWithKibanaMock();
const fetchContext = createCollectorFetchContextMock();
usageCollectionMock.makeUsageCollector.mockImplementation((config) => {
collector = new Collector(logger, config);
return createUsageCollectionSetupMock().makeUsageCollector(config);
@ -53,7 +53,7 @@ describe('registerTaskManagerUsageCollector', () => {
it('should report telemetry on the excluded task types', async () => {
const monitoringStats$ = new Subject<MonitoredHealth>();
const usageCollectionMock = createUsageCollectionSetupMock();
const fetchContext = createCollectorFetchContextWithKibanaMock();
const fetchContext = createCollectorFetchContextMock();
usageCollectionMock.makeUsageCollector.mockImplementation((config) => {
collector = new Collector(logger, config);
return createUsageCollectionSetupMock().makeUsageCollector(config);

View file

@ -112,7 +112,6 @@ describe('Telemetry Collection: Get Aggregated Stats', () => {
esClient,
usageCollection,
soClient,
kibanaRequest: undefined,
refreshCache: false,
},
context
@ -135,7 +134,6 @@ describe('Telemetry Collection: Get Aggregated Stats', () => {
esClient,
usageCollection,
soClient,
kibanaRequest: undefined,
refreshCache: false,
},
context
@ -163,7 +161,6 @@ describe('Telemetry Collection: Get Aggregated Stats', () => {
esClient,
usageCollection,
soClient,
kibanaRequest: undefined,
refreshCache: false,
},
context

View file

@ -3394,12 +3394,6 @@
"home.addData.uploadFileButtonLabel": "ファイルをアップロード",
"home.breadcrumbs.homeTitle": "ホーム",
"home.breadcrumbs.integrationsAppTitle": "統合",
"home.dataManagementDisableCollection": " 収集を停止するには、",
"home.dataManagementDisableCollectionLink": "ここで使用状況データを無効にします。",
"home.dataManagementDisclaimerPrivacy": "使用状況データがどのように製品とサービスの管理と改善につながるのかに関する詳細については ",
"home.dataManagementDisclaimerPrivacyLink": "プライバシーポリシーをご覧ください。",
"home.dataManagementEnableCollection": " 収集を開始するには、",
"home.dataManagementEnableCollectionLink": "ここで使用状況データを有効にします。",
"home.exploreButtonLabel": "独りで閲覧",
"home.exploreYourDataDescription": "すべてのステップを終えたら、データ閲覧準備の完了です。",
"home.header.title": "ようこそホーム",

View file

@ -3402,12 +3402,6 @@
"home.addData.uploadFileButtonLabel": "上传文件",
"home.breadcrumbs.homeTitle": "主页",
"home.breadcrumbs.integrationsAppTitle": "集成",
"home.dataManagementDisableCollection": " 要停止收集,",
"home.dataManagementDisableCollectionLink": "请在此禁用使用情况数据。",
"home.dataManagementDisclaimerPrivacy": "要了解使用情况数据如何帮助我们管理和改善产品和服务,请参阅我们的 ",
"home.dataManagementDisclaimerPrivacyLink": "隐私声明。",
"home.dataManagementEnableCollection": " 要启动收集,",
"home.dataManagementEnableCollectionLink": "请在此处启用使用情况数据。",
"home.exploreButtonLabel": "自己浏览",
"home.exploreYourDataDescription": "完成所有步骤后,您便可以随时浏览自己的数据。",
"home.header.title": "欢迎归来",

View file

@ -10,6 +10,7 @@ import moment from 'moment';
import type SuperTest from 'supertest';
import deepmerge from 'deepmerge';
import type { FtrProviderContext } from '../../ftr_provider_context';
import type { SecurityService } from '../../../../../test/common/services/security/security';
import multiClusterFixture from './fixtures/multicluster.json';
import basicClusterFixture from './fixtures/basiccluster.json';
@ -90,10 +91,31 @@ function updateMonitoringDates(
]);
}
async function createUserWithRole(
security: SecurityService,
userName: string,
roleName: string,
role: unknown
) {
await security.role.create(roleName, role);
await security.user.create(userName, {
password: password(userName),
roles: [roleName],
full_name: `User ${userName}`,
});
}
function password(userName: string) {
return `${userName}-password`;
}
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth'); // We need this because `.auth` in the already authed one does not work as expected
const esArchiver = getService('esArchiver');
const esSupertest = getService('esSupertest');
const security = getService('security');
describe('/api/telemetry/v2/clusters/_stats', () => {
const timestamp = new Date().toISOString();
@ -236,5 +258,114 @@ export default function ({ getService }: FtrProviderContext) {
expect(new Date(fetchedAt).getTime()).to.be.greaterThan(now);
});
});
describe('Only global read+ users can fetch unencrypted telemetry', () => {
describe('superadmin user', () => {
it('should return unencrypted telemetry for the admin user', async () => {
await supertest
.post('/api/telemetry/v2/clusters/_stats')
.set('kbn-xsrf', 'xxx')
.send({ unencrypted: true })
.expect(200);
});
it('should return encrypted telemetry for the admin user', async () => {
await supertest
.post('/api/telemetry/v2/clusters/_stats')
.set('kbn-xsrf', 'xxx')
.send({ unencrypted: false })
.expect(200);
});
});
describe('global-read user', () => {
const globalReadOnlyUser = 'telemetry-global-read-only-user';
const globalReadOnlyRole = 'telemetry-global-read-only-role';
before('create user', async () => {
await createUserWithRole(security, globalReadOnlyUser, globalReadOnlyRole, {
kibana: [
{
spaces: ['*'],
base: ['read'],
feature: {},
},
],
});
});
after(async () => {
await security.user.delete(globalReadOnlyUser);
await security.role.delete(globalReadOnlyRole);
});
it('should return encrypted telemetry for the global-read user', async () => {
await supertestWithoutAuth
.post('/api/telemetry/v2/clusters/_stats')
.auth(globalReadOnlyUser, password(globalReadOnlyUser))
.set('kbn-xsrf', 'xxx')
.send({ unencrypted: false })
.expect(200);
});
it('should return unencrypted telemetry for the global-read user', async () => {
await supertestWithoutAuth
.post('/api/telemetry/v2/clusters/_stats')
.auth(globalReadOnlyUser, password(globalReadOnlyUser))
.set('kbn-xsrf', 'xxx')
.send({ unencrypted: true })
.expect(200);
});
});
describe('non global-read user', () => {
const noGlobalUser = 'telemetry-no-global-user';
const noGlobalRole = 'telemetry-no-global-role';
before('create user', async () => {
await createUserWithRole(security, noGlobalUser, noGlobalRole, {
kibana: [
{
spaces: ['*'],
base: [],
feature: {
// It has access to many features specified individually but not a global one
discover: ['all'],
dashboard: ['all'],
canvas: ['all'],
maps: ['all'],
ml: ['all'],
visualize: ['all'],
dev_tools: ['all'],
},
},
],
});
});
after(async () => {
await security.user.delete(noGlobalUser);
await security.role.delete(noGlobalRole);
});
it('should return encrypted telemetry for the read-only user', async () => {
await supertestWithoutAuth
.post('/api/telemetry/v2/clusters/_stats')
.auth(noGlobalUser, password(noGlobalUser))
.set('kbn-xsrf', 'xxx')
.send({ unencrypted: false })
.expect(200);
});
it('should return 403 when the read-only user requests unencrypted telemetry', async () => {
await supertestWithoutAuth
.post('/api/telemetry/v2/clusters/_stats')
.auth(noGlobalUser, password(noGlobalUser))
.set('kbn-xsrf', 'xxx')
.send({ unencrypted: true })
.expect(403);
});
});
});
});
}