[7.x] Add plugin status API - take 2 (#76732) (#77129)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Josh Dover 2020-09-14 12:02:43 -06:00 committed by GitHub
parent 5913988141
commit c3a2451833
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 928 additions and 68 deletions

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.md) &gt; [dependencies$](./kibana-plugin-core-server.statusservicesetup.dependencies_.md)
## StatusServiceSetup.dependencies$ property
Current status for all plugins this plugin depends on. Each key of the `Record` is a plugin id.
<b>Signature:</b>
```typescript
dependencies$: Observable<Record<string, ServiceStatus>>;
```

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.md) &gt; [derivedStatus$](./kibana-plugin-core-server.statusservicesetup.derivedstatus_.md)
## StatusServiceSetup.derivedStatus$ property
The status of this plugin as derived from its dependencies.
<b>Signature:</b>
```typescript
derivedStatus$: Observable<ServiceStatus>;
```
## Remarks
By default, plugins inherit this derived status from their dependencies. Calling overrides this default status.
This may emit multliple times for a single status change event as propagates through the dependency tree

View file

@ -12,10 +12,73 @@ API for accessing status of Core and this plugin's dependencies as well as for c
export interface StatusServiceSetup
```
## Remarks
By default, a plugin inherits it's current status from the most severe status level of any Core services and any plugins that it depends on. This default status is available on the API.
Plugins may customize their status calculation by calling the API with an Observable. Within this Observable, a plugin may choose to only depend on the status of some of its dependencies, to ignore severe status levels of particular Core services they are not concerned with, or to make its status dependent on other external services.
## Example 1
Customize a plugin's status to only depend on the status of SavedObjects:
```ts
core.status.set(
core.status.core$.pipe(
. map((coreStatus) => {
return coreStatus.savedObjects;
}) ;
);
);
```
## Example 2
Customize a plugin's status to include an external service:
```ts
const externalStatus$ = interval(1000).pipe(
switchMap(async () => {
const resp = await fetch(`https://myexternaldep.com/_healthz`);
const body = await resp.json();
if (body.ok) {
return of({ level: ServiceStatusLevels.available, summary: 'External Service is up'});
} else {
return of({ level: ServiceStatusLevels.available, summary: 'External Service is unavailable'});
}
}),
catchError((error) => {
of({ level: ServiceStatusLevels.unavailable, summary: `External Service is down`, meta: { error }})
})
);
core.status.set(
combineLatest([core.status.derivedStatus$, externalStatus$]).pipe(
map(([derivedStatus, externalStatus]) => {
if (externalStatus.level > derivedStatus) {
return externalStatus;
} else {
return derivedStatus;
}
})
)
);
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [core$](./kibana-plugin-core-server.statusservicesetup.core_.md) | <code>Observable&lt;CoreStatus&gt;</code> | Current status for all Core services. |
| [dependencies$](./kibana-plugin-core-server.statusservicesetup.dependencies_.md) | <code>Observable&lt;Record&lt;string, ServiceStatus&gt;&gt;</code> | Current status for all plugins this plugin depends on. Each key of the <code>Record</code> is a plugin id. |
| [derivedStatus$](./kibana-plugin-core-server.statusservicesetup.derivedstatus_.md) | <code>Observable&lt;ServiceStatus&gt;</code> | The status of this plugin as derived from its dependencies. |
| [overall$](./kibana-plugin-core-server.statusservicesetup.overall_.md) | <code>Observable&lt;ServiceStatus&gt;</code> | Overall system status for all of Kibana. |
## Methods
| Method | Description |
| --- | --- |
| [set(status$)](./kibana-plugin-core-server.statusservicesetup.set.md) | Allows a plugin to specify a custom status dependent on its own criteria. Completely overrides the default inherited status. |

View file

@ -0,0 +1,28 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.md) &gt; [set](./kibana-plugin-core-server.statusservicesetup.set.md)
## StatusServiceSetup.set() method
Allows a plugin to specify a custom status dependent on its own criteria. Completely overrides the default inherited status.
<b>Signature:</b>
```typescript
set(status$: Observable<ServiceStatus>): void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| status$ | <code>Observable&lt;ServiceStatus&gt;</code> | |
<b>Returns:</b>
`void`
## Remarks
See the [StatusServiceSetup.derivedStatus$](./kibana-plugin-core-server.statusservicesetup.derivedstatus_.md) API for leveraging the default status calculation that is provided by Core.

View file

@ -311,6 +311,17 @@ export class LegacyService implements CoreService {
status: {
core$: setupDeps.core.status.core$,
overall$: setupDeps.core.status.overall$,
set: () => {
throw new Error(`core.status.set is unsupported in legacy`);
},
// @ts-expect-error
get dependencies$() {
throw new Error(`core.status.dependencies$ is unsupported in legacy`);
},
// @ts-expect-error
get derivedStatus$() {
throw new Error(`core.status.derivedStatus$ is unsupported in legacy`);
},
},
uiSettings: {
register: setupDeps.core.uiSettings.register,

View file

@ -185,6 +185,9 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
status: {
core$: deps.status.core$,
overall$: deps.status.overall$,
set: deps.status.plugins.set.bind(null, plugin.name),
dependencies$: deps.status.plugins.getDependenciesStatus$(plugin.name),
derivedStatus$: deps.status.plugins.getDerivedStatus$(plugin.name),
},
uiSettings: {
register: deps.uiSettings.register,

View file

@ -100,15 +100,27 @@ test('getPluginDependencies returns dependency tree of symbols', () => {
pluginsSystem.addPlugin(createPlugin('no-dep'));
expect(pluginsSystem.getPluginDependencies()).toMatchInlineSnapshot(`
Map {
Symbol(plugin-a) => Array [
Symbol(no-dep),
],
Symbol(plugin-b) => Array [
Symbol(plugin-a),
Symbol(no-dep),
],
Symbol(no-dep) => Array [],
Object {
"asNames": Map {
"plugin-a" => Array [
"no-dep",
],
"plugin-b" => Array [
"plugin-a",
"no-dep",
],
"no-dep" => Array [],
},
"asOpaqueIds": Map {
Symbol(plugin-a) => Array [
Symbol(no-dep),
],
Symbol(plugin-b) => Array [
Symbol(plugin-a),
Symbol(no-dep),
],
Symbol(no-dep) => Array [],
},
}
`);
});

View file

@ -20,10 +20,11 @@
import { CoreContext } from '../core_context';
import { Logger } from '../logging';
import { PluginWrapper } from './plugin';
import { DiscoveredPlugin, PluginName, PluginOpaqueId } from './types';
import { DiscoveredPlugin, PluginName } from './types';
import { createPluginSetupContext, createPluginStartContext } from './plugin_context';
import { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service';
import { withTimeout } from '../../utils';
import { PluginDependencies } from '.';
const Sec = 1000;
/** @internal */
@ -45,9 +46,19 @@ export class PluginsSystem {
* @returns a ReadonlyMap of each plugin and an Array of its available dependencies
* @internal
*/
public getPluginDependencies(): ReadonlyMap<PluginOpaqueId, PluginOpaqueId[]> {
// Return dependency map of opaque ids
return new Map(
public getPluginDependencies(): PluginDependencies {
const asNames = new Map(
[...this.plugins].map(([name, plugin]) => [
plugin.name,
[
...new Set([
...plugin.requiredPlugins,
...plugin.optionalPlugins.filter((optPlugin) => this.plugins.has(optPlugin)),
]),
].map((depId) => this.plugins.get(depId)!.name),
])
);
const asOpaqueIds = new Map(
[...this.plugins].map(([name, plugin]) => [
plugin.opaqueId,
[
@ -58,6 +69,8 @@ export class PluginsSystem {
].map((depId) => this.plugins.get(depId)!.opaqueId),
])
);
return { asNames, asOpaqueIds };
}
public async setupPlugins(deps: PluginsServiceSetupDeps) {

View file

@ -93,6 +93,12 @@ export type PluginName = string;
/** @public */
export type PluginOpaqueId = symbol;
/** @internal */
export interface PluginDependencies {
asNames: ReadonlyMap<PluginName, PluginName[]>;
asOpaqueIds: ReadonlyMap<PluginOpaqueId, PluginOpaqueId[]>;
}
/**
* Describes the set of required and optional properties plugin can define in its
* mandatory JSON manifest file.

View file

@ -120,9 +120,17 @@ export class KibanaMigrator {
Array<{ status: string }>
> {
if (this.migrationResult === undefined || rerun) {
this.status$.next({ status: 'running' });
// Reruns are only used by CI / EsArchiver. Publishing status updates on reruns results in slowing down CI
// unnecessarily, so we skip it in this case.
if (!rerun) {
this.status$.next({ status: 'running' });
}
this.migrationResult = this.runMigrationsInternal().then((result) => {
this.status$.next({ status: 'completed', result });
// Similar to above, don't publish status updates when rerunning in CI.
if (!rerun) {
this.status$.next({ status: 'completed', result });
}
return result;
});
}

View file

@ -2785,10 +2785,17 @@ export type SharedGlobalConfig = RecursiveReadonly<{
// @public
export type StartServicesAccessor<TPluginsStart extends object = object, TStart = unknown> = () => Promise<[CoreStart, TPluginsStart, TStart]>;
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "ServiceStatusSetup"
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "ServiceStatusSetup"
//
// @public
export interface StatusServiceSetup {
core$: Observable<CoreStatus>;
dependencies$: Observable<Record<string, ServiceStatus>>;
// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "StatusSetup"
derivedStatus$: Observable<ServiceStatus>;
overall$: Observable<ServiceStatus>;
set(status$: Observable<ServiceStatus>): void;
}
// @public
@ -2877,8 +2884,8 @@ export const validBodyOutput: readonly ["data", "stream"];
//
// src/core/server/http/router/response.ts:316:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts
// src/core/server/legacy/types.ts:135:16 - (ae-forgotten-export) The symbol "LegacyPluginSpec" needs to be exported by the entry point index.d.ts
// src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts
// src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts
// src/core/server/plugins/types.ts:268:3 - (ae-forgotten-export) The symbol "PathConfigType" needs to be exported by the entry point index.d.ts
// src/core/server/plugins/types.ts:272:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts
// src/core/server/plugins/types.ts:272:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts
// src/core/server/plugins/types.ts:274:3 - (ae-forgotten-export) The symbol "PathConfigType" needs to be exported by the entry point index.d.ts
```

View file

@ -49,7 +49,7 @@ const rawConfigService = rawConfigServiceMock.create({});
beforeEach(() => {
mockConfigService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: true }));
mockPluginsService.discover.mockResolvedValue({
pluginTree: new Map(),
pluginTree: { asOpaqueIds: new Map(), asNames: new Map() },
uiPlugins: { internal: new Map(), public: new Map(), browserConfigs: new Map() },
});
});
@ -98,7 +98,7 @@ test('injects legacy dependency to context#setup()', async () => {
[pluginB, [pluginA]],
]);
mockPluginsService.discover.mockResolvedValue({
pluginTree: pluginDependencies,
pluginTree: { asOpaqueIds: pluginDependencies, asNames: new Map() },
uiPlugins: { internal: new Map(), public: new Map(), browserConfigs: new Map() },
});

View file

@ -121,10 +121,13 @@ export class Server {
const contextServiceSetup = this.context.setup({
// We inject a fake "legacy plugin" with dependencies on every plugin so that legacy plugins:
// 1) Can access context from any NP plugin
// 1) Can access context from any KP plugin
// 2) Can register context providers that will only be available to other legacy plugins and will not leak into
// New Platform plugins.
pluginDependencies: new Map([...pluginTree, [this.legacy.legacyId, [...pluginTree.keys()]]]),
pluginDependencies: new Map([
...pluginTree.asOpaqueIds,
[this.legacy.legacyId, [...pluginTree.asOpaqueIds.keys()]],
]),
});
const auditTrailSetup = this.auditTrail.setup();
@ -153,6 +156,7 @@ export class Server {
const statusSetup = await this.status.setup({
elasticsearch: elasticsearchServiceSetup,
pluginDependencies: pluginTree.asNames,
savedObjects: savedObjectsSetup,
});

View file

@ -94,6 +94,38 @@ describe('getSummaryStatus', () => {
describe('summary', () => {
describe('when a single service is at highest level', () => {
it('returns all information about that single service', () => {
expect(
getSummaryStatus(
Object.entries({
s1: degraded,
s2: {
level: ServiceStatusLevels.unavailable,
summary: 'Lorem ipsum',
meta: {
custom: { data: 'here' },
},
},
})
)
).toEqual({
level: ServiceStatusLevels.unavailable,
summary: '[s2]: Lorem ipsum',
detail: 'See the status page for more information',
meta: {
affectedServices: {
s2: {
level: ServiceStatusLevels.unavailable,
summary: 'Lorem ipsum',
meta: {
custom: { data: 'here' },
},
},
},
},
});
});
it('allows the single service to override the detail and documentationUrl fields', () => {
expect(
getSummaryStatus(
Object.entries({
@ -115,7 +147,17 @@ describe('getSummaryStatus', () => {
detail: 'Vivamus pulvinar sem ac luctus ultrices.',
documentationUrl: 'http://helpmenow.com/problem1',
meta: {
custom: { data: 'here' },
affectedServices: {
s2: {
level: ServiceStatusLevels.unavailable,
summary: 'Lorem ipsum',
detail: 'Vivamus pulvinar sem ac luctus ultrices.',
documentationUrl: 'http://helpmenow.com/problem1',
meta: {
custom: { data: 'here' },
},
},
},
},
});
});

View file

@ -23,62 +23,60 @@ import { ServiceStatus, ServiceStatusLevels, ServiceStatusLevel } from './types'
* Returns a single {@link ServiceStatus} that summarizes the most severe status level from a group of statuses.
* @param statuses
*/
export const getSummaryStatus = (statuses: Array<[string, ServiceStatus]>): ServiceStatus => {
const grouped = groupByLevel(statuses);
const highestSeverityLevel = getHighestSeverityLevel(grouped.keys());
const highestSeverityGroup = grouped.get(highestSeverityLevel)!;
export const getSummaryStatus = (
statuses: Array<[string, ServiceStatus]>,
{ allAvailableSummary = `All services are available` }: { allAvailableSummary?: string } = {}
): ServiceStatus => {
const { highestLevel, highestStatuses } = highestLevelSummary(statuses);
if (highestSeverityLevel === ServiceStatusLevels.available) {
if (highestLevel === ServiceStatusLevels.available) {
return {
level: ServiceStatusLevels.available,
summary: `All services are available`,
summary: allAvailableSummary,
};
} else if (highestSeverityGroup.size === 1) {
const [serviceName, status] = [...highestSeverityGroup.entries()][0];
} else if (highestStatuses.length === 1) {
const [serviceName, status] = highestStatuses[0]! as [string, ServiceStatus];
return {
...status,
summary: `[${serviceName}]: ${status.summary!}`,
// TODO: include URL to status page
detail: status.detail ?? `See the status page for more information`,
meta: {
affectedServices: { [serviceName]: status },
},
};
} else {
return {
level: highestSeverityLevel,
summary: `[${highestSeverityGroup.size}] services are ${highestSeverityLevel.toString()}`,
level: highestLevel,
summary: `[${highestStatuses.length}] services are ${highestLevel.toString()}`,
// TODO: include URL to status page
detail: `See the status page for more information`,
meta: {
affectedServices: Object.fromEntries([...highestSeverityGroup]),
affectedServices: Object.fromEntries(highestStatuses),
},
};
}
};
const groupByLevel = (
statuses: Array<[string, ServiceStatus]>
): Map<ServiceStatusLevel, Map<string, ServiceStatus>> => {
const byLevel = new Map<ServiceStatusLevel, Map<string, ServiceStatus>>();
type StatusPair = [string, ServiceStatus];
for (const [serviceName, status] of statuses) {
let levelMap = byLevel.get(status.level);
if (!levelMap) {
levelMap = new Map<string, ServiceStatus>();
byLevel.set(status.level, levelMap);
const highestLevelSummary = (
statuses: StatusPair[]
): { highestLevel: ServiceStatusLevel; highestStatuses: StatusPair[] } => {
let highestLevel: ServiceStatusLevel = ServiceStatusLevels.available;
let highestStatuses: StatusPair[] = [];
for (const pair of statuses) {
if (pair[1].level === highestLevel) {
highestStatuses.push(pair);
} else if (pair[1].level > highestLevel) {
highestLevel = pair[1].level;
highestStatuses = [pair];
}
levelMap.set(serviceName, status);
}
return byLevel;
};
const getHighestSeverityLevel = (levels: Iterable<ServiceStatusLevel>): ServiceStatusLevel => {
const sorted = [...levels].sort((a, b) => {
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else {
return 0;
}
});
return sorted[sorted.length - 1] ?? ServiceStatusLevels.available;
return {
highestLevel,
highestStatuses,
};
};

View file

@ -0,0 +1,338 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { PluginName } from '../plugins';
import { PluginsStatusService } from './plugins_status';
import { of, Observable, BehaviorSubject } from 'rxjs';
import { ServiceStatusLevels, CoreStatus, ServiceStatus } from './types';
import { first } from 'rxjs/operators';
import { ServiceStatusLevelSnapshotSerializer } from './test_utils';
expect.addSnapshotSerializer(ServiceStatusLevelSnapshotSerializer);
describe('PluginStatusService', () => {
const coreAllAvailable$: Observable<CoreStatus> = of({
elasticsearch: { level: ServiceStatusLevels.available, summary: 'elasticsearch avail' },
savedObjects: { level: ServiceStatusLevels.available, summary: 'savedObjects avail' },
});
const coreOneDegraded$: Observable<CoreStatus> = of({
elasticsearch: { level: ServiceStatusLevels.available, summary: 'elasticsearch avail' },
savedObjects: { level: ServiceStatusLevels.degraded, summary: 'savedObjects degraded' },
});
const coreOneCriticalOneDegraded$: Observable<CoreStatus> = of({
elasticsearch: { level: ServiceStatusLevels.critical, summary: 'elasticsearch critical' },
savedObjects: { level: ServiceStatusLevels.degraded, summary: 'savedObjects degraded' },
});
const pluginDependencies: Map<PluginName, PluginName[]> = new Map([
['a', []],
['b', ['a']],
['c', ['a', 'b']],
]);
describe('getDerivedStatus$', () => {
it(`defaults to core's most severe status`, async () => {
const serviceAvailable = new PluginsStatusService({
core$: coreAllAvailable$,
pluginDependencies,
});
expect(await serviceAvailable.getDerivedStatus$('a').pipe(first()).toPromise()).toEqual({
level: ServiceStatusLevels.available,
summary: 'All dependencies are available',
});
const serviceDegraded = new PluginsStatusService({
core$: coreOneDegraded$,
pluginDependencies,
});
expect(await serviceDegraded.getDerivedStatus$('a').pipe(first()).toPromise()).toEqual({
level: ServiceStatusLevels.degraded,
summary: '[savedObjects]: savedObjects degraded',
detail: 'See the status page for more information',
meta: expect.any(Object),
});
const serviceCritical = new PluginsStatusService({
core$: coreOneCriticalOneDegraded$,
pluginDependencies,
});
expect(await serviceCritical.getDerivedStatus$('a').pipe(first()).toPromise()).toEqual({
level: ServiceStatusLevels.critical,
summary: '[elasticsearch]: elasticsearch critical',
detail: 'See the status page for more information',
meta: expect.any(Object),
});
});
it(`provides a summary status when core and dependencies are at same severity level`, async () => {
const service = new PluginsStatusService({ core$: coreOneDegraded$, pluginDependencies });
service.set('a', of({ level: ServiceStatusLevels.degraded, summary: 'a is degraded' }));
expect(await service.getDerivedStatus$('b').pipe(first()).toPromise()).toEqual({
level: ServiceStatusLevels.degraded,
summary: '[2] services are degraded',
detail: 'See the status page for more information',
meta: expect.any(Object),
});
});
it(`allows dependencies status to take precedence over lower severity core statuses`, async () => {
const service = new PluginsStatusService({ core$: coreOneDegraded$, pluginDependencies });
service.set('a', of({ level: ServiceStatusLevels.unavailable, summary: 'a is not working' }));
expect(await service.getDerivedStatus$('b').pipe(first()).toPromise()).toEqual({
level: ServiceStatusLevels.unavailable,
summary: '[a]: a is not working',
detail: 'See the status page for more information',
meta: expect.any(Object),
});
});
it(`allows core status to take precedence over lower severity dependencies statuses`, async () => {
const service = new PluginsStatusService({
core$: coreOneCriticalOneDegraded$,
pluginDependencies,
});
service.set('a', of({ level: ServiceStatusLevels.unavailable, summary: 'a is not working' }));
expect(await service.getDerivedStatus$('b').pipe(first()).toPromise()).toEqual({
level: ServiceStatusLevels.critical,
summary: '[elasticsearch]: elasticsearch critical',
detail: 'See the status page for more information',
meta: expect.any(Object),
});
});
it(`allows a severe dependency status to take precedence over a less severe dependency status`, async () => {
const service = new PluginsStatusService({ core$: coreOneDegraded$, pluginDependencies });
service.set('a', of({ level: ServiceStatusLevels.degraded, summary: 'a is degraded' }));
service.set('b', of({ level: ServiceStatusLevels.unavailable, summary: 'b is not working' }));
expect(await service.getDerivedStatus$('c').pipe(first()).toPromise()).toEqual({
level: ServiceStatusLevels.unavailable,
summary: '[b]: b is not working',
detail: 'See the status page for more information',
meta: expect.any(Object),
});
});
});
describe('getAll$', () => {
it('defaults to empty record if no plugins', async () => {
const service = new PluginsStatusService({
core$: coreAllAvailable$,
pluginDependencies: new Map(),
});
expect(await service.getAll$().pipe(first()).toPromise()).toEqual({});
});
it('defaults to core status when no plugin statuses are set', async () => {
const serviceAvailable = new PluginsStatusService({
core$: coreAllAvailable$,
pluginDependencies,
});
expect(await serviceAvailable.getAll$().pipe(first()).toPromise()).toEqual({
a: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' },
b: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' },
c: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' },
});
const serviceDegraded = new PluginsStatusService({
core$: coreOneDegraded$,
pluginDependencies,
});
expect(await serviceDegraded.getAll$().pipe(first()).toPromise()).toEqual({
a: {
level: ServiceStatusLevels.degraded,
summary: '[savedObjects]: savedObjects degraded',
detail: 'See the status page for more information',
meta: expect.any(Object),
},
b: {
level: ServiceStatusLevels.degraded,
summary: '[2] services are degraded',
detail: 'See the status page for more information',
meta: expect.any(Object),
},
c: {
level: ServiceStatusLevels.degraded,
summary: '[3] services are degraded',
detail: 'See the status page for more information',
meta: expect.any(Object),
},
});
const serviceCritical = new PluginsStatusService({
core$: coreOneCriticalOneDegraded$,
pluginDependencies,
});
expect(await serviceCritical.getAll$().pipe(first()).toPromise()).toEqual({
a: {
level: ServiceStatusLevels.critical,
summary: '[elasticsearch]: elasticsearch critical',
detail: 'See the status page for more information',
meta: expect.any(Object),
},
b: {
level: ServiceStatusLevels.critical,
summary: '[2] services are critical',
detail: 'See the status page for more information',
meta: expect.any(Object),
},
c: {
level: ServiceStatusLevels.critical,
summary: '[3] services are critical',
detail: 'See the status page for more information',
meta: expect.any(Object),
},
});
});
it('uses the manually set status level if plugin specifies one', async () => {
const service = new PluginsStatusService({ core$: coreOneDegraded$, pluginDependencies });
service.set('a', of({ level: ServiceStatusLevels.available, summary: 'a status' }));
expect(await service.getAll$().pipe(first()).toPromise()).toEqual({
a: { level: ServiceStatusLevels.available, summary: 'a status' }, // a is available depsite savedObjects being degraded
b: {
level: ServiceStatusLevels.degraded,
summary: '[savedObjects]: savedObjects degraded',
detail: 'See the status page for more information',
meta: expect.any(Object),
},
c: {
level: ServiceStatusLevels.degraded,
summary: '[2] services are degraded',
detail: 'See the status page for more information',
meta: expect.any(Object),
},
});
});
it('updates when a new plugin status observable is set', async () => {
const service = new PluginsStatusService({
core$: coreAllAvailable$,
pluginDependencies: new Map([['a', []]]),
});
const statusUpdates: Array<Record<PluginName, ServiceStatus>> = [];
const subscription = service
.getAll$()
.subscribe((pluginStatuses) => statusUpdates.push(pluginStatuses));
service.set('a', of({ level: ServiceStatusLevels.degraded, summary: 'a degraded' }));
service.set('a', of({ level: ServiceStatusLevels.unavailable, summary: 'a unavailable' }));
service.set('a', of({ level: ServiceStatusLevels.available, summary: 'a available' }));
subscription.unsubscribe();
expect(statusUpdates).toEqual([
{ a: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' } },
{ a: { level: ServiceStatusLevels.degraded, summary: 'a degraded' } },
{ a: { level: ServiceStatusLevels.unavailable, summary: 'a unavailable' } },
{ a: { level: ServiceStatusLevels.available, summary: 'a available' } },
]);
});
});
describe('getDependenciesStatus$', () => {
it('only includes dependencies of specified plugin', async () => {
const service = new PluginsStatusService({
core$: coreAllAvailable$,
pluginDependencies,
});
expect(await service.getDependenciesStatus$('a').pipe(first()).toPromise()).toEqual({});
expect(await service.getDependenciesStatus$('b').pipe(first()).toPromise()).toEqual({
a: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' },
});
expect(await service.getDependenciesStatus$('c').pipe(first()).toPromise()).toEqual({
a: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' },
b: { level: ServiceStatusLevels.available, summary: 'All dependencies are available' },
});
});
it('uses the manually set status level if plugin specifies one', async () => {
const service = new PluginsStatusService({ core$: coreOneDegraded$, pluginDependencies });
service.set('a', of({ level: ServiceStatusLevels.available, summary: 'a status' }));
expect(await service.getDependenciesStatus$('c').pipe(first()).toPromise()).toEqual({
a: { level: ServiceStatusLevels.available, summary: 'a status' }, // a is available depsite savedObjects being degraded
b: {
level: ServiceStatusLevels.degraded,
summary: '[savedObjects]: savedObjects degraded',
detail: 'See the status page for more information',
meta: expect.any(Object),
},
});
});
it('throws error if unknown plugin passed', () => {
const service = new PluginsStatusService({ core$: coreAllAvailable$, pluginDependencies });
expect(() => {
service.getDependenciesStatus$('dont-exist');
}).toThrowError();
});
it('debounces events in quick succession', async () => {
const service = new PluginsStatusService({
core$: coreAllAvailable$,
pluginDependencies,
});
const available: ServiceStatus = {
level: ServiceStatusLevels.available,
summary: 'a available',
};
const degraded: ServiceStatus = {
level: ServiceStatusLevels.degraded,
summary: 'a degraded',
};
const pluginA$ = new BehaviorSubject(available);
service.set('a', pluginA$);
const statusUpdates: Array<Record<string, ServiceStatus>> = [];
const subscription = service
.getDependenciesStatus$('b')
.subscribe((status) => statusUpdates.push(status));
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
pluginA$.next(degraded);
pluginA$.next(available);
pluginA$.next(degraded);
pluginA$.next(available);
pluginA$.next(degraded);
pluginA$.next(available);
pluginA$.next(degraded);
// Waiting for the debounce timeout should cut a new update
await delay(500);
pluginA$.next(available);
await delay(500);
subscription.unsubscribe();
expect(statusUpdates).toMatchInlineSnapshot(`
Array [
Object {
"a": Object {
"level": degraded,
"summary": "a degraded",
},
},
Object {
"a": Object {
"level": available,
"summary": "a available",
},
},
]
`);
});
});
});

View file

@ -0,0 +1,98 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { BehaviorSubject, Observable, combineLatest, of } from 'rxjs';
import { map, distinctUntilChanged, switchMap, debounceTime } from 'rxjs/operators';
import { isDeepStrictEqual } from 'util';
import { PluginName } from '../plugins';
import { ServiceStatus, CoreStatus } from './types';
import { getSummaryStatus } from './get_summary_status';
interface Deps {
core$: Observable<CoreStatus>;
pluginDependencies: ReadonlyMap<PluginName, PluginName[]>;
}
export class PluginsStatusService {
private readonly pluginStatuses = new Map<PluginName, Observable<ServiceStatus>>();
private readonly update$ = new BehaviorSubject(true);
constructor(private readonly deps: Deps) {}
public set(plugin: PluginName, status$: Observable<ServiceStatus>) {
this.pluginStatuses.set(plugin, status$);
this.update$.next(true); // trigger all existing Observables to update from the new source Observable
}
public getAll$(): Observable<Record<PluginName, ServiceStatus>> {
return this.getPluginStatuses$([...this.deps.pluginDependencies.keys()]);
}
public getDependenciesStatus$(plugin: PluginName): Observable<Record<PluginName, ServiceStatus>> {
const dependencies = this.deps.pluginDependencies.get(plugin);
if (!dependencies) {
throw new Error(`Unknown plugin: ${plugin}`);
}
return this.getPluginStatuses$(dependencies).pipe(
// Prevent many emissions at once from dependency status resolution from making this too noisy
debounceTime(500)
);
}
public getDerivedStatus$(plugin: PluginName): Observable<ServiceStatus> {
return combineLatest([this.deps.core$, this.getDependenciesStatus$(plugin)]).pipe(
map(([coreStatus, pluginStatuses]) => {
return getSummaryStatus(
[...Object.entries(coreStatus), ...Object.entries(pluginStatuses)],
{
allAvailableSummary: `All dependencies are available`,
}
);
})
);
}
private getPluginStatuses$(plugins: PluginName[]): Observable<Record<PluginName, ServiceStatus>> {
if (plugins.length === 0) {
return of({});
}
return this.update$.pipe(
switchMap(() => {
const pluginStatuses = plugins
.map(
(depName) =>
[depName, this.pluginStatuses.get(depName) ?? this.getDerivedStatus$(depName)] as [
PluginName,
Observable<ServiceStatus>
]
)
.map(([pName, status$]) =>
status$.pipe(map((status) => [pName, status] as [PluginName, ServiceStatus]))
);
return combineLatest(pluginStatuses).pipe(
map((statuses) => Object.fromEntries(statuses)),
distinctUntilChanged(isDeepStrictEqual)
);
})
);
}
}

View file

@ -40,6 +40,9 @@ const createSetupContractMock = () => {
const setupContract: jest.Mocked<StatusServiceSetup> = {
core$: new BehaviorSubject(availableCoreStatus),
overall$: new BehaviorSubject(available),
set: jest.fn(),
dependencies$: new BehaviorSubject({}),
derivedStatus$: new BehaviorSubject(available),
};
return setupContract;
@ -50,6 +53,11 @@ const createInternalSetupContractMock = () => {
core$: new BehaviorSubject(availableCoreStatus),
overall$: new BehaviorSubject(available),
isStatusPageAnonymous: jest.fn().mockReturnValue(false),
plugins: {
set: jest.fn(),
getDependenciesStatus$: jest.fn(),
getDerivedStatus$: jest.fn(),
},
};
return setupContract;

View file

@ -34,6 +34,7 @@ describe('StatusService', () => {
service = new StatusService(mockCoreContext.create());
});
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const available: ServiceStatus<any> = {
level: ServiceStatusLevels.available,
summary: 'Available',
@ -53,6 +54,7 @@ describe('StatusService', () => {
savedObjects: {
status$: of(degraded),
},
pluginDependencies: new Map(),
});
expect(await setup.core$.pipe(first()).toPromise()).toEqual({
elasticsearch: available,
@ -68,6 +70,7 @@ describe('StatusService', () => {
savedObjects: {
status$: of(degraded),
},
pluginDependencies: new Map(),
});
const subResult1 = await setup.core$.pipe(first()).toPromise();
const subResult2 = await setup.core$.pipe(first()).toPromise();
@ -96,6 +99,7 @@ describe('StatusService', () => {
savedObjects: {
status$: savedObjects$,
},
pluginDependencies: new Map(),
});
const statusUpdates: CoreStatus[] = [];
@ -158,6 +162,7 @@ describe('StatusService', () => {
savedObjects: {
status$: of(degraded),
},
pluginDependencies: new Map(),
});
expect(await setup.overall$.pipe(first()).toPromise()).toMatchObject({
level: ServiceStatusLevels.degraded,
@ -173,6 +178,7 @@ describe('StatusService', () => {
savedObjects: {
status$: of(degraded),
},
pluginDependencies: new Map(),
});
const subResult1 = await setup.overall$.pipe(first()).toPromise();
const subResult2 = await setup.overall$.pipe(first()).toPromise();
@ -201,26 +207,95 @@ describe('StatusService', () => {
savedObjects: {
status$: savedObjects$,
},
pluginDependencies: new Map(),
});
const statusUpdates: ServiceStatus[] = [];
const subscription = setup.overall$.subscribe((status) => statusUpdates.push(status));
// Wait for timers to ensure that duplicate events are still filtered out regardless of debouncing.
elasticsearch$.next(available);
await delay(500);
elasticsearch$.next(available);
await delay(500);
elasticsearch$.next({
level: ServiceStatusLevels.available,
summary: `Wow another summary`,
});
await delay(500);
savedObjects$.next(degraded);
await delay(500);
savedObjects$.next(available);
await delay(500);
savedObjects$.next(available);
await delay(500);
subscription.unsubscribe();
expect(statusUpdates).toMatchInlineSnapshot(`
Array [
Object {
"detail": "See the status page for more information",
"level": degraded,
"meta": Object {
"affectedServices": Object {
"savedObjects": Object {
"level": degraded,
"summary": "This is degraded!",
},
},
},
"summary": "[savedObjects]: This is degraded!",
},
Object {
"level": available,
"summary": "All services are available",
},
]
`);
});
it('debounces events in quick succession', async () => {
const savedObjects$ = new BehaviorSubject(available);
const setup = await service.setup({
elasticsearch: {
status$: new BehaviorSubject(available),
},
savedObjects: {
status$: savedObjects$,
},
pluginDependencies: new Map(),
});
const statusUpdates: ServiceStatus[] = [];
const subscription = setup.overall$.subscribe((status) => statusUpdates.push(status));
// All of these should debounced into a single `available` status
savedObjects$.next(degraded);
savedObjects$.next(available);
savedObjects$.next(degraded);
savedObjects$.next(available);
savedObjects$.next(degraded);
savedObjects$.next(available);
savedObjects$.next(degraded);
// Waiting for the debounce timeout should cut a new update
await delay(500);
savedObjects$.next(available);
await delay(500);
subscription.unsubscribe();
expect(statusUpdates).toMatchInlineSnapshot(`
Array [
Object {
"detail": "See the status page for more information",
"level": degraded,
"meta": Object {
"affectedServices": Object {
"savedObjects": Object {
"level": degraded,
"summary": "This is degraded!",
},
},
},
"summary": "[savedObjects]: This is degraded!",
},
Object {

View file

@ -18,7 +18,7 @@
*/
import { Observable, combineLatest } from 'rxjs';
import { map, distinctUntilChanged, shareReplay, take } from 'rxjs/operators';
import { map, distinctUntilChanged, shareReplay, take, debounceTime } from 'rxjs/operators';
import { isDeepStrictEqual } from 'util';
import { CoreService } from '../../types';
@ -26,13 +26,16 @@ import { CoreContext } from '../core_context';
import { Logger } from '../logging';
import { InternalElasticsearchServiceSetup } from '../elasticsearch';
import { InternalSavedObjectsServiceSetup } from '../saved_objects';
import { PluginName } from '../plugins';
import { config, StatusConfigType } from './status_config';
import { ServiceStatus, CoreStatus, InternalStatusServiceSetup } from './types';
import { getSummaryStatus } from './get_summary_status';
import { PluginsStatusService } from './plugins_status';
interface SetupDeps {
elasticsearch: Pick<InternalElasticsearchServiceSetup, 'status$'>;
pluginDependencies: ReadonlyMap<PluginName, PluginName[]>;
savedObjects: Pick<InternalSavedObjectsServiceSetup, 'status$'>;
}
@ -40,26 +43,44 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
private readonly logger: Logger;
private readonly config$: Observable<StatusConfigType>;
private pluginsStatus?: PluginsStatusService;
constructor(coreContext: CoreContext) {
this.logger = coreContext.logger.get('status');
this.config$ = coreContext.configService.atPath<StatusConfigType>(config.path);
}
public async setup(core: SetupDeps) {
public async setup({ elasticsearch, pluginDependencies, savedObjects }: SetupDeps) {
const statusConfig = await this.config$.pipe(take(1)).toPromise();
const core$ = this.setupCoreStatus(core);
const overall$: Observable<ServiceStatus> = core$.pipe(
map((coreStatus) => {
const summary = getSummaryStatus(Object.entries(coreStatus));
const core$ = this.setupCoreStatus({ elasticsearch, savedObjects });
this.pluginsStatus = new PluginsStatusService({ core$, pluginDependencies });
const overall$: Observable<ServiceStatus> = combineLatest(
core$,
this.pluginsStatus.getAll$()
).pipe(
// Prevent many emissions at once from dependency status resolution from making this too noisy
debounceTime(500),
map(([coreStatus, pluginsStatus]) => {
const summary = getSummaryStatus([
...Object.entries(coreStatus),
...Object.entries(pluginsStatus),
]);
this.logger.debug(`Recalculated overall status`, { status: summary });
return summary;
}),
distinctUntilChanged(isDeepStrictEqual)
distinctUntilChanged(isDeepStrictEqual),
shareReplay(1)
);
return {
core$,
overall$,
plugins: {
set: this.pluginsStatus.set.bind(this.pluginsStatus),
getDependenciesStatus$: this.pluginsStatus.getDependenciesStatus$.bind(this.pluginsStatus),
getDerivedStatus$: this.pluginsStatus.getDerivedStatus$.bind(this.pluginsStatus),
},
isStatusPageAnonymous: () => statusConfig.allowAnonymous,
};
}
@ -68,7 +89,10 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
public stop() {}
private setupCoreStatus({ elasticsearch, savedObjects }: SetupDeps): Observable<CoreStatus> {
private setupCoreStatus({
elasticsearch,
savedObjects,
}: Pick<SetupDeps, 'elasticsearch' | 'savedObjects'>): Observable<CoreStatus> {
return combineLatest([elasticsearch.status$, savedObjects.status$]).pipe(
map(([elasticsearchStatus, savedObjectsStatus]) => ({
elasticsearch: elasticsearchStatus,

View file

@ -19,6 +19,7 @@
import { Observable } from 'rxjs';
import { deepFreeze } from '../../utils';
import { PluginName } from '../plugins';
/**
* The current status of a service at a point in time.
@ -116,6 +117,60 @@ export interface CoreStatus {
/**
* API for accessing status of Core and this plugin's dependencies as well as for customizing this plugin's status.
*
* @remarks
* By default, a plugin inherits it's current status from the most severe status level of any Core services and any
* plugins that it depends on. This default status is available on the
* {@link ServiceStatusSetup.derivedStatus$ | core.status.derviedStatus$} API.
*
* Plugins may customize their status calculation by calling the {@link ServiceStatusSetup.set | core.status.set} API
* with an Observable. Within this Observable, a plugin may choose to only depend on the status of some of its
* dependencies, to ignore severe status levels of particular Core services they are not concerned with, or to make its
* status dependent on other external services.
*
* @example
* Customize a plugin's status to only depend on the status of SavedObjects:
* ```ts
* core.status.set(
* core.status.core$.pipe(
* . map((coreStatus) => {
* return coreStatus.savedObjects;
* }) ;
* );
* );
* ```
*
* @example
* Customize a plugin's status to include an external service:
* ```ts
* const externalStatus$ = interval(1000).pipe(
* switchMap(async () => {
* const resp = await fetch(`https://myexternaldep.com/_healthz`);
* const body = await resp.json();
* if (body.ok) {
* return of({ level: ServiceStatusLevels.available, summary: 'External Service is up'});
* } else {
* return of({ level: ServiceStatusLevels.available, summary: 'External Service is unavailable'});
* }
* }),
* catchError((error) => {
* of({ level: ServiceStatusLevels.unavailable, summary: `External Service is down`, meta: { error }})
* })
* );
*
* core.status.set(
* combineLatest([core.status.derivedStatus$, externalStatus$]).pipe(
* map(([derivedStatus, externalStatus]) => {
* if (externalStatus.level > derivedStatus) {
* return externalStatus;
* } else {
* return derivedStatus;
* }
* })
* )
* );
* ```
*
* @public
*/
export interface StatusServiceSetup {
@ -134,9 +189,43 @@ export interface StatusServiceSetup {
* only depend on the statuses of {@link StatusServiceSetup.core$ | Core} or their dependencies.
*/
overall$: Observable<ServiceStatus>;
/**
* Allows a plugin to specify a custom status dependent on its own criteria.
* Completely overrides the default inherited status.
*
* @remarks
* See the {@link StatusServiceSetup.derivedStatus$} API for leveraging the default status
* calculation that is provided by Core.
*/
set(status$: Observable<ServiceStatus>): void;
/**
* Current status for all plugins this plugin depends on.
* Each key of the `Record` is a plugin id.
*/
dependencies$: Observable<Record<string, ServiceStatus>>;
/**
* The status of this plugin as derived from its dependencies.
*
* @remarks
* By default, plugins inherit this derived status from their dependencies.
* Calling {@link StatusSetup.set} overrides this default status.
*
* This may emit multliple times for a single status change event as propagates
* through the dependency tree
*/
derivedStatus$: Observable<ServiceStatus>;
}
/** @internal */
export interface InternalStatusServiceSetup extends StatusServiceSetup {
export interface InternalStatusServiceSetup extends Pick<StatusServiceSetup, 'core$' | 'overall$'> {
isStatusPageAnonymous: () => boolean;
// Namespaced under `plugins` key to improve clarity that these are APIs for plugins specifically.
plugins: {
set(plugin: PluginName, status$: Observable<ServiceStatus>): void;
getDependenciesStatus$(plugin: PluginName): Observable<Record<string, ServiceStatus>>;
getDerivedStatus$(plugin: PluginName): Observable<ServiceStatus>;
};
}