diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/solutions/observability/plugins/infra/public/pages/logs/page_content.tsx index f4df4bbc011d4..4479c3d2c4bbf 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/solutions/observability/plugins/infra/public/pages/logs/page_content.tsx @@ -9,7 +9,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiHeaderLink, EuiHeaderLinks } from '@elast import { i18n } from '@kbn/i18n'; import React, { useContext } from 'react'; import { Routes, Route } from '@kbn/shared-ux-router'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useKibana, useUiSetting } from '@kbn/kibana-react-plugin/public'; import { HeaderMenuPortal, useLinkProps } from '@kbn/observability-shared-plugin/public'; import type { SharePublicStart } from '@kbn/share-plugin/public/plugin'; import { @@ -21,6 +21,7 @@ import { dynamic } from '@kbn/shared-ux-utility'; import { isDevMode } from '@kbn/xstate-utils'; import type { LogsLocatorParams } from '@kbn/logs-shared-plugin/common'; import { safeDecode } from '@kbn/rison'; +import { OBSERVABILITY_ENABLE_LOGS_STREAM } from '@kbn/management-settings-ids'; import { LazyAlertDropdownWrapper } from '../../alerting/log_threshold'; import { HelpCenterContent } from '../../components/help_center_content'; import { useReadOnlyBadge } from '../../hooks/use_readonly_badge'; @@ -29,6 +30,8 @@ import { RedirectWithQueryParams } from '../../utils/redirect_with_query_params' import { NotFoundPage } from '../404'; import { getLogsAppRoutes } from './routes'; +const StreamPage = dynamic(() => import('./stream').then((mod) => ({ default: mod.StreamPage }))); + const LogEntryCategoriesPage = dynamic(() => import('./log_entry_categories').then((mod) => ({ default: mod.LogEntryCategoriesPage })) ); @@ -50,6 +53,8 @@ const StateMachinePlayground = dynamic(() => export const LogsPageContent: React.FunctionComponent = () => { const { application, share } = useKibana<{ share: SharePublicStart }>().services; + const isLogsStreamEnabled: boolean = useUiSetting(OBSERVABILITY_ENABLE_LOGS_STREAM, false); + const uiCapabilities = application?.capabilities; const onboardingLocator = share?.url.locators.get( OBSERVABILITY_ONBOARDING_LOCATOR @@ -59,7 +64,7 @@ export const LogsPageContent: React.FunctionComponent = () => { const enableDeveloperRoutes = isDevMode(); useReadOnlyBadge(!uiCapabilities?.logs?.save); - const routes = getLogsAppRoutes(); + const routes = getLogsAppRoutes({ isLogsStreamEnabled }); const settingsLinkProps = useLinkProps({ app: 'logs', @@ -93,30 +98,34 @@ export const LogsPageContent: React.FunctionComponent = () => { )} - { - const searchParams = new URLSearchParams(props.location.search); - const logFilterEncoded = searchParams.get('logFilter'); - let locatorParams: LogsLocatorParams = {}; - - if (logFilterEncoded) { - const logFilter = safeDecode(logFilterEncoded) as LogsLocatorParams; - locatorParams = { - timeRange: logFilter?.timeRange, - query: logFilter?.query, - filters: logFilter?.filters, - refreshInterval: logFilter?.refreshInterval, - }; - } - - share.url.locators - .get(ALL_DATASETS_LOCATOR_ID) - ?.navigate(locatorParams); - return null; - }} - /> + {routes.stream ? ( + + ) : ( + { + const searchParams = new URLSearchParams(props.location.search); + const logFilterEncoded = searchParams.get('logFilter'); + let locatorParams: LogsLocatorParams = {}; + + if (logFilterEncoded) { + const logFilter = safeDecode(logFilterEncoded) as LogsLocatorParams; + locatorParams = { + timeRange: logFilter?.timeRange, + query: logFilter?.query, + filters: logFilter?.filters, + refreshInterval: logFilter?.refreshInterval, + }; + } + + share.url.locators + .get(ALL_DATASETS_LOCATOR_ID) + ?.navigate(locatorParams); + return null; + }} + /> + )} diff --git a/x-pack/solutions/observability/plugins/infra/public/pages/logs/routes.ts b/x-pack/solutions/observability/plugins/infra/public/pages/logs/routes.ts index 08decc1ce4725..a5c38672a8bed 100644 --- a/x-pack/solutions/observability/plugins/infra/public/pages/logs/routes.ts +++ b/x-pack/solutions/observability/plugins/infra/public/pages/logs/routes.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { logsAnomaliesTitle, logCategoriesTitle, settingsTitle } from '../../translations'; +import { + logsAnomaliesTitle, + logCategoriesTitle, + settingsTitle, + streamTitle, +} from '../../translations'; export interface LogsRoute { id: string; @@ -17,9 +22,10 @@ export interface LogsAppRoutes { logsAnomalies: LogsRoute; logsCategories: LogsRoute; settings: LogsRoute; + stream?: LogsRoute; } -export const getLogsAppRoutes = () => { +export const getLogsAppRoutes = ({ isLogsStreamEnabled }: { isLogsStreamEnabled: boolean }) => { const routes: LogsAppRoutes = { logsAnomalies: { id: 'anomalies', @@ -38,5 +44,13 @@ export const getLogsAppRoutes = () => { }, }; + if (isLogsStreamEnabled) { + routes.stream = { + id: 'stream', + title: streamTitle, + path: '/stream', + }; + } + return routes; }; diff --git a/x-pack/solutions/observability/plugins/infra/public/plugin.ts b/x-pack/solutions/observability/plugins/infra/public/plugin.ts index 423531e355cc6..a3ae73e074f4c 100644 --- a/x-pack/solutions/observability/plugins/infra/public/plugin.ts +++ b/x-pack/solutions/observability/plugins/infra/public/plugin.ts @@ -35,6 +35,7 @@ import { } from '@kbn/observability-shared-plugin/common'; import type { NavigationEntry } from '@kbn/observability-shared-plugin/public'; import { OBSERVABILITY_LOGS_EXPLORER_APP_ID } from '@kbn/deeplinks-observability/constants'; +import { OBSERVABILITY_ENABLE_LOGS_STREAM } from '@kbn/management-settings-ids'; import type { InfraPublicConfig } from '../common/plugin_config_types'; import { createInventoryMetricRuleType } from './alerting/inventory'; import { createLogThresholdRuleType } from './alerting/log_threshold'; @@ -85,6 +86,8 @@ export class Plugin implements InfraClientPluginClass { } setup(core: InfraClientCoreSetup, pluginsSetup: InfraClientSetupDeps) { + const isLogsStreamEnabled = core.uiSettings.get(OBSERVABILITY_ENABLE_LOGS_STREAM, false); + if (pluginsSetup.home) { registerFeatures(pluginsSetup.home); } @@ -130,7 +133,7 @@ export class Plugin implements InfraClientPluginClass { ) ); - const logRoutes = getLogsAppRoutes(); + const logRoutes = getLogsAppRoutes({ isLogsStreamEnabled }); /** !! Need to be kept in sync with the deepLinks in x-pack/solutions/observability/plugins/infra/public/plugin.ts */ pluginsSetup.observabilityShared.navigation.registerSections( @@ -352,10 +355,13 @@ const getLogsNavigationEntries = ({ }); } + // Display Stream nav entry when Logs Stream is enabled + if (routes.stream) entries.push(createNavEntryFromRoute(routes.stream)); // Display always Logs Anomalies and Logs Categories entries entries.push(createNavEntryFromRoute(routes.logsAnomalies)); entries.push(createNavEntryFromRoute(routes.logsCategories)); - entries.push(createNavEntryFromRoute(routes.settings)); + // Display Logs Settings entry when Logs Stream is not enabled + if (!routes.stream) entries.push(createNavEntryFromRoute(routes.settings)); return entries; }; diff --git a/x-pack/test/functional/apps/infra/index.ts b/x-pack/test/functional/apps/infra/index.ts index f1ce8b0c4071d..999d7342f4639 100644 --- a/x-pack/test/functional/apps/infra/index.ts +++ b/x-pack/test/functional/apps/infra/index.ts @@ -27,7 +27,9 @@ export default ({ loadTestFile }: FtrProviderContext) => { loadTestFile(require.resolve('./logs/log_entry_categories_tab')); loadTestFile(require.resolve('./logs/log_entry_rate_tab')); loadTestFile(require.resolve('./logs/logs_source_configuration')); + loadTestFile(require.resolve('./logs/log_stream_date_nano')); loadTestFile(require.resolve('./logs/link_to')); + loadTestFile(require.resolve('./logs/log_stream')); loadTestFile(require.resolve('./logs/ml_job_id_formats/tests')); }); }); diff --git a/x-pack/test/functional/apps/infra/logs/log_stream.ts b/x-pack/test/functional/apps/infra/logs/log_stream.ts new file mode 100644 index 0000000000000..16dcc038f7aab --- /dev/null +++ b/x-pack/test/functional/apps/infra/logs/log_stream.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { OBSERVABILITY_ENABLE_LOGS_STREAM } from '@kbn/management-settings-ids'; +import { URL } from 'url'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +const SERVICE_ID = '49a18510598271e924253ed2581d7ada'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['common']); + const retry = getService('retry'); + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + describe('Log stream', function () { + describe('Legacy URL handling', () => { + describe('Correctly handles legacy versions of logFilter', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/infra/8.0.0/logs_and_metrics'); + await kibanaServer.uiSettings.update({ [OBSERVABILITY_ENABLE_LOGS_STREAM]: true }); + }); + after(async () => { + await esArchiver.unload( + 'x-pack/test/functional/es_archives/infra/8.0.0/logs_and_metrics' + ); + await kibanaServer.uiSettings.update({ [OBSERVABILITY_ENABLE_LOGS_STREAM]: false }); + }); + it('Expression and kind', async () => { + const location = { + hash: '', + pathname: '/stream', + search: `logFilter=(expression:'service.id:"${SERVICE_ID}"',kind:kuery)`, + state: undefined, + }; + + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'infraLogs', + location.pathname, + location.search, + { + ensureCurrentUrl: false, + } + ); + + await retry.tryForTime(5000, async () => { + const currentUrl = await browser.getCurrentUrl(); + const parsedUrl = new URL(currentUrl); + + expect(parsedUrl.pathname).to.be('/app/logs/stream'); + expect(parsedUrl.searchParams.get('logFilter')).to.contain( + `(filters:!(),query:(language:kuery,query:\'service.id:"${SERVICE_ID}"\')` + ); + }); + }); + it('Top-level query and language', async () => { + const location = { + hash: '', + pathname: '/stream', + search: `logFilter=(query:'service.id:"${SERVICE_ID}"',language:kuery)`, + state: undefined, + }; + + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'infraLogs', + location.pathname, + location.search, + { + ensureCurrentUrl: false, + } + ); + + await retry.tryForTime(5000, async () => { + const currentUrl = await browser.getCurrentUrl(); + const parsedUrl = new URL(currentUrl); + + expect(parsedUrl.pathname).to.be('/app/logs/stream'); + expect(parsedUrl.searchParams.get('logFilter')).to.contain( + `(filters:!(),query:(language:kuery,query:\'service.id:"${SERVICE_ID}"\')` + ); + }); + }); + }); + }); + }); +}; diff --git a/x-pack/test/functional/apps/infra/logs/log_stream_date_nano.ts b/x-pack/test/functional/apps/infra/logs/log_stream_date_nano.ts new file mode 100644 index 0000000000000..141d1bc38c3d3 --- /dev/null +++ b/x-pack/test/functional/apps/infra/logs/log_stream_date_nano.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { OBSERVABILITY_ENABLE_LOGS_STREAM } from '@kbn/management-settings-ids'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { DATES } from '../constants'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const retry = getService('retry'); + const esArchiver = getService('esArchiver'); + const logsUi = getService('logsUi'); + const find = getService('find'); + const kibanaServer = getService('kibanaServer'); + const logFilter = { + timeRange: { + from: DATES.metricsAndLogs.stream.startWithData, + to: DATES.metricsAndLogs.stream.endWithData, + }, + }; + + describe('Log stream supports nano precision', function () { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/infra/logs_with_nano_date'); + await kibanaServer.uiSettings.update({ [OBSERVABILITY_ENABLE_LOGS_STREAM]: true }); + }); + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/infra/logs_with_nano_date'); + await kibanaServer.uiSettings.update({ [OBSERVABILITY_ENABLE_LOGS_STREAM]: false }); + }); + + it('should display logs entries containing date_nano timestamps properly ', async () => { + await logsUi.logStreamPage.navigateTo({ logFilter }); + + const logStreamEntries = await logsUi.logStreamPage.getStreamEntries(); + + expect(logStreamEntries.length).to.be(4); + }); + + it('should render timestamp column properly', async () => { + await logsUi.logStreamPage.navigateTo({ logFilter }); + + await retry.try(async () => { + const columnHeaderLabels = await logsUi.logStreamPage.getColumnHeaderLabels(); + expect(columnHeaderLabels[0]).to.eql('Oct 17, 2018'); + }); + }); + + it('should render timestamp column values properly', async () => { + await logsUi.logStreamPage.navigateTo({ logFilter }); + + const logStreamEntries = await logsUi.logStreamPage.getStreamEntries(); + + const firstLogStreamEntry = logStreamEntries[0]; + + const entryTimestamp = await logsUi.logStreamPage.getLogEntryColumnValueByName( + firstLogStreamEntry, + 'timestampLogColumn' + ); + + expect(entryTimestamp).to.be('19:43:22.111'); + }); + + it('should properly render timestamp in flyout with nano precision', async () => { + await logsUi.logStreamPage.navigateTo({ logFilter }); + + const logStreamEntries = await logsUi.logStreamPage.getStreamEntries(); + const firstLogStreamEntry = logStreamEntries[0]; + + await logsUi.logStreamPage.openLogEntryDetailsFlyout(firstLogStreamEntry); + + const cells = await find.allByCssSelector('.euiTableCellContent'); + + let isFound = false; + + for (const cell of cells) { + const cellText = await cell.getVisibleText(); + if (cellText === '2018-10-17T19:43:22.111111111Z') { + isFound = true; + return; + } + } + + expect(isFound).to.be(true); + }); + }); +}; diff --git a/x-pack/test/functional/apps/infra/logs/logs_source_configuration.ts b/x-pack/test/functional/apps/infra/logs/logs_source_configuration.ts index c173f60f2ba10..8dba29b5fff20 100644 --- a/x-pack/test/functional/apps/infra/logs/logs_source_configuration.ts +++ b/x-pack/test/functional/apps/infra/logs/logs_source_configuration.ts @@ -6,11 +6,19 @@ */ import expect from '@kbn/expect'; +import { + ELASTIC_HTTP_VERSION_HEADER, + X_ELASTIC_INTERNAL_ORIGIN_REQUEST, +} from '@kbn/core-http-common'; import { OBSERVABILITY_ENABLE_LOGS_STREAM } from '@kbn/management-settings-ids'; import { DATES } from '../constants'; import { FtrProviderContext } from '../../../ftr_provider_context'; +const COMMON_REQUEST_HEADERS = { + 'kbn-xsrf': 'some-xsrf-token', +}; + export default ({ getPageObjects, getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const browser = getService('browser'); @@ -19,8 +27,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common', 'header', 'infraLogs']); const retry = getService('retry'); const kibanaServer = getService('kibanaServer'); - const testSubjects = getService('testSubjects'); - const dataGrid = getService('dataGrid'); + const supertest = getService('supertest'); describe('Logs Source Configuration', function () { before(async () => { @@ -40,6 +47,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }, }; + const formattedLocalStart = new Date(logFilter.timeRange.from).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'); }); @@ -81,12 +94,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('renders the no indices screen when no indices match the pattern', async () => { - // We will still navigate to the log stream page, but it should redirect to Log Explorer. - // This way this test, serves 2 purposes: await logsUi.logStreamPage.navigateTo(); - await retry.tryForTime(5 * 1000, async () => { - await testSubjects.existOrFail('discoverNoResults'); + await retry.try(async () => { + await logsUi.logStreamPage.getNoDataPage(); }); }); @@ -109,15 +120,44 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('renders the default log columns with their headers', async () => { await logsUi.logStreamPage.navigateTo({ logFilter }); - await dataGrid.waitForDataTableToLoad(); - const columnHeaders = await dataGrid.getHeaders(); + await retry.try(async () => { + const columnHeaderLabels = await logsUi.logStreamPage.getColumnHeaderLabels(); + + expect(columnHeaderLabels).to.eql([formattedLocalStart, 'event.dataset', 'Message']); + }); + + const logStreamEntries = await logsUi.logStreamPage.getStreamEntries(); + expect(logStreamEntries.length).to.be.greaterThan(0); - expect(columnHeaders).to.eql([ - 'Select column', - 'Actions columnActionsActions', - '@timestamp ', - 'Summary', - ]); + const firstLogStreamEntry = logStreamEntries[0]; + const logStreamEntryColumns = await logsUi.logStreamPage.getLogColumnsOfStreamEntry( + firstLogStreamEntry + ); + + expect(logStreamEntryColumns).to.have.length(3); + }); + + it('records telemetry for logs', async () => { + await logsUi.logStreamPage.navigateTo({ logFilter }); + + await logsUi.logStreamPage.getStreamEntries(); + + const [{ stats }] = await supertest + .post(`/internal/telemetry/clusters/_stats`) + .set(COMMON_REQUEST_HEADERS) + .set(ELASTIC_HTTP_VERSION_HEADER, '2') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('Accept', 'application/json') + .send({ + unencrypted: true, + refreshCache: true, + }) + .expect(200) + .then((res: any) => res.body); + + expect(stats.stack_stats.kibana.plugins.infraops.last_24_hours.hits.logs).to.be.greaterThan( + 0 + ); }); it('can change the log columns', async () => { @@ -137,28 +177,22 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('renders the changed log columns with their headers', async () => { await logsUi.logStreamPage.navigateTo({ logFilter }); - await dataGrid.waitForDataTableToLoad(); - const columnHeaders = await dataGrid.getHeaders(); + await retry.try(async () => { + const columnHeaderLabels = await logsUi.logStreamPage.getColumnHeaderLabels(); + + expect(columnHeaderLabels).to.eql([formattedLocalStart, 'host.name']); + }); - expect(columnHeaders).to.eql([ - 'Select column', - 'Actions columnActionsActions', - '@timestamp ', - 'Summary', - ]); + const logStreamEntries = await logsUi.logStreamPage.getStreamEntries(); - await testSubjects.click('field-host.name'); - await testSubjects.click('fieldPopoverHeader_addField-host.name'); + expect(logStreamEntries.length).to.be.greaterThan(0); - await dataGrid.waitForDataTableToLoad(); - const updatedColumnHeaders = await dataGrid.getHeaders(); + const firstLogStreamEntry = logStreamEntries[0]; + const logStreamEntryColumns = await logsUi.logStreamPage.getLogColumnsOfStreamEntry( + firstLogStreamEntry + ); - expect(updatedColumnHeaders).to.eql([ - 'Select column', - 'Actions columnActionsActions', - '@timestamp ', - 'Keywordhost.name', - ]); + expect(logStreamEntryColumns).to.have.length(2); }); }); }); diff --git a/x-pack/test/functional/apps/infra/page_not_found.ts b/x-pack/test/functional/apps/infra/page_not_found.ts index 0867958fd17b9..eb1fc77b4f9f9 100644 --- a/x-pack/test/functional/apps/infra/page_not_found.ts +++ b/x-pack/test/functional/apps/infra/page_not_found.ts @@ -9,7 +9,7 @@ import expect from '@kbn/expect'; import { OBSERVABILITY_ENABLE_LOGS_STREAM } from '@kbn/management-settings-ids'; import { FtrProviderContext } from '../../ftr_provider_context'; -const logsPages = ['logs/anomalies', 'logs/log-categories', 'logs/settings']; +const logsPages = ['logs/stream', 'logs/anomalies', 'logs/log-categories', 'logs/settings']; const metricsPages = [ 'metrics/inventory',