From 6221afaa7c23bc668b73f79810af9654acf38a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:26:22 +0100 Subject: [PATCH] [APM][ECO] Service name and trace id links on Logs Explorer and Discover (#192349) closes https://github.com/elastic/kibana/issues/192164 This PR adds links to the **logs explorer** and **discover** service.name fields and trace.id field. - service.name link points to `/link-to/entity/{serviceName}`. - trace.id link points to `/link-to/trace/{traceId}`. ### Logs explorer https://github.com/user-attachments/assets/4b2ec665-8968-4b19-822d-f06ba7d1978a #### When EEM setting is disabled: --- ### Discover https://github.com/user-attachments/assets/563e91c2-0e54-4ef3-9f98-d5c83573e513 #### When EEM setting is disabled: ### EEM callout --- ### Remove links when APM is not enabled --------- Co-authored-by: Kate Patticha Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-optimizer/limits.yml | 2 +- .../src/types/index.ts | 201 -------------- .../data_types/logs/popover_chip.tsx | 18 +- .../logs/service_name_chip_with_popover.tsx | 48 ++++ .../virtual_columns/logs/resource.tsx | 3 +- src/plugins/discover/tsconfig.json | 2 + .../logs_overview.test.tsx | 108 +++++++- .../logs_overview_highlights.tsx | 6 +- .../sub_components/highlight_field.tsx | 22 +- .../service_name_highlight_field.tsx | 59 ++++ .../trace_id_highlight_field.tsx | 50 ++++ src/plugins/unified_doc_viewer/tsconfig.json | 3 +- .../entities/entity_link/entity_link.test.tsx | 257 ++++++++++++++++++ .../app/entities/entity_link/index.tsx | 138 ++++++++++ .../components/app/trace_link/index.tsx | 29 +- .../app/trace_link/trace_link.test.tsx | 26 ++ .../components/routing/apm_route_config.tsx | 13 + .../analyze_data_button.stories.tsx | 27 +- .../unified_search_bar.test.tsx | 77 +++--- .../apm_plugin/mock_apm_plugin_context.tsx | 60 ++-- .../apm_plugin/mock_apm_plugin_storybook.tsx | 4 +- .../entity_manager_context.tsx | 13 +- .../use_entity_manager.ts | 2 +- .../apm/public/hooks/use_apm_router.ts | 11 +- .../apm/public/plugin.ts | 7 +- .../observability_solution/apm/tsconfig.json | 2 +- .../observability_shared/common/index.ts | 8 + .../locators/apm/service_entity_locator.ts | 30 ++ ...transaction_details_by_trace_id_locator.ts | 31 +++ .../common/locators/index.ts | 2 + .../observability_shared/public/plugin.ts | 10 + 31 files changed, 958 insertions(+), 311 deletions(-) create mode 100644 src/plugins/discover/public/components/data_types/logs/service_name_chip_with_popover.tsx create mode 100644 src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/service_name_highlight_field.tsx create mode 100644 src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/trace_id_highlight_field.tsx create mode 100644 x-pack/plugins/observability_solution/apm/public/components/app/entities/entity_link/entity_link.test.tsx create mode 100644 x-pack/plugins/observability_solution/apm/public/components/app/entities/entity_link/index.tsx rename x-pack/plugins/observability_solution/apm/public/{hooks => context/entity_manager_context}/use_entity_manager.ts (96%) create mode 100644 x-pack/plugins/observability_solution/observability_shared/common/locators/apm/service_entity_locator.ts create mode 100644 x-pack/plugins/observability_solution/observability_shared/common/locators/apm/transaction_details_by_trace_id_locator.ts diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index fc9120e99bde9..a7d94a423d606 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -119,7 +119,7 @@ pageLoadAssetSize: observabilityAiAssistantManagement: 19279 observabilityLogsExplorer: 46650 observabilityOnboarding: 19573 - observabilityShared: 72039 + observabilityShared: 80000 osquery: 107090 painlessLab: 179748 presentationPanel: 55463 diff --git a/packages/kbn-typed-react-router-config/src/types/index.ts b/packages/kbn-typed-react-router-config/src/types/index.ts index 8f2daba368fa3..3b4c36c42af53 100644 --- a/packages/kbn-typed-react-router-config/src/types/index.ts +++ b/packages/kbn-typed-react-router-config/src/types/index.ts @@ -191,204 +191,3 @@ type MapRoutes extends Record ? FromRouteMap : never; - -// const element = null as any; - -// const routes = { -// '/link-to/transaction/{transactionId}': { -// element, -// }, -// '/link-to/trace/{traceId}': { -// element, -// }, -// '/': { -// element, -// children: { -// '/settings': { -// element, -// children: { -// '/settings/agent-configuration': { -// element, -// }, -// '/settings/agent-configuration/create': { -// element, -// params: t.partial({ -// query: t.partial({ -// pageStep: t.string, -// }), -// }), -// }, -// '/settings/agent-configuration/edit': { -// element, -// params: t.partial({ -// query: t.partial({ -// pageStep: t.string, -// }), -// }), -// }, -// '/settings/apm-indices': { -// element, -// }, -// '/settings/custom-links': { -// element, -// }, -// '/settings/schema': { -// element, -// }, -// '/settings/anomaly-detection': { -// element, -// }, -// '/settings/agent-keys': { -// element, -// }, -// '/settings': { -// element, -// }, -// }, -// }, -// '/services/:serviceName': { -// element, -// params: t.intersection([ -// t.type({ -// path: t.type({ -// serviceName: t.string, -// }), -// }), -// t.partial({ -// query: t.partial({ -// environment: t.string, -// rangeFrom: t.string, -// rangeTo: t.string, -// comparisonEnabled: t.string, -// comparisonType: t.string, -// latencyAggregationType: t.string, -// transactionType: t.string, -// kuery: t.string, -// }), -// }), -// ]), -// children: { -// '/services/:serviceName/overview': { -// element, -// }, -// '/services/:serviceName/transactions': { -// element, -// }, -// '/services/:serviceName/transactions/view': { -// element, -// }, -// '/services/:serviceName/dependencies': { -// element, -// }, -// '/services/:serviceName/errors': { -// element, -// children: { -// '/services/:serviceName/errors/:groupId': { -// element, -// params: t.type({ -// path: t.type({ -// groupId: t.string, -// }), -// }), -// }, -// '/services/:serviceName/errors': { -// element, -// params: t.partial({ -// query: t.partial({ -// sortDirection: t.string, -// sortField: t.string, -// pageSize: t.string, -// page: t.string, -// }), -// }), -// }, -// }, -// }, -// '/services/:serviceName/metrics': { -// element, -// }, -// '/services/:serviceName/nodes': { -// element, -// children: { -// '/services/{serviceName}/nodes/{serviceNodeName}/metrics': { -// element, -// }, -// '/services/:serviceName/nodes': { -// element, -// }, -// }, -// }, -// '/services/:serviceName/service-map': { -// element, -// }, -// '/services/:serviceName/logs': { -// element, -// }, -// '/services/:serviceName/profiling': { -// element, -// }, -// '/services/:serviceName': { -// element, -// }, -// }, -// }, -// '/': { -// element, -// params: t.partial({ -// query: t.partial({ -// rangeFrom: t.string, -// rangeTo: t.string, -// }), -// }), -// children: { -// '/services': { -// element, -// }, -// '/traces': { -// element, -// }, -// '/service-map': { -// element, -// }, -// '/backends': { -// element, -// children: { -// '/backends/{backendName}/overview': { -// element, -// }, -// '/backends/overview': { -// element, -// }, -// '/backends': { -// element, -// }, -// }, -// }, -// '/': { -// element, -// }, -// }, -// }, -// }, -// }, -// }; - -// type Routes = typeof routes; - -// type Mapped = MapRoutes; -// type Paths = PathsOf; - -// type Bar = Match; -// type Foo = OutputOf; -// type Baz = OutputOf; - -// const { path }: Foo = {} as any; - -// function _useApmParams>(p: TPath): OutputOf { -// return {} as any; -// } - -// const { -// path: { serviceName }, -// query: { comparisonType }, -// } = _useApmParams('/services/:serviceName/nodes/*'); diff --git a/src/plugins/discover/public/components/data_types/logs/popover_chip.tsx b/src/plugins/discover/public/components/data_types/logs/popover_chip.tsx index e5244a60eedac..e84fcca52d627 100644 --- a/src/plugins/discover/public/components/data_types/logs/popover_chip.tsx +++ b/src/plugins/discover/public/components/data_types/logs/popover_chip.tsx @@ -30,7 +30,11 @@ const DataTablePopoverCellValue = dynamic( () => import('@kbn/unified-data-table/src/components/data_table_cell_value') ); -interface ChipWithPopoverProps { +type ChipWithPopoverChildrenType = (props: { + content: string; +}) => React.ReactNode | React.ReactNode; + +export interface ChipWithPopoverProps { /** * ECS mapping for the key */ @@ -42,6 +46,7 @@ interface ChipWithPopoverProps { dataTestSubj?: string; leftSideIcon?: React.ReactNode; rightSideIcon?: EuiBadgeProps['iconType']; + children?: ChipWithPopoverChildrenType; } export function ChipWithPopover({ @@ -50,6 +55,7 @@ export function ChipWithPopover({ dataTestSubj = `dataTablePopoverChip_${property}`, leftSideIcon, rightSideIcon, + children, }: ChipWithPopoverProps) { return ( )} - /> + > + {children} + ); } @@ -89,9 +97,10 @@ interface ChipPopoverProps { handleChipClickAriaLabel: string; chipCss: SerializedStyles; }) => ReactElement; + children?: ChipWithPopoverChildrenType; } -export function ChipPopover({ property, text, renderChip }: ChipPopoverProps) { +export function ChipPopover({ property, text, renderChip, children }: ChipPopoverProps) { const xsFontSize = useEuiFontSize('xs').fontSize; const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -131,7 +140,8 @@ export function ChipPopover({ property, text, renderChip }: ChipPopoverProps) { - {property} {text} + {property}{' '} + {typeof children === 'function' ? children({ content: text }) : text} diff --git a/src/plugins/discover/public/components/data_types/logs/service_name_chip_with_popover.tsx b/src/plugins/discover/public/components/data_types/logs/service_name_chip_with_popover.tsx new file mode 100644 index 0000000000000..ecb456017c7a9 --- /dev/null +++ b/src/plugins/discover/public/components/data_types/logs/service_name_chip_with_popover.tsx @@ -0,0 +1,48 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { getRouterLinkProps } from '@kbn/router-utils'; +import { EuiLink } from '@elastic/eui'; +import { OBSERVABILITY_ENTITY_CENTRIC_EXPERIENCE } from '@kbn/management-settings-ids'; +import { type ChipWithPopoverProps, ChipWithPopover } from './popover_chip'; +import { useDiscoverServices } from '../../../hooks/use_discover_services'; + +const SERVICE_ENTITY_LOCATOR = 'SERVICE_ENTITY_LOCATOR'; + +export function ServiceNameChipWithPopover(props: ChipWithPopoverProps) { + const { share, core } = useDiscoverServices(); + const canViewApm = core.application.capabilities.apm?.show || false; + const isEntityCentricExperienceSettingEnabled = canViewApm + ? core.uiSettings.get(OBSERVABILITY_ENTITY_CENTRIC_EXPERIENCE) + : false; + const urlService = share?.url; + + const apmLinkToServiceEntityLocator = urlService?.locators.get<{ serviceName: string }>( + SERVICE_ENTITY_LOCATOR + ); + const href = apmLinkToServiceEntityLocator?.getRedirectUrl({ + serviceName: props.text, + }); + + const routeLinkProps = href + ? getRouterLinkProps({ + href, + onClick: () => apmLinkToServiceEntityLocator?.navigate({ serviceName: props.text }), + }) + : undefined; + + return ( + + {canViewApm && isEntityCentricExperienceSettingEnabled && routeLinkProps + ? ({ content }) => {content} + : undefined} + + ); +} diff --git a/src/plugins/discover/public/components/discover_grid/virtual_columns/logs/resource.tsx b/src/plugins/discover/public/components/discover_grid/virtual_columns/logs/resource.tsx index 1cd2ef6ecc55f..e00a84228ed0f 100644 --- a/src/plugins/discover/public/components/discover_grid/virtual_columns/logs/resource.tsx +++ b/src/plugins/discover/public/components/discover_grid/virtual_columns/logs/resource.tsx @@ -15,6 +15,7 @@ import { LogDocument } from '@kbn/discover-utils/src'; import * as constants from '../../../../../common/data_types/logs/constants'; import { getUnformattedResourceFields } from './utils/resource'; import { ChipWithPopover } from '../../../data_types/logs/popover_chip'; +import { ServiceNameChipWithPopover } from '../../../data_types/logs/service_name_chip_with_popover'; const AgentIcon = dynamic(() => import('@kbn/custom-icons/src/components/agent_icon')); @@ -23,7 +24,7 @@ export const Resource = ({ row }: DataGridCellValueElementProps) => { return ( {(resourceDoc[constants.SERVICE_NAME_FIELD] as string) && ( - ({ + core: { + application: { + capabilities: { apm: { show: params?.showApm || false } }, + }, + uiSettings: { + get: () => params?.entityCentricExperienceEnabled || false, + }, + }, + share: { + url: { + locators: { + get: () => ({ getRedirectUrl: jest.fn().mockReturnValue('/apm/foo'), navigate: jest.fn() }), + }, + }, + }, +}); + +setUnifiedDocViewerServices( + merge(mockUnifiedDocViewerServices, getCustomUnifedDocViewerServices()) +); const renderLogsOverview = (props: Partial = {}) => { const { rerender: baseRerender, ...tools } = render( @@ -200,3 +224,85 @@ describe('LogsOverview', () => { }); }); }); + +describe('LogsOverview with APM links', () => { + describe('Highlights section', () => { + describe('When APM and Entity centric experience are enabled', () => { + beforeEach(() => { + setUnifiedDocViewerServices( + merge( + mockUnifiedDocViewerServices, + getCustomUnifedDocViewerServices({ + showApm: true, + entityCentricExperienceEnabled: true, + }) + ) + ); + renderLogsOverview(); + }); + it('should render service name link', () => { + expect( + screen.queryByTestId('unifiedDocViewLogsOverviewServiceNameHighlightLink') + ).toBeInTheDocument(); + }); + + it('should render trace id link', () => { + expect( + screen.queryByTestId('unifiedDocViewLogsOverviewTraceIdHighlightLink') + ).toBeInTheDocument(); + }); + }); + + describe('When APM is enabled and Entity centric experience is disabled', () => { + beforeEach(() => { + setUnifiedDocViewerServices( + merge( + mockUnifiedDocViewerServices, + getCustomUnifedDocViewerServices({ + showApm: true, + entityCentricExperienceEnabled: false, + }) + ) + ); + renderLogsOverview(); + }); + it('should not render service name link', () => { + expect( + screen.queryByTestId('unifiedDocViewLogsOverviewServiceNameHighlightLink') + ).not.toBeInTheDocument(); + }); + + it('should render trace id link', () => { + expect( + screen.queryByTestId('unifiedDocViewLogsOverviewTraceIdHighlightLink') + ).toBeInTheDocument(); + }); + }); + + describe('When APM is disabled and Entity centric experience is enabled', () => { + beforeEach(() => { + setUnifiedDocViewerServices( + merge( + mockUnifiedDocViewerServices, + getCustomUnifedDocViewerServices({ + showApm: false, + entityCentricExperienceEnabled: true, + }) + ) + ); + renderLogsOverview(); + }); + it('should not render service name link', () => { + expect( + screen.queryByTestId('unifiedDocViewLogsOverviewServiceNameHighlightLink') + ).not.toBeInTheDocument(); + }); + + it('should not render trace id link', () => { + expect( + screen.queryByTestId('unifiedDocViewLogsOverviewTraceIdHighlightLink') + ).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview_highlights.tsx b/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview_highlights.tsx index aef9289fcaf04..583e3b5c03fa1 100644 --- a/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview_highlights.tsx +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/logs_overview_highlights.tsx @@ -15,6 +15,8 @@ import { DataTableRecord, LogDocumentOverview, fieldConstants } from '@kbn/disco import { HighlightField } from './sub_components/highlight_field'; import { HighlightSection } from './sub_components/highlight_section'; import { getUnifiedDocViewerServices } from '../../plugin'; +import { ServiceNameHighlightField } from './sub_components/service_name_highlight_field'; +import { TraceIdHighlightField } from './sub_components/trace_id_highlight_field'; export function LogsOverviewHighlights({ formattedDoc, @@ -65,7 +67,7 @@ export function LogsOverviewHighlights({ data-test-subj="unifiedDocViewLogsOverviewHighlightSectionServiceInfra" > {shouldRenderHighlight(fieldConstants.SERVICE_NAME_FIELD) && ( - )} {shouldRenderHighlight(fieldConstants.TRACE_ID_FIELD) && ( - import('./highlight_field_description')); -interface HighlightFieldProps { +export interface HighlightFieldProps { field: string; fieldMetadata?: PartialFieldMetadataPlain; formattedValue?: string; @@ -25,6 +25,7 @@ interface HighlightFieldProps { label: string; useBadge?: boolean; value?: unknown; + children?: (props: { content: React.ReactNode }) => React.ReactNode | React.ReactNode; } export function HighlightField({ @@ -35,6 +36,7 @@ export function HighlightField({ label, useBadge = false, value, + children, ...props }: HighlightFieldProps) { const hasFieldDescription = !!fieldMetadata?.short; @@ -59,13 +61,10 @@ export function HighlightField({ {formattedValue} + ) : typeof children === 'function' ? ( + children({ content: }) ) : ( - + )} @@ -73,6 +72,15 @@ export function HighlightField({ ) : null; } +const FormattedValue = ({ value }: { value: string }) => ( + +); + const fieldNameStyle = css` color: ${euiThemeVars.euiColorDarkShade}; `; diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/service_name_highlight_field.tsx b/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/service_name_highlight_field.tsx new file mode 100644 index 0000000000000..351f45b9a871c --- /dev/null +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/service_name_highlight_field.tsx @@ -0,0 +1,59 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { EuiLink } from '@elastic/eui'; +import { getRouterLinkProps } from '@kbn/router-utils'; +import { OBSERVABILITY_ENTITY_CENTRIC_EXPERIENCE } from '@kbn/management-settings-ids'; +import { HighlightField, HighlightFieldProps } from './highlight_field'; +import { getUnifiedDocViewerServices } from '../../../plugin'; + +const SERVICE_ENTITY_LOCATOR = 'SERVICE_ENTITY_LOCATOR'; + +export function ServiceNameHighlightField(props: HighlightFieldProps) { + const { + share: { url: urlService }, + core, + } = getUnifiedDocViewerServices(); + const canViewApm = core.application.capabilities.apm?.show || false; + + const isEntityCentricExperienceSettingEnabled = canViewApm + ? core.uiSettings.get(OBSERVABILITY_ENTITY_CENTRIC_EXPERIENCE) + : false; + + const apmLinkToServiceEntityLocator = urlService.locators.get<{ serviceName: string }>( + SERVICE_ENTITY_LOCATOR + ); + const href = apmLinkToServiceEntityLocator?.getRedirectUrl({ + serviceName: props.value as string, + }); + + const routeLinkProps = href + ? getRouterLinkProps({ + href, + onClick: () => + apmLinkToServiceEntityLocator?.navigate({ serviceName: props.value as string }), + }) + : undefined; + + return ( + + {canViewApm && isEntityCentricExperienceSettingEnabled && routeLinkProps + ? ({ content }) => ( + + {content} + + ) + : undefined} + + ); +} diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/trace_id_highlight_field.tsx b/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/trace_id_highlight_field.tsx new file mode 100644 index 0000000000000..bbcdbd0e44de5 --- /dev/null +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_logs_overview/sub_components/trace_id_highlight_field.tsx @@ -0,0 +1,50 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { EuiLink } from '@elastic/eui'; +import { getRouterLinkProps } from '@kbn/router-utils'; +import { HighlightField, HighlightFieldProps } from './highlight_field'; +import { getUnifiedDocViewerServices } from '../../../plugin'; + +const TRANSACTION_DETAILS_BY_TRACE_ID_LOCATOR = 'TRANSACTION_DETAILS_BY_TRACE_ID_LOCATOR'; + +export function TraceIdHighlightField(props: HighlightFieldProps) { + const { + share: { url: urlService }, + core, + } = getUnifiedDocViewerServices(); + const canViewApm = core.application.capabilities.apm?.show || false; + + const apmLinkToServiceEntityLocator = urlService.locators.get<{ traceId: string }>( + TRANSACTION_DETAILS_BY_TRACE_ID_LOCATOR + ); + const href = apmLinkToServiceEntityLocator?.getRedirectUrl({ + traceId: props.value as string, + }); + + const routeLinkProps = getRouterLinkProps({ + href, + onClick: () => apmLinkToServiceEntityLocator?.navigate({ traceId: props.value as string }), + }); + return ( + + {canViewApm + ? ({ content }) => ( + + {content} + + ) + : undefined} + + ); +} diff --git a/src/plugins/unified_doc_viewer/tsconfig.json b/src/plugins/unified_doc_viewer/tsconfig.json index eab6884b972ec..212fcb0335c75 100644 --- a/src/plugins/unified_doc_viewer/tsconfig.json +++ b/src/plugins/unified_doc_viewer/tsconfig.json @@ -36,7 +36,8 @@ "@kbn/share-plugin", "@kbn/router-utils", "@kbn/unified-field-list", - "@kbn/core-lifecycle-browser" + "@kbn/core-lifecycle-browser", + "@kbn/management-settings-ids" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/entities/entity_link/entity_link.test.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/entities/entity_link/entity_link.test.tsx new file mode 100644 index 0000000000000..4054614838954 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/public/components/app/entities/entity_link/entity_link.test.tsx @@ -0,0 +1,257 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import React from 'react'; +import { EntityLink } from '.'; +import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; +import type { ServiceEntitySummary } from '../../../../context/apm_service/use_service_entity_summary_fetcher'; +import * as useServiceEntitySummary from '../../../../context/apm_service/use_service_entity_summary_fetcher'; +import type { EntityManagerEnablementContextValue } from '../../../../context/entity_manager_context/entity_manager_context'; +import * as useEntityManagerEnablementContext from '../../../../context/entity_manager_context/use_entity_manager_enablement_context'; +import * as useFetcher from '../../../../hooks/use_fetcher'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; +import { fromQuery } from '../../../shared/links/url_helpers'; +import { APIReturnType } from '../../../../services/rest/create_call_apm_api'; +import { Redirect } from 'react-router-dom'; +import { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context'; +import { ApmThemeProvider } from '../../../routing/app_root'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), // Keep other functionality intact + Redirect: jest.fn(() => Mocked Redirect), // Mock Redirect with a custom implementation +})); + +export type HasApmData = APIReturnType<'GET /internal/apm/has_data'>; + +const renderEntityLink = ({ + entityManagerMockReturnValue, + serviceEntitySummaryMockReturnValue, + hasApmDataFetcherMockReturnValue, + query = {}, +}: { + entityManagerMockReturnValue: Partial; + serviceEntitySummaryMockReturnValue: ReturnType< + typeof useServiceEntitySummary.useServiceEntitySummaryFetcher + >; + hasApmDataFetcherMockReturnValue: { data?: HasApmData; status: FETCH_STATUS }; + query?: { + rangeFrom?: string; + rangeTo?: string; + }; +}) => { + jest + .spyOn(useEntityManagerEnablementContext, 'useEntityManagerEnablementContext') + .mockReturnValue( + entityManagerMockReturnValue as unknown as EntityManagerEnablementContextValue + ); + + jest + .spyOn(useServiceEntitySummary, 'useServiceEntitySummaryFetcher') + .mockReturnValue(serviceEntitySummaryMockReturnValue); + + jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ + ...hasApmDataFetcherMockReturnValue, + refetch: jest.fn(), + }); + + const history = createMemoryHistory(); + + history.replace({ + pathname: '/link-to/entity/foo', + search: fromQuery(query), + }); + + const { rerender, ...tools } = render( + ({ + from: 'now-24h', + to: 'now', + }), + }, + }, + }, + }, + }, + } as unknown as ApmPluginContextValue + } + > + + + + + ); + return { rerender, ...tools }; +}; + +describe('Entity link', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders a loading spinner while fetching data', () => { + renderEntityLink({ + entityManagerMockReturnValue: { + isEntityCentricExperienceViewEnabled: undefined, + isEnablementPending: true, + }, + serviceEntitySummaryMockReturnValue: { + serviceEntitySummary: undefined, + serviceEntitySummaryStatus: FETCH_STATUS.LOADING, + }, + hasApmDataFetcherMockReturnValue: { + data: undefined, + status: FETCH_STATUS.LOADING, + }, + }); + expect(screen.queryByTestId('apmEntityLinkLoadingSpinner')).toBeInTheDocument(); + }); + + it('renders EEM callout when EEM is enabled but service is not found on EEM indices', () => { + renderEntityLink({ + entityManagerMockReturnValue: { + isEntityCentricExperienceViewEnabled: true, + isEnablementPending: false, + }, + serviceEntitySummaryMockReturnValue: { + serviceEntitySummary: undefined, + serviceEntitySummaryStatus: FETCH_STATUS.SUCCESS, + }, + hasApmDataFetcherMockReturnValue: { + data: { hasData: false }, + status: FETCH_STATUS.SUCCESS, + }, + }); + + expect(screen.queryByTestId('apmEntityLinkLoadingSpinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('apmEntityLinkEEMCallout')).toBeInTheDocument(); + }); + + it('renders Service Overview page when EEM is disabled', () => { + renderEntityLink({ + entityManagerMockReturnValue: { + isEntityCentricExperienceViewEnabled: false, + isEnablementPending: false, + }, + serviceEntitySummaryMockReturnValue: { + serviceEntitySummary: undefined, + serviceEntitySummaryStatus: FETCH_STATUS.SUCCESS, + }, + hasApmDataFetcherMockReturnValue: { + data: { hasData: false }, + status: FETCH_STATUS.SUCCESS, + }, + query: { + rangeFrom: 'now-1h', + rangeTo: 'now', + }, + }); + + expect(screen.queryByTestId('apmEntityLinkLoadingSpinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('apmEntityLinkEEMCallout')).not.toBeInTheDocument(); + expect(Redirect).toHaveBeenCalledWith( + expect.objectContaining({ + to: '/services/foo/overview?comparisonEnabled=true&environment=ENVIRONMENT_ALL&kuery=&latencyAggregationType=avg&rangeFrom=now-1h&rangeTo=now&serviceGroup=', + }), + {} + ); + }); + + it('renders Service Overview page when EEM is enabled but Service is not found on EEM but it has raw APM data', () => { + renderEntityLink({ + entityManagerMockReturnValue: { + isEntityCentricExperienceViewEnabled: true, + isEnablementPending: false, + }, + serviceEntitySummaryMockReturnValue: { + serviceEntitySummary: undefined, + serviceEntitySummaryStatus: FETCH_STATUS.SUCCESS, + }, + hasApmDataFetcherMockReturnValue: { + data: { hasData: true }, + status: FETCH_STATUS.SUCCESS, + }, + query: { + rangeFrom: 'now-1h', + rangeTo: 'now', + }, + }); + + expect(screen.queryByTestId('apmEntityLinkLoadingSpinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('apmEntityLinkEEMCallout')).not.toBeInTheDocument(); + expect(Redirect).toHaveBeenCalledWith( + expect.objectContaining({ + to: '/services/foo/overview?comparisonEnabled=true&environment=ENVIRONMENT_ALL&kuery=&latencyAggregationType=avg&rangeFrom=now-1h&rangeTo=now&serviceGroup=', + }), + {} + ); + }); + + it('renders Service Overview page when EEM is enabled and Service is found on EEM', () => { + renderEntityLink({ + entityManagerMockReturnValue: { + isEntityCentricExperienceViewEnabled: true, + isEnablementPending: false, + }, + serviceEntitySummaryMockReturnValue: { + serviceEntitySummary: { dataStreamTypes: ['metrics'] } as unknown as ServiceEntitySummary, + serviceEntitySummaryStatus: FETCH_STATUS.SUCCESS, + }, + hasApmDataFetcherMockReturnValue: { + data: { hasData: true }, + status: FETCH_STATUS.SUCCESS, + }, + query: { + rangeFrom: 'now-1h', + rangeTo: 'now', + }, + }); + + expect(screen.queryByTestId('apmEntityLinkLoadingSpinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('apmEntityLinkEEMCallout')).not.toBeInTheDocument(); + expect(Redirect).toHaveBeenCalledWith( + expect.objectContaining({ + to: '/services/foo/overview?comparisonEnabled=true&environment=ENVIRONMENT_ALL&kuery=&latencyAggregationType=avg&rangeFrom=now-1h&rangeTo=now&serviceGroup=', + }), + {} + ); + }); + + it('renders Service Overview page setting time range from data plugin', () => { + renderEntityLink({ + entityManagerMockReturnValue: { + isEntityCentricExperienceViewEnabled: true, + isEnablementPending: false, + }, + serviceEntitySummaryMockReturnValue: { + serviceEntitySummary: { dataStreamTypes: ['metrics'] } as unknown as ServiceEntitySummary, + serviceEntitySummaryStatus: FETCH_STATUS.SUCCESS, + }, + hasApmDataFetcherMockReturnValue: { + data: { hasData: true }, + status: FETCH_STATUS.SUCCESS, + }, + }); + + expect(screen.queryByTestId('apmEntityLinkLoadingSpinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('apmEntityLinkEEMCallout')).not.toBeInTheDocument(); + expect(Redirect).toHaveBeenCalledWith( + expect.objectContaining({ + to: '/services/foo/overview?comparisonEnabled=true&environment=ENVIRONMENT_ALL&kuery=&latencyAggregationType=avg&rangeFrom=now-24h&rangeTo=now&serviceGroup=', + }), + {} + ); + }); +}); diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/entities/entity_link/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/entities/entity_link/index.tsx new file mode 100644 index 0000000000000..5fdbcc9399258 --- /dev/null +++ b/x-pack/plugins/observability_solution/apm/public/components/app/entities/entity_link/index.tsx @@ -0,0 +1,138 @@ +/* + * 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 { EuiButtonEmpty, EuiEmptyPrompt, EuiImage, EuiLink, EuiLoadingSpinner } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { dashboardsDark, dashboardsLight } from '@kbn/shared-svg'; +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import { ENVIRONMENT_ALL_VALUE } from '../../../../../common/environment_filter_values'; +import { useServiceEntitySummaryFetcher } from '../../../../context/apm_service/use_service_entity_summary_fetcher'; +import { useEntityManagerEnablementContext } from '../../../../context/entity_manager_context/use_entity_manager_enablement_context'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { useApmRouter } from '../../../../hooks/use_apm_router'; +import { FETCH_STATUS, isPending, useFetcher } from '../../../../hooks/use_fetcher'; +import { useTheme } from '../../../../hooks/use_theme'; +import { ApmPluginStartDeps } from '../../../../plugin'; + +const pageHeader = { + pageTitle: 'APM', +}; + +export function EntityLink() { + const router = useApmRouter({ prependBasePath: false }); + const theme = useTheme(); + const { services } = useKibana(); + const { observabilityShared, data } = services; + const timeRange = data.query.timefilter.timefilter.getTime(); + const ObservabilityPageTemplate = observabilityShared.navigation.PageTemplate; + const { + path: { serviceName }, + query: { rangeFrom = timeRange.from, rangeTo = timeRange.to }, + } = useApmParams('/link-to/entity/{serviceName}'); + const { isEntityCentricExperienceViewEnabled, isEnablementPending } = + useEntityManagerEnablementContext(); + + const { serviceEntitySummary, serviceEntitySummaryStatus } = useServiceEntitySummaryFetcher({ + serviceName, + environment: ENVIRONMENT_ALL_VALUE, + }); + + const { data: hasApmData, status: hasApmDataStatus } = useFetcher((callApmApi) => { + return callApmApi('GET /internal/apm/has_data'); + }, []); + + if ( + isEnablementPending || + serviceEntitySummaryStatus === FETCH_STATUS.LOADING || + isPending(hasApmDataStatus) + ) { + return ; + } + + if ( + // When EEM is enabled and the service is not found on the EEM indices and there's no APM data, display a callout guiding on the limitations of EEM + isEntityCentricExperienceViewEnabled === true && + (serviceEntitySummary?.dataStreamTypes === undefined || + serviceEntitySummary.dataStreamTypes.length === 0) && + hasApmData?.hasData !== true + ) { + return ( + + + } + title={ + + {i18n.translate('xpack.apm.entityLink.eemGuide.title', { + defaultMessage: 'Service not supported', + })} + + } + body={ + + + {i18n.translate('xpack.apm.entityLink.eemGuide.description.link', { + defaultMessage: 'limitations with the Elastic Entity Model', + })} + + ), + }} + /> + + } + actions={[ + { + window.history.back(); + }} + > + {i18n.translate('xpack.apm.entityLink.eemGuide.goBackButtonLabel', { + defaultMessage: 'Go back', + })} + , + ]} + /> + + ); + } + + return ( + + ); +} diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/trace_link/index.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/trace_link/index.tsx index 24a30364e266d..74b3975335c90 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/trace_link/index.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/trace_link/index.tsx @@ -7,13 +7,16 @@ import { EuiEmptyPrompt } from '@elastic/eui'; import React from 'react'; +import { i18n } from '@kbn/i18n'; import { Redirect } from 'react-router-dom'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { getRedirectToTransactionDetailPageUrl } from './get_redirect_to_transaction_detail_page_url'; import { getRedirectToTracePageUrl } from './get_redirect_to_trace_page_url'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useTimeRange } from '../../../hooks/use_time_range'; +import { ApmPluginStartDeps } from '../../../plugin'; const CentralizedContainer = euiStyled.div` height: 100%; @@ -21,9 +24,12 @@ const CentralizedContainer = euiStyled.div` `; export function TraceLink() { + const { services } = useKibana(); + const { data: dataService } = services; + const timeRange = dataService.query.timefilter.timefilter.getTime(); const { path: { traceId }, - query: { rangeFrom, rangeTo }, + query: { rangeFrom = timeRange.from, rangeTo = timeRange.to }, } = useApmParams('/link-to/trace/{traceId}'); const { start, end } = useTimeRange({ @@ -35,15 +41,7 @@ export function TraceLink() { (callApmApi) => { if (traceId) { return callApmApi('GET /internal/apm/traces/{traceId}/root_transaction', { - params: { - path: { - traceId, - }, - query: { - start, - end, - }, - }, + params: { path: { traceId }, query: { start, end } }, }); } }, @@ -62,7 +60,16 @@ export function TraceLink() { return ( - Fetching trace...} /> + + {i18n.translate('xpack.apm.traceLink.fetchingTraceLabel', { + defaultMessage: 'Fetching trace...', + })} + + } + /> ); } diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/trace_link/trace_link.test.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/trace_link/trace_link.test.tsx index 432262bb79b11..3250702b0cb80 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/trace_link/trace_link.test.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/trace_link/trace_link.test.tsx @@ -18,6 +18,17 @@ import { import * as hooks from '../../../hooks/use_fetcher'; import * as useApmParamsHooks from '../../../hooks/use_apm_params'; +jest.mock('@kbn/kibana-react-plugin/public', () => ({ + ...jest.requireActual('@kbn/kibana-react-plugin/public'), + useKibana: jest.fn().mockReturnValue({ + services: { + data: { + query: { timefilter: { timefilter: { getTime: () => ({ from: 'now-1h', to: 'now' }) } } }, + }, + }, + }), +})); + function Wrapper({ children }: { children?: ReactNode }) { return ( @@ -125,5 +136,20 @@ describe('TraceLink', () => { '/services/foo/transactions/view?traceId=123&transactionId=456&transactionName=bar&transactionType=GET&rangeFrom=now-24h&rangeTo=now&waterfallItemId=' ); }); + + it('sets time range from data plugin when client does not pass it', () => { + jest.spyOn(useApmParamsHooks as any, 'useApmParams').mockReturnValue({ + path: { + traceId: '123', + }, + query: {}, + }); + + const component = shallow(); + + expect(component.prop('to')).toEqual( + '/services/foo/transactions/view?traceId=123&transactionId=456&transactionName=bar&transactionType=GET&rangeFrom=now-1h&rangeTo=now&waterfallItemId=' + ); + }); }); }); diff --git a/x-pack/plugins/observability_solution/apm/public/components/routing/apm_route_config.tsx b/x-pack/plugins/observability_solution/apm/public/components/routing/apm_route_config.tsx index dd82e775d556d..4a83df349035a 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/routing/apm_route_config.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/routing/apm_route_config.tsx @@ -24,6 +24,7 @@ import { ServiceGroupsList } from '../app/service_groups'; import { offsetRt } from '../../../common/comparison_rt'; import { diagnosticsRoute } from '../app/diagnostics'; import { TransactionDetailsByNameLink } from '../app/transaction_details_link'; +import { EntityLink } from '../app/entities/entity_link'; const ServiceGroupsTitle = i18n.translate('xpack.apm.views.serviceGroups.title', { defaultMessage: 'Services', @@ -34,6 +35,18 @@ const ServiceGroupsTitle = i18n.translate('xpack.apm.views.serviceGroups.title', * creates the routes. */ const apmRoutes = { + '/link-to/entity/{serviceName}': { + element: , + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.partial({ + rangeFrom: t.string, + rangeTo: t.string, + }), + }), + }, '/link-to/transaction': { element: , params: t.type({ diff --git a/x-pack/plugins/observability_solution/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx b/x-pack/plugins/observability_solution/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx index 4bfb69810a524..3ea8707fe7849 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx @@ -8,13 +8,12 @@ import type { Story, DecoratorFn } from '@storybook/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { CoreStart } from '@kbn/core/public'; -import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values'; import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; import { APMServiceContext } from '../../../../context/apm_service/apm_service_context'; import { AnalyzeDataButton } from './analyze_data_button'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; +import { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context'; interface Args { agentName: string; @@ -30,13 +29,6 @@ export default { (StoryComponent, { args }) => { const { agentName, canShowDashboard, environment, serviceName } = args; - const KibanaContext = createKibanaReactContext({ - application: { - capabilities: { dashboard: { show: canShowDashboard } }, - }, - http: { basePath: { get: () => '' } }, - } as unknown as Partial); - return ( - + '' } }, + }, + } as unknown as ApmPluginContextValue + } + > - - - + diff --git a/x-pack/plugins/observability_solution/apm/public/components/shared/unified_search_bar/unified_search_bar.test.tsx b/x-pack/plugins/observability_solution/apm/public/components/shared/unified_search_bar/unified_search_bar.test.tsx index 4156861f3049d..8a7e71907a62a 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/shared/unified_search_bar/unified_search_bar.test.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/shared/unified_search_bar/unified_search_bar.test.tsx @@ -5,20 +5,19 @@ * 2.0. */ +import { mount } from 'enzyme'; import { createMemoryHistory, MemoryHistory } from 'history'; import React from 'react'; import { useLocation } from 'react-router-dom'; -import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; +import { UnifiedSearchBar } from '.'; +import { ApmPluginContextValue } from '../../../context/apm_plugin/apm_plugin_context'; import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; -import * as useFetcherHook from '../../../hooks/use_fetcher'; +import { UrlParams } from '../../../context/url_params_context/types'; import * as useApmDataViewHook from '../../../hooks/use_adhoc_apm_data_view'; import * as useApmParamsHook from '../../../hooks/use_apm_params'; +import * as useFetcherHook from '../../../hooks/use_fetcher'; import * as useProcessorEventHook from '../../../hooks/use_processor_event'; import { fromQuery } from '../links/url_helpers'; -import { CoreStart } from '@kbn/core/public'; -import { UnifiedSearchBar } from '.'; -import { UrlParams } from '../../../context/url_params_context/types'; -import { mount } from 'enzyme'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -37,41 +36,44 @@ function setup({ urlParams, history }: { urlParams: UrlParams; history: MemoryHi const setTimeSpy = jest.fn(); const setRefreshIntervalSpy = jest.fn(); - const KibanaReactContext = createKibanaReactContext({ - usageCollection: { - reportUiCounter: () => {}, - }, - dataViews: { - get: async () => {}, - }, - data: { - query: { - queryString: { - setQuery: setQuerySpy, - getQuery: getQuerySpy, - clearQuery: clearQuerySpy, - }, - timefilter: { - timefilter: { - setTime: setTimeSpy, - setRefreshInterval: setRefreshIntervalSpy, - }, - }, - }, - }, - } as Partial); - // mock transaction types jest.spyOn(useApmDataViewHook, 'useAdHocApmDataView').mockReturnValue({ dataView: undefined }); jest.spyOn(useFetcherHook, 'useFetcher').mockReturnValue({} as any); const wrapper = mount( - - - - - + {}, + }, + dataViews: { + get: async () => {}, + }, + data: { + query: { + queryString: { + setQuery: setQuerySpy, + getQuery: getQuerySpy, + clearQuery: clearQuerySpy, + }, + timefilter: { + timefilter: { + setTime: setTimeSpy, + setRefreshInterval: setRefreshIntervalSpy, + }, + }, + }, + }, + }, + } as unknown as ApmPluginContextValue + } + > + + ); return { @@ -91,6 +93,11 @@ describe('when kuery is already present in the url, the search bar must reflect jest.spyOn(history, 'push'); jest.spyOn(history, 'replace'); }); + + afterAll(() => { + jest.clearAllMocks(); + }); + jest.spyOn(useProcessorEventHook, 'useProcessorEvent').mockReturnValue(undefined); const search = '?method=json'; diff --git a/x-pack/plugins/observability_solution/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/observability_solution/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index e026dc210df25..961ac7c733e50 100644 --- a/x-pack/plugins/observability_solution/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/observability_solution/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -5,28 +5,32 @@ * 2.0. */ -import React, { ReactNode, useMemo } from 'react'; -import { RouterProvider } from '@kbn/typed-react-router-config'; -import { useHistory } from 'react-router-dom'; -import { createMemoryHistory, History } from 'history'; -import { merge, noop } from 'lodash'; import { coreMock } from '@kbn/core/public/mocks'; -import { UrlService } from '@kbn/share-plugin/common/url_service'; -import { createObservabilityRuleTypeRegistryMock } from '@kbn/observability-plugin/public'; +import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { LogsLocatorParams, NodeLogsLocatorParams, TraceLogsLocatorParams, } from '@kbn/logs-shared-plugin/common'; -import { UI_SETTINGS } from '@kbn/data-plugin/common'; import { MlLocatorDefinition } from '@kbn/ml-plugin/public'; -import { enableComparisonByDefault } from '@kbn/observability-plugin/public'; -import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; import { apmEnableProfilingIntegration } from '@kbn/observability-plugin/common'; -import { ApmPluginContext, ApmPluginContextValue } from './apm_plugin_context'; +import { + createObservabilityRuleTypeRegistryMock, + enableComparisonByDefault, +} from '@kbn/observability-plugin/public'; +import { UrlService } from '@kbn/share-plugin/common/url_service'; +import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; +import { RouterProvider } from '@kbn/typed-react-router-config'; +import { History, createMemoryHistory } from 'history'; +import { merge, noop } from 'lodash'; +import React, { ReactNode, useMemo } from 'react'; +import { useHistory } from 'react-router-dom'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { ConfigSchema } from '../..'; -import { createCallApmApi } from '../../services/rest/create_call_apm_api'; import { apmRouter } from '../../components/routing/apm_route_config'; +import { createCallApmApi } from '../../services/rest/create_call_apm_api'; +import { ApmPluginContext, ApmPluginContextValue } from './apm_plugin_context'; const coreStart = coreMock.createStart({ basePath: '/basepath' }); @@ -67,6 +71,23 @@ const mockCore = merge({}, coreStart, { return uiSettings[key]; }, }, + data: { + query: { + queryString: { getQuery: jest.fn(), setQuery: jest.fn(), clearQuery: jest.fn() }, + timefilter: { + timefilter: { + setTime: jest.fn(), + setRefreshInterval: jest.fn(), + getTime: jest.fn(), + }, + }, + }, + }, + observabilityShared: { + navigation: { + PageTemplate: ({ children }: { children: React.ReactNode }) => {children}, + }, + }, }); const mockConfig: ConfigSchema = { @@ -203,11 +224,16 @@ export function MockApmPluginContextWrapper({ }) ); }, [history, contextHistory]); + return ( - - - {children} - - + + + + + {children} + + + + ); } diff --git a/x-pack/plugins/observability_solution/apm/public/context/apm_plugin/mock_apm_plugin_storybook.tsx b/x-pack/plugins/observability_solution/apm/public/context/apm_plugin/mock_apm_plugin_storybook.tsx index a342f84e9c5c3..14d8d4404a719 100644 --- a/x-pack/plugins/observability_solution/apm/public/context/apm_plugin/mock_apm_plugin_storybook.tsx +++ b/x-pack/plugins/observability_solution/apm/public/context/apm_plugin/mock_apm_plugin_storybook.tsx @@ -187,7 +187,7 @@ export function MockApmPluginStorybook({ contextMock.core as unknown as Partial ); - const history2 = createMemoryHistory({ + const history = createMemoryHistory({ initialEntries: [routePath || '/services/?rangeFrom=now-15m&rangeTo=now'], }); @@ -197,7 +197,7 @@ export function MockApmPluginStorybook({ - + {children} diff --git a/x-pack/plugins/observability_solution/apm/public/context/entity_manager_context/entity_manager_context.tsx b/x-pack/plugins/observability_solution/apm/public/context/entity_manager_context/entity_manager_context.tsx index c2c3d6c1a57be..93205c907caa0 100644 --- a/x-pack/plugins/observability_solution/apm/public/context/entity_manager_context/entity_manager_context.tsx +++ b/x-pack/plugins/observability_solution/apm/public/context/entity_manager_context/entity_manager_context.tsx @@ -4,21 +4,20 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { createContext } from 'react'; import { entityCentricExperience } from '@kbn/observability-plugin/common'; -import { ENTITY_FETCH_STATUS, useEntityManager } from '../../hooks/use_entity_manager'; -import { useLocalStorage } from '../../hooks/use_local_storage'; -import { useApmPluginContext } from '../apm_plugin/use_apm_plugin_context'; +import React, { createContext } from 'react'; import { SERVICE_INVENTORY_STORAGE_KEY, serviceInventoryViewType$, } from '../../analytics/register_service_inventory_view_type_context'; -import { useKibana } from '../kibana_context/use_kibana'; +import { useLocalStorage } from '../../hooks/use_local_storage'; import { ApmPluginStartDeps, ApmServices } from '../../plugin'; +import { useApmPluginContext } from '../apm_plugin/use_apm_plugin_context'; +import { useKibana } from '../kibana_context/use_kibana'; +import { ENTITY_FETCH_STATUS, useEntityManager } from './use_entity_manager'; export interface EntityManagerEnablementContextValue { isEntityManagerEnabled: boolean; - entityManagerEnablementStatus: ENTITY_FETCH_STATUS; isEnablementPending: boolean; refetch: () => void; serviceInventoryViewLocalStorageSetting: ServiceInventoryView; @@ -55,7 +54,6 @@ export function EntityManagerEnablementContextProvider({ const { services } = useKibana(); const { isEnabled: isEntityManagerEnabled, status, refetch } = useEntityManager(); const [tourState, setTourState] = useLocalStorage('apm.serviceEcoTour', TOUR_INITIAL_STATE); - const [serviceInventoryViewLocalStorageSetting, setServiceInventoryViewLocalStorageSetting] = useLocalStorage(SERVICE_INVENTORY_STORAGE_KEY, ServiceInventoryView.classic); @@ -86,7 +84,6 @@ export function EntityManagerEnablementContextProvider({ ({ ...router, - link: (...args: [any]) => core.http.basePath.prepend('/app/apm' + router.link(...args)), + link: (...args: [any]) => + prependBasePath + ? core.http.basePath.prepend('/app/apm' + router.link(...args)) + : router.link(...args), } as unknown as ApmRouter), - [core.http.basePath, router] + [core.http.basePath, prependBasePath, router] ); } diff --git a/x-pack/plugins/observability_solution/apm/public/plugin.ts b/x-pack/plugins/observability_solution/apm/public/plugin.ts index 9524f328755c0..c429fdaeb9ec5 100644 --- a/x-pack/plugins/observability_solution/apm/public/plugin.ts +++ b/x-pack/plugins/observability_solution/apm/public/plugin.ts @@ -19,7 +19,10 @@ import { PluginInitializerContext, SecurityServiceStart, } from '@kbn/core/public'; -import { EntityManagerPublicPluginSetup } from '@kbn/entityManager-plugin/public'; +import { + EntityManagerPublicPluginSetup, + EntityManagerPublicPluginStart, +} from '@kbn/entityManager-plugin/public'; import type { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { DiscoverSetup, DiscoverStart } from '@kbn/discover-plugin/public'; @@ -145,7 +148,7 @@ export interface ApmPluginStartDeps { dashboard: DashboardStart; metricsDataAccess: MetricsDataPluginStart; uiSettings: IUiSettingsClient; - entityManager: EntityManagerPublicPluginSetup; + entityManager: EntityManagerPublicPluginStart; } const applicationsTitle = i18n.translate('xpack.apm.navigation.rootTitle', { diff --git a/x-pack/plugins/observability_solution/apm/tsconfig.json b/x-pack/plugins/observability_solution/apm/tsconfig.json index 7de9609dbbafe..9195c2547a71a 100644 --- a/x-pack/plugins/observability_solution/apm/tsconfig.json +++ b/x-pack/plugins/observability_solution/apm/tsconfig.json @@ -130,7 +130,7 @@ "@kbn/entities-schema", "@kbn/serverless", "@kbn/aiops-log-rate-analysis", - "@kbn/router-utils" + "@kbn/router-utils", ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/observability_solution/observability_shared/common/index.ts b/x-pack/plugins/observability_solution/observability_shared/common/index.ts index ab49080f313ba..82d4bbfe6b3d6 100644 --- a/x-pack/plugins/observability_solution/observability_shared/common/index.ts +++ b/x-pack/plugins/observability_solution/observability_shared/common/index.ts @@ -169,6 +169,10 @@ export type { StacktracesLocator, TopNFunctionsLocatorParams, TopNFunctionsLocator, + ServiceEntityLocator, + ServiceEntityLocatorParams, + TransactionDetailsByTraceIdLocator, + TransactionDetailsByTraceIdLocatorParams, } from './locators'; export { @@ -188,6 +192,10 @@ export { StacktracesLocatorDefinition, TopNFunctionsLocatorDefinition, HOSTS_LOCATOR_ID, + ServiceEntityLocatorDefinition, + SERVICE_ENTITY_LOCATOR, + TransactionDetailsByTraceIdLocatorDefinition, + TRANSACTION_DETAILS_BY_TRACE_ID_LOCATOR, } from './locators'; export { COMMON_OBSERVABILITY_GROUPING } from './embeddable_grouping'; diff --git a/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/service_entity_locator.ts b/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/service_entity_locator.ts new file mode 100644 index 0000000000000..3301d0c616231 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/service_entity_locator.ts @@ -0,0 +1,30 @@ +/* + * 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 type { SerializableRecord } from '@kbn/utility-types'; +import type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; + +export const SERVICE_ENTITY_LOCATOR = 'SERVICE_ENTITY_LOCATOR'; + +export interface ServiceEntityLocatorParams extends SerializableRecord { + serviceName: string; +} + +export type ServiceEntityLocator = LocatorPublic; + +export class ServiceEntityLocatorDefinition + implements LocatorDefinition +{ + public readonly id = SERVICE_ENTITY_LOCATOR; + + public readonly getLocation = async ({ serviceName }: ServiceEntityLocatorParams) => { + return { + app: 'apm', + path: `/link-to/entity/${encodeURIComponent(serviceName)}`, + state: {}, + }; + }; +} diff --git a/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/transaction_details_by_trace_id_locator.ts b/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/transaction_details_by_trace_id_locator.ts new file mode 100644 index 0000000000000..2e461bc4f9d55 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_shared/common/locators/apm/transaction_details_by_trace_id_locator.ts @@ -0,0 +1,31 @@ +/* + * 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 type { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public'; +import type { SerializableRecord } from '@kbn/utility-types'; + +export const TRANSACTION_DETAILS_BY_TRACE_ID_LOCATOR = 'TRANSACTION_DETAILS_BY_TRACE_ID_LOCATOR'; + +export interface TransactionDetailsByTraceIdLocatorParams extends SerializableRecord { + traceId: string; +} + +export type TransactionDetailsByTraceIdLocator = + LocatorPublic; + +export class TransactionDetailsByTraceIdLocatorDefinition + implements LocatorDefinition +{ + public readonly id = TRANSACTION_DETAILS_BY_TRACE_ID_LOCATOR; + + public readonly getLocation = async ({ traceId }: TransactionDetailsByTraceIdLocatorParams) => { + return { + app: 'apm', + path: `/link-to/trace/${encodeURIComponent(traceId)}`, + state: {}, + }; + }; +} diff --git a/x-pack/plugins/observability_solution/observability_shared/common/locators/index.ts b/x-pack/plugins/observability_solution/observability_shared/common/locators/index.ts index 98604adc201a2..9c5ded4940d5a 100644 --- a/x-pack/plugins/observability_solution/observability_shared/common/locators/index.ts +++ b/x-pack/plugins/observability_solution/observability_shared/common/locators/index.ts @@ -7,6 +7,8 @@ export * from './apm/service_overview_locator'; export * from './apm/transaction_details_by_name_locator'; +export * from './apm/transaction_details_by_trace_id_locator'; +export * from './apm/service_entity_locator'; export * from './infra/asset_details_flyout_locator'; export * from './infra/asset_details_locator'; export * from './infra/hosts_locator'; diff --git a/x-pack/plugins/observability_solution/observability_shared/public/plugin.ts b/x-pack/plugins/observability_solution/observability_shared/public/plugin.ts index 723ab4f758af4..7cd63d7be7602 100644 --- a/x-pack/plugins/observability_solution/observability_shared/public/plugin.ts +++ b/x-pack/plugins/observability_solution/observability_shared/public/plugin.ts @@ -31,6 +31,8 @@ import { TopNFunctionsLocatorDefinition, ServiceOverviewLocatorDefinition, TransactionDetailsByNameLocatorDefinition, + ServiceEntityLocatorDefinition, + TransactionDetailsByTraceIdLocatorDefinition, type AssetDetailsFlyoutLocator, type AssetDetailsLocator, type InventoryLocator, @@ -41,6 +43,8 @@ import { type ServiceOverviewLocator, type TransactionDetailsByNameLocator, type MetricsExplorerLocator, + type ServiceEntityLocator, + type TransactionDetailsByTraceIdLocator, } from '../common'; import { updateGlobalNavigation } from './services/update_global_navigation'; export interface ObservabilitySharedSetup { @@ -75,6 +79,8 @@ interface ObservabilitySharedLocators { apm: { serviceOverview: ServiceOverviewLocator; transactionDetailsByName: TransactionDetailsByNameLocator; + transactionDetailsByTraceId: TransactionDetailsByTraceIdLocator; + serviceEntity: ServiceEntityLocator; }; } @@ -148,6 +154,10 @@ export class ObservabilitySharedPlugin implements Plugin { transactionDetailsByName: urlService.locators.create( new TransactionDetailsByNameLocatorDefinition() ), + transactionDetailsByTraceId: urlService.locators.create( + new TransactionDetailsByTraceIdLocatorDefinition() + ), + serviceEntity: urlService.locators.create(new ServiceEntityLocatorDefinition()), }, }; }
+ + {i18n.translate('xpack.apm.entityLink.eemGuide.description.link', { + defaultMessage: 'limitations with the Elastic Entity Model', + })} + + ), + }} + /> +