> {
- const url = Url.resolve(this.pickUrl(), options.path);
+ const url = this.resolveUrl(options.path);
const description = options.description || `${options.method} ${url}`;
let attempt = 0;
const maxAttempts = options.retries ?? DEFAULT_MAX_ATTEMPTS;
@@ -107,6 +113,9 @@ export class KbnClientRequester {
'kbn-xsrf': 'kbn-client',
},
httpsAgent: this.httpsAgent,
+ responseType: options.responseType,
+ // work around https://github.com/axios/axios/issues/2791
+ transformResponse: options.responseType === 'text' ? [(x) => x] : undefined,
paramsSerializer: (params) => Qs.stringify(params),
});
diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js
index f14c793d22a09..4029ce28faf5b 100644
--- a/packages/kbn-ui-shared-deps/entry.js
+++ b/packages/kbn-ui-shared-deps/entry.js
@@ -49,3 +49,4 @@ export const TsLib = require('tslib');
export const KbnAnalytics = require('@kbn/analytics');
export const KbnStd = require('@kbn/std');
export const SaferLodashSet = require('@elastic/safer-lodash-set');
+export const RisonNode = require('rison-node');
diff --git a/packages/kbn-ui-shared-deps/index.js b/packages/kbn-ui-shared-deps/index.js
index 0542bc89ff9e4..62ddb09d25add 100644
--- a/packages/kbn-ui-shared-deps/index.js
+++ b/packages/kbn-ui-shared-deps/index.js
@@ -60,5 +60,6 @@ exports.externals = {
'@kbn/analytics': '__kbnSharedDeps__.KbnAnalytics',
'@kbn/std': '__kbnSharedDeps__.KbnStd',
'@elastic/safer-lodash-set': '__kbnSharedDeps__.SaferLodashSet',
+ 'rison-node': '__kbnSharedDeps__.RisonNode',
};
exports.publicPathLoader = require.resolve('./public_path_loader');
diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json
index 47a2fa19e7a8e..00c6f677cd223 100644
--- a/packages/kbn-ui-shared-deps/package.json
+++ b/packages/kbn-ui-shared-deps/package.json
@@ -14,7 +14,6 @@
"@kbn/monaco": "link:../kbn-monaco"
},
"devDependencies": {
- "@kbn/babel-preset": "link:../kbn-babel-preset",
"@kbn/dev-utils": "link:../kbn-dev-utils"
}
}
\ No newline at end of file
diff --git a/rfcs/images/url_service/new_architecture.png b/rfcs/images/url_service/new_architecture.png
new file mode 100644
index 0000000000000..9faa025d429bf
Binary files /dev/null and b/rfcs/images/url_service/new_architecture.png differ
diff --git a/rfcs/images/url_service/old_architecture.png b/rfcs/images/url_service/old_architecture.png
new file mode 100644
index 0000000000000..fdb1c13fabf34
Binary files /dev/null and b/rfcs/images/url_service/old_architecture.png differ
diff --git a/rfcs/text/0017_url_service.md b/rfcs/text/0017_url_service.md
new file mode 100644
index 0000000000000..87a8a92c090d6
--- /dev/null
+++ b/rfcs/text/0017_url_service.md
@@ -0,0 +1,600 @@
+- Start Date: 2021-03-26
+- RFC PR: (leave this empty)
+- Kibana Issue: (leave this empty)
+
+
+# Summary
+
+Currently in the Kibana `share` plugin we have two services that deal with URLs.
+
+One is *Short URL Service*: given a long internal Kibana URL it returns an ID.
+That ID can be used to "resolve" back to the long URL and redirect the user to
+that long URL page. (The Short URL Service is now used in Dashboard, Discover,
+Visualize apps, and have a few upcoming users, for example, when sharing panels
+by Slack or e-mail we will want to use short URLs.)
+
+```ts
+// It does not have a plugin API, you can only use it through an HTTP request.
+const shortUrl = await http.post('/api/shorten_url', {
+ url: '/some/long/kibana/url/.../very?long=true#q=(rison:approved)'
+});
+```
+
+The other is the *URL Generator Service*: it simply receives an object of
+parameters and returns back a deep link within Kibana. (You can use it, for
+example, to navigate to some specific query with specific filters for a
+specific index pattern in the Discover app. As of this writing, there are
+eight registered URL generators, which are used by ten plugins.)
+
+```ts
+// You first register a URL generator.
+const myGenerator = plugins.share.registerUrlGenerator(/* ... */);
+
+// You can fetch it from the registry (if you don't already have it).
+const myGenerator = plugins.share.getUrlGenerator(/* ... */);
+
+// Now you can use it to generate a deep link into Kibana.
+const deepLink: string = myGenerator.createUrl({ /* ... */ });
+```
+
+
+## Goals of the project
+
+The proposal is to unify both of these services (Short URL Service and URL
+Generator Service) into a single new *URL Service*. The new unified service
+will still provide all the functionality the above mentioned services provide
+and in addition will implement the following improvements:
+
+1. Standardize a way for apps to deep link and navigate into other Kibana apps,
+ with ability to use *location state* to specify the state of the app which is
+ not part of the URL.
+2. Combine Short URL Service with URL Generator Service to allow short URLs to
+ be constructed from URL generators, which will also allow us to automatically
+ migrate the short URLs if the parameters of the underlying URL generator
+ change and be able to store location state in every short URL.
+3. Make the short url service easier to use. (It was previously undocumented,
+ and no server side plugin APIs existed, which meant consumers had to use
+ REST APIs which is discouraged. Merging the two services will help achieve
+ this goal by simplifying the APIs.)
+4. Support short urls being deleted (previously not possible).
+5. Support short urls being migrated (previously not possible).
+
+See more detailed explanation and other small improvements in the "Motivation"
+section below.
+
+
+# Terminology
+
+In the proposed new service we introduce "locators". This is mostly a change
+in language, we are renaming "URL generators" to "locators". The old name would
+no longer make sense as we are not returning URLs from locators.
+
+
+# Basic example
+
+The URL Service will have a client (`UrlServiceClient`) which will have the same
+interface, both, on the server-side and the client-side. It will also have a
+documented public set of HTTP API endpoints for use by: (1) the client-side
+client; (2) external users, Elastic Cloud, and Support.
+
+The following code examples will work, both, on the server-side and the
+client-side, as the base `UrlServiceClient` interface will be similar in both
+environments.
+
+Below we consider four main examples of usage of the URL Service. All four
+examples are existing use cases we currently have in Kibana.
+
+
+## Navigating within Kibana using locators
+
+In this example let's consider a case where Discover app creates a locator,
+then another plugin uses that locator to navigate to a deep link within the
+Discover app.
+
+First, the Discover plugin creates its locator (usually one per app). It needs
+to do this on the client and server.
+
+
+```ts
+const locator = plugins.share.locators.create({
+ id: 'DISCOVER_DEEP_LINKS',
+ getLocation: ({
+ indexPattern,
+ highlightedField,
+ filters: [],
+ query: {},
+ fields: [],
+ activeDoc: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx',
+ }) => {
+ app: 'discover',
+ route: `/${indexPatten}#_a=(${risonEncode({filters, query, fields})})`,
+ state: {
+ highlightedField,
+ activeDoc,
+ },
+ },
+});
+```
+
+Now, the Discover plugin exports this locator from its plugin contract.
+
+```ts
+class DiscoverPlugin() {
+ start() {
+ return {
+ locator,
+ };
+ }
+}
+```
+
+Finally, if any other app now wants to navigate to a deep link within the
+Discover application, they use this exported locator.
+
+```ts
+plugins.discover.locator.navigate({
+ indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ highlightedField: 'foo',
+});
+```
+
+Note, in this example the `highlightedField` parameter will not appear in the
+URL bar, it will be passed to the Discover app through [`history.pushState()`](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState)
+mechanism (in Kibana case, using the [`history`](https://www.npmjs.com/package/history) package, which is used by `core.application.navigateToApp`).
+
+
+## Sending a deep link to Kibana
+
+We have use cases were a deep link to some Kibana app is sent out, for example,
+through e-mail or as a Slack message.
+
+In this example, lets consider some plugin gets hold of the Discover locator
+on the server-side.
+
+```ts
+const location = plugins.discover.locator.getRedirectPath({
+ indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ highlightedField: 'foo',
+});
+```
+
+This would return the location of the client-side redirect endpoint. The redirect
+endpoint could look like this:
+
+```
+/app/goto/_redirect/DISCOVER_DEEP_LINKS?params={"indexPattern":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","highlightedField":"foo"}¶msVersion=7.x
+```
+
+This redirect client-side endpoint would find the Discover locator and and
+execute the `.navigate()` method on it.
+
+
+## Creating a short link
+
+In this example, lets create a short link using the Discover locator.
+
+```ts
+const shortUrl = await plugins.discover.locator.createShortUrl(
+ {
+ indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ highlightedField: 'foo',
+ }
+ 'human-readable-slug',
+});
+```
+
+The above example creates a short link and persists it in a saved object. The
+short URL can have a human-readable slug, which uniquely identifies that short
+URL.
+
+```ts
+shortUrl.slug === 'human-readable-slug'
+```
+
+The short URL can be used to navigate to the Discover app. The redirect
+client-side endpoint currently looks like this:
+
+```
+/app/goto/human-readable-slug
+```
+
+This persisted short URL would effectively work the same as the full version:
+
+```
+/app/goto/_redirect/DISCOVER_DEEP_LINKS?params={"indexPattern":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","highlightedField":"foo"}¶msVersion=7.x
+```
+
+
+## External users navigating to a Kibana deep link
+
+Currently Elastic Cloud and Support have many links linking into Kibana. Most of
+them are deep links into Discover and Dashboard apps where, for example, index
+pattern is selected, or filters and time range are set.
+
+The external users could use the above mentioned client-side redirect endpoint
+to navigate to their desired deep location within Kibana, for example, to the
+Discover application:
+
+```
+/app/goto/_redirect/DISCOVER_DEEP_LINKS?params={"indexPattern":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","highlightedField":"foo"}¶msVersion=7.x
+```
+
+
+# Motivation
+
+Our motivation to improve the URL services comes from us intending to use them
+more, for example, for panel sharing to Slack or e-mail; and we believe that the
+current state of the URL services needs an upgrade.
+
+
+## Limitations of the Short URL Service
+
+We have identified the following limitations in the current implementation of
+the Short URL Service:
+
+1. There is no migration system. If an application exposes this functionality,
+ every possible URL that might be generated should be supported forever. A
+ migration could be written inside the app itself, on page load, but this is a
+ risky path for URLs with many possibilities.
+ 1. __Will do:__ Short URLs will be created using locators. We will use
+ migrations provided by the locators to migrate the stored parameters
+ in the short URL saved object.
+1. Short URLs store only the URL of the destination page. However, the
+ destination page might have other state which affects the display of the page
+ but is not present in the URL. Once the short URL is used to navigate to that
+ page, any state that is kept only in memory is lost.
+ 1. __Will do:__ The new implementation of the short URLs will also persist
+ the location state of the URL. That state would be provided to a
+ Kibana app once a user navigates to that app using a short URL.
+1. It exposes only HTTP endpoint API.
+ 1. __Will do:__ We will also expose a URL Service client through plugin
+ contract on the server and browser.
+1. It only has 3 HTTP endpoints, yet all three have different paths:
+ (1) `/short_url`, (2) `/shorten_url`; and (3) `/goto`.
+ 1. __Will do:__ We will normalize the HTTP endpoints. We will use HTTP
+ method "verbs" like POST, instead of verbs in the url like "shorten_url".
+1. There is not much documentation for developers.
+ 1. __Will do:__ The new service will have a much nicer API and docs.
+1. There is no way to delete short URLs once they are created.
+ 1. __Will do:__ The new service will provide CRUD API to manage short URLs,
+ including deletion.
+1. Short URL service uses MD5 algorithm to hash long URLs. Security team
+ requested to stop using that algorithm.
+ 1. __Will do:__ The new URL Service will not use MD5 algorithm.
+1. Short URLs are not automatically deleted when the target (say dashboard) is
+ deleted. (#10450)
+ 1. __Could do:__ The URL Service will not provide such feature. Though the
+ short URLs will keep track of saved object references used in the params
+ to generate a short URL. Maybe those saved references could somehow be
+ used in the future to provide such a facility.
+
+ Currently, there are two possible avenues for deleting a short URL when
+ the underlying dashboard is deleted:
+
+ 1. The Dashboard app could keep track of short URLs it generates for each
+ dashboard. Once a dashboard is deleted, the Dashboard app also
+ deletes all short URLs associated with that dashboard.
+ 1. Saved Objects Service could implement *cascading deletes*. Once a saved
+ object is deleted, the associated saved objects are also deleted
+ (#71453).
+1. Add additional metadata to each short URL.
+ 1. __Could do:__ Each short URL already keeps a counter of how often it was
+ resolved, we could also keep track of a timestamp when it was last
+ resolved, and have an ability for users to give a title to each short URL.
+1. Short URLs don't have a management UI.
+ 1. __Will NOT do:__ We will not create a dedicated UI for managing short
+ URLs. We could improve how short URLs saved objects are presented in saved
+ object management UI.
+1. Short URLs can't be created by read-only users (#18006).
+ 1. __Will NOT do:__ Currently short URLs are stored as saved objects of type
+ `url`, we would like to keep it that way and benefit from saved object
+ facilities like references, migrations, authorization etc.. The consensus
+ is that we will not allow anonymous users to create short URLs. We want to
+ continue using saved object for short URLs going forward and not
+ compromise on their security model.
+
+
+## Limitations of the URL Generator Service
+
+We have identified the following limitations in the current implementation of
+the URL Generator Service:
+
+1. URL generator generate only the URL of the destination. However there is
+ also the ability to use location state with `core.application.navigateToApp`
+ navigation method.
+ 1. __Will do:__ The new locators will also generate the location state, which
+ will be used in `.navigateToApp` method.
+1. URL generators are available only on the client-side. There is no way to use
+ them together with short URLs.
+ 1. __Will do:__ We will implement locators also on the server-side
+ (they will be available in both environments) and we will combine them
+ with the Short URL Service.
+1. URL generators are not exposed externally, thus Cloud and Support cannot use
+ them to generate deep links into Kibana.
+ 1. __Will do:__ We will expose HTTP endpoints on the server-side and the
+ "redirect" app on the client-side which external users will be able to use
+ to deep link into Kibana using locators.
+
+
+## Limitations of the architecture
+
+One major reason we want to "refresh" the Short URL Service and the URL
+Generator Service is their architecture.
+
+Currently, the Short URL Service is implemented on top of the `url` type saved
+object on the server-side. However, it only exposes the
+HTTP endpoints, it does not expose any API on the server for the server-side
+plugins to consume; on the client-side there is no plugin API either, developers
+need to manually execute HTTP requests.
+
+The URL Generator Service is only available on the client-side, there is no way
+to use it on the server-side, yet we already have use cases (for example ML
+team) where a server-side plugin wants to use a URL generator.
+
+![Current Short URL Service and URL Generator Service architecture](../images/url_service/old_architecture.png)
+
+The current architecture does not allow both services to be conveniently used,
+also as they are implemented in different locations, they are disjointed—
+we cannot create a short URL using an URL generator.
+
+
+# Detailed design
+
+In general we will try to provide as much as possible the same API on the
+server-side and the client-side.
+
+
+## High level architecture
+
+Below diagram shows the proposed architecture of the URL Service.
+
+![URL Service architecture](../images/url_service/new_architecture.png)
+
+
+## Plugin contracts
+
+The aim is to provide developers the same experience on the server and browser.
+
+Below are preliminary interfaces of the new URL Service. `IUrlService` will be
+a shared interface defined in `/common` folder shared across server and browser.
+This will allow us to provide users a common API interface on the server and
+browser, wherever they choose to use the URL Service:
+
+```ts
+/**
+ * Common URL Service client interface for the server-side and the client-side.
+ */
+interface IUrlService {
+ locators: ILocatorClient;
+ shortUrls: IShortUrlClient;
+}
+```
+
+
+### Locators
+
+The locator business logic will be contained in `ILocatorClient` client and will
+provide two main functionalities:
+
+1. It will provide a facility to create locators.
+1. It will also be a registry of locators, every newly created locator is
+ automatically added to the registry. The registry should never be used when
+ locator ID is known at the compile time, but is reserved only for use cases
+ when we only know ID of a locator at runtime.
+
+```ts
+interface ILocatorClient {
+ create(definition: LocatorDefinition
): Locator
;
+ get
(id: string): Locator
;
+}
+```
+
+The `LocatorDefinition` interface is a developer-friendly interface for creating
+new locators. Mainly two things will be required from each new locator:
+
+1. Implement the `getLocation()` method, which gives the locator specific `params`
+ object returns a Kibana location, see description of `KibanaLocation` below.
+2. Implement the `PersistableState` interface which we use in Kibana. This will
+ allow to migrate the locator `params`. Implementation of the `PersistableState`
+ interface will replace the `.isDeprecated` and `.migrate()` properties of URL
+ generators.
+
+
+```ts
+interface LocatorDefinition
extends PeristableState
{
+ id: string;
+ getLocation(params: P): KibanaLocation;
+}
+```
+
+Each constructed locator will have the following interface:
+
+```ts
+interface Locator
{
+ /** Creates a new short URL saved object using this locator. */
+ createShortUrl(params: P, slug?: string): Promise;
+ /** Returns a relative URL to the client-side redirect endpoint using this locator. */
+ getRedirectPath(params: P): string;
+ /** Navigate using core.application.navigateToApp() using this locator. */
+ navigate(params: P): void; // Only on browser.
+}
+```
+
+
+### Short URLs
+
+The short URL client `IShortUrlClient` which will be the same on the server and
+browser. However, the server and browser might add extra utility methods for
+convenience.
+
+```ts
+/**
+ * CRUD-like API for short URLs.
+ */
+interface IShortUrlClient {
+ /**
+ * Delete a short URL.
+ *
+ * @param slug The slug (ID) of the short URL.
+ * @return Returns true if deletion was successful.
+ */
+ delete(slug: string): Promise;
+
+ /**
+ * Fetch short URL.
+ *
+ * @param slug The slug (ID) of the short URL.
+ */
+ get(slug: string): Promise;
+
+ /**
+ * Same as `get()` but it also increments the "view" counter and the
+ * "last view" timestamp of this short URL.
+ *
+ * @param slug The slug (ID) of the short URL.
+ */
+ resolve(slug: string): Promise;
+}
+```
+
+Note, that in this new service to create a short URL the developer will have to
+use a locator (instead of creating it directly from a long URL).
+
+```ts
+const shortUrl = await plugins.share.shortUrls.create(
+ plugins.discover.locator,
+ {
+ indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ highlightedField: 'foo',
+ },
+ 'optional-human-readable-slug',
+);
+```
+
+These short URLs will be stored in saved objects of type `url` and will be
+automatically migrated using the locator. The long URL will NOT be stored in the
+saved object. The locator ID and locator params will be stored in the saved
+object, that will allow us to do the migrations for short URLs.
+
+
+### `KibanaLocation` interface
+
+The `KibanaLocation` interface is a simple interface to store a location in some
+Kibana application.
+
+```ts
+interface KibanaLocation {
+ app: string;
+ route: string;
+ state: object;
+}
+```
+
+It maps directly to a `.navigateToApp()` call.
+
+```ts
+let location: KibanaLocation;
+
+core.application.navigateToApp(location.app, {
+ route: location.route,
+ state: location.state,
+});
+```
+
+
+## HTTP endpoints
+
+
+### Short URL CRUD+ HTTP endpoints
+
+Below HTTP endpoints are designed to work specifically with short URLs:
+
+| HTTP method | Path | Description |
+|-----------------------|-------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
+| __POST__ | `/api/short_url` | Endpoint for creating new short URLs. |
+| __GET__ | `/api/short_url/` | Endpoint for retrieving information about an existing short URL. |
+| __DELETE__ | `/api/short_url/` | Endpoint for deleting an existing short URL. |
+| __POST__ | `/api/short_url/` | Endpoint for updating information about an existing short URL. |
+| __POST__ | `/api/short_url//_resolve` | Similar to `GET /api/short_url/`, but also increments the short URL access count counter and the last access timestamp. |
+
+
+### The client-side navigate endpoint
+
+__NOTE.__ We are currently investigating if we really need this endpoint. The
+main user of it was expected to be Cloud and Support to deeply link into Kibana,
+but we are now reconsidering if we want to support this endpoint and possibly
+find a different solution.
+
+The `/app/goto/_redirect/?params=...¶msVersion=...` client-side
+endpoint will receive the locator ID and locator params, it will use those to
+find the locator and execute `locator.navigate(params)` method.
+
+The `paramsVersion` parameter will be used to specify the version of the
+`params` parameter. If the version is behind the latest version, then the migration
+facilities of the locator will be used to on-the-fly migrate the `params` to the
+latest version.
+
+
+### Legacy endpoints
+
+Below are the legacy HTTP endpoints implemented by the `share` plugin, with a
+plan of action for each endpoint:
+
+| HTTP method | Path | Description |
+|-----------------------|-------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
+| __ANY__ | `/goto/` | Endpoint for redirecting short URLs, we will keep it to redirect short URLs. |
+| __GET__ | `/api/short_url/` | The new `GET /api/short_url/` endpoint will return a superset of the payload that the legacy endpoint now returns. |
+| __POST__ | `/api/shorten_url` | The legacy endpoints for creating short URLs. We will remove it or deprecate this endpoint and maintain it until 8.0 major release. |
+
+
+# Drawbacks
+
+Why should we *not* do this?
+
+- Implementation cost will be a few weeks, but the code complexity and quality
+ will improve.
+- There is a cost of migrating existing Kibana plugins to use the new API.
+
+
+# Alternatives
+
+We haven't considered other design alternatives.
+
+One alternative is still do the short URL improvements outlined above. But
+reconsider URL generators:
+
+- Do we need URL generators at all?
+ - Kibana URLs are not stable and have changed in our past experience. Hence,
+ the URL generators were created to make the URL generator parameters stable
+ unless a migration is available.
+- Do we want to put migration support in URL generators?
+ - Alternative would be for each app to support URLs forever or do the
+ migrations on the fly for old URLs.
+- Should Kibana URLs be stable and break only during major releases?
+- Should the Kibana application interface be extended such that some version of
+ URL generators is built in?
+
+The impact of not doing this change is essentially extending technical debt.
+
+
+# Adoption strategy
+
+Is this a breaking change? It is a breaking change in the sense that the API
+will change. However, all the existing use cases will be supported. When
+implementing this we will also adjust all Kibana code to use the new API. From
+the perspective of the developers when using the existing URL services nothing
+will change, they will simply need to review a PR which stops using the URL
+Generator Service and uses the combined URL Service instead, which will provide
+a superset of features.
+
+Alternatively, we can deprecate the URL Generator Service and maintain it for a
+few minor releases.
+
+
+# How we teach this
+
+For the existing short URL and URL generator functionality there is nothing to
+teach, as they will continue working with a largely similar API.
+
+Everything else in the new URL Service will have JSDoc comments and good
+documentation on our website.
diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts
index 4220d3e490f63..0ecfc152197d3 100644
--- a/src/core/public/doc_links/doc_links_service.ts
+++ b/src/core/public/doc_links/doc_links_service.ts
@@ -21,12 +21,16 @@ export class DocLinksService {
const DOC_LINK_VERSION = injectedMetadata.getKibanaBranch();
const ELASTIC_WEBSITE_URL = 'https://www.elastic.co/';
const ELASTICSEARCH_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`;
+ const KIBANA_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/`;
const PLUGIN_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/plugins/${DOC_LINK_VERSION}/`;
return deepFreeze({
DOC_LINK_VERSION,
ELASTIC_WEBSITE_URL,
links: {
+ canvas: {
+ guide: `${KIBANA_DOCS}canvas.html`,
+ },
dashboard: {
guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/dashboard.html`,
drilldowns: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/drilldowns.html`,
@@ -245,10 +249,10 @@ export class DocLinksService {
guide: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/index.html`,
},
alerting: {
- guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/managing-alerts-and-actions.html`,
+ guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-management.html`,
actionTypes: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/action-types.html`,
emailAction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/email-action-type.html`,
- emailActionConfig: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/email-action-type.html#configuring-email`,
+ emailActionConfig: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/email-action-type.html`,
generalSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-action-settings-kb.html#general-alert-action-settings`,
indexAction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index-action-type.html`,
esQuery: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/rule-type-es-query.html`,
@@ -397,6 +401,9 @@ export interface DocLinksStart {
readonly DOC_LINK_VERSION: string;
readonly ELASTIC_WEBSITE_URL: string;
readonly links: {
+ readonly canvas: {
+ readonly guide: string;
+ };
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
diff --git a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap
index d0374511515d1..801fa452e8332 100644
--- a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap
+++ b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap
@@ -6,27 +6,57 @@ exports[`#start() returns \`Context\` component 1`] = `
i18n={
Object {
"mapping": Object {
+ "euiAccordion.isLoading": "Loading",
"euiBasicTable.selectAllRows": "Select all rows",
"euiBasicTable.selectThisRow": "Select this row",
- "euiBasicTable.tableDescription": [Function],
- "euiBottomBar.screenReaderAnnouncement": "There is a new menu opening with page level controls at the end of the document.",
- "euiBreadcrumbs.collapsedBadge.ariaLabel": "Show all breadcrumbs",
+ "euiBasicTable.tableAutoCaptionWithPagination": [Function],
+ "euiBasicTable.tableAutoCaptionWithoutPagination": [Function],
+ "euiBasicTable.tableCaptionWithPagination": [Function],
+ "euiBasicTable.tablePagination": [Function],
+ "euiBasicTable.tableSimpleAutoCaptionWithPagination": [Function],
+ "euiBottomBar.customScreenReaderAnnouncement": [Function],
+ "euiBottomBar.screenReaderAnnouncement": "There is a new region landmark with page level controls at the end of the document.",
+ "euiBottomBar.screenReaderHeading": "Page level controls",
+ "euiBreadcrumbs.collapsedBadge.ariaLabel": "Show collapsed breadcrumbs",
"euiCardSelect.select": "Select",
"euiCardSelect.selected": "Selected",
"euiCardSelect.unavailable": "Unavailable",
"euiCodeBlock.copyButton": "Copy",
+ "euiCodeBlock.fullscreenCollapse": "Collapse",
+ "euiCodeBlock.fullscreenExpand": "Expand",
"euiCodeEditor.startEditing": "Press Enter to start editing.",
"euiCodeEditor.startInteracting": "Press Enter to start interacting with the code.",
"euiCodeEditor.stopEditing": "When you're done, press Escape to stop editing.",
"euiCodeEditor.stopInteracting": "When you're done, press Escape to stop interacting with the code.",
"euiCollapsedItemActions.allActions": "All actions",
+ "euiCollapsibleNav.closeButtonLabel": "close",
+ "euiColorPicker.alphaLabel": "Alpha channel (opacity) value",
+ "euiColorPicker.closeLabel": "Press the down key to open a popover containing color options",
+ "euiColorPicker.colorErrorMessage": "Invalid color value",
+ "euiColorPicker.colorLabel": "Color value",
+ "euiColorPicker.openLabel": "Press the escape key to close the popover",
"euiColorPicker.screenReaderAnnouncement": "A popup with a range of selectable colors opened. Tab forward to cycle through colors choices or press escape to close this popup.",
"euiColorPicker.swatchAriaLabel": [Function],
+ "euiColorPicker.transparent": "Transparent",
+ "euiColorStopThumb.buttonAriaLabel": "Press the Enter key to modify this stop. Press Escape to focus the group",
+ "euiColorStopThumb.buttonTitle": "Click to edit, drag to reposition",
"euiColorStopThumb.removeLabel": "Remove this stop",
"euiColorStopThumb.screenReaderAnnouncement": "A popup with a color stop edit form opened. Tab forward to cycle through form controls or press escape to close this popup.",
+ "euiColorStopThumb.stopErrorMessage": "Value is out of range",
+ "euiColorStopThumb.stopLabel": "Stop value",
"euiColorStops.screenReaderAnnouncement": [Function],
+ "euiColumnActions.moveLeft": "Move left",
+ "euiColumnActions.moveRight": "Move right",
+ "euiColumnActions.sort": [Function],
+ "euiColumnSelector.button": "Columns",
+ "euiColumnSelector.buttonActivePlural": [Function],
+ "euiColumnSelector.buttonActiveSingular": [Function],
"euiColumnSelector.hideAll": "Hide all",
+ "euiColumnSelector.search": "Search",
+ "euiColumnSelector.searchcolumns": "Search columns",
"euiColumnSelector.selectAll": "Show all",
+ "euiColumnSorting.button": "Sort fields",
+ "euiColumnSorting.buttonActive": "fields sorted",
"euiColumnSorting.clearAll": "Clear sorting",
"euiColumnSorting.emptySorting": "Currently no fields are sorted",
"euiColumnSorting.pickFields": "Pick fields to sort by",
@@ -39,15 +69,25 @@ exports[`#start() returns \`Context\` component 1`] = `
"euiComboBoxOptionsList.allOptionsSelected": "You've selected all available options",
"euiComboBoxOptionsList.alreadyAdded": [Function],
"euiComboBoxOptionsList.createCustomOption": [Function],
+ "euiComboBoxOptionsList.delimiterMessage": [Function],
"euiComboBoxOptionsList.loadingOptions": "Loading options",
"euiComboBoxOptionsList.noAvailableOptions": "There aren't any options available",
"euiComboBoxOptionsList.noMatchingOptions": [Function],
"euiComboBoxPill.removeSelection": [Function],
"euiCommonlyUsedTimeRanges.legend": "Commonly used",
+ "euiDataGrid.ariaLabel": [Function],
+ "euiDataGrid.ariaLabelGridPagination": [Function],
+ "euiDataGrid.ariaLabelledBy": [Function],
+ "euiDataGrid.ariaLabelledByGridPagination": "Pagination for preceding grid",
+ "euiDataGrid.fullScreenButton": "Full screen",
+ "euiDataGrid.fullScreenButtonActive": "Exit full screen",
"euiDataGrid.screenReaderNotice": "Cell contains interactive content.",
- "euiDataGridCell.expandButtonTitle": "Click or hit enter to interact with cell content",
- "euiDataGridSchema.booleanSortTextAsc": "True-False",
- "euiDataGridSchema.booleanSortTextDesc": "False-True",
+ "euiDataGridCell.column": "Column",
+ "euiDataGridCell.row": "Row",
+ "euiDataGridCellButtons.expandButtonTitle": "Click or hit enter to interact with cell content",
+ "euiDataGridHeaderCell.headerActions": "Header actions",
+ "euiDataGridSchema.booleanSortTextAsc": "False-True",
+ "euiDataGridSchema.booleanSortTextDesc": "True-False",
"euiDataGridSchema.currencySortTextAsc": "Low-High",
"euiDataGridSchema.currencySortTextDesc": "High-Low",
"euiDataGridSchema.dateSortTextAsc": "New-Old",
@@ -56,22 +96,56 @@ exports[`#start() returns \`Context\` component 1`] = `
"euiDataGridSchema.jsonSortTextDesc": "Large-Small",
"euiDataGridSchema.numberSortTextAsc": "Low-High",
"euiDataGridSchema.numberSortTextDesc": "High-Low",
+ "euiFieldPassword.maskPassword": "Mask password",
+ "euiFieldPassword.showPassword": "Show password as plain text. Note: this will visually expose your password on the screen.",
+ "euiFilePicker.clearSelectedFiles": "Clear selected files",
+ "euiFilePicker.filesSelected": "files selected",
"euiFilterButton.filterBadge": [Function],
- "euiForm.addressFormErrors": "Please address the errors in your form.",
+ "euiFlyout.closeAriaLabel": "Close this dialog",
+ "euiForm.addressFormErrors": "Please address the highlighted errors.",
"euiFormControlLayoutClearButton.label": "Clear input",
"euiHeaderAlert.dismiss": "Dismiss",
- "euiHeaderLinks.appNavigation": "App navigation",
- "euiHeaderLinks.openNavigationMenu": "Open navigation menu",
+ "euiHeaderLinks.appNavigation": "App menu",
+ "euiHeaderLinks.openNavigationMenu": "Open menu",
"euiHue.label": "Select the HSV color mode \\"hue\\" value",
"euiImage.closeImage": [Function],
"euiImage.openImage": [Function],
"euiLink.external.ariaLabel": "External link",
+ "euiLink.newTarget.screenReaderOnlyText": "(opens in a new tab or window)",
+ "euiMarkdownEditorFooter.closeButton": "Close",
+ "euiMarkdownEditorFooter.descriptionPrefix": "This editor uses",
+ "euiMarkdownEditorFooter.descriptionSuffix": "You can also utilize these additional syntax plugins to add rich content to your text.",
+ "euiMarkdownEditorFooter.errorsTitle": "Errors",
+ "euiMarkdownEditorFooter.openUploadModal": "Open upload files modal",
+ "euiMarkdownEditorFooter.showMarkdownHelp": "Show markdown help",
+ "euiMarkdownEditorFooter.showSyntaxErrors": "Show errors",
+ "euiMarkdownEditorFooter.supportedFileTypes": [Function],
+ "euiMarkdownEditorFooter.syntaxTitle": "Syntax help",
+ "euiMarkdownEditorFooter.unsupportedFileType": "File type not supported",
+ "euiMarkdownEditorFooter.uploadingFiles": "Click to upload files",
+ "euiMarkdownEditorToolbar.editor": "Editor",
+ "euiMarkdownEditorToolbar.previewMarkdown": "Preview",
"euiModal.closeModal": "Closes this modal window",
- "euiPagination.jumpToLastPage": [Function],
- "euiPagination.nextPage": "Next page",
- "euiPagination.pageOfTotal": [Function],
- "euiPagination.previousPage": "Previous page",
+ "euiNotificationEventMessages.accordionAriaLabelButtonText": [Function],
+ "euiNotificationEventMessages.accordionButtonText": [Function],
+ "euiNotificationEventMessages.accordionHideText": "hide",
+ "euiNotificationEventMeta.contextMenuButton": [Function],
+ "euiNotificationEventReadButton.markAsRead": "Mark as read",
+ "euiNotificationEventReadButton.markAsReadAria": [Function],
+ "euiNotificationEventReadButton.markAsUnread": "Mark as unread",
+ "euiNotificationEventReadButton.markAsUnreadAria": [Function],
+ "euiPagination.disabledNextPage": "Next page",
+ "euiPagination.disabledPreviousPage": "Previous page",
+ "euiPagination.firstRangeAriaLabel": [Function],
+ "euiPagination.lastRangeAriaLabel": [Function],
+ "euiPagination.nextPage": [Function],
+ "euiPagination.previousPage": [Function],
+ "euiPaginationButton.longPageString": [Function],
+ "euiPaginationButton.shortPageString": [Function],
+ "euiPinnableListGroup.pinExtraActionLabel": "Pin item",
+ "euiPinnableListGroup.pinnedExtraActionLabel": "Unpin item",
"euiPopover.screenReaderAnnouncement": "You are in a dialog. To close this dialog, hit escape.",
+ "euiProgress.valueText": [Function],
"euiQuickSelect.applyButton": "Apply",
"euiQuickSelect.fullDescription": [Function],
"euiQuickSelect.legendText": "Quick select a time range",
@@ -81,27 +155,54 @@ exports[`#start() returns \`Context\` component 1`] = `
"euiQuickSelect.tenseLabel": "Time tense",
"euiQuickSelect.unitLabel": "Time unit",
"euiQuickSelect.valueLabel": "Time value",
+ "euiRecentlyUsed.legend": "Recently used date ranges",
"euiRefreshInterval.fullDescription": [Function],
"euiRefreshInterval.legend": "Refresh every",
"euiRefreshInterval.start": "Start",
"euiRefreshInterval.stop": "Stop",
"euiRelativeTab.fullDescription": [Function],
+ "euiRelativeTab.numberInputError": "Must be >= 0",
+ "euiRelativeTab.numberInputLabel": "Time span amount",
"euiRelativeTab.relativeDate": [Function],
"euiRelativeTab.roundingLabel": [Function],
"euiRelativeTab.unitInputLabel": "Relative time span",
+ "euiResizableButton.horizontalResizerAriaLabel": "Press left or right to adjust panels size",
+ "euiResizableButton.verticalResizerAriaLabel": "Press up or down to adjust panels size",
+ "euiResizablePanel.toggleButtonAriaLabel": "Press to toggle this panel",
"euiSaturation.roleDescription": "HSV color mode saturation and value selection",
"euiSaturation.screenReaderAnnouncement": "Use the arrow keys to navigate the square color gradient. The coordinates resulting from each key press will be used to calculate HSV color mode \\"saturation\\" and \\"value\\" numbers, in the range of 0 to 1. Left and right decrease and increase (respectively) the \\"saturation\\" value. Up and down decrease and increase (respectively) the \\"value\\" value.",
"euiSelectable.loadingOptions": "Loading options",
"euiSelectable.noAvailableOptions": "There aren't any options available",
"euiSelectable.noMatchingOptions": [Function],
+ "euiSelectable.placeholderName": "Filter options",
+ "euiSelectableListItem.excludedOption": "Excluded option.",
+ "euiSelectableListItem.excludedOptionInstructions": "To deselect this option, press enter",
+ "euiSelectableListItem.includedOption": "Included option.",
+ "euiSelectableListItem.includedOptionInstructions": "To exclude this option, press enter.",
+ "euiSelectableTemplateSitewide.loadingResults": "Loading results",
+ "euiSelectableTemplateSitewide.noResults": "No results available",
+ "euiSelectableTemplateSitewide.onFocusBadgeGoTo": "Go to",
+ "euiSelectableTemplateSitewide.searchPlaceholder": "Search for anything...",
"euiStat.loadingText": "Statistic is loading",
- "euiStep.ariaLabel": [Function],
- "euiStepHorizontal.buttonTitle": [Function],
- "euiStepHorizontal.step": "Step",
- "euiStepNumber.hasErrors": "has errors",
- "euiStepNumber.hasWarnings": "has warnings",
- "euiStepNumber.isComplete": "complete",
+ "euiStepStrings.complete": [Function],
+ "euiStepStrings.disabled": [Function],
+ "euiStepStrings.errors": [Function],
+ "euiStepStrings.incomplete": [Function],
+ "euiStepStrings.loading": [Function],
+ "euiStepStrings.simpleComplete": [Function],
+ "euiStepStrings.simpleDisabled": [Function],
+ "euiStepStrings.simpleErrors": [Function],
+ "euiStepStrings.simpleIncomplete": [Function],
+ "euiStepStrings.simpleLoading": [Function],
+ "euiStepStrings.simpleStep": [Function],
+ "euiStepStrings.simpleWarning": [Function],
+ "euiStepStrings.step": [Function],
+ "euiStepStrings.warning": [Function],
+ "euiStyleSelector.buttonLegend": "Select the display density for the data grid",
"euiStyleSelector.buttonText": "Density",
+ "euiStyleSelector.labelCompact": "Compact density",
+ "euiStyleSelector.labelExpanded": "Expanded density",
+ "euiStyleSelector.labelNormal": "Normal density",
"euiSuperDatePicker.showDatesButtonLabel": "Show dates",
"euiSuperSelect.screenReaderAnnouncement": [Function],
"euiSuperSelectControl.selectAnOption": [Function],
@@ -110,12 +211,23 @@ exports[`#start() returns \`Context\` component 1`] = `
"euiSuperUpdateButton.refreshButtonLabel": "Refresh",
"euiSuperUpdateButton.updateButtonLabel": "Update",
"euiSuperUpdateButton.updatingButtonLabel": "Updating",
+ "euiTableHeaderCell.clickForAscending": "Click to sort in ascending order",
+ "euiTableHeaderCell.clickForDescending": "Click to sort in descending order",
+ "euiTableHeaderCell.clickForUnsort": "Click to unsort",
+ "euiTableHeaderCell.titleTextWithSort": [Function],
"euiTablePagination.rowsPerPage": "Rows per page",
"euiTablePagination.rowsPerPageOption": [Function],
"euiTableSortMobile.sorting": "Sorting",
"euiToast.dismissToast": "Dismiss toast",
"euiToast.newNotification": "A new notification appears",
"euiToast.notification": "Notification",
+ "euiTour.closeTour": "Close tour",
+ "euiTour.endTour": "End tour",
+ "euiTour.skipTour": "Skip tour",
+ "euiTourStepIndicator.ariaLabel": [Function],
+ "euiTourStepIndicator.isActive": "active",
+ "euiTourStepIndicator.isComplete": "complete",
+ "euiTourStepIndicator.isIncomplete": "incomplete",
"euiTreeView.ariaLabel": [Function],
"euiTreeView.listNavigationInstructions": "You can quickly navigate this list using arrow keys.",
},
diff --git a/src/core/public/i18n/i18n_eui_mapping.tsx b/src/core/public/i18n/i18n_eui_mapping.tsx
index 1ef033289e542..1cccc4d94a78d 100644
--- a/src/core/public/i18n/i18n_eui_mapping.tsx
+++ b/src/core/public/i18n/i18n_eui_mapping.tsx
@@ -16,6 +16,9 @@ interface EuiValues {
export const getEuiContextMapping = () => {
const euiContextMapping = {
+ 'euiAccordion.isLoading': i18n.translate('core.euiAccordion.isLoading', {
+ defaultMessage: 'Loading',
+ }),
'euiBasicTable.selectAllRows': i18n.translate('core.euiBasicTable.selectAllRows', {
defaultMessage: 'Select all rows',
description: 'ARIA and displayed label on a checkbox to select all table rows',
@@ -24,25 +27,71 @@ export const getEuiContextMapping = () => {
defaultMessage: 'Select this row',
description: 'ARIA and displayed label on a checkbox to select a single table row',
}),
- 'euiBasicTable.tableDescription': ({ itemCount }: EuiValues) =>
- i18n.translate('core.euiBasicTable.tableDescription', {
- defaultMessage: 'Below is a table of {itemCount} items.',
+ 'euiBasicTable.tableCaptionWithPagination': ({ tableCaption, page, pageCount }: EuiValues) =>
+ i18n.translate('core.euiBasicTable.tableCaptionWithPagination', {
+ defaultMessage: '{tableCaption}; Page {page} of {pageCount}.',
+ values: { tableCaption, page, pageCount },
+ description: 'Screen reader text to describe the size of a paginated table',
+ }),
+ 'euiBasicTable.tableAutoCaptionWithPagination': ({
+ itemCount,
+ totalItemCount,
+ page,
+ pageCount,
+ }: EuiValues) =>
+ i18n.translate('core.euiBasicTable.tableDescriptionWithoutPagination', {
+ defaultMessage:
+ 'This table contains {itemCount} rows out of {totalItemCount} rows; Page {page} of {pageCount}.',
+ values: { itemCount, totalItemCount, page, pageCount },
+ description: 'Screen reader text to describe the size of a paginated table',
+ }),
+ 'euiBasicTable.tableSimpleAutoCaptionWithPagination': ({
+ itemCount,
+ page,
+ pageCount,
+ }: EuiValues) =>
+ i18n.translate('core.euiBasicTable.tableSimpleAutoCaptionWithPagination', {
+ defaultMessage: 'This table contains {itemCount} rows; Page {page} of {pageCount}.',
+ values: { itemCount, page, pageCount },
+ description: 'Screen reader text to describe the size of a paginated table',
+ }),
+ 'euiBasicTable.tableAutoCaptionWithoutPagination': ({ itemCount }: EuiValues) =>
+ i18n.translate('core.euiBasicTable.tableAutoCaptionWithoutPagination', {
+ defaultMessage: 'This table contains {itemCount} rows.',
values: { itemCount },
description: 'Screen reader text to describe the size of a table',
}),
+ 'euiBasicTable.tablePagination': ({ tableCaption }: EuiValues) =>
+ i18n.translate('core.euiBasicTable.tablePagination', {
+ defaultMessage: 'Pagination for preceding table: {tableCaption}',
+ values: { tableCaption },
+ description: 'Screen reader text to describe the pagination controls',
+ }),
+ 'euiBottomBar.customScreenReaderAnnouncement': ({ landmarkHeading }: EuiValues) =>
+ i18n.translate('core.euiBottomBar.customScreenReaderAnnouncement', {
+ defaultMessage:
+ 'There is a new region landmark called {landmarkHeading} with page level controls at the end of the document.',
+ values: { landmarkHeading },
+ description:
+ 'Screen reader announcement that functionality is available in the page document',
+ }),
'euiBottomBar.screenReaderAnnouncement': i18n.translate(
'core.euiBottomBar.screenReaderAnnouncement',
{
defaultMessage:
- 'There is a new menu opening with page level controls at the end of the document.',
+ 'There is a new region landmark with page level controls at the end of the document.',
description:
'Screen reader announcement that functionality is available in the page document',
}
),
+ 'euiBottomBar.screenReaderHeading': i18n.translate('core.euiBottomBar.screenReaderHeading', {
+ defaultMessage: 'Page level controls',
+ description: 'Screen reader announcement about heading controls',
+ }),
'euiBreadcrumbs.collapsedBadge.ariaLabel': i18n.translate(
'core.euiBreadcrumbs.collapsedBadge.ariaLabel',
{
- defaultMessage: 'Show all breadcrumbs',
+ defaultMessage: 'Show collapsed breadcrumbs',
description: 'Displayed when one or more breadcrumbs are hidden.',
}
),
@@ -62,17 +111,29 @@ export const getEuiContextMapping = () => {
defaultMessage: 'Copy',
description: 'ARIA label for a button that copies source code text to the clipboard',
}),
+ 'euiCodeBlock.fullscreenCollapse': i18n.translate('core.euiCodeBlock.fullscreenCollapse', {
+ defaultMessage: 'Collapse',
+ description: 'ARIA label for a button that exits fullscreen view',
+ }),
+ 'euiCodeBlock.fullscreenExpand': i18n.translate('core.euiCodeBlock.fullscreenExpand', {
+ defaultMessage: 'Expand',
+ description: 'ARIA label for a button that enters fullscreen view',
+ }),
'euiCodeEditor.startEditing': i18n.translate('core.euiCodeEditor.startEditing', {
defaultMessage: 'Press Enter to start editing.',
+ description: 'Screen reader text to prompt editing',
}),
'euiCodeEditor.startInteracting': i18n.translate('core.euiCodeEditor.startInteracting', {
defaultMessage: 'Press Enter to start interacting with the code.',
+ description: 'Screen reader text to prompt interaction',
}),
'euiCodeEditor.stopEditing': i18n.translate('core.euiCodeEditor.stopEditing', {
defaultMessage: "When you're done, press Escape to stop editing.",
+ description: 'Screen reader text to describe ending editing',
}),
'euiCodeEditor.stopInteracting': i18n.translate('core.euiCodeEditor.stopInteracting', {
defaultMessage: "When you're done, press Escape to stop interacting with the code.",
+ description: 'Screen reader text to describe ending interactions',
}),
'euiCollapsedItemActions.allActions': i18n.translate(
'core.euiCollapsedItemActions.allActions',
@@ -82,6 +143,12 @@ export const getEuiContextMapping = () => {
'ARIA label and tooltip content describing a button that expands an actions menu',
}
),
+ 'euiCollapsibleNav.closeButtonLabel': i18n.translate(
+ 'core.euiCollapsibleNav.closeButtonLabel',
+ {
+ defaultMessage: 'close',
+ }
+ ),
'euiColorPicker.screenReaderAnnouncement': i18n.translate(
'core.euiColorPicker.screenReaderAnnouncement',
{
@@ -98,6 +165,27 @@ export const getEuiContextMapping = () => {
description:
'Screen reader text to describe the action and hex value of the selectable option',
}),
+ 'euiColorPicker.alphaLabel': i18n.translate('core.euiColorPicker.alphaLabel', {
+ defaultMessage: 'Alpha channel (opacity) value',
+ description: 'Label describing color alpha channel',
+ }),
+ 'euiColorPicker.colorLabel': i18n.translate('core.euiColorPicker.colorLabel', {
+ defaultMessage: 'Color value',
+ }),
+ 'euiColorPicker.colorErrorMessage': i18n.translate('core.euiColorPicker.colorErrorMessage', {
+ defaultMessage: 'Invalid color value',
+ }),
+ 'euiColorPicker.transparent': i18n.translate('core.euiColorPicker.transparent', {
+ defaultMessage: 'Transparent',
+ }),
+ 'euiColorPicker.openLabel': i18n.translate('core.euiColorPicker.openLabel', {
+ defaultMessage: 'Press the escape key to close the popover',
+ description: 'Screen reader text to describe how to close the picker',
+ }),
+ 'euiColorPicker.closeLabel': i18n.translate('core.euiColorPicker.closeLabel', {
+ defaultMessage: 'Press the down key to open a popover containing color options',
+ description: 'Screen reader text to describe how to open the picker',
+ }),
'euiColorStopThumb.removeLabel': i18n.translate('core.euiColorStopThumb.removeLabel', {
defaultMessage: 'Remove this stop',
description: 'Label accompanying a button whose action will remove the color stop',
@@ -111,6 +199,23 @@ export const getEuiContextMapping = () => {
'Message when the color picker popover has opened for an individual color stop thumb.',
}
),
+ 'euiColorStopThumb.buttonAriaLabel': i18n.translate('core.euiColorStopThumb.buttonAriaLabel', {
+ defaultMessage: 'Press the Enter key to modify this stop. Press Escape to focus the group',
+ description: 'Screen reader text to describe picker interaction',
+ }),
+ 'euiColorStopThumb.buttonTitle': i18n.translate('core.euiColorStopThumb.buttonTitle', {
+ defaultMessage: 'Click to edit, drag to reposition',
+ description: 'Screen reader text to describe button interaction',
+ }),
+ 'euiColorStopThumb.stopLabel': i18n.translate('core.euiColorStopThumb.stopLabel', {
+ defaultMessage: 'Stop value',
+ }),
+ 'euiColorStopThumb.stopErrorMessage': i18n.translate(
+ 'core.euiColorStopThumb.stopErrorMessage',
+ {
+ defaultMessage: 'Value is out of range',
+ }
+ ),
'euiColorStops.screenReaderAnnouncement': ({ label, readOnly, disabled }: EuiValues) =>
i18n.translate('core.euiColorStops.screenReaderAnnouncement', {
defaultMessage:
@@ -119,12 +224,42 @@ export const getEuiContextMapping = () => {
description:
'Screen reader text to describe the composite behavior of the color stops component.',
}),
+ 'euiColumnActions.sort': ({ schemaLabel }: EuiValues) =>
+ i18n.translate('core.euiColumnActions.sort', {
+ defaultMessage: 'Sort {schemaLabel}',
+ values: { schemaLabel },
+ }),
+ 'euiColumnActions.moveLeft': i18n.translate('core.euiColumnActions.moveLeft', {
+ defaultMessage: 'Move left',
+ }),
+ 'euiColumnActions.moveRight': i18n.translate('core.euiColumnActions.moveRight', {
+ defaultMessage: 'Move right',
+ }),
'euiColumnSelector.hideAll': i18n.translate('core.euiColumnSelector.hideAll', {
defaultMessage: 'Hide all',
}),
'euiColumnSelector.selectAll': i18n.translate('core.euiColumnSelector.selectAll', {
defaultMessage: 'Show all',
}),
+ 'euiColumnSelector.button': i18n.translate('core.euiColumnSelector.button', {
+ defaultMessage: 'Columns',
+ }),
+ 'euiColumnSelector.search': i18n.translate('core.euiColumnSelector.search', {
+ defaultMessage: 'Search',
+ }),
+ 'euiColumnSelector.searchcolumns': i18n.translate('core.euiColumnSelector.searchcolumns', {
+ defaultMessage: 'Search columns',
+ }),
+ 'euiColumnSelector.buttonActiveSingular': ({ numberOfHiddenFields }: EuiValues) =>
+ i18n.translate('core.euiColumnSelector.buttonActiveSingular', {
+ defaultMessage: '{numberOfHiddenFields} column hidden',
+ values: { numberOfHiddenFields },
+ }),
+ 'euiColumnSelector.buttonActivePlural': ({ numberOfHiddenFields }: EuiValues) =>
+ i18n.translate('core.euiColumnSelector.buttonActivePlural', {
+ defaultMessage: '{numberOfHiddenFields} columns hidden',
+ values: { numberOfHiddenFields },
+ }),
'euiColumnSorting.clearAll': i18n.translate('core.euiColumnSorting.clearAll', {
defaultMessage: 'Clear sorting',
}),
@@ -140,6 +275,12 @@ export const getEuiContextMapping = () => {
defaultMessage: 'Sort by:',
}
),
+ 'euiColumnSorting.button': i18n.translate('core.euiColumnSorting.button', {
+ defaultMessage: 'Sort fields',
+ }),
+ 'euiColumnSorting.buttonActive': i18n.translate('core.euiColumnSorting.buttonActive', {
+ defaultMessage: 'fields sorted',
+ }),
'euiColumnSortingDraggable.activeSortLabel': i18n.translate(
'core.euiColumnSortingDraggable.activeSortLabel',
{
@@ -185,11 +326,11 @@ export const getEuiContextMapping = () => {
values={{ label }}
/>
),
- 'euiComboBoxOptionsList.createCustomOption': ({ key, searchValue }: EuiValues) => (
+ 'euiComboBoxOptionsList.createCustomOption': ({ searchValue }: EuiValues) => (
),
'euiComboBoxOptionsList.loadingOptions': i18n.translate(
@@ -212,6 +353,12 @@ export const getEuiContextMapping = () => {
values={{ searchValue }}
/>
),
+ 'euiComboBoxOptionsList.delimiterMessage': ({ delimiter }: EuiValues) =>
+ i18n.translate('core.euiComboBoxOptionsList.delimiterMessage', {
+ defaultMessage: 'Add each item separated by {delimiter}',
+ values: { delimiter },
+ description: 'Screen reader text describing adding delimited options',
+ }),
'euiComboBoxPill.removeSelection': ({ children }: EuiValues) =>
i18n.translate('core.euiComboBoxPill.removeSelection', {
defaultMessage: 'Remove {children} from selection in this group',
@@ -224,20 +371,69 @@ export const getEuiContextMapping = () => {
'euiDataGrid.screenReaderNotice': i18n.translate('core.euiDataGrid.screenReaderNotice', {
defaultMessage: 'Cell contains interactive content.',
}),
- 'euiDataGridCell.expandButtonTitle': i18n.translate('core.euiDataGridCell.expandButtonTitle', {
- defaultMessage: 'Click or hit enter to interact with cell content',
+ 'euiDataGrid.ariaLabelGridPagination': ({ label }: EuiValues) =>
+ i18n.translate('core.euiDataGrid.ariaLabelGridPagination', {
+ defaultMessage: 'Pagination for preceding grid: {label}',
+ values: { label },
+ description: 'Screen reader text to describe the pagination controls',
+ }),
+ 'euiDataGrid.ariaLabelledByGridPagination': i18n.translate(
+ 'core.euiDataGrid.ariaLabelledByGridPagination',
+ {
+ defaultMessage: 'Pagination for preceding grid',
+ description: 'Screen reader text to describe the pagination controls',
+ }
+ ),
+ 'euiDataGrid.ariaLabel': ({ label, page, pageCount }: EuiValues) =>
+ i18n.translate('core.euiDataGrid.ariaLabel', {
+ defaultMessage: '{label}; Page {page} of {pageCount}.',
+ values: { label, page, pageCount },
+ description: 'Screen reader text to describe the size of the data grid',
+ }),
+ 'euiDataGrid.ariaLabelledBy': ({ page, pageCount }: EuiValues) =>
+ i18n.translate('core.euiDataGrid.ariaLabelledBy', {
+ defaultMessage: 'Page {page} of {pageCount}.',
+ values: { page, pageCount },
+ description: 'Screen reader text to describe the size of the data grid',
+ }),
+ 'euiDataGrid.fullScreenButton': i18n.translate('core.euiDataGrid.fullScreenButton', {
+ defaultMessage: 'Full screen',
}),
+ 'euiDataGrid.fullScreenButtonActive': i18n.translate(
+ 'core.euiDataGrid.fullScreenButtonActive',
+ {
+ defaultMessage: 'Exit full screen',
+ }
+ ),
+ 'euiDataGridCell.row': i18n.translate('core.euiDataGridCell.row', {
+ defaultMessage: 'Row',
+ }),
+ 'euiDataGridCell.column': i18n.translate('core.euiDataGridCell.column', {
+ defaultMessage: 'Column',
+ }),
+ 'euiDataGridCellButtons.expandButtonTitle': i18n.translate(
+ 'core.euiDataGridCellButtons.expandButtonTitle',
+ {
+ defaultMessage: 'Click or hit enter to interact with cell content',
+ }
+ ),
+ 'euiDataGridHeaderCell.headerActions': i18n.translate(
+ 'core.euiDataGridHeaderCell.headerActions',
+ {
+ defaultMessage: 'Header actions',
+ }
+ ),
'euiDataGridSchema.booleanSortTextAsc': i18n.translate(
'core.euiDataGridSchema.booleanSortTextAsc',
{
- defaultMessage: 'True-False',
+ defaultMessage: 'False-True',
description: 'Ascending boolean label',
}
),
'euiDataGridSchema.booleanSortTextDesc': i18n.translate(
'core.euiDataGridSchema.booleanSortTextDesc',
{
- defaultMessage: 'False-True',
+ defaultMessage: 'True-False',
description: 'Descending boolean label',
}
),
@@ -291,13 +487,29 @@ export const getEuiContextMapping = () => {
description: 'Descending size label',
}
),
+ 'euiFieldPassword.showPassword': i18n.translate('core.euiFieldPassword.showPassword', {
+ defaultMessage:
+ 'Show password as plain text. Note: this will visually expose your password on the screen.',
+ }),
+ 'euiFieldPassword.maskPassword': i18n.translate('core.euiFieldPassword.maskPassword', {
+ defaultMessage: 'Mask password',
+ }),
+ 'euiFilePicker.clearSelectedFiles': i18n.translate('core.euiFilePicker.clearSelectedFiles', {
+ defaultMessage: 'Clear selected files',
+ }),
+ 'euiFilePicker.filesSelected': i18n.translate('core.euiFilePicker.filesSelected', {
+ defaultMessage: 'files selected',
+ }),
'euiFilterButton.filterBadge': ({ count, hasActiveFilters }: EuiValues) =>
i18n.translate('core.euiFilterButton.filterBadge', {
defaultMessage: '${count} ${filterCountLabel} filters',
values: { count, filterCountLabel: hasActiveFilters ? 'active' : 'available' },
}),
+ 'euiFlyout.closeAriaLabel': i18n.translate('core.euiFlyout.closeAriaLabel', {
+ defaultMessage: 'Close this dialog',
+ }),
'euiForm.addressFormErrors': i18n.translate('core.euiForm.addressFormErrors', {
- defaultMessage: 'Please address the errors in your form.',
+ defaultMessage: 'Please address the highlighted errors.',
}),
'euiFormControlLayoutClearButton.label': i18n.translate(
'core.euiFormControlLayoutClearButton.label',
@@ -311,11 +523,11 @@ export const getEuiContextMapping = () => {
description: 'ARIA label on a button that dismisses/removes a notification',
}),
'euiHeaderLinks.appNavigation': i18n.translate('core.euiHeaderLinks.appNavigation', {
- defaultMessage: 'App navigation',
+ defaultMessage: 'App menu',
description: 'ARIA label on a `nav` element',
}),
'euiHeaderLinks.openNavigationMenu': i18n.translate('core.euiHeaderLinks.openNavigationMenu', {
- defaultMessage: 'Open navigation menu',
+ defaultMessage: 'Open menu',
}),
'euiHue.label': i18n.translate('core.euiHue.label', {
defaultMessage: 'Select the HSV color mode "hue" value',
@@ -333,31 +545,200 @@ export const getEuiContextMapping = () => {
'euiLink.external.ariaLabel': i18n.translate('core.euiLink.external.ariaLabel', {
defaultMessage: 'External link',
}),
+ 'euiLink.newTarget.screenReaderOnlyText': i18n.translate(
+ 'core.euiLink.newTarget.screenReaderOnlyText',
+ {
+ defaultMessage: '(opens in a new tab or window)',
+ }
+ ),
+ 'euiMarkdownEditorFooter.closeButton': i18n.translate(
+ 'core.euiMarkdownEditorFooter.closeButton',
+ {
+ defaultMessage: 'Close',
+ }
+ ),
+ 'euiMarkdownEditorFooter.uploadingFiles': i18n.translate(
+ 'core.euiMarkdownEditorFooter.uploadingFiles',
+ {
+ defaultMessage: 'Click to upload files',
+ }
+ ),
+ 'euiMarkdownEditorFooter.openUploadModal': i18n.translate(
+ 'core.euiMarkdownEditorFooter.openUploadModal',
+ {
+ defaultMessage: 'Open upload files modal',
+ }
+ ),
+ 'euiMarkdownEditorFooter.unsupportedFileType': i18n.translate(
+ 'core.euiMarkdownEditorFooter.unsupportedFileType',
+ {
+ defaultMessage: 'File type not supported',
+ }
+ ),
+ 'euiMarkdownEditorFooter.supportedFileTypes': ({ supportedFileTypes }: EuiValues) =>
+ i18n.translate('core.euiMarkdownEditorFooter.supportedFileTypes', {
+ defaultMessage: 'Supported files: {supportedFileTypes}',
+ values: { supportedFileTypes },
+ }),
+ 'euiMarkdownEditorFooter.showSyntaxErrors': i18n.translate(
+ 'core.euiMarkdownEditorFooter.showSyntaxErrors',
+ {
+ defaultMessage: 'Show errors',
+ }
+ ),
+ 'euiMarkdownEditorFooter.showMarkdownHelp': i18n.translate(
+ 'core.euiMarkdownEditorFooter.showMarkdownHelp',
+ {
+ defaultMessage: 'Show markdown help',
+ }
+ ),
+ 'euiMarkdownEditorFooter.errorsTitle': i18n.translate(
+ 'core.euiMarkdownEditorFooter.errorsTitle',
+ {
+ defaultMessage: 'Errors',
+ }
+ ),
+ 'euiMarkdownEditorFooter.syntaxTitle': i18n.translate(
+ 'core.euiMarkdownEditorFooter.syntaxTitle',
+ {
+ defaultMessage: 'Syntax help',
+ }
+ ),
+ 'euiMarkdownEditorFooter.descriptionPrefix': i18n.translate(
+ 'core.euiMarkdownEditorFooter.descriptionPrefix',
+ {
+ defaultMessage: 'This editor uses',
+ }
+ ),
+ 'euiMarkdownEditorFooter.descriptionSuffix': i18n.translate(
+ 'core.euiMarkdownEditorFooter.descriptionSuffix',
+ {
+ defaultMessage:
+ 'You can also utilize these additional syntax plugins to add rich content to your text.',
+ }
+ ),
+ 'euiMarkdownEditorToolbar.editor': i18n.translate('core.euiMarkdownEditorToolbar.editor', {
+ defaultMessage: 'Editor',
+ }),
+ 'euiMarkdownEditorToolbar.previewMarkdown': i18n.translate(
+ 'core.euiMarkdownEditorToolbar.previewMarkdown',
+ {
+ defaultMessage: 'Preview',
+ }
+ ),
'euiModal.closeModal': i18n.translate('core.euiModal.closeModal', {
defaultMessage: 'Closes this modal window',
}),
- 'euiPagination.jumpToLastPage': ({ pageCount }: EuiValues) =>
- i18n.translate('core.euiPagination.jumpToLastPage', {
- defaultMessage: 'Jump to the last page, number {pageCount}',
- values: { pageCount },
+ 'euiNotificationEventMessages.accordionButtonText': ({
+ messagesLength,
+ eventName,
+ }: EuiValues) =>
+ i18n.translate('core.euiNotificationEventMessages.accordionButtonText', {
+ defaultMessage: '+ {messagesLength} messages for {eventName}',
+ values: { messagesLength, eventName },
+ }),
+ 'euiNotificationEventMessages.accordionAriaLabelButtonText': ({ messagesLength }: EuiValues) =>
+ i18n.translate('core.euiNotificationEventMessages.accordionAriaLabelButtonText', {
+ defaultMessage: '+ {messagesLength} more',
+ values: { messagesLength },
+ }),
+ 'euiNotificationEventMeta.contextMenuButton': ({ eventName }: EuiValues) =>
+ i18n.translate('core.euiNotificationEventMeta.contextMenuButton', {
+ defaultMessage: 'Menu for {eventName}',
+ values: { eventName },
+ }),
+ 'euiNotificationEventReadButton.markAsReadAria': ({ eventName }: EuiValues) =>
+ i18n.translate('core.euiNotificationEventReadButton.markAsReadAria', {
+ defaultMessage: 'Mark {eventName} as read',
+ values: { eventName },
+ }),
+ 'euiNotificationEventReadButton.markAsUnreadAria': ({ eventName }: EuiValues) =>
+ i18n.translate('core.euiNotificationEventReadButton.markAsUnreadAria', {
+ defaultMessage: 'Mark {eventName} as unread',
+ values: { eventName },
+ }),
+ 'euiNotificationEventReadButton.markAsRead': i18n.translate(
+ 'core.euiNotificationEventReadButton.markAsRead',
+ {
+ defaultMessage: 'Mark as read',
+ }
+ ),
+ 'euiNotificationEventReadButton.markAsUnread': i18n.translate(
+ 'core.euiNotificationEventReadButton.markAsUnread',
+ {
+ defaultMessage: 'Mark as unread',
+ }
+ ),
+ 'euiNotificationEventMessages.accordionHideText': i18n.translate(
+ 'core.euiNotificationEventMessages.accordionHideText',
+ {
+ defaultMessage: 'hide',
+ }
+ ),
+ 'euiPagination.nextPage': ({ page }: EuiValues) =>
+ i18n.translate('core.euiPagination.nextPage', {
+ defaultMessage: 'Next page, {page}',
+ values: { page },
}),
- 'euiPagination.nextPage': i18n.translate('core.euiPagination.nextPage', {
+ 'euiPagination.previousPage': ({ page }: EuiValues) =>
+ i18n.translate('core.euiPagination.previousPage', {
+ defaultMessage: 'Previous page, {page}',
+ values: { page },
+ }),
+ 'euiPagination.disabledPreviousPage': i18n.translate(
+ 'core.euiPagination.disabledPreviousPage',
+ {
+ defaultMessage: 'Previous page',
+ }
+ ),
+ 'euiPagination.disabledNextPage': i18n.translate('core.euiPagination.disabledNextPage', {
defaultMessage: 'Next page',
}),
- 'euiPagination.pageOfTotal': ({ page, total }: EuiValues) =>
- i18n.translate('core.euiPagination.pageOfTotal', {
- defaultMessage: 'Page {page} of {total}',
- values: { page, total },
+ 'euiPagination.firstRangeAriaLabel': ({ lastPage }: EuiValues) =>
+ i18n.translate('core.euiPagination.firstRangeAriaLabel', {
+ defaultMessage: 'Skipping pages 2 to {lastPage}',
+ values: { lastPage },
}),
- 'euiPagination.previousPage': i18n.translate('core.euiPagination.previousPage', {
- defaultMessage: 'Previous page',
- }),
+ 'euiPagination.lastRangeAriaLabel': ({ firstPage, lastPage }: EuiValues) =>
+ i18n.translate('core.euiPagination.lastRangeAriaLabel', {
+ defaultMessage: 'Skipping pages {firstPage} to {lastPage}',
+ values: { firstPage, lastPage },
+ }),
+ 'euiPaginationButton.longPageString': ({ page, totalPages }: EuiValues) =>
+ i18n.translate('core.euiPaginationButton.longPageString', {
+ defaultMessage: 'Page {page} of {totalPages}',
+ values: { page, totalPages },
+ description: 'Text to describe the size of a paginated section',
+ }),
+ 'euiPaginationButton.shortPageString': ({ page }: EuiValues) =>
+ i18n.translate('core.euiPaginationButton.shortPageString', {
+ defaultMessage: 'Page {page}',
+ values: { page },
+ description: 'Text to describe the current page of a paginated section',
+ }),
+ 'euiPinnableListGroup.pinExtraActionLabel': i18n.translate(
+ 'core.euiPinnableListGroup.pinExtraActionLabel',
+ {
+ defaultMessage: 'Pin item',
+ }
+ ),
+ 'euiPinnableListGroup.pinnedExtraActionLabel': i18n.translate(
+ 'core.euiPinnableListGroup.pinnedExtraActionLabel',
+ {
+ defaultMessage: 'Unpin item',
+ }
+ ),
'euiPopover.screenReaderAnnouncement': i18n.translate(
'core.euiPopover.screenReaderAnnouncement',
{
defaultMessage: 'You are in a dialog. To close this dialog, hit escape.',
}
),
+ 'euiProgress.valueText': ({ value }: EuiValues) =>
+ i18n.translate('core.euiProgress.valueText', {
+ defaultMessage: '{value}%',
+ values: { value },
+ }),
'euiQuickSelect.applyButton': i18n.translate('core.euiQuickSelect.applyButton', {
defaultMessage: 'Apply',
}),
@@ -387,9 +768,12 @@ export const getEuiContextMapping = () => {
'euiQuickSelect.valueLabel': i18n.translate('core.euiQuickSelect.valueLabel', {
defaultMessage: 'Time value',
}),
+ 'euiRecentlyUsed.legend': i18n.translate('core.euiRecentlyUsed.legend', {
+ defaultMessage: 'Recently used date ranges',
+ }),
'euiRefreshInterval.fullDescription': ({ optionValue, optionText }: EuiValues) =>
i18n.translate('core.euiRefreshInterval.fullDescription', {
- defaultMessage: 'Currently set to {optionValue} {optionText}.',
+ defaultMessage: 'Refresh interval currently set to {optionValue} {optionText}.',
values: { optionValue, optionText },
}),
'euiRefreshInterval.legend': i18n.translate('core.euiRefreshInterval.legend', {
@@ -419,6 +803,30 @@ export const getEuiContextMapping = () => {
'euiRelativeTab.unitInputLabel': i18n.translate('core.euiRelativeTab.unitInputLabel', {
defaultMessage: 'Relative time span',
}),
+ 'euiRelativeTab.numberInputError': i18n.translate('core.euiRelativeTab.numberInputError', {
+ defaultMessage: 'Must be >= 0',
+ }),
+ 'euiRelativeTab.numberInputLabel': i18n.translate('core.euiRelativeTab.numberInputLabel', {
+ defaultMessage: 'Time span amount',
+ }),
+ 'euiResizableButton.horizontalResizerAriaLabel': i18n.translate(
+ 'core.euiResizableButton.horizontalResizerAriaLabel',
+ {
+ defaultMessage: 'Press left or right to adjust panels size',
+ }
+ ),
+ 'euiResizableButton.verticalResizerAriaLabel': i18n.translate(
+ 'core.euiResizableButton.verticalResizerAriaLabel',
+ {
+ defaultMessage: 'Press up or down to adjust panels size',
+ }
+ ),
+ 'euiResizablePanel.toggleButtonAriaLabel': i18n.translate(
+ 'core.euiResizablePanel.toggleButtonAriaLabel',
+ {
+ defaultMessage: 'Press to toggle this panel',
+ }
+ ),
'euiSaturation.roleDescription': i18n.translate('core.euiSaturation.roleDescription', {
defaultMessage: 'HSV color mode saturation and value selection',
}),
@@ -443,46 +851,145 @@ export const getEuiContextMapping = () => {
values={{ searchValue }}
/>
),
+ 'euiSelectable.placeholderName': i18n.translate('core.euiSelectable.placeholderName', {
+ defaultMessage: 'Filter options',
+ }),
+ 'euiSelectableListItem.includedOption': i18n.translate(
+ 'core.euiSelectableListItem.includedOption',
+ {
+ defaultMessage: 'Included option.',
+ }
+ ),
+ 'euiSelectableListItem.includedOptionInstructions': i18n.translate(
+ 'core.euiSelectableListItem.includedOptionInstructions',
+ {
+ defaultMessage: 'To exclude this option, press enter.',
+ }
+ ),
+ 'euiSelectableListItem.excludedOption': i18n.translate(
+ 'core.euiSelectableListItem.excludedOption',
+ {
+ defaultMessage: 'Excluded option.',
+ }
+ ),
+ 'euiSelectableListItem.excludedOptionInstructions': i18n.translate(
+ 'core.euiSelectableListItem.excludedOptionInstructions',
+ {
+ defaultMessage: 'To deselect this option, press enter',
+ }
+ ),
+ 'euiSelectableTemplateSitewide.loadingResults': i18n.translate(
+ 'core.euiSelectableTemplateSitewide.loadingResults',
+ {
+ defaultMessage: 'Loading results',
+ }
+ ),
+ 'euiSelectableTemplateSitewide.noResults': i18n.translate(
+ 'core.euiSelectableTemplateSitewide.noResults',
+ {
+ defaultMessage: 'No results available',
+ }
+ ),
+ 'euiSelectableTemplateSitewide.onFocusBadgeGoTo': i18n.translate(
+ 'core.euiSelectableTemplateSitewide.onFocusBadgeGoTo',
+ {
+ defaultMessage: 'Go to',
+ }
+ ),
+ 'euiSelectableTemplateSitewide.searchPlaceholder': i18n.translate(
+ 'core.euiSelectableTemplateSitewide.searchPlaceholder',
+ {
+ defaultMessage: 'Search for anything...',
+ }
+ ),
'euiStat.loadingText': i18n.translate('core.euiStat.loadingText', {
defaultMessage: 'Statistic is loading',
}),
- 'euiStep.ariaLabel': ({ status }: EuiValues) =>
- i18n.translate('core.euiStep.ariaLabel', {
- defaultMessage: '{stepStatus}',
- values: { stepStatus: status === 'incomplete' ? 'Incomplete Step' : 'Step' },
- }),
- 'euiStepHorizontal.buttonTitle': ({ step, title, disabled, isComplete }: EuiValues) => {
- return i18n.translate('core.euiStepHorizontal.buttonTitle', {
- defaultMessage: 'Step {step}: {title}{titleAppendix}',
- values: {
- step,
- title,
- titleAppendix: disabled ? ' is disabled' : isComplete ? ' is complete' : '',
- },
- });
- },
- 'euiStepHorizontal.step': i18n.translate('core.euiStepHorizontal.step', {
- defaultMessage: 'Step',
- description: 'Screen reader text announcing information about a step in some process',
- }),
- 'euiStepNumber.hasErrors': i18n.translate('core.euiStepNumber.hasErrors', {
- defaultMessage: 'has errors',
- description:
- 'Used as the title attribute on an image or svg icon to indicate a given process step has errors',
- }),
- 'euiStepNumber.hasWarnings': i18n.translate('core.euiStepNumber.hasWarnings', {
- defaultMessage: 'has warnings',
- description:
- 'Used as the title attribute on an image or svg icon to indicate a given process step has warnings',
- }),
- 'euiStepNumber.isComplete': i18n.translate('core.euiStepNumber.isComplete', {
- defaultMessage: 'complete',
- description:
- 'Used as the title attribute on an image or svg icon to indicate a given process step is complete',
- }),
+ 'euiStepStrings.step': ({ number, title }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.step', {
+ defaultMessage: 'Step {number}: {title}',
+ values: { number, title },
+ }),
+ 'euiStepStrings.simpleStep': ({ number }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.simpleStep', {
+ defaultMessage: 'Step {number}',
+ values: { number },
+ }),
+ 'euiStepStrings.complete': ({ number, title }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.complete', {
+ defaultMessage: 'Step {number}: {title} is complete',
+ values: { number, title },
+ }),
+ 'euiStepStrings.simpleComplete': ({ number }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.simpleComplete', {
+ defaultMessage: 'Step {number} is complete',
+ values: { number },
+ }),
+ 'euiStepStrings.warning': ({ number, title }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.warning', {
+ defaultMessage: 'Step {number}: {title} has warnings',
+ values: { number, title },
+ }),
+ 'euiStepStrings.simpleWarning': ({ number }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.simpleWarning', {
+ defaultMessage: 'Step {number} has warnings',
+ values: { number },
+ }),
+ 'euiStepStrings.errors': ({ number, title }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.errors', {
+ defaultMessage: 'Step {number}: {title} has errors',
+ values: { number, title },
+ }),
+ 'euiStepStrings.simpleErrors': ({ number }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.simpleErrors', {
+ defaultMessage: 'Step {number} has errors',
+ values: { number },
+ }),
+ 'euiStepStrings.incomplete': ({ number, title }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.incomplete', {
+ defaultMessage: 'Step {number}: {title} is incomplete',
+ values: { number, title },
+ }),
+ 'euiStepStrings.simpleIncomplete': ({ number }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.simpleIncomplete', {
+ defaultMessage: 'Step {number} is incomplete',
+ values: { number },
+ }),
+ 'euiStepStrings.disabled': ({ number, title }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.disabled', {
+ defaultMessage: 'Step {number}: {title} is disabled',
+ values: { number, title },
+ }),
+ 'euiStepStrings.simpleDisabled': ({ number }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.simpleDisabled', {
+ defaultMessage: 'Step {number} is disabled',
+ values: { number },
+ }),
+ 'euiStepStrings.loading': ({ number, title }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.loading', {
+ defaultMessage: 'Step {number}: {title} is loading',
+ values: { number, title },
+ }),
+ 'euiStepStrings.simpleLoading': ({ number }: EuiValues) =>
+ i18n.translate('core.euiStepStrings.simpleLoading', {
+ defaultMessage: 'Step {number} is loading',
+ values: { number },
+ }),
'euiStyleSelector.buttonText': i18n.translate('core.euiStyleSelector.buttonText', {
defaultMessage: 'Density',
}),
+ 'euiStyleSelector.buttonLegend': i18n.translate('core.euiStyleSelector.buttonLegend', {
+ defaultMessage: 'Select the display density for the data grid',
+ }),
+ 'euiStyleSelector.labelExpanded': i18n.translate('core.euiStyleSelector.labelExpanded', {
+ defaultMessage: 'Expanded density',
+ }),
+ 'euiStyleSelector.labelNormal': i18n.translate('core.euiStyleSelector.labelNormal', {
+ defaultMessage: 'Normal density',
+ }),
+ 'euiStyleSelector.labelCompact': i18n.translate('core.euiStyleSelector.labelCompact', {
+ defaultMessage: 'Compact density',
+ }),
'euiSuperDatePicker.showDatesButtonLabel': i18n.translate(
'core.euiSuperDatePicker.showDatesButtonLabel',
{
@@ -536,6 +1043,30 @@ export const getEuiContextMapping = () => {
description: 'Displayed in a button that updates based on date picked',
}
),
+ 'euiTableHeaderCell.clickForAscending': i18n.translate(
+ 'core.euiTableHeaderCell.clickForAscending',
+ {
+ defaultMessage: 'Click to sort in ascending order',
+ description: 'Displayed in a button that toggles a table sorting',
+ }
+ ),
+ 'euiTableHeaderCell.clickForDescending': i18n.translate(
+ 'core.euiTableHeaderCell.clickForDescending',
+ {
+ defaultMessage: 'Click to sort in descending order',
+ description: 'Displayed in a button that toggles a table sorting',
+ }
+ ),
+ 'euiTableHeaderCell.clickForUnsort': i18n.translate('core.euiTableHeaderCell.clickForUnsort', {
+ defaultMessage: 'Click to unsort',
+ description: 'Displayed in a button that toggles a table sorting',
+ }),
+ 'euiTableHeaderCell.titleTextWithSort': ({ innerText, ariaSortValue }: EuiValues) =>
+ i18n.translate('core.euiTableHeaderCell.titleTextWithSort', {
+ defaultMessage: '{innerText}; Sorted in {ariaSortValue} order',
+ values: { innerText, ariaSortValue },
+ description: 'Text describing the table sort order',
+ }),
'euiTablePagination.rowsPerPage': i18n.translate('core.euiTablePagination.rowsPerPage', {
defaultMessage: 'Rows per page',
description: 'Displayed in a button that toggles a table pagination menu',
@@ -560,6 +1091,33 @@ export const getEuiContextMapping = () => {
defaultMessage: 'Notification',
description: 'ARIA label on an element containing a notification',
}),
+ 'euiTour.endTour': i18n.translate('core.euiTour.endTour', {
+ defaultMessage: 'End tour',
+ }),
+ 'euiTour.skipTour': i18n.translate('core.euiTour.skipTour', {
+ defaultMessage: 'Skip tour',
+ }),
+ 'euiTour.closeTour': i18n.translate('core.euiTour.closeTour', {
+ defaultMessage: 'Close tour',
+ }),
+ 'euiTourStepIndicator.isActive': i18n.translate('core.euiTourStepIndicator.isActive', {
+ defaultMessage: 'active',
+ description: 'Text for an active tour step',
+ }),
+ 'euiTourStepIndicator.isComplete': i18n.translate('core.euiTourStepIndicator.isComplete', {
+ defaultMessage: 'complete',
+ description: 'Text for a completed tour step',
+ }),
+ 'euiTourStepIndicator.isIncomplete': i18n.translate('core.euiTourStepIndicator.isIncomplete', {
+ defaultMessage: 'incomplete',
+ description: 'Text for an incomplete tour step',
+ }),
+ 'euiTourStepIndicator.ariaLabel': ({ status, number }: EuiValues) =>
+ i18n.translate('core.euiTourStepIndicator.ariaLabel', {
+ defaultMessage: 'Step {number} {status}',
+ values: { status, number },
+ description: 'Screen reader text describing the state of a tour step',
+ }),
'euiTreeView.ariaLabel': ({ nodeLabel, ariaLabel }: EuiValues) =>
i18n.translate('core.euiTreeView.ariaLabel', {
defaultMessage: '{nodeLabel} child of {ariaLabel}',
diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md
index 8c1753c2cabab..b3ded52a98171 100644
--- a/src/core/public/public.api.md
+++ b/src/core/public/public.api.md
@@ -490,6 +490,9 @@ export interface DocLinksStart {
readonly ELASTIC_WEBSITE_URL: string;
// (undocumented)
readonly links: {
+ readonly canvas: {
+ readonly guide: string;
+ };
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
@@ -1224,7 +1227,7 @@ export class SavedObjectsClient {
// Warning: (ae-forgotten-export) The symbol "SavedObjectsClientContract" needs to be exported by the entry point index.d.ts
delete: (type: string, id: string, options?: SavedObjectsDeleteOptions | undefined) => ReturnType;
// Warning: (ae-forgotten-export) The symbol "SavedObjectsFindOptions" needs to be exported by the entry point index.d.ts
- find: (options: SavedObjectsFindOptions_2) => Promise>;
+ find: (options: SavedObjectsFindOptions_2) => Promise>;
get: (type: string, id: string) => Promise>;
update(type: string, id: string, attributes: T, { version, migrationVersion, references }?: SavedObjectsUpdateOptions): Promise>;
}
@@ -1244,6 +1247,8 @@ export interface SavedObjectsCreateOptions {
// @public (undocumented)
export interface SavedObjectsFindOptions {
+ // @alpha
+ aggs?: Record;
defaultSearchOperator?: 'AND' | 'OR';
fields?: string[];
// Warning: (ae-forgotten-export) The symbol "KueryNode" needs to be exported by the entry point index.d.ts
@@ -1284,7 +1289,9 @@ export interface SavedObjectsFindOptionsReference {
}
// @public
-export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse {
+export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse {
+ // (undocumented)
+ aggregations?: A;
// (undocumented)
page: number;
// (undocumented)
diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts
index 44466025de7e3..782ffa6897048 100644
--- a/src/core/public/saved_objects/saved_objects_client.ts
+++ b/src/core/public/saved_objects/saved_objects_client.ts
@@ -103,7 +103,9 @@ export interface SavedObjectsDeleteOptions {
*
* @public
*/
-export interface SavedObjectsFindResponsePublic extends SavedObjectsBatchResponse {
+export interface SavedObjectsFindResponsePublic
+ extends SavedObjectsBatchResponse {
+ aggregations?: A;
total: number;
perPage: number;
page: number;
@@ -310,7 +312,7 @@ export class SavedObjectsClient {
* @property {object} [options.hasReference] - { type, id }
* @returns A find result with objects matching the specified search.
*/
- public find = (
+ public find = (
options: SavedObjectsFindOptions
): Promise> => {
const path = this.getPath(['_find']);
@@ -326,6 +328,7 @@ export class SavedObjectsClient {
sortField: 'sort_field',
type: 'type',
filter: 'filter',
+ aggs: 'aggs',
namespaces: 'namespaces',
preference: 'preference',
};
@@ -342,6 +345,12 @@ export class SavedObjectsClient {
query.has_reference = JSON.stringify(query.has_reference);
}
+ // `aggs` is a structured object. we need to stringify it before sending it, as `fetch`
+ // is not doing it implicitly.
+ if (query.aggs) {
+ query.aggs = JSON.stringify(query.aggs);
+ }
+
const request: ReturnType = this.savedObjectsFetch(path, {
method: 'GET',
query,
@@ -349,6 +358,7 @@ export class SavedObjectsClient {
return request.then((resp) => {
return renameKeys(
{
+ aggregations: 'aggregations',
saved_objects: 'savedObjects',
total: 'total',
per_page: 'perPage',
diff --git a/src/core/server/core_app/core_app.ts b/src/core/server/core_app/core_app.ts
index bc1098832bac5..e728cb0b82475 100644
--- a/src/core/server/core_app/core_app.ts
+++ b/src/core/server/core_app/core_app.ts
@@ -65,7 +65,7 @@ export class CoreApp {
async (context, req, res) => {
const { query, params } = req;
const { path } = params;
- if (!path || !path.endsWith('/')) {
+ if (!path || !path.endsWith('/') || path.startsWith('/')) {
return res.notFound();
}
diff --git a/src/core/server/core_app/integration_tests/core_app_routes.test.ts b/src/core/server/core_app/integration_tests/core_app_routes.test.ts
index 6b0643f7d1bc7..faa1c905afa9d 100644
--- a/src/core/server/core_app/integration_tests/core_app_routes.test.ts
+++ b/src/core/server/core_app/integration_tests/core_app_routes.test.ts
@@ -39,6 +39,10 @@ describe('Core app routes', () => {
expect(response.get('location')).toEqual('/base-path/some-path?foo=bar');
});
+ it('does not redirect if the path starts with `//`', async () => {
+ await kbnTestServer.request.get(root, '//some-path/').expect(404);
+ });
+
it('does not redirect if the path does not end with `/`', async () => {
await kbnTestServer.request.get(root, '/some-path').expect(404);
});
diff --git a/src/core/server/core_usage_data/core_usage_data_service.mock.ts b/src/core/server/core_usage_data/core_usage_data_service.mock.ts
index 8ed627cebec7e..e09f595747c30 100644
--- a/src/core/server/core_usage_data/core_usage_data_service.mock.ts
+++ b/src/core/server/core_usage_data/core_usage_data_service.mock.ts
@@ -95,6 +95,13 @@ const createStartContractMock = () => {
supportedProtocols: ['TLSv1.1', 'TLSv1.2'],
truststoreConfigured: false,
},
+ securityResponseHeaders: {
+ strictTransportSecurity: 'NULL', // `null` values are coalesced to `"NULL"` strings
+ xContentTypeOptions: 'nosniff',
+ referrerPolicy: 'no-referrer-when-downgrade',
+ permissionsPolicyConfigured: false,
+ disableEmbedding: false,
+ },
xsrf: {
disableProtection: false,
allowlistConfigured: false,
@@ -132,6 +139,7 @@ const createStartContractMock = () => {
},
})
),
+ getConfigsUsageData: jest.fn(),
};
return startContract;
diff --git a/src/core/server/core_usage_data/core_usage_data_service.test.ts b/src/core/server/core_usage_data/core_usage_data_service.test.ts
index 1c28eca1f1dec..dc74b65c8dcfc 100644
--- a/src/core/server/core_usage_data/core_usage_data_service.test.ts
+++ b/src/core/server/core_usage_data/core_usage_data_service.test.ts
@@ -35,7 +35,35 @@ describe('CoreUsageDataService', () => {
});
let service: CoreUsageDataService;
- const configService = configServiceMock.create();
+ const mockConfig = {
+ unused_config: {},
+ elasticsearch: { username: 'kibana_system', password: 'changeme' },
+ plugins: { paths: ['pluginA', 'pluginAB', 'pluginB'] },
+ server: { port: 5603, basePath: '/zvt', rewriteBasePath: true },
+ logging: { json: false },
+ pluginA: {
+ enabled: true,
+ objectConfig: {
+ debug: true,
+ username: 'some_user',
+ },
+ arrayOfNumbers: [1, 2, 3],
+ },
+ pluginAB: {
+ enabled: false,
+ },
+ pluginB: {
+ arrayOfObjects: [
+ { propA: 'a', propB: 'b' },
+ { propA: 'a2', propB: 'b2' },
+ ],
+ },
+ };
+
+ const configService = configServiceMock.create({
+ getConfig$: mockConfig,
+ });
+
configService.atPath.mockImplementation((path) => {
if (path === 'elasticsearch') {
return new BehaviorSubject(RawElasticsearchConfig.schema.validate({}));
@@ -146,6 +174,7 @@ describe('CoreUsageDataService', () => {
const { getCoreUsageData } = service.start({
savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage: new Map(),
elasticsearch,
});
expect(getCoreUsageData()).resolves.toMatchInlineSnapshot(`
@@ -187,6 +216,13 @@ describe('CoreUsageDataService', () => {
"ipAllowlistConfigured": false,
},
"rewriteBasePath": false,
+ "securityResponseHeaders": Object {
+ "disableEmbedding": false,
+ "permissionsPolicyConfigured": false,
+ "referrerPolicy": "no-referrer-when-downgrade",
+ "strictTransportSecurity": "NULL",
+ "xContentTypeOptions": "nosniff",
+ },
"socketTimeout": 120000,
"ssl": Object {
"certificateAuthoritiesConfigured": false,
@@ -274,6 +310,453 @@ describe('CoreUsageDataService', () => {
`);
});
});
+
+ describe('getConfigsUsageData', () => {
+ const elasticsearch = elasticsearchServiceMock.createStart();
+ const typeRegistry = savedObjectsServiceMock.createTypeRegistryMock();
+ let exposedConfigsToUsage: Map>;
+ beforeEach(() => {
+ exposedConfigsToUsage = new Map();
+ });
+
+ it('loops over all used configs once each', async () => {
+ configService.getUsedPaths.mockResolvedValue([
+ 'pluginA.objectConfig.debug',
+ 'logging.json',
+ ]);
+
+ exposedConfigsToUsage.set('pluginA', {
+ objectConfig: true,
+ });
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ const mockGetMarkedAsSafe = jest.fn().mockReturnValue({});
+ // @ts-expect-error
+ service.getMarkedAsSafe = mockGetMarkedAsSafe;
+ await getConfigsUsageData();
+
+ expect(mockGetMarkedAsSafe).toBeCalledTimes(2);
+ expect(mockGetMarkedAsSafe.mock.calls).toMatchInlineSnapshot(`
+ Array [
+ Array [
+ Map {
+ "pluginA" => Object {
+ "objectConfig": true,
+ },
+ },
+ "pluginA.objectConfig.debug",
+ "pluginA",
+ ],
+ Array [
+ Map {
+ "pluginA" => Object {
+ "objectConfig": true,
+ },
+ },
+ "logging.json",
+ undefined,
+ ],
+ ]
+ `);
+ });
+
+ it('plucks pluginId from config path correctly', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ enabled: false,
+ });
+ exposedConfigsToUsage.set('pluginAB', {
+ enabled: false,
+ });
+
+ configService.getUsedPaths.mockResolvedValue(['pluginA.enabled', 'pluginAB.enabled']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.enabled": "[redacted]",
+ "pluginAB.enabled": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns an object of plugin config usage', async () => {
+ exposedConfigsToUsage.set('unused_config', { never_reported: true });
+ exposedConfigsToUsage.set('server', { basePath: true });
+ exposedConfigsToUsage.set('pluginA', { elasticsearch: false });
+ exposedConfigsToUsage.set('plugins', { paths: false });
+ exposedConfigsToUsage.set('pluginA', { arrayOfNumbers: false });
+
+ configService.getUsedPaths.mockResolvedValue([
+ 'elasticsearch.username',
+ 'elasticsearch.password',
+ 'plugins.paths',
+ 'server.port',
+ 'server.basePath',
+ 'server.rewriteBasePath',
+ 'logging.json',
+ 'pluginA.enabled',
+ 'pluginA.objectConfig.debug',
+ 'pluginA.objectConfig.username',
+ 'pluginA.arrayOfNumbers',
+ 'pluginAB.enabled',
+ 'pluginB.arrayOfObjects',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "elasticsearch.password": "[redacted]",
+ "elasticsearch.username": "[redacted]",
+ "logging.json": false,
+ "pluginA.arrayOfNumbers": "[redacted]",
+ "pluginA.enabled": true,
+ "pluginA.objectConfig.debug": true,
+ "pluginA.objectConfig.username": "[redacted]",
+ "pluginAB.enabled": false,
+ "pluginB.arrayOfObjects": "[redacted]",
+ "plugins.paths": "[redacted]",
+ "server.basePath": "/zvt",
+ "server.port": 5603,
+ "server.rewriteBasePath": true,
+ }
+ `);
+ });
+
+ describe('config explicitly exposed to usage', () => {
+ it('returns [redacted] on unsafe complete match', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ 'objectConfig.debug': false,
+ });
+ exposedConfigsToUsage.set('server', {
+ basePath: false,
+ });
+
+ configService.getUsedPaths.mockResolvedValue([
+ 'pluginA.objectConfig.debug',
+ 'server.basePath',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.objectConfig.debug": "[redacted]",
+ "server.basePath": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns config value on safe complete match', async () => {
+ exposedConfigsToUsage.set('server', {
+ basePath: true,
+ });
+
+ configService.getUsedPaths.mockResolvedValue(['server.basePath']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "server.basePath": "/zvt",
+ }
+ `);
+ });
+
+ it('returns [redacted] on unsafe parent match', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ objectConfig: false,
+ });
+
+ configService.getUsedPaths.mockResolvedValue([
+ 'pluginA.objectConfig.debug',
+ 'pluginA.objectConfig.username',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.objectConfig.debug": "[redacted]",
+ "pluginA.objectConfig.username": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns config value on safe parent match', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ objectConfig: true,
+ });
+
+ configService.getUsedPaths.mockResolvedValue([
+ 'pluginA.objectConfig.debug',
+ 'pluginA.objectConfig.username',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.objectConfig.debug": true,
+ "pluginA.objectConfig.username": "some_user",
+ }
+ `);
+ });
+
+ it('returns [redacted] on explicitly marked as safe array of objects', async () => {
+ exposedConfigsToUsage.set('pluginB', {
+ arrayOfObjects: true,
+ });
+
+ configService.getUsedPaths.mockResolvedValue(['pluginB.arrayOfObjects']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginB.arrayOfObjects": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns values on explicitly marked as safe array of numbers', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ arrayOfNumbers: true,
+ });
+
+ configService.getUsedPaths.mockResolvedValue(['pluginA.arrayOfNumbers']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.arrayOfNumbers": Array [
+ 1,
+ 2,
+ 3,
+ ],
+ }
+ `);
+ });
+
+ it('returns values on explicitly marked as safe array of strings', async () => {
+ exposedConfigsToUsage.set('plugins', {
+ paths: true,
+ });
+
+ configService.getUsedPaths.mockResolvedValue(['plugins.paths']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "plugins.paths": Array [
+ "pluginA",
+ "pluginAB",
+ "pluginB",
+ ],
+ }
+ `);
+ });
+ });
+
+ describe('config not explicitly exposed to usage', () => {
+ it('returns [redacted] for string configs', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ objectConfig: false,
+ });
+
+ configService.getUsedPaths.mockResolvedValue([
+ 'pluginA.objectConfig.debug',
+ 'pluginA.objectConfig.username',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.objectConfig.debug": "[redacted]",
+ "pluginA.objectConfig.username": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns config value on safe parent match', async () => {
+ configService.getUsedPaths.mockResolvedValue([
+ 'elasticsearch.password',
+ 'elasticsearch.username',
+ 'pluginA.objectConfig.username',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "elasticsearch.password": "[redacted]",
+ "elasticsearch.username": "[redacted]",
+ "pluginA.objectConfig.username": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns [redacted] on implicit array of objects', async () => {
+ configService.getUsedPaths.mockResolvedValue(['pluginB.arrayOfObjects']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginB.arrayOfObjects": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns values on implicit array of numbers', async () => {
+ configService.getUsedPaths.mockResolvedValue(['pluginA.arrayOfNumbers']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.arrayOfNumbers": Array [
+ 1,
+ 2,
+ 3,
+ ],
+ }
+ `);
+ });
+ it('returns [redacted] on implicit array of strings', async () => {
+ configService.getUsedPaths.mockResolvedValue(['plugins.paths']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "plugins.paths": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns config value for numbers', async () => {
+ configService.getUsedPaths.mockResolvedValue(['server.port']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "server.port": 5603,
+ }
+ `);
+ });
+
+ it('returns config value for booleans', async () => {
+ configService.getUsedPaths.mockResolvedValue([
+ 'pluginA.objectConfig.debug',
+ 'logging.json',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "logging.json": false,
+ "pluginA.objectConfig.debug": true,
+ }
+ `);
+ });
+
+ it('ignores exposed to usage configs but not used', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ objectConfig: true,
+ });
+
+ configService.getUsedPaths.mockResolvedValue(['logging.json']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "logging.json": false,
+ }
+ `);
+ });
+ });
+ });
});
describe('setup and stop', () => {
diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts
index dff68bf1c524f..85abdca9ea5dc 100644
--- a/src/core/server/core_usage_data/core_usage_data_service.ts
+++ b/src/core/server/core_usage_data/core_usage_data_service.ts
@@ -7,7 +7,9 @@
*/
import { Subject } from 'rxjs';
-import { takeUntil } from 'rxjs/operators';
+import { takeUntil, first } from 'rxjs/operators';
+import { get } from 'lodash';
+import { hasConfigPathIntersection } from '@kbn/config';
import { CoreService } from 'src/core/types';
import { Logger, SavedObjectsServiceStart, SavedObjectTypeRegistry } from 'src/core/server';
@@ -16,11 +18,12 @@ import { ElasticsearchConfigType } from '../elasticsearch/elasticsearch_config';
import { HttpConfigType, InternalHttpServiceSetup } from '../http';
import { LoggingConfigType } from '../logging';
import { SavedObjectsConfigType } from '../saved_objects/saved_objects_config';
-import {
+import type {
CoreServicesUsageData,
CoreUsageData,
CoreUsageDataStart,
CoreUsageDataSetup,
+ ConfigUsageData,
} from './types';
import { isConfigured } from './is_configured';
import { ElasticsearchServiceStart } from '../elasticsearch';
@@ -30,6 +33,8 @@ import { CORE_USAGE_STATS_TYPE } from './constants';
import { CoreUsageStatsClient } from './core_usage_stats_client';
import { MetricsServiceSetup, OpsMetrics } from '..';
+export type ExposedConfigsToUsage = Map>;
+
export interface SetupDeps {
http: InternalHttpServiceSetup;
metrics: MetricsServiceSetup;
@@ -39,6 +44,7 @@ export interface SetupDeps {
export interface StartDeps {
savedObjects: SavedObjectsServiceStart;
elasticsearch: ElasticsearchServiceStart;
+ exposedConfigsToUsage: ExposedConfigsToUsage;
}
/**
@@ -225,6 +231,16 @@ export class CoreUsageDataService implements CoreService {
+ const fullPath = `${pluginId}.${exposeKey}`;
+ return hasConfigPathIntersection(usedPath, fullPath);
+ });
+
+ if (exposeKeyDetails) {
+ const explicitlyMarkedAsSafe = exposeDetails[exposeKeyDetails];
+
+ if (typeof explicitlyMarkedAsSafe === 'boolean') {
+ return {
+ explicitlyMarked: true,
+ isSafe: explicitlyMarkedAsSafe,
+ };
+ }
+ }
+ }
+
+ return { explicitlyMarked: false, isSafe: false };
+ }
+
+ private async getNonDefaultKibanaConfigs(
+ exposedConfigsToUsage: ExposedConfigsToUsage
+ ): Promise {
+ const config = await this.configService.getConfig$().pipe(first()).toPromise();
+ const nonDefaultConfigs = config.toRaw();
+ const usedPaths = await this.configService.getUsedPaths();
+ const exposedConfigsKeys = [...exposedConfigsToUsage.keys()];
+
+ return usedPaths.reduce((acc, usedPath) => {
+ const rawConfigValue = get(nonDefaultConfigs, usedPath);
+ const pluginId = exposedConfigsKeys.find(
+ (exposedConfigsKey) =>
+ usedPath === exposedConfigsKey || usedPath.startsWith(`${exposedConfigsKey}.`)
+ );
+
+ const { explicitlyMarked, isSafe } = this.getMarkedAsSafe(
+ exposedConfigsToUsage,
+ usedPath,
+ pluginId
+ );
+
+ // explicitly marked as safe
+ if (explicitlyMarked && isSafe) {
+ // report array of objects as redacted even if explicitly marked as safe.
+ // TS typings prevent explicitly marking arrays of objects as safe
+ // this makes sure to report redacted even if TS was bypassed.
+ if (
+ Array.isArray(rawConfigValue) &&
+ rawConfigValue.some((item) => typeof item === 'object')
+ ) {
+ acc[usedPath] = '[redacted]';
+ } else {
+ acc[usedPath] = rawConfigValue;
+ }
+ }
+
+ // explicitly marked as unsafe
+ if (explicitlyMarked && !isSafe) {
+ acc[usedPath] = '[redacted]';
+ }
+
+ /**
+ * not all types of values may contain sensitive values.
+ * Report boolean and number configs if not explicitly marked as unsafe.
+ */
+ if (!explicitlyMarked) {
+ switch (typeof rawConfigValue) {
+ case 'number':
+ case 'boolean':
+ acc[usedPath] = rawConfigValue;
+ break;
+ case 'undefined':
+ acc[usedPath] = 'undefined';
+ break;
+ case 'object': {
+ // non-array object types are already handled
+ if (Array.isArray(rawConfigValue)) {
+ if (
+ rawConfigValue.every(
+ (item) => typeof item === 'number' || typeof item === 'boolean'
+ )
+ ) {
+ acc[usedPath] = rawConfigValue;
+ break;
+ }
+ }
+ }
+ default: {
+ acc[usedPath] = '[redacted]';
+ }
+ }
+ }
+
+ return acc;
+ }, {} as Record);
+ }
+
setup({ http, metrics, savedObjectsStartPromise }: SetupDeps) {
metrics
.getOpsMetrics$()
@@ -316,10 +436,13 @@ export class CoreUsageDataService implements CoreService {
- return this.getCoreUsageData(savedObjects, elasticsearch);
+ getCoreUsageData: async () => {
+ return await this.getCoreUsageData(savedObjects, elasticsearch);
+ },
+ getConfigsUsageData: async () => {
+ return await this.getNonDefaultKibanaConfigs(exposedConfigsToUsage);
},
};
}
diff --git a/src/core/server/core_usage_data/index.ts b/src/core/server/core_usage_data/index.ts
index 4e0200ed1e4ea..638fc65522433 100644
--- a/src/core/server/core_usage_data/index.ts
+++ b/src/core/server/core_usage_data/index.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-export type { CoreUsageDataSetup, CoreUsageDataStart } from './types';
+export type { CoreUsageDataSetup, ConfigUsageData, CoreUsageDataStart } from './types';
export { CoreUsageDataService } from './core_usage_data_service';
export { CoreUsageStatsClient } from './core_usage_stats_client';
diff --git a/src/core/server/core_usage_data/types.ts b/src/core/server/core_usage_data/types.ts
index 46148e314bfee..1d5ef6d893f53 100644
--- a/src/core/server/core_usage_data/types.ts
+++ b/src/core/server/core_usage_data/types.ts
@@ -122,6 +122,18 @@ export interface CoreUsageData extends CoreUsageStats {
environment: CoreEnvironmentUsageData;
}
+/**
+ * Type describing Core's usage data payload
+ * @internal
+ */
+export type ConfigUsageData = Record;
+
+/**
+ * Type describing Core's usage data payload
+ * @internal
+ */
+export type ExposedConfigsToUsage = Map>;
+
/**
* Usage data from Core services
* @internal
@@ -212,6 +224,13 @@ export interface CoreConfigUsageData {
supportedProtocols: string[];
clientAuthentication: 'none' | 'optional' | 'required';
};
+ securityResponseHeaders: {
+ strictTransportSecurity: string;
+ xContentTypeOptions: string;
+ referrerPolicy: string;
+ permissionsPolicyConfigured: boolean;
+ disableEmbedding: boolean;
+ };
};
logging: {
@@ -263,4 +282,5 @@ export interface CoreUsageDataStart {
* @internal
* */
getCoreUsageData(): Promise;
+ getConfigsUsageData(): Promise;
}
diff --git a/src/core/server/csp/config.test.ts b/src/core/server/csp/config.test.ts
new file mode 100644
index 0000000000000..c7f6c4a214fac
--- /dev/null
+++ b/src/core/server/csp/config.test.ts
@@ -0,0 +1,19 @@
+/*
+ * 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 { config } from './config';
+
+describe('config.validate()', () => {
+ test(`does not allow "disableEmbedding" to be set to true`, () => {
+ // This is intentionally not editable in the raw CSP config.
+ // Users should set `server.securityResponseHeaders.disableEmbedding` to control this config property.
+ expect(() => config.schema.validate({ disableEmbedding: true })).toThrowError(
+ '[disableEmbedding.0]: expected value to equal [false]'
+ );
+ });
+});
diff --git a/src/core/server/csp/config.ts b/src/core/server/csp/config.ts
index 3fc9faa26179e..a61fa1b03a45c 100644
--- a/src/core/server/csp/config.ts
+++ b/src/core/server/csp/config.ts
@@ -27,5 +27,8 @@ export const config = {
}),
strict: schema.boolean({ defaultValue: true }),
warnLegacyBrowsers: schema.boolean({ defaultValue: true }),
+ disableEmbedding: schema.oneOf([schema.literal(false)], { defaultValue: false }),
}),
};
+
+export const FRAME_ANCESTORS_RULE = `frame-ancestors 'self'`; // only used by CspConfig when embedding is disabled
diff --git a/src/core/server/csp/csp_config.test.ts b/src/core/server/csp/csp_config.test.ts
index ed13d363c4166..1e023c6f08ea8 100644
--- a/src/core/server/csp/csp_config.test.ts
+++ b/src/core/server/csp/csp_config.test.ts
@@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
-import { CspConfig } from '.';
+import { CspConfig } from './csp_config';
+import { FRAME_ANCESTORS_RULE } from './config';
// CSP rules aren't strictly additive, so any change can potentially expand or
// restrict the policy in a way we consider a breaking change. For that reason,
@@ -25,6 +26,7 @@ describe('CspConfig', () => {
test('DEFAULT', () => {
expect(CspConfig.DEFAULT).toMatchInlineSnapshot(`
CspConfig {
+ "disableEmbedding": false,
"header": "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'",
"rules": Array [
"script-src 'unsafe-eval' 'self'",
@@ -38,49 +40,51 @@ describe('CspConfig', () => {
});
test('defaults from config', () => {
- expect(new CspConfig()).toMatchInlineSnapshot(`
- CspConfig {
- "header": "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'",
- "rules": Array [
- "script-src 'unsafe-eval' 'self'",
- "worker-src blob: 'self'",
- "style-src 'unsafe-inline' 'self'",
- ],
- "strict": true,
- "warnLegacyBrowsers": true,
- }
- `);
+ expect(new CspConfig()).toEqual(CspConfig.DEFAULT);
});
- test('creates from partial config', () => {
- expect(new CspConfig({ strict: false, warnLegacyBrowsers: false })).toMatchInlineSnapshot(`
- CspConfig {
- "header": "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'",
- "rules": Array [
- "script-src 'unsafe-eval' 'self'",
- "worker-src blob: 'self'",
- "style-src 'unsafe-inline' 'self'",
- ],
- "strict": false,
- "warnLegacyBrowsers": false,
- }
- `);
- });
+ describe('partial config', () => {
+ test('allows "rules" to be set and changes header', () => {
+ const rules = ['foo', 'bar'];
+ const config = new CspConfig({ rules });
+ expect(config.rules).toEqual(rules);
+ expect(config.header).toMatchInlineSnapshot(`"foo; bar"`);
+ });
- test('computes header from rules', () => {
- const cspConfig = new CspConfig({ rules: ['alpha', 'beta', 'gamma'] });
+ test('allows "strict" to be set', () => {
+ const config = new CspConfig({ strict: false });
+ expect(config.strict).toEqual(false);
+ expect(config.strict).not.toEqual(CspConfig.DEFAULT.strict);
+ });
- expect(cspConfig).toMatchInlineSnapshot(`
- CspConfig {
- "header": "alpha; beta; gamma",
- "rules": Array [
- "alpha",
- "beta",
- "gamma",
- ],
- "strict": true,
- "warnLegacyBrowsers": true,
- }
- `);
+ test('allows "warnLegacyBrowsers" to be set', () => {
+ const warnLegacyBrowsers = false;
+ const config = new CspConfig({ warnLegacyBrowsers });
+ expect(config.warnLegacyBrowsers).toEqual(warnLegacyBrowsers);
+ expect(config.warnLegacyBrowsers).not.toEqual(CspConfig.DEFAULT.warnLegacyBrowsers);
+ });
+
+ describe('allows "disableEmbedding" to be set', () => {
+ const disableEmbedding = true;
+
+ test('and changes rules/header if custom rules are not defined', () => {
+ const config = new CspConfig({ disableEmbedding });
+ expect(config.disableEmbedding).toEqual(disableEmbedding);
+ expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding);
+ expect(config.rules).toEqual(expect.arrayContaining([FRAME_ANCESTORS_RULE]));
+ expect(config.header).toMatchInlineSnapshot(
+ `"script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'; frame-ancestors 'self'"`
+ );
+ });
+
+ test('and does not change rules/header if custom rules are defined', () => {
+ const rules = ['foo', 'bar'];
+ const config = new CspConfig({ disableEmbedding, rules });
+ expect(config.disableEmbedding).toEqual(disableEmbedding);
+ expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding);
+ expect(config.rules).toEqual(rules);
+ expect(config.header).toMatchInlineSnapshot(`"foo; bar"`);
+ });
+ });
});
});
diff --git a/src/core/server/csp/csp_config.ts b/src/core/server/csp/csp_config.ts
index dd0e7ef2dbee4..649c81576ef52 100644
--- a/src/core/server/csp/csp_config.ts
+++ b/src/core/server/csp/csp_config.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import { config } from './config';
+import { config, FRAME_ANCESTORS_RULE } from './config';
const DEFAULT_CONFIG = Object.freeze(config.schema.validate({}));
@@ -32,6 +32,12 @@ export interface ICspConfig {
*/
readonly warnLegacyBrowsers: boolean;
+ /**
+ * Whether or not embedding (using iframes) should be allowed by the CSP. If embedding is disabled *and* no custom rules have been
+ * defined, a restrictive 'frame-ancestors' rule will be added to the default CSP rules.
+ */
+ readonly disableEmbedding: boolean;
+
/**
* The CSP rules in a formatted directives string for use
* in a `Content-Security-Policy` header.
@@ -49,6 +55,7 @@ export class CspConfig implements ICspConfig {
public readonly rules: string[];
public readonly strict: boolean;
public readonly warnLegacyBrowsers: boolean;
+ public readonly disableEmbedding: boolean;
public readonly header: string;
/**
@@ -58,9 +65,13 @@ export class CspConfig implements ICspConfig {
constructor(rawCspConfig: Partial> = {}) {
const source = { ...DEFAULT_CONFIG, ...rawCspConfig };
- this.rules = source.rules;
+ this.rules = [...source.rules];
this.strict = source.strict;
this.warnLegacyBrowsers = source.warnLegacyBrowsers;
- this.header = source.rules.join('; ');
+ this.disableEmbedding = source.disableEmbedding;
+ if (!rawCspConfig.rules?.length && source.disableEmbedding) {
+ this.rules.push(FRAME_ANCESTORS_RULE);
+ }
+ this.header = this.rules.join('; ');
}
}
diff --git a/src/core/server/environment/write_pid_file.ts b/src/core/server/environment/write_pid_file.ts
index b7d47111a4d53..46096ca347e8a 100644
--- a/src/core/server/environment/write_pid_file.ts
+++ b/src/core/server/environment/write_pid_file.ts
@@ -31,13 +31,23 @@ export const writePidFile = async ({
if (pidConfig.exclusive) {
throw new Error(message);
} else {
- logger.warn(message, { path, pid });
+ logger.warn(message, {
+ process: {
+ pid: process.pid,
+ path,
+ },
+ });
}
}
await writeFile(path, pid);
- logger.debug(`wrote pid file to ${path}`, { path, pid });
+ logger.debug(`wrote pid file to ${path}`, {
+ process: {
+ pid: process.pid,
+ path,
+ },
+ });
const clean = once(() => {
unlink(path);
diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap
index 4545396c27b5e..42710aad40ac1 100644
--- a/src/core/server/http/__snapshots__/http_config.test.ts.snap
+++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap
@@ -64,6 +64,14 @@ Object {
"ipAllowlist": Array [],
},
"rewriteBasePath": false,
+ "securityResponseHeaders": Object {
+ "disableEmbedding": false,
+ "permissionsPolicy": null,
+ "referrerPolicy": "no-referrer-when-downgrade",
+ "strictTransportSecurity": null,
+ "xContentTypeOptions": "nosniff",
+ },
+ "shutdownTimeout": "PT30S",
"socketTimeout": 120000,
"ssl": Object {
"cipherSuites": Array [
diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts
index 9868d89888110..2a140388cc184 100644
--- a/src/core/server/http/http_config.test.ts
+++ b/src/core/server/http/http_config.test.ts
@@ -108,6 +108,35 @@ test('can specify max payload as string', () => {
expect(configValue.maxPayload.getValueInBytes()).toBe(2 * 1024 * 1024);
});
+describe('shutdownTimeout', () => {
+ test('can specify a valid shutdownTimeout', () => {
+ const configValue = config.schema.validate({ shutdownTimeout: '5s' });
+ expect(configValue.shutdownTimeout.asMilliseconds()).toBe(5000);
+ });
+
+ test('can specify a valid shutdownTimeout (lower-edge of 1 second)', () => {
+ const configValue = config.schema.validate({ shutdownTimeout: '1s' });
+ expect(configValue.shutdownTimeout.asMilliseconds()).toBe(1000);
+ });
+
+ test('can specify a valid shutdownTimeout (upper-edge of 2 minutes)', () => {
+ const configValue = config.schema.validate({ shutdownTimeout: '2m' });
+ expect(configValue.shutdownTimeout.asMilliseconds()).toBe(120000);
+ });
+
+ test('should error if below 1s', () => {
+ expect(() => config.schema.validate({ shutdownTimeout: '100ms' })).toThrow(
+ '[shutdownTimeout]: the value should be between 1 second and 2 minutes'
+ );
+ });
+
+ test('should error if over 2 minutes', () => {
+ expect(() => config.schema.validate({ shutdownTimeout: '3m' })).toThrow(
+ '[shutdownTimeout]: the value should be between 1 second and 2 minutes'
+ );
+ });
+});
+
describe('basePath', () => {
test('throws if missing prepended slash', () => {
const httpSchema = config.schema;
diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts
index daf7424b8f8bd..9d0008e1c4011 100644
--- a/src/core/server/http/http_config.ts
+++ b/src/core/server/http/http_config.ts
@@ -11,9 +11,14 @@ import { IHttpConfig, SslConfig, sslSchema } from '@kbn/server-http-tools';
import { hostname } from 'os';
import url from 'url';
+import type { Duration } from 'moment';
import { ServiceConfigDescriptor } from '../internal_types';
import { CspConfigType, CspConfig, ICspConfig } from '../csp';
import { ExternalUrlConfig, IExternalUrlConfig } from '../external_url';
+import {
+ securityResponseHeadersSchema,
+ parseRawSecurityResponseHeadersConfig,
+} from './security_response_headers_config';
const validBasePathRegex = /^\/.*[^\/]$/;
const uuidRegexp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -31,6 +36,15 @@ const configSchema = schema.object(
validate: match(validBasePathRegex, "must start with a slash, don't end with one"),
})
),
+ shutdownTimeout: schema.duration({
+ defaultValue: '30s',
+ validate: (duration) => {
+ const durationMs = duration.asMilliseconds();
+ if (durationMs < 1000 || durationMs > 2 * 60 * 1000) {
+ return 'the value should be between 1 second and 2 minutes';
+ }
+ },
+ }),
cors: schema.object(
{
enabled: schema.boolean({ defaultValue: false }),
@@ -53,6 +67,7 @@ const configSchema = schema.object(
},
}
),
+ securityResponseHeaders: securityResponseHeadersSchema,
customResponseHeaders: schema.recordOf(schema.string(), schema.any(), {
defaultValue: {},
}),
@@ -171,6 +186,7 @@ export class HttpConfig implements IHttpConfig {
allowCredentials: boolean;
allowOrigin: string[];
};
+ public securityResponseHeaders: Record;
public customResponseHeaders: Record;
public maxPayload: ByteSizeValue;
public basePath?: string;
@@ -182,6 +198,7 @@ export class HttpConfig implements IHttpConfig {
public externalUrl: IExternalUrlConfig;
public xsrf: { disableProtection: boolean; allowlist: string[] };
public requestId: { allowFromAnyIp: boolean; ipAllowlist: string[] };
+ public shutdownTimeout: Duration;
/**
* @internal
@@ -195,6 +212,10 @@ export class HttpConfig implements IHttpConfig {
this.host = rawHttpConfig.host;
this.port = rawHttpConfig.port;
this.cors = rawHttpConfig.cors;
+ const { securityResponseHeaders, disableEmbedding } = parseRawSecurityResponseHeadersConfig(
+ rawHttpConfig.securityResponseHeaders
+ );
+ this.securityResponseHeaders = securityResponseHeaders;
this.customResponseHeaders = Object.entries(rawHttpConfig.customResponseHeaders ?? {}).reduce(
(headers, [key, value]) => {
return {
@@ -213,10 +234,11 @@ export class HttpConfig implements IHttpConfig {
this.rewriteBasePath = rawHttpConfig.rewriteBasePath;
this.ssl = new SslConfig(rawHttpConfig.ssl || {});
this.compression = rawHttpConfig.compression;
- this.csp = new CspConfig(rawCspConfig);
+ this.csp = new CspConfig({ ...rawCspConfig, disableEmbedding });
this.externalUrl = rawExternalUrlConfig;
this.xsrf = rawHttpConfig.xsrf;
this.requestId = rawHttpConfig.requestId;
+ this.shutdownTimeout = rawHttpConfig.shutdownTimeout;
}
}
diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts
index ccd14d4b99e11..1a82907849cea 100644
--- a/src/core/server/http/http_server.test.ts
+++ b/src/core/server/http/http_server.test.ts
@@ -26,6 +26,8 @@ import { HttpServer } from './http_server';
import { Readable } from 'stream';
import { RequestHandlerContext } from 'kibana/server';
import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils';
+import moment from 'moment';
+import { of } from 'rxjs';
const cookieOptions = {
name: 'sid',
@@ -65,6 +67,7 @@ beforeEach(() => {
cors: {
enabled: false,
},
+ shutdownTimeout: moment.duration(500, 'ms'),
} as any;
configWithSSL = {
@@ -79,7 +82,7 @@ beforeEach(() => {
},
} as HttpConfig;
- server = new HttpServer(loggingService, 'tests');
+ server = new HttpServer(loggingService, 'tests', of(config.shutdownTimeout));
});
afterEach(async () => {
@@ -1431,3 +1434,79 @@ describe('setup contract', () => {
});
});
});
+
+describe('Graceful shutdown', () => {
+ let shutdownTimeout: number;
+ let innerServerListener: Server;
+
+ beforeEach(async () => {
+ shutdownTimeout = config.shutdownTimeout.asMilliseconds();
+ const { registerRouter, server: innerServer } = await server.setup(config);
+ innerServerListener = innerServer.listener;
+
+ const router = new Router('', logger, enhanceWithContext);
+ router.post(
+ {
+ path: '/',
+ validate: false,
+ options: { body: { accepts: 'application/json' } },
+ },
+ async (context, req, res) => {
+ // It takes to resolve the same period of the shutdownTimeout.
+ // Since we'll trigger the stop a few ms after, it should have time to finish
+ await new Promise((resolve) => setTimeout(resolve, shutdownTimeout));
+ return res.ok({ body: { ok: 1 } });
+ }
+ );
+ registerRouter(router);
+
+ await server.start();
+ });
+
+ test('any ongoing requests should be resolved with `connection: close`', async () => {
+ const [response] = await Promise.all([
+ // Trigger a request that should hold the server from stopping until fulfilled
+ supertest(innerServerListener).post('/'),
+ // Stop the server while the request is in progress
+ (async () => {
+ await new Promise((resolve) => setTimeout(resolve, shutdownTimeout / 3));
+ await server.stop();
+ })(),
+ ]);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toStrictEqual({ ok: 1 });
+ // The server is about to be closed, we need to ask connections to close on their end (stop their keep-alive policies)
+ expect(response.header.connection).toBe('close');
+ });
+
+ test('any requests triggered while stopping should be rejected with 503', async () => {
+ const [, , response] = await Promise.all([
+ // Trigger a request that should hold the server from stopping until fulfilled (otherwise the server will stop straight away)
+ supertest(innerServerListener).post('/'),
+ // Stop the server while the request is in progress
+ (async () => {
+ await new Promise((resolve) => setTimeout(resolve, shutdownTimeout / 3));
+ await server.stop();
+ })(),
+ // Trigger a new request while shutting down (should be rejected)
+ (async () => {
+ await new Promise((resolve) => setTimeout(resolve, (2 * shutdownTimeout) / 3));
+ return supertest(innerServerListener).post('/');
+ })(),
+ ]);
+ expect(response.status).toBe(503);
+ expect(response.body).toStrictEqual({
+ statusCode: 503,
+ error: 'Service Unavailable',
+ message: 'Kibana is shutting down and not accepting new incoming requests',
+ });
+ expect(response.header.connection).toBe('close');
+ });
+
+ test('when no ongoing connections, the server should stop without waiting any longer', async () => {
+ const preStop = Date.now();
+ await server.stop();
+ expect(Date.now() - preStop).toBeLessThan(shutdownTimeout);
+ });
+});
diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts
index cd7d7ccc5aeff..d845ac1b639b6 100644
--- a/src/core/server/http/http_server.ts
+++ b/src/core/server/http/http_server.ts
@@ -17,6 +17,9 @@ import {
getRequestId,
} from '@kbn/server-http-tools';
+import type { Duration } from 'moment';
+import { Observable } from 'rxjs';
+import { take } from 'rxjs/operators';
import { Logger, LoggerFactory } from '../logging';
import { HttpConfig } from './http_config';
import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth';
@@ -80,6 +83,7 @@ export class HttpServer {
private authRegistered = false;
private cookieSessionStorageCreated = false;
private handleServerResponseEvent?: (req: Request) => void;
+ private stopping = false;
private stopped = false;
private readonly log: Logger;
@@ -87,7 +91,11 @@ export class HttpServer {
private readonly authRequestHeaders: AuthHeadersStorage;
private readonly authResponseHeaders: AuthHeadersStorage;
- constructor(private readonly logger: LoggerFactory, private readonly name: string) {
+ constructor(
+ private readonly logger: LoggerFactory,
+ private readonly name: string,
+ private readonly shutdownTimeout$: Observable
+ ) {
this.authState = new AuthStateStorage(() => this.authRegistered);
this.authRequestHeaders = new AuthHeadersStorage();
this.authResponseHeaders = new AuthHeadersStorage();
@@ -118,6 +126,7 @@ export class HttpServer {
this.setupConditionalCompression(config);
this.setupResponseLogging();
this.setupRequestStateAssignment(config);
+ this.setupGracefulShutdownHandlers();
return {
registerRouter: this.registerRouter.bind(this),
@@ -153,7 +162,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Http server is not setup up yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`start called after stop`);
return;
}
@@ -213,19 +222,29 @@ export class HttpServer {
}
public async stop() {
- this.stopped = true;
+ this.stopping = true;
if (this.server === undefined) {
+ this.stopping = false;
+ this.stopped = true;
return;
}
const hasStarted = this.server.info.started > 0;
if (hasStarted) {
this.log.debug('stopping http server');
+
+ const shutdownTimeout = await this.shutdownTimeout$.pipe(take(1)).toPromise();
+ await this.server.stop({ timeout: shutdownTimeout.asMilliseconds() });
+
+ this.log.debug(`http server stopped`);
+
+ // Removing the listener after stopping so we don't leave any pending requests unhandled
if (this.handleServerResponseEvent) {
this.server.events.removeListener('response', this.handleServerResponseEvent);
}
- await this.server.stop();
}
+ this.stopping = false;
+ this.stopped = true;
}
private getAuthOption(
@@ -246,6 +265,18 @@ export class HttpServer {
}
}
+ private setupGracefulShutdownHandlers() {
+ this.registerOnPreRouting((request, response, toolkit) => {
+ if (this.stopping || this.stopped) {
+ return response.customError({
+ statusCode: 503,
+ body: { message: 'Kibana is shutting down and not accepting new incoming requests' },
+ });
+ }
+ return toolkit.next();
+ });
+ }
+
private setupBasePathRewrite(config: HttpConfig, basePathService: BasePath) {
if (config.basePath === undefined || !config.rewriteBasePath) {
return;
@@ -266,7 +297,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`setupConditionalCompression called after stop`);
}
@@ -296,14 +327,14 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`setupResponseLogging called after stop`);
}
const log = this.logger.get('http', 'server', 'response');
this.handleServerResponseEvent = (request) => {
- const { message, ...meta } = getEcsResponseLog(request, this.log);
+ const { message, meta } = getEcsResponseLog(request, this.log);
log.debug(message!, meta);
};
@@ -325,7 +356,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`registerOnPreAuth called after stop`);
}
@@ -336,7 +367,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`registerOnPostAuth called after stop`);
}
@@ -347,7 +378,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`registerOnPreRouting called after stop`);
}
@@ -358,7 +389,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`registerOnPreResponse called after stop`);
}
@@ -372,7 +403,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`createCookieSessionStorageFactory called after stop`);
}
if (this.cookieSessionStorageCreated) {
@@ -392,7 +423,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`registerAuth called after stop`);
}
if (this.authRegistered) {
@@ -438,7 +469,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Http server is not setup up yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`registerStaticDir called after stop`);
}
diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts
index 5b90440f6ad70..fdf9b738a9833 100644
--- a/src/core/server/http/http_service.ts
+++ b/src/core/server/http/http_service.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import { Observable, Subscription, combineLatest } from 'rxjs';
+import { Observable, Subscription, combineLatest, of } from 'rxjs';
import { first, map } from 'rxjs/operators';
import { Server } from '@hapi/hapi';
import { pick } from '@kbn/std';
@@ -69,7 +69,8 @@ export class HttpService
configService.atPath(cspConfig.path),
configService.atPath(externalUrlConfig.path),
]).pipe(map(([http, csp, externalUrl]) => new HttpConfig(http, csp, externalUrl)));
- this.httpServer = new HttpServer(logger, 'Kibana');
+ const shutdownTimeout$ = this.config$.pipe(map(({ shutdownTimeout }) => shutdownTimeout));
+ this.httpServer = new HttpServer(logger, 'Kibana', shutdownTimeout$);
this.httpsRedirectServer = new HttpsRedirectServer(logger.get('http', 'redirect', 'server'));
}
@@ -167,7 +168,7 @@ export class HttpService
return;
}
- this.configSubscription.unsubscribe();
+ this.configSubscription?.unsubscribe();
this.configSubscription = undefined;
if (this.notReadyServer) {
@@ -179,7 +180,7 @@ export class HttpService
private async runNotReadyServer(config: HttpConfig) {
this.log.debug('starting NotReady server');
- const httpServer = new HttpServer(this.logger, 'NotReady');
+ const httpServer = new HttpServer(this.logger, 'NotReady', of(config.shutdownTimeout));
const { server } = await httpServer.setup(config);
this.notReadyServer = server;
// use hapi server while KibanaResponseFactory doesn't allow specifying custom headers
diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts
index 2f9d393b632f7..cbd300fdc9c09 100644
--- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts
+++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts
@@ -7,6 +7,7 @@
*/
import supertest from 'supertest';
+import moment from 'moment';
import { BehaviorSubject } from 'rxjs';
import { ByteSizeValue } from '@kbn/config-schema';
@@ -44,6 +45,7 @@ describe('core lifecycle handlers', () => {
return new BehaviorSubject({
hosts: ['localhost'],
maxPayload: new ByteSizeValue(1024),
+ shutdownTimeout: moment.duration(30, 'seconds'),
autoListen: true,
ssl: {
enabled: false,
@@ -53,8 +55,16 @@ describe('core lifecycle handlers', () => {
},
compression: { enabled: true },
name: kibanaName,
+ securityResponseHeaders: {
+ // reflects default config
+ strictTransportSecurity: null,
+ xContentTypeOptions: 'nosniff',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ permissionsPolicy: null,
+ },
customResponseHeaders: {
'some-header': 'some-value',
+ 'referrer-policy': 'strict-origin', // overrides a header that is defined by securityResponseHeaders
},
xsrf: { disableProtection: false, allowlist: [allowlistedTestPath] },
requestId: {
@@ -117,6 +127,13 @@ describe('core lifecycle handlers', () => {
const testRoute = '/custom_headers/test/route';
const testErrorRoute = '/custom_headers/test/error_route';
+ const expectedHeaders = {
+ [nameHeader]: kibanaName,
+ 'x-content-type-options': 'nosniff',
+ 'referrer-policy': 'strict-origin',
+ 'some-header': 'some-value',
+ };
+
beforeEach(async () => {
router.get({ path: testRoute, validate: false }, (context, req, res) => {
return res.ok({ body: 'ok' });
@@ -127,36 +144,16 @@ describe('core lifecycle handlers', () => {
await server.start();
});
- it('adds the kbn-name header', async () => {
- const result = await supertest(innerServer.listener).get(testRoute).expect(200, 'ok');
- const headers = result.header as Record;
- expect(headers).toEqual(
- expect.objectContaining({
- [nameHeader]: kibanaName,
- })
- );
- });
-
- it('adds the kbn-name header in case of error', async () => {
- const result = await supertest(innerServer.listener).get(testErrorRoute).expect(400);
- const headers = result.header as Record;
- expect(headers).toEqual(
- expect.objectContaining({
- [nameHeader]: kibanaName,
- })
- );
- });
-
- it('adds the custom headers', async () => {
+ it('adds the expected headers in case of success', async () => {
const result = await supertest(innerServer.listener).get(testRoute).expect(200, 'ok');
const headers = result.header as Record;
- expect(headers).toEqual(expect.objectContaining({ 'some-header': 'some-value' }));
+ expect(headers).toEqual(expect.objectContaining(expectedHeaders));
});
- it('adds the custom headers in case of error', async () => {
+ it('adds the expected headers in case of error', async () => {
const result = await supertest(innerServer.listener).get(testErrorRoute).expect(400);
const headers = result.header as Record;
- expect(headers).toEqual(expect.objectContaining({ 'some-header': 'some-value' }));
+ expect(headers).toEqual(expect.objectContaining(expectedHeaders));
});
});
diff --git a/src/core/server/http/lifecycle_handlers.test.ts b/src/core/server/http/lifecycle_handlers.test.ts
index cd8caa7c76ab1..e777cbb1c1ff0 100644
--- a/src/core/server/http/lifecycle_handlers.test.ts
+++ b/src/core/server/http/lifecycle_handlers.test.ts
@@ -241,12 +241,15 @@ describe('customHeaders pre-response handler', () => {
expect(toolkit.next).toHaveBeenCalledWith({ headers: { 'kbn-name': 'my-server-name' } });
});
- it('adds the custom headers defined in the configuration', () => {
+ it('adds the security headers and custom headers defined in the configuration', () => {
const config = createConfig({
name: 'my-server-name',
- customResponseHeaders: {
+ securityResponseHeaders: {
headerA: 'value-A',
- headerB: 'value-B',
+ headerB: 'value-B', // will be overridden by the custom response header below
+ },
+ customResponseHeaders: {
+ headerB: 'x',
},
});
const handler = createCustomHeadersPreResponseHandler(config as HttpConfig);
@@ -258,7 +261,7 @@ describe('customHeaders pre-response handler', () => {
headers: {
'kbn-name': 'my-server-name',
headerA: 'value-A',
- headerB: 'value-B',
+ headerB: 'x',
},
});
});
diff --git a/src/core/server/http/lifecycle_handlers.ts b/src/core/server/http/lifecycle_handlers.ts
index a1fae89b68e11..eed24c8071eaf 100644
--- a/src/core/server/http/lifecycle_handlers.ts
+++ b/src/core/server/http/lifecycle_handlers.ts
@@ -62,12 +62,12 @@ export const createVersionCheckPostAuthHandler = (kibanaVersion: string): OnPost
};
export const createCustomHeadersPreResponseHandler = (config: HttpConfig): OnPreResponseHandler => {
- const serverName = config.name;
- const customHeaders = config.customResponseHeaders;
+ const { name: serverName, securityResponseHeaders, customResponseHeaders } = config;
return (request, response, toolkit) => {
const additionalHeaders = {
- ...customHeaders,
+ ...securityResponseHeaders,
+ ...customResponseHeaders,
[KIBANA_NAME_HEADER]: serverName,
};
diff --git a/src/core/server/http/logging/get_response_log.test.ts b/src/core/server/http/logging/get_response_log.test.ts
index 64241ff44fc6b..5f749220138d7 100644
--- a/src/core/server/http/logging/get_response_log.test.ts
+++ b/src/core/server/http/logging/get_response_log.test.ts
@@ -81,7 +81,8 @@ describe('getEcsResponseLog', () => {
},
});
const result = getEcsResponseLog(req, logger);
- expect(result.http.response.responseTime).toBe(1000);
+ // @ts-expect-error ECS custom field
+ expect(result.meta.http.response.responseTime).toBe(1000);
});
test('with response.info.responded', () => {
@@ -92,14 +93,16 @@ describe('getEcsResponseLog', () => {
},
});
const result = getEcsResponseLog(req, logger);
- expect(result.http.response.responseTime).toBe(500);
+ // @ts-expect-error ECS custom field
+ expect(result.meta.http.response.responseTime).toBe(500);
});
test('excludes responseTime from message if none is provided', () => {
const req = createMockHapiRequest();
const result = getEcsResponseLog(req, logger);
expect(result.message).toMatchInlineSnapshot(`"GET /path 200 - 1.2KB"`);
- expect(result.http.response.responseTime).toBeUndefined();
+ // @ts-expect-error ECS custom field
+ expect(result.meta.http.response.responseTime).toBeUndefined();
});
});
@@ -112,7 +115,7 @@ describe('getEcsResponseLog', () => {
},
});
const result = getEcsResponseLog(req, logger);
- expect(result.url.query).toMatchInlineSnapshot(`"a=hello&b=world"`);
+ expect(result.meta.url!.query).toMatchInlineSnapshot(`"a=hello&b=world"`);
expect(result.message).toMatchInlineSnapshot(`"GET /path?a=hello&b=world 200 - 1.2KB"`);
});
@@ -121,7 +124,7 @@ describe('getEcsResponseLog', () => {
query: { a: '¡hola!' },
});
const result = getEcsResponseLog(req, logger);
- expect(result.url.query).toMatchInlineSnapshot(`"a=%C2%A1hola!"`);
+ expect(result.meta.url!.query).toMatchInlineSnapshot(`"a=%C2%A1hola!"`);
expect(result.message).toMatchInlineSnapshot(`"GET /path?a=%C2%A1hola! 200 - 1.2KB"`);
});
});
@@ -145,7 +148,7 @@ describe('getEcsResponseLog', () => {
response: Boom.badRequest(),
});
const result = getEcsResponseLog(req, logger);
- expect(result.http.response.status_code).toBe(400);
+ expect(result.meta.http!.response!.status_code).toBe(400);
});
describe('filters sensitive headers', () => {
@@ -155,14 +158,16 @@ describe('getEcsResponseLog', () => {
response: { headers: { 'content-length': 123, 'set-cookie': 'c' } },
});
const result = getEcsResponseLog(req, logger);
- expect(result.http.request.headers).toMatchInlineSnapshot(`
+ // @ts-expect-error ECS custom field
+ expect(result.meta.http.request.headers).toMatchInlineSnapshot(`
Object {
"authorization": "[REDACTED]",
"cookie": "[REDACTED]",
"user-agent": "hi",
}
`);
- expect(result.http.response.headers).toMatchInlineSnapshot(`
+ // @ts-expect-error ECS custom field
+ expect(result.meta.http.response.headers).toMatchInlineSnapshot(`
Object {
"content-length": 123,
"set-cookie": "[REDACTED]",
@@ -196,9 +201,12 @@ describe('getEcsResponseLog', () => {
}
`);
- responseLog.http.request.headers.a = 'testA';
- responseLog.http.request.headers.b[1] = 'testB';
- responseLog.http.request.headers.c = 'testC';
+ // @ts-expect-error ECS custom field
+ responseLog.meta.http.request.headers.a = 'testA';
+ // @ts-expect-error ECS custom field
+ responseLog.meta.http.request.headers.b[1] = 'testB';
+ // @ts-expect-error ECS custom field
+ responseLog.meta.http.request.headers.c = 'testC';
expect(reqHeaders).toMatchInlineSnapshot(`
Object {
"a": "foo",
@@ -244,48 +252,41 @@ describe('getEcsResponseLog', () => {
});
describe('ecs', () => {
- test('specifies correct ECS version', () => {
- const req = createMockHapiRequest();
- const result = getEcsResponseLog(req, logger);
- expect(result.ecs.version).toBe('1.7.0');
- });
-
test('provides an ECS-compatible response', () => {
const req = createMockHapiRequest();
const result = getEcsResponseLog(req, logger);
expect(result).toMatchInlineSnapshot(`
Object {
- "client": Object {
- "ip": undefined,
- },
- "ecs": Object {
- "version": "1.7.0",
- },
- "http": Object {
- "request": Object {
- "headers": Object {
- "user-agent": "",
- },
- "method": "GET",
- "mime_type": "application/json",
- "referrer": "localhost:5601/app/home",
+ "message": "GET /path 200 - 1.2KB",
+ "meta": Object {
+ "client": Object {
+ "ip": undefined,
},
- "response": Object {
- "body": Object {
- "bytes": 1234,
+ "http": Object {
+ "request": Object {
+ "headers": Object {
+ "user-agent": "",
+ },
+ "method": "GET",
+ "mime_type": "application/json",
+ "referrer": "localhost:5601/app/home",
+ },
+ "response": Object {
+ "body": Object {
+ "bytes": 1234,
+ },
+ "headers": Object {},
+ "responseTime": undefined,
+ "status_code": 200,
},
- "headers": Object {},
- "responseTime": undefined,
- "status_code": 200,
},
- },
- "message": "GET /path 200 - 1.2KB",
- "url": Object {
- "path": "/path",
- "query": "",
- },
- "user_agent": Object {
- "original": "",
+ "url": Object {
+ "path": "/path",
+ "query": "",
+ },
+ "user_agent": Object {
+ "original": "",
+ },
},
}
`);
diff --git a/src/core/server/http/logging/get_response_log.ts b/src/core/server/http/logging/get_response_log.ts
index 57c02e05bebff..37ee618e43395 100644
--- a/src/core/server/http/logging/get_response_log.ts
+++ b/src/core/server/http/logging/get_response_log.ts
@@ -11,10 +11,9 @@ import { isBoom } from '@hapi/boom';
import type { Request } from '@hapi/hapi';
import numeral from '@elastic/numeral';
import { LogMeta } from '@kbn/logging';
-import { EcsEvent, Logger } from '../../logging';
+import { Logger } from '../../logging';
import { getResponsePayloadBytes } from './get_payload_size';
-const ECS_VERSION = '1.7.0';
const FORBIDDEN_HEADERS = ['authorization', 'cookie', 'set-cookie'];
const REDACTED_HEADER_TEXT = '[REDACTED]';
@@ -44,7 +43,7 @@ function cloneAndFilterHeaders(headers?: HapiHeaders) {
*
* @internal
*/
-export function getEcsResponseLog(request: Request, log: Logger): LogMeta {
+export function getEcsResponseLog(request: Request, log: Logger) {
const { path, response } = request;
const method = request.method.toUpperCase();
@@ -66,9 +65,7 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta {
const bytes = getResponsePayloadBytes(response, log);
const bytesMsg = bytes ? ` - ${numeral(bytes).format('0.0b')}` : '';
- const meta: EcsEvent = {
- ecs: { version: ECS_VERSION },
- message: `${method} ${pathWithQuery} ${status_code}${responseTimeMsg}${bytesMsg}`,
+ const meta: LogMeta = {
client: {
ip: request.info.remoteAddress,
},
@@ -77,7 +74,7 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta {
method,
mime_type: request.mime,
referrer: request.info.referrer,
- // @ts-expect-error Headers are not yet part of ECS: https://github.com/elastic/ecs/issues/232.
+ // @ts-expect-error ECS custom field: https://github.com/elastic/ecs/issues/232.
headers: requestHeaders,
},
response: {
@@ -85,7 +82,7 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta {
bytes,
},
status_code,
- // @ts-expect-error Headers are not yet part of ECS: https://github.com/elastic/ecs/issues/232.
+ // @ts-expect-error ECS custom field: https://github.com/elastic/ecs/issues/232.
headers: responseHeaders,
// responseTime is a custom non-ECS field
responseTime: !isNaN(responseTime) ? responseTime : undefined,
@@ -100,5 +97,8 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta {
},
};
- return meta;
+ return {
+ message: `${method} ${pathWithQuery} ${status_code}${responseTimeMsg}${bytesMsg}`,
+ meta,
+ };
}
diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts
index 77b40ca5995bb..ea70f1b4f543b 100644
--- a/src/core/server/http/router/route.ts
+++ b/src/core/server/http/router/route.ts
@@ -70,7 +70,7 @@ export interface RouteConfigOptionsBody {
/**
* Limits the size of incoming payloads to the specified byte count. Allowing very large payloads may cause the server to run out of memory.
*
- * Default value: The one set in the kibana.yml config file under the parameter `server.maxPayloadBytes`.
+ * Default value: The one set in the kibana.yml config file under the parameter `server.maxPayload`.
*/
maxBytes?: number;
diff --git a/src/core/server/http/security_response_headers_config.test.ts b/src/core/server/http/security_response_headers_config.test.ts
new file mode 100644
index 0000000000000..b1c8bb23102f5
--- /dev/null
+++ b/src/core/server/http/security_response_headers_config.test.ts
@@ -0,0 +1,99 @@
+/*
+ * 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 {
+ securityResponseHeadersSchema as schema,
+ parseRawSecurityResponseHeadersConfig as parse,
+} from './security_response_headers_config';
+
+describe('parseRawSecurityResponseHeadersConfig', () => {
+ it('returns default values', () => {
+ const config = schema.validate({});
+ const result = parse(config);
+ expect(result.disableEmbedding).toBe(false);
+ expect(result.securityResponseHeaders).toMatchInlineSnapshot(`
+ Object {
+ "Referrer-Policy": "no-referrer-when-downgrade",
+ "X-Content-Type-Options": "nosniff",
+ }
+ `);
+ });
+
+ describe('strictTransportSecurity', () => {
+ it('a custom value results in the expected Strict-Transport-Security header', () => {
+ const strictTransportSecurity = 'max-age=31536000; includeSubDomains';
+ const config = schema.validate({ strictTransportSecurity });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['Strict-Transport-Security']).toEqual(
+ strictTransportSecurity
+ );
+ });
+
+ it('a null value removes the Strict-Transport-Security header', () => {
+ const config = schema.validate({ strictTransportSecurity: null });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['Strict-Transport-Security']).toBeUndefined();
+ });
+ });
+
+ describe('xContentTypeOptions', () => {
+ it('a custom value results in the expected X-Content-Type-Options header', () => {
+ const xContentTypeOptions = 'nosniff'; // there is no other valid value to test with
+ const config = schema.validate({ xContentTypeOptions });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['X-Content-Type-Options']).toEqual(xContentTypeOptions);
+ });
+
+ it('a null value removes the X-Content-Type-Options header', () => {
+ const config = schema.validate({ xContentTypeOptions: null });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['X-Content-Type-Options']).toBeUndefined();
+ });
+ });
+
+ describe('referrerPolicy', () => {
+ it('a custom value results in the expected Referrer-Policy header', () => {
+ const referrerPolicy = 'strict-origin-when-cross-origin';
+ const config = schema.validate({ referrerPolicy });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['Referrer-Policy']).toEqual(referrerPolicy);
+ });
+
+ it('a null value removes the Referrer-Policy header', () => {
+ const config = schema.validate({ referrerPolicy: null });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['Referrer-Policy']).toBeUndefined();
+ });
+ });
+
+ describe('permissionsPolicy', () => {
+ it('a custom value results in the expected Permissions-Policy header', () => {
+ const permissionsPolicy = 'display-capture=(self)';
+ const config = schema.validate({ permissionsPolicy });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['Permissions-Policy']).toEqual(permissionsPolicy);
+ });
+
+ it('a null value removes the Permissions-Policy header', () => {
+ const config = schema.validate({ permissionsPolicy: null });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['Permissions-Policy']).toBeUndefined();
+ });
+ });
+
+ describe('disableEmbedding', () => {
+ it('a true value results in the expected X-Frame-Options header and expected disableEmbedding result value', () => {
+ const config = schema.validate({ disableEmbedding: true });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['X-Frame-Options']).toMatchInlineSnapshot(
+ `"SAMEORIGIN"`
+ );
+ expect(result.disableEmbedding).toBe(true);
+ });
+ });
+});
diff --git a/src/core/server/http/security_response_headers_config.ts b/src/core/server/http/security_response_headers_config.ts
new file mode 100644
index 0000000000000..917d737d59297
--- /dev/null
+++ b/src/core/server/http/security_response_headers_config.ts
@@ -0,0 +1,72 @@
+/*
+ * 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 { schema, TypeOf } from '@kbn/config-schema';
+
+export const securityResponseHeadersSchema = schema.object({
+ strictTransportSecurity: schema.oneOf([schema.string(), schema.literal(null)], {
+ // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
+ defaultValue: null,
+ }),
+ xContentTypeOptions: schema.oneOf([schema.literal('nosniff'), schema.literal(null)], {
+ // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
+ defaultValue: 'nosniff',
+ }),
+ referrerPolicy: schema.oneOf(
+ // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
+ [
+ schema.literal('no-referrer'),
+ schema.literal('no-referrer-when-downgrade'),
+ schema.literal('origin'),
+ schema.literal('origin-when-cross-origin'),
+ schema.literal('same-origin'),
+ schema.literal('strict-origin'),
+ schema.literal('strict-origin-when-cross-origin'),
+ schema.literal('unsafe-url'),
+ schema.literal(null),
+ ],
+ { defaultValue: 'no-referrer-when-downgrade' }
+ ),
+ permissionsPolicy: schema.oneOf([schema.string(), schema.literal(null)], {
+ // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy
+ // Note: Feature-Policy is superseded by Permissions-Policy; the link above is temporary until MDN releases an updated page
+ defaultValue: null,
+ }),
+ disableEmbedding: schema.boolean({ defaultValue: false }), // is used to control X-Frame-Options and CSP headers
+});
+
+/**
+ * Parses raw security header config info, returning an object with the appropriate header keys and values.
+ *
+ * @param raw
+ * @internal
+ */
+export function parseRawSecurityResponseHeadersConfig(
+ raw: TypeOf
+) {
+ const securityResponseHeaders: Record = {};
+ const { disableEmbedding } = raw;
+
+ if (raw.strictTransportSecurity) {
+ securityResponseHeaders['Strict-Transport-Security'] = raw.strictTransportSecurity;
+ }
+ if (raw.xContentTypeOptions) {
+ securityResponseHeaders['X-Content-Type-Options'] = raw.xContentTypeOptions;
+ }
+ if (raw.referrerPolicy) {
+ securityResponseHeaders['Referrer-Policy'] = raw.referrerPolicy;
+ }
+ if (raw.permissionsPolicy) {
+ securityResponseHeaders['Permissions-Policy'] = raw.permissionsPolicy;
+ }
+ if (disableEmbedding) {
+ securityResponseHeaders['X-Frame-Options'] = 'SAMEORIGIN';
+ }
+
+ return { securityResponseHeaders, disableEmbedding };
+}
diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts
index b9b877e193fbd..b3180b43d0026 100644
--- a/src/core/server/http/test_utils.ts
+++ b/src/core/server/http/test_utils.ts
@@ -7,6 +7,7 @@
*/
import { BehaviorSubject } from 'rxjs';
+import moment from 'moment';
import { REPO_ROOT } from '@kbn/dev-utils';
import { ByteSizeValue } from '@kbn/config-schema';
import { Env } from '../config';
@@ -38,11 +39,13 @@ configService.atPath.mockImplementation((path) => {
disableProtection: true,
allowlist: [],
},
+ securityResponseHeaders: {},
customResponseHeaders: {},
requestId: {
allowFromAnyIp: true,
ipAllowlist: [],
},
+ shutdownTimeout: moment.duration(30, 'seconds'),
keepaliveTimeout: 120_000,
socketTimeout: 120_000,
} as any);
diff --git a/src/core/server/index.ts b/src/core/server/index.ts
index 2c6fa74cb54a0..9fccc4b8bc1f0 100644
--- a/src/core/server/index.ts
+++ b/src/core/server/index.ts
@@ -64,6 +64,7 @@ import {
CoreUsageStats,
CoreUsageData,
CoreConfigUsageData,
+ ConfigUsageData,
CoreEnvironmentUsageData,
CoreServicesUsageData,
} from './core_usage_data';
@@ -74,6 +75,7 @@ export type {
CoreConfigUsageData,
CoreEnvironmentUsageData,
CoreServicesUsageData,
+ ConfigUsageData,
};
export { bootstrap } from './bootstrap';
@@ -236,6 +238,11 @@ export type { IRenderOptions } from './rendering';
export type {
Logger,
LoggerFactory,
+ Ecs,
+ EcsEventCategory,
+ EcsEventKind,
+ EcsEventOutcome,
+ EcsEventType,
LogMeta,
LogRecord,
LogLevel,
@@ -256,6 +263,7 @@ export type {
PluginManifest,
PluginName,
SharedGlobalConfig,
+ MakeUsageFromSchema,
} from './plugins';
export {
diff --git a/src/core/server/kibana_config.ts b/src/core/server/kibana_config.ts
index 97783a7657db5..848c51dcb69f3 100644
--- a/src/core/server/kibana_config.ts
+++ b/src/core/server/kibana_config.ts
@@ -33,4 +33,8 @@ export const config = {
autocompleteTimeout: schema.duration({ defaultValue: 1000 }),
}),
deprecations,
+ exposeToUsage: {
+ autocompleteTerminateAfter: true,
+ autocompleteTimeout: true,
+ },
};
diff --git a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap
index 81321a3b1fe44..d74317203d78e 100644
--- a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap
+++ b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap
@@ -15,6 +15,9 @@ exports[`appends records via multiple appenders.: file logs 2`] = `
exports[`asLoggerFactory() only allows to create new loggers. 1`] = `
Object {
"@timestamp": "2012-01-30T22:33:22.011-05:00",
+ "ecs": Object {
+ "version": "1.9.0",
+ },
"log": Object {
"level": "TRACE",
"logger": "test.context",
@@ -29,6 +32,9 @@ Object {
exports[`asLoggerFactory() only allows to create new loggers. 2`] = `
Object {
"@timestamp": "2012-01-30T17:33:22.011-05:00",
+ "ecs": Object {
+ "version": "1.9.0",
+ },
"log": Object {
"level": "INFO",
"logger": "test.context",
@@ -44,6 +50,9 @@ Object {
exports[`asLoggerFactory() only allows to create new loggers. 3`] = `
Object {
"@timestamp": "2012-01-30T12:33:22.011-05:00",
+ "ecs": Object {
+ "version": "1.9.0",
+ },
"log": Object {
"level": "FATAL",
"logger": "test.context",
@@ -58,6 +67,9 @@ Object {
exports[`flushes memory buffer logger and switches to real logger once config is provided: buffered messages 1`] = `
Object {
"@timestamp": "2012-02-01T09:33:22.011-05:00",
+ "ecs": Object {
+ "version": "1.9.0",
+ },
"log": Object {
"level": "INFO",
"logger": "test.context",
@@ -73,6 +85,9 @@ Object {
exports[`flushes memory buffer logger and switches to real logger once config is provided: new messages 1`] = `
Object {
"@timestamp": "2012-01-31T23:33:22.011-05:00",
+ "ecs": Object {
+ "version": "1.9.0",
+ },
"log": Object {
"level": "INFO",
"logger": "test.context",
diff --git a/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts
index 52b88331a75be..faa026363ed40 100644
--- a/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts
+++ b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts
@@ -26,12 +26,14 @@ describe('MetaRewritePolicy', () => {
describe('mode: update', () => {
it('updates existing properties in LogMeta', () => {
+ // @ts-expect-error ECS custom meta
const log = createLogRecord({ a: 'before' });
const policy = createPolicy('update', [{ path: 'a', value: 'after' }]);
expect(policy.rewrite(log).meta!.a).toBe('after');
});
it('updates nested properties in LogMeta', () => {
+ // @ts-expect-error ECS custom meta
const log = createLogRecord({ a: 'before a', b: { c: 'before b.c' }, d: [0, 1] });
const policy = createPolicy('update', [
{ path: 'a', value: 'after a' },
@@ -60,6 +62,7 @@ describe('MetaRewritePolicy', () => {
{ path: 'd', value: 'hi' },
]);
const log = createLogRecord({
+ // @ts-expect-error ECS custom meta
a: 'a',
b: 'b',
c: 'c',
@@ -80,6 +83,7 @@ describe('MetaRewritePolicy', () => {
{ path: 'a.b', value: 'foo' },
{ path: 'a.c', value: 'bar' },
]);
+ // @ts-expect-error ECS custom meta
const log = createLogRecord({ a: { b: 'existing meta' } });
const { meta } = policy.rewrite(log);
expect(meta!.a.b).toBe('foo');
@@ -106,12 +110,14 @@ describe('MetaRewritePolicy', () => {
describe('mode: remove', () => {
it('removes existing properties in LogMeta', () => {
+ // @ts-expect-error ECS custom meta
const log = createLogRecord({ a: 'goodbye' });
const policy = createPolicy('remove', [{ path: 'a' }]);
expect(policy.rewrite(log).meta!.a).toBeUndefined();
});
it('removes nested properties in LogMeta', () => {
+ // @ts-expect-error ECS custom meta
const log = createLogRecord({ a: 'a', b: { c: 'b.c' }, d: [0, 1] });
const policy = createPolicy('remove', [{ path: 'b.c' }, { path: 'd[1]' }]);
expect(policy.rewrite(log).meta).toMatchInlineSnapshot(`
@@ -127,6 +133,7 @@ describe('MetaRewritePolicy', () => {
});
it('has no effect if property does not exist', () => {
+ // @ts-expect-error ECS custom meta
const log = createLogRecord({ a: 'a' });
const policy = createPolicy('remove', [{ path: 'b' }]);
expect(policy.rewrite(log).meta).toMatchInlineSnapshot(`
diff --git a/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts
index 72a54b5012ce5..f4ce64ee65075 100644
--- a/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts
+++ b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts
@@ -85,8 +85,8 @@ describe('RewriteAppender', () => {
const appender = new RewriteAppender(config);
appenderMocks.forEach((mock) => appender.addAppender(...mock));
- const log1 = createLogRecord({ a: 'b' });
- const log2 = createLogRecord({ c: 'd' });
+ const log1 = createLogRecord({ user_agent: { name: 'a' } });
+ const log2 = createLogRecord({ user_agent: { name: 'b' } });
appender.append(log1);
@@ -109,8 +109,8 @@ describe('RewriteAppender', () => {
const appender = new RewriteAppender(config);
appender.addAppender(...createAppenderMock('mock1'));
- const log1 = createLogRecord({ a: 'b' });
- const log2 = createLogRecord({ c: 'd' });
+ const log1 = createLogRecord({ user_agent: { name: 'a' } });
+ const log2 = createLogRecord({ user_agent: { name: 'b' } });
appender.append(log1);
diff --git a/src/core/server/logging/ecs.ts b/src/core/server/logging/ecs.ts
deleted file mode 100644
index f6db79819d819..0000000000000
--- a/src/core/server/logging/ecs.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-/*
- * 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.
- */
-
-/**
- * Typings for some ECS fields which core uses internally.
- * These are not a complete set of ECS typings and should not
- * be used externally; the only types included here are ones
- * currently used in core.
- *
- * @internal
- */
-export interface EcsEvent {
- /**
- * These typings were written as of ECS 1.7.0.
- * Don't change this value without checking the rest
- * of the types to conform to that ECS version.
- *
- * https://www.elastic.co/guide/en/ecs/1.7/index.html
- */
- ecs: { version: '1.7.0' };
-
- // base fields
- ['@timestamp']?: string;
- labels?: Record;
- message?: string;
- tags?: string[];
-
- // other fields
- client?: EcsClientField;
- event?: EcsEventField;
- http?: EcsHttpField;
- process?: EcsProcessField;
- url?: EcsUrlField;
- user_agent?: EcsUserAgentField;
-}
-
-/** @internal */
-export enum EcsEventKind {
- ALERT = 'alert',
- EVENT = 'event',
- METRIC = 'metric',
- STATE = 'state',
- PIPELINE_ERROR = 'pipeline_error',
- SIGNAL = 'signal',
-}
-
-/** @internal */
-export enum EcsEventCategory {
- AUTHENTICATION = 'authentication',
- CONFIGURATION = 'configuration',
- DATABASE = 'database',
- DRIVER = 'driver',
- FILE = 'file',
- HOST = 'host',
- IAM = 'iam',
- INTRUSION_DETECTION = 'intrusion_detection',
- MALWARE = 'malware',
- NETWORK = 'network',
- PACKAGE = 'package',
- PROCESS = 'process',
- WEB = 'web',
-}
-
-/** @internal */
-export enum EcsEventType {
- ACCESS = 'access',
- ADMIN = 'admin',
- ALLOWED = 'allowed',
- CHANGE = 'change',
- CONNECTION = 'connection',
- CREATION = 'creation',
- DELETION = 'deletion',
- DENIED = 'denied',
- END = 'end',
- ERROR = 'error',
- GROUP = 'group',
- INFO = 'info',
- INSTALLATION = 'installation',
- PROTOCOL = 'protocol',
- START = 'start',
- USER = 'user',
-}
-
-interface EcsEventField {
- kind?: EcsEventKind;
- category?: EcsEventCategory[];
- type?: EcsEventType;
-}
-
-interface EcsProcessField {
- uptime?: number;
-}
-
-interface EcsClientField {
- ip?: string;
-}
-
-interface EcsHttpFieldRequest {
- body?: { bytes?: number; content?: string };
- method?: string;
- mime_type?: string;
- referrer?: string;
-}
-
-interface EcsHttpFieldResponse {
- body?: { bytes?: number; content?: string };
- bytes?: number;
- status_code?: number;
-}
-
-interface EcsHttpField {
- version?: string;
- request?: EcsHttpFieldRequest;
- response?: EcsHttpFieldResponse;
-}
-
-interface EcsUrlField {
- path?: string;
- query?: string;
-}
-
-interface EcsUserAgentField {
- original?: string;
-}
diff --git a/src/core/server/logging/index.ts b/src/core/server/logging/index.ts
index cef96be54870e..9d17b289bfa4c 100644
--- a/src/core/server/logging/index.ts
+++ b/src/core/server/logging/index.ts
@@ -9,6 +9,11 @@ export { LogLevel } from '@kbn/logging';
export type {
DisposableAppender,
Appender,
+ Ecs,
+ EcsEventCategory,
+ EcsEventKind,
+ EcsEventOutcome,
+ EcsEventType,
LogRecord,
Layout,
LoggerFactory,
@@ -16,8 +21,6 @@ export type {
Logger,
LogLevelId,
} from '@kbn/logging';
-export { EcsEventType, EcsEventCategory, EcsEventKind } from './ecs';
-export type { EcsEvent } from './ecs';
export { config } from './logging_config';
export type {
LoggingConfigType,
diff --git a/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap b/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap
index 0e7ce8d0b2f3c..a131d5c8a9248 100644
--- a/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap
+++ b/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap
@@ -1,13 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`\`format()\` correctly formats record. 1`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-1\\",\\"error\\":{\\"message\\":\\"Some error message\\",\\"type\\":\\"Some error name\\",\\"stack_trace\\":\\"Some error stack\\"},\\"log\\":{\\"level\\":\\"FATAL\\",\\"logger\\":\\"context-1\\"},\\"process\\":{\\"pid\\":5355}}"`;
+exports[`\`format()\` correctly formats record. 1`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-1\\",\\"error\\":{\\"message\\":\\"Some error message\\",\\"type\\":\\"Some error name\\",\\"stack_trace\\":\\"Some error stack\\"},\\"log\\":{\\"level\\":\\"FATAL\\",\\"logger\\":\\"context-1\\"},\\"process\\":{\\"pid\\":5355}}"`;
-exports[`\`format()\` correctly formats record. 2`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-2\\",\\"log\\":{\\"level\\":\\"ERROR\\",\\"logger\\":\\"context-2\\"},\\"process\\":{\\"pid\\":5355}}"`;
+exports[`\`format()\` correctly formats record. 2`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-2\\",\\"log\\":{\\"level\\":\\"ERROR\\",\\"logger\\":\\"context-2\\"},\\"process\\":{\\"pid\\":5355}}"`;
-exports[`\`format()\` correctly formats record. 3`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-3\\",\\"log\\":{\\"level\\":\\"WARN\\",\\"logger\\":\\"context-3\\"},\\"process\\":{\\"pid\\":5355}}"`;
+exports[`\`format()\` correctly formats record. 3`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-3\\",\\"log\\":{\\"level\\":\\"WARN\\",\\"logger\\":\\"context-3\\"},\\"process\\":{\\"pid\\":5355}}"`;
-exports[`\`format()\` correctly formats record. 4`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-4\\",\\"log\\":{\\"level\\":\\"DEBUG\\",\\"logger\\":\\"context-4\\"},\\"process\\":{\\"pid\\":5355}}"`;
+exports[`\`format()\` correctly formats record. 4`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-4\\",\\"log\\":{\\"level\\":\\"DEBUG\\",\\"logger\\":\\"context-4\\"},\\"process\\":{\\"pid\\":5355}}"`;
-exports[`\`format()\` correctly formats record. 5`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-5\\",\\"log\\":{\\"level\\":\\"INFO\\",\\"logger\\":\\"context-5\\"},\\"process\\":{\\"pid\\":5355}}"`;
+exports[`\`format()\` correctly formats record. 5`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-5\\",\\"log\\":{\\"level\\":\\"INFO\\",\\"logger\\":\\"context-5\\"},\\"process\\":{\\"pid\\":5355}}"`;
-exports[`\`format()\` correctly formats record. 6`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-6\\",\\"log\\":{\\"level\\":\\"TRACE\\",\\"logger\\":\\"context-6\\"},\\"process\\":{\\"pid\\":5355}}"`;
+exports[`\`format()\` correctly formats record. 6`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-6\\",\\"log\\":{\\"level\\":\\"TRACE\\",\\"logger\\":\\"context-6\\"},\\"process\\":{\\"pid\\":5355}}"`;
diff --git a/src/core/server/logging/layouts/json_layout.test.ts b/src/core/server/logging/layouts/json_layout.test.ts
index e55f69daab110..e76e3fb4402bb 100644
--- a/src/core/server/logging/layouts/json_layout.test.ts
+++ b/src/core/server/logging/layouts/json_layout.test.ts
@@ -94,6 +94,7 @@ test('`format()` correctly formats record with meta-data', () => {
})
)
).toStrictEqual({
+ ecs: { version: '1.9.0' },
'@timestamp': '2012-02-01T09:30:22.011-05:00',
log: {
level: 'DEBUG',
@@ -135,6 +136,7 @@ test('`format()` correctly formats error record with meta-data', () => {
})
)
).toStrictEqual({
+ ecs: { version: '1.9.0' },
'@timestamp': '2012-02-01T09:30:22.011-05:00',
log: {
level: 'DEBUG',
@@ -156,7 +158,39 @@ test('`format()` correctly formats error record with meta-data', () => {
});
});
-test('format() meta can override @timestamp', () => {
+test('format() meta can merge override logs', () => {
+ const layout = new JsonLayout();
+ expect(
+ JSON.parse(
+ layout.format({
+ timestamp,
+ message: 'foo',
+ level: LogLevel.Error,
+ context: 'bar',
+ pid: 3,
+ meta: {
+ log: {
+ kbn_custom_field: 'hello',
+ },
+ },
+ })
+ )
+ ).toStrictEqual({
+ ecs: { version: '1.9.0' },
+ '@timestamp': '2012-02-01T09:30:22.011-05:00',
+ message: 'foo',
+ log: {
+ level: 'ERROR',
+ logger: 'bar',
+ kbn_custom_field: 'hello',
+ },
+ process: {
+ pid: 3,
+ },
+ });
+});
+
+test('format() meta can not override message', () => {
const layout = new JsonLayout();
expect(
JSON.parse(
@@ -167,12 +201,13 @@ test('format() meta can override @timestamp', () => {
context: 'bar',
pid: 3,
meta: {
- '@timestamp': '2099-05-01T09:30:22.011-05:00',
+ message: 'baz',
},
})
)
).toStrictEqual({
- '@timestamp': '2099-05-01T09:30:22.011-05:00',
+ ecs: { version: '1.9.0' },
+ '@timestamp': '2012-02-01T09:30:22.011-05:00',
message: 'foo',
log: {
level: 'DEBUG',
@@ -184,30 +219,60 @@ test('format() meta can override @timestamp', () => {
});
});
-test('format() meta can merge override logs', () => {
+test('format() meta can not override ecs version', () => {
const layout = new JsonLayout();
expect(
JSON.parse(
layout.format({
+ message: 'foo',
timestamp,
+ level: LogLevel.Debug,
+ context: 'bar',
+ pid: 3,
+ meta: {
+ message: 'baz',
+ },
+ })
+ )
+ ).toStrictEqual({
+ ecs: { version: '1.9.0' },
+ '@timestamp': '2012-02-01T09:30:22.011-05:00',
+ message: 'foo',
+ log: {
+ level: 'DEBUG',
+ logger: 'bar',
+ },
+ process: {
+ pid: 3,
+ },
+ });
+});
+
+test('format() meta can not override logger or level', () => {
+ const layout = new JsonLayout();
+ expect(
+ JSON.parse(
+ layout.format({
message: 'foo',
- level: LogLevel.Error,
+ timestamp,
+ level: LogLevel.Debug,
context: 'bar',
pid: 3,
meta: {
log: {
- kbn_custom_field: 'hello',
+ level: 'IGNORE',
+ logger: 'me',
},
},
})
)
).toStrictEqual({
+ ecs: { version: '1.9.0' },
'@timestamp': '2012-02-01T09:30:22.011-05:00',
message: 'foo',
log: {
- level: 'ERROR',
+ level: 'DEBUG',
logger: 'bar',
- kbn_custom_field: 'hello',
},
process: {
pid: 3,
@@ -215,29 +280,28 @@ test('format() meta can merge override logs', () => {
});
});
-test('format() meta can override log level objects', () => {
+test('format() meta can not override timestamp', () => {
const layout = new JsonLayout();
expect(
JSON.parse(
layout.format({
- timestamp,
- context: '123',
message: 'foo',
- level: LogLevel.Error,
+ timestamp,
+ level: LogLevel.Debug,
+ context: 'bar',
pid: 3,
meta: {
- log: {
- level: 'FATAL',
- },
+ '@timestamp': '2099-02-01T09:30:22.011-05:00',
},
})
)
).toStrictEqual({
+ ecs: { version: '1.9.0' },
'@timestamp': '2012-02-01T09:30:22.011-05:00',
message: 'foo',
log: {
- level: 'FATAL',
- logger: '123',
+ level: 'DEBUG',
+ logger: 'bar',
},
process: {
pid: 3,
diff --git a/src/core/server/logging/layouts/json_layout.ts b/src/core/server/logging/layouts/json_layout.ts
index bb8423f8240af..add88cc01b6d2 100644
--- a/src/core/server/logging/layouts/json_layout.ts
+++ b/src/core/server/logging/layouts/json_layout.ts
@@ -9,7 +9,7 @@
import moment from 'moment-timezone';
import { merge } from '@kbn/std';
import { schema } from '@kbn/config-schema';
-import { LogRecord, Layout } from '@kbn/logging';
+import { Ecs, LogRecord, Layout } from '@kbn/logging';
const { literal, object } = schema;
@@ -42,7 +42,8 @@ export class JsonLayout implements Layout {
}
public format(record: LogRecord): string {
- const log = {
+ const log: Ecs = {
+ ecs: { version: '1.9.0' },
'@timestamp': moment(record.timestamp).format('YYYY-MM-DDTHH:mm:ss.SSSZ'),
message: record.message,
error: JsonLayout.errorToSerializableObject(record.error),
@@ -54,7 +55,8 @@ export class JsonLayout implements Layout {
pid: record.pid,
},
};
- const output = record.meta ? merge(log, record.meta) : log;
+ const output = record.meta ? merge({ ...record.meta }, log) : log;
+
return JSON.stringify(output);
}
}
diff --git a/src/core/server/logging/logger.test.ts b/src/core/server/logging/logger.test.ts
index b7f224e73cb8b..c57ce2563ca3d 100644
--- a/src/core/server/logging/logger.test.ts
+++ b/src/core/server/logging/logger.test.ts
@@ -45,6 +45,7 @@ test('`trace()` correctly forms `LogRecord` and passes it to all appenders.', ()
});
}
+ // @ts-expect-error ECS custom meta
logger.trace('message-2', { trace: true });
for (const appenderMock of appenderMocks) {
expect(appenderMock.append).toHaveBeenCalledTimes(2);
@@ -75,6 +76,7 @@ test('`debug()` correctly forms `LogRecord` and passes it to all appenders.', ()
});
}
+ // @ts-expect-error ECS custom meta
logger.debug('message-2', { debug: true });
for (const appenderMock of appenderMocks) {
expect(appenderMock.append).toHaveBeenCalledTimes(2);
@@ -105,6 +107,7 @@ test('`info()` correctly forms `LogRecord` and passes it to all appenders.', ()
});
}
+ // @ts-expect-error ECS custom meta
logger.info('message-2', { info: true });
for (const appenderMock of appenderMocks) {
expect(appenderMock.append).toHaveBeenCalledTimes(2);
@@ -150,6 +153,7 @@ test('`warn()` correctly forms `LogRecord` and passes it to all appenders.', ()
});
}
+ // @ts-expect-error ECS custom meta
logger.warn('message-3', { warn: true });
for (const appenderMock of appenderMocks) {
expect(appenderMock.append).toHaveBeenCalledTimes(3);
@@ -195,6 +199,7 @@ test('`error()` correctly forms `LogRecord` and passes it to all appenders.', ()
});
}
+ // @ts-expect-error ECS custom meta
logger.error('message-3', { error: true });
for (const appenderMock of appenderMocks) {
expect(appenderMock.append).toHaveBeenCalledTimes(3);
@@ -240,6 +245,7 @@ test('`fatal()` correctly forms `LogRecord` and passes it to all appenders.', ()
});
}
+ // @ts-expect-error ECS custom meta
logger.fatal('message-3', { fatal: true });
for (const appenderMock of appenderMocks) {
expect(appenderMock.append).toHaveBeenCalledTimes(3);
diff --git a/src/core/server/logging/logger.ts b/src/core/server/logging/logger.ts
index 4ba334cec2fb9..e025c28a88f0e 100644
--- a/src/core/server/logging/logger.ts
+++ b/src/core/server/logging/logger.ts
@@ -21,28 +21,28 @@ export class BaseLogger implements Logger {
private readonly factory: LoggerFactory
) {}
- public trace(message: string, meta?: LogMeta): void {
- this.log(this.createLogRecord(LogLevel.Trace, message, meta));
+ public trace(message: string, meta?: Meta): void {
+ this.log(this.createLogRecord(LogLevel.Trace, message, meta));
}
- public debug(message: string, meta?: LogMeta): void {
- this.log(this.createLogRecord(LogLevel.Debug, message, meta));
+ public debug(message: string, meta?: Meta): void {
+ this.log(this.createLogRecord(LogLevel.Debug, message, meta));
}
- public info(message: string, meta?: LogMeta): void {
- this.log(this.createLogRecord(LogLevel.Info, message, meta));
+ public info(message: string, meta?: Meta): void {
+ this.log(this.createLogRecord(LogLevel.Info, message, meta));
}
- public warn(errorOrMessage: string | Error, meta?: LogMeta): void {
- this.log(this.createLogRecord(LogLevel.Warn, errorOrMessage, meta));
+ public warn(errorOrMessage: string | Error, meta?: Meta): void {
+ this.log(this.createLogRecord(LogLevel.Warn, errorOrMessage, meta));
}
- public error(errorOrMessage: string | Error, meta?: LogMeta): void {
- this.log(this.createLogRecord(LogLevel.Error, errorOrMessage, meta));
+ public error(errorOrMessage: string | Error, meta?: Meta): void {
+ this.log(this.createLogRecord(LogLevel.Error, errorOrMessage, meta));
}
- public fatal(errorOrMessage: string | Error, meta?: LogMeta): void {
- this.log(this.createLogRecord(LogLevel.Fatal, errorOrMessage, meta));
+ public fatal(errorOrMessage: string | Error, meta?: Meta): void {
+ this.log(this.createLogRecord(LogLevel.Fatal, errorOrMessage, meta));
}
public log(record: LogRecord) {
@@ -59,10 +59,10 @@ export class BaseLogger implements Logger {
return this.factory.get(...[this.context, ...childContextPaths]);
}
- private createLogRecord(
+ private createLogRecord(
level: LogLevel,
errorOrMessage: string | Error,
- meta?: LogMeta
+ meta?: Meta
): LogRecord {
if (isError(errorOrMessage)) {
return {
diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts
index b67be384732cb..9c4313bc0c49d 100644
--- a/src/core/server/logging/logging_system.test.ts
+++ b/src/core/server/logging/logging_system.test.ts
@@ -49,6 +49,7 @@ test('uses default memory buffer logger until config is provided', () => {
// We shouldn't create new buffer appender for another context name.
const anotherLogger = system.get('test', 'context2');
+ // @ts-expect-error ECS custom meta
anotherLogger.fatal('fatal message', { some: 'value' });
expect(bufferAppendSpy).toHaveBeenCalledTimes(2);
@@ -62,6 +63,7 @@ test('flushes memory buffer logger and switches to real logger once config is pr
const logger = system.get('test', 'context');
logger.trace('buffered trace message');
+ // @ts-expect-error ECS custom meta
logger.info('buffered info message', { some: 'value' });
logger.fatal('buffered fatal message');
@@ -159,6 +161,7 @@ test('attaches appenders to appenders that declare refs', async () => {
);
const testLogger = system.get('tests');
+ // @ts-expect-error ECS custom meta
testLogger.warn('This message goes to a test context.', { a: 'hi', b: 'remove me' });
expect(mockConsoleLog).toHaveBeenCalledTimes(1);
@@ -233,6 +236,7 @@ test('asLoggerFactory() only allows to create new loggers.', async () => {
);
logger.trace('buffered trace message');
+ // @ts-expect-error ECS custom meta
logger.info('buffered info message', { some: 'value' });
logger.fatal('buffered fatal message');
diff --git a/src/core/server/metrics/logging/get_ops_metrics_log.test.ts b/src/core/server/metrics/logging/get_ops_metrics_log.test.ts
index 014d3ae258823..e535b9babf92b 100644
--- a/src/core/server/metrics/logging/get_ops_metrics_log.test.ts
+++ b/src/core/server/metrics/logging/get_ops_metrics_log.test.ts
@@ -66,7 +66,7 @@ describe('getEcsOpsMetricsLog', () => {
it('correctly formats process uptime', () => {
const logMeta = getEcsOpsMetricsLog(createMockOpsMetrics(testMetrics));
- expect(logMeta.process!.uptime).toEqual(1);
+ expect(logMeta.meta.process!.uptime).toEqual(1);
});
it('excludes values from the message if unavailable', () => {
@@ -80,44 +80,40 @@ describe('getEcsOpsMetricsLog', () => {
expect(logMeta.message).toMatchInlineSnapshot(`""`);
});
- it('specifies correct ECS version', () => {
- const logMeta = getEcsOpsMetricsLog(createBaseOpsMetrics());
- expect(logMeta.ecs.version).toBe('1.7.0');
- });
-
it('provides an ECS-compatible response', () => {
const logMeta = getEcsOpsMetricsLog(createBaseOpsMetrics());
expect(logMeta).toMatchInlineSnapshot(`
Object {
- "ecs": Object {
- "version": "1.7.0",
- },
- "event": Object {
- "category": Array [
- "process",
- "host",
- ],
- "kind": "metric",
- "type": "info",
- },
- "host": Object {
- "os": Object {
- "load": Object {
- "15m": 1,
- "1m": 1,
- "5m": 1,
+ "message": "memory: 1.0B load: [1.00,1.00,1.00] delay: 1.000",
+ "meta": Object {
+ "event": Object {
+ "category": Array [
+ "process",
+ "host",
+ ],
+ "kind": "metric",
+ "type": Array [
+ "info",
+ ],
+ },
+ "host": Object {
+ "os": Object {
+ "load": Object {
+ "15m": 1,
+ "1m": 1,
+ "5m": 1,
+ },
},
},
- },
- "message": "memory: 1.0B load: [1.00,1.00,1.00] delay: 1.000",
- "process": Object {
- "eventLoopDelay": 1,
- "memory": Object {
- "heap": Object {
- "usedInBytes": 1,
+ "process": Object {
+ "eventLoopDelay": 1,
+ "memory": Object {
+ "heap": Object {
+ "usedInBytes": 1,
+ },
},
+ "uptime": 0,
},
- "uptime": 0,
},
}
`);
@@ -125,8 +121,8 @@ describe('getEcsOpsMetricsLog', () => {
it('logs ECS fields in the log meta', () => {
const logMeta = getEcsOpsMetricsLog(createBaseOpsMetrics());
- expect(logMeta.event!.kind).toBe('metric');
- expect(logMeta.event!.category).toEqual(expect.arrayContaining(['process', 'host']));
- expect(logMeta.event!.type).toBe('info');
+ expect(logMeta.meta.event!.kind).toBe('metric');
+ expect(logMeta.meta.event!.category).toEqual(expect.arrayContaining(['process', 'host']));
+ expect(logMeta.meta.event!.type).toEqual(expect.arrayContaining(['info']));
});
});
diff --git a/src/core/server/metrics/logging/get_ops_metrics_log.ts b/src/core/server/metrics/logging/get_ops_metrics_log.ts
index 02c3ad312c7dd..7e13f35889ec7 100644
--- a/src/core/server/metrics/logging/get_ops_metrics_log.ts
+++ b/src/core/server/metrics/logging/get_ops_metrics_log.ts
@@ -7,16 +7,15 @@
*/
import numeral from '@elastic/numeral';
-import { EcsEvent, EcsEventKind, EcsEventCategory, EcsEventType } from '../../logging';
+import { LogMeta } from '@kbn/logging';
import { OpsMetrics } from '..';
-const ECS_VERSION = '1.7.0';
/**
* Converts ops metrics into ECS-compliant `LogMeta` for logging
*
* @internal
*/
-export function getEcsOpsMetricsLog(metrics: OpsMetrics): EcsEvent {
+export function getEcsOpsMetricsLog(metrics: OpsMetrics) {
const { process, os } = metrics;
const processMemoryUsedInBytes = process?.memory?.heap?.used_in_bytes;
const processMemoryUsedInBytesMsg = processMemoryUsedInBytes
@@ -51,13 +50,11 @@ export function getEcsOpsMetricsLog(metrics: OpsMetrics): EcsEvent {
})}] `
: '';
- return {
- ecs: { version: ECS_VERSION },
- message: `${processMemoryUsedInBytesMsg}${uptimeValMsg}${loadValsMsg}${eventLoopDelayValMsg}`,
+ const meta: LogMeta = {
event: {
- kind: EcsEventKind.METRIC,
- category: [EcsEventCategory.PROCESS, EcsEventCategory.HOST],
- type: EcsEventType.INFO,
+ kind: 'metric',
+ category: ['process', 'host'],
+ type: ['info'],
},
process: {
uptime: uptimeVal,
@@ -71,8 +68,14 @@ export function getEcsOpsMetricsLog(metrics: OpsMetrics): EcsEvent {
},
host: {
os: {
+ // @ts-expect-error custom fields not yet part of ECS
load: loadEntries,
},
},
};
+
+ return {
+ message: `${processMemoryUsedInBytesMsg}${uptimeValMsg}${loadValsMsg}${eventLoopDelayValMsg}`,
+ meta,
+ };
}
diff --git a/src/core/server/metrics/metrics_service.test.ts b/src/core/server/metrics/metrics_service.test.ts
index 4fbca5addda11..d7de41fd7ccf7 100644
--- a/src/core/server/metrics/metrics_service.test.ts
+++ b/src/core/server/metrics/metrics_service.test.ts
@@ -182,16 +182,15 @@ describe('MetricsService', () => {
Array [
"",
Object {
- "ecs": Object {
- "version": "1.7.0",
- },
"event": Object {
"category": Array [
"process",
"host",
],
"kind": "metric",
- "type": "info",
+ "type": Array [
+ "info",
+ ],
},
"host": Object {
"os": Object {
diff --git a/src/core/server/metrics/metrics_service.ts b/src/core/server/metrics/metrics_service.ts
index 382848e0a80c3..78e4dd98f93d6 100644
--- a/src/core/server/metrics/metrics_service.ts
+++ b/src/core/server/metrics/metrics_service.ts
@@ -73,7 +73,7 @@ export class MetricsService
private async refreshMetrics() {
const metrics = await this.metricsCollector!.collect();
- const { message, ...meta } = getEcsOpsMetricsLog(metrics);
+ const { message, meta } = getEcsOpsMetricsLog(metrics);
this.opsMetricsLogger.debug(message!, meta);
this.metricsCollector!.reset();
this.metrics$.next(metrics);
diff --git a/src/core/server/plugins/plugins_service.mock.ts b/src/core/server/plugins/plugins_service.mock.ts
index 1d0ed7cb09299..f4f2263a1bdb0 100644
--- a/src/core/server/plugins/plugins_service.mock.ts
+++ b/src/core/server/plugins/plugins_service.mock.ts
@@ -19,6 +19,7 @@ const createStartContractMock = () => ({ contracts: new Map() });
const createServiceMock = (): PluginsServiceMock => ({
discover: jest.fn(),
+ getExposedPluginConfigsToUsage: jest.fn(),
setup: jest.fn().mockResolvedValue(createSetupContractMock()),
start: jest.fn().mockResolvedValue(createStartContractMock()),
stop: jest.fn(),
diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts
index 6bf7a1fadb4d3..5c50df07dc697 100644
--- a/src/core/server/plugins/plugins_service.test.ts
+++ b/src/core/server/plugins/plugins_service.test.ts
@@ -78,7 +78,7 @@ const createPlugin = (
manifest: {
id,
version,
- configPath: `${configPath}${disabled ? '-disabled' : ''}`,
+ configPath: disabled ? configPath.concat('-disabled') : configPath,
kibanaVersion,
requiredPlugins,
requiredBundles,
@@ -374,7 +374,6 @@ describe('PluginsService', () => {
expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin);
-
expect(mockDiscover).toHaveBeenCalledTimes(1);
expect(mockDiscover).toHaveBeenCalledWith(
{
@@ -472,6 +471,88 @@ describe('PluginsService', () => {
expect(pluginPaths).toEqual(['/plugin-A-path', '/plugin-B-path']);
});
+
+ it('ppopulates pluginConfigUsageDescriptors with plugins exposeToUsage property', async () => {
+ const pluginA = createPlugin('plugin-with-expose-usage', {
+ path: 'plugin-with-expose-usage',
+ configPath: 'pathA',
+ });
+
+ jest.doMock(
+ join('plugin-with-expose-usage', 'server'),
+ () => ({
+ config: {
+ exposeToUsage: {
+ test: true,
+ nested: {
+ prop: true,
+ },
+ },
+ schema: schema.maybe(schema.any()),
+ },
+ }),
+ {
+ virtual: true,
+ }
+ );
+
+ const pluginB = createPlugin('plugin-with-array-configPath', {
+ path: 'plugin-with-array-configPath',
+ configPath: ['plugin', 'pathB'],
+ });
+
+ jest.doMock(
+ join('plugin-with-array-configPath', 'server'),
+ () => ({
+ config: {
+ exposeToUsage: {
+ test: true,
+ },
+ schema: schema.maybe(schema.any()),
+ },
+ }),
+ {
+ virtual: true,
+ }
+ );
+
+ jest.doMock(
+ join('plugin-without-expose', 'server'),
+ () => ({
+ config: {
+ schema: schema.maybe(schema.any()),
+ },
+ }),
+ {
+ virtual: true,
+ }
+ );
+
+ const pluginC = createPlugin('plugin-without-expose', {
+ path: 'plugin-without-expose',
+ configPath: 'pathC',
+ });
+
+ mockDiscover.mockReturnValue({
+ error$: from([]),
+ plugin$: from([pluginA, pluginB, pluginC]),
+ });
+
+ await pluginsService.discover({ environment: environmentSetup });
+
+ // eslint-disable-next-line dot-notation
+ expect(pluginsService['pluginConfigUsageDescriptors']).toMatchInlineSnapshot(`
+ Map {
+ "pathA" => Object {
+ "nested.prop": true,
+ "test": true,
+ },
+ "plugin.pathB" => Object {
+ "test": true,
+ },
+ }
+ `);
+ });
});
describe('#generateUiPluginsConfigs()', () => {
@@ -624,6 +705,20 @@ describe('PluginsService', () => {
});
});
+ describe('#getExposedPluginConfigsToUsage', () => {
+ it('returns pluginConfigUsageDescriptors', () => {
+ // eslint-disable-next-line dot-notation
+ pluginsService['pluginConfigUsageDescriptors'].set('test', { enabled: true });
+ expect(pluginsService.getExposedPluginConfigsToUsage()).toMatchInlineSnapshot(`
+ Map {
+ "test" => Object {
+ "enabled": true,
+ },
+ }
+ `);
+ });
+ });
+
describe('#stop()', () => {
it('`stop` stops plugins system', async () => {
await pluginsService.stop();
diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts
index 09be40ecaf2a2..547fe00fdb1cf 100644
--- a/src/core/server/plugins/plugins_service.ts
+++ b/src/core/server/plugins/plugins_service.ts
@@ -9,7 +9,7 @@
import Path from 'path';
import { Observable } from 'rxjs';
import { filter, first, map, mergeMap, tap, toArray } from 'rxjs/operators';
-import { pick } from '@kbn/std';
+import { pick, getFlattenedObject } from '@kbn/std';
import { CoreService } from '../../types';
import { CoreContext } from '../core_context';
@@ -75,6 +75,7 @@ export class PluginsService implements CoreService;
private readonly pluginConfigDescriptors = new Map();
private readonly uiPluginInternalInfo = new Map();
+ private readonly pluginConfigUsageDescriptors = new Map>();
constructor(private readonly coreContext: CoreContext) {
this.log = coreContext.logger.get('plugins-service');
@@ -109,6 +110,10 @@ export class PluginsService implements CoreService = T | undefined;
+
/**
* Dedicated type for plugin configuration schema.
*
@@ -70,8 +72,39 @@ export interface PluginConfigDescriptor {
* {@link PluginConfigSchema}
*/
schema: PluginConfigSchema;
+ /**
+ * Expose non-default configs to usage collection to be sent via telemetry.
+ * set a config to `true` to report the actual changed config value.
+ * set a config to `false` to report the changed config value as [redacted].
+ *
+ * All changed configs except booleans and numbers will be reported
+ * as [redacted] unless otherwise specified.
+ *
+ * {@link MakeUsageFromSchema}
+ */
+ exposeToUsage?: MakeUsageFromSchema;
}
+/**
+ * List of configuration values that will be exposed to usage collection.
+ * If parent node or actual config path is set to `true` then the actual value
+ * of these configs will be reoprted.
+ * If parent node or actual config path is set to `false` then the config
+ * will be reported as [redacted].
+ *
+ * @public
+ */
+export type MakeUsageFromSchema = {
+ [Key in keyof T]?: T[Key] extends Maybe