diff --git a/src/platform/packages/shared/kbn-synthtrace/src/scenarios/missing_service_environment.ts b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/missing_service_environment.ts new file mode 100644 index 0000000000000..9a8a8285d7174 --- /dev/null +++ b/src/platform/packages/shared/kbn-synthtrace/src/scenarios/missing_service_environment.ts @@ -0,0 +1,112 @@ +/* + * 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". + */ + +/** + * Generates OTel traces with no service.environment (namespace omitted). + * Use to verify UI does not crash when opening error samples, transaction/span + * flyouts, or agent config links for data without environment. + */ + +import type { ApmOtelFields, OtelInstance } from '@kbn/synthtrace-client'; +import { apm, ApmSynthtracePipelineSchema } from '@kbn/synthtrace-client'; +import { withClient } from '../lib/utils/with_client'; +import type { Scenario } from '../cli/scenario'; + +const scenario: Scenario = async (runOptions) => { + return { + generate: ({ range, clients: { apmEsClient } }) => { + const transactionName = 'oteldemo.AdServiceSynth/GetAds'; + + const { logger } = runOptions; + + const edotInstance = apm + .otelService({ + name: 'adservice-edot-synth', + sdkLanguage: 'java', + sdkName: 'opentelemetry', + distro: 'elastic', + }) + .instance('edot-instance'); + + const otelNativeInstance = apm + .otelService({ + name: 'sendotlp-otel-native-synth', + sdkName: 'otlp', + sdkLanguage: 'nodejs', + }) + .instance('otel-native-instance'); + + const successfulTimestamps = range.interval('1m').rate(180); + const failedTimestamps = range.interval('1m').rate(40); + + const instanceSpans = (instance: OtelInstance) => { + const successfulTraceEvents = successfulTimestamps.generator((timestamp) => + instance + .span({ + name: transactionName, + kind: 'Server', + }) + .timestamp(timestamp) + .duration(1000) + .success() + .children( + instance + .dbExitSpan({ + name: 'GET apm-*/_search', + type: 'elasticsearch', + }) + .duration(1000) + .success() + .timestamp(timestamp), + instance + .span({ + name: 'custom_operation', + kind: 'Internal', + }) + .duration(100) + .success() + .timestamp(timestamp) + ) + ); + + const failedTraceEvents = failedTimestamps.generator((timestamp) => + instance + .span({ name: transactionName, kind: 'Server' }) + .timestamp(timestamp) + .duration(1000) + .failure() + .errors( + instance + .error({ + message: '[ResponseError] index_not_found_exception', + type: 'ResponseError', + }) + .timestamp(timestamp + 50) + ) + ); + + return [successfulTraceEvents, failedTraceEvents]; + }; + + return [ + withClient( + apmEsClient, + logger.perf('generating_otel_trace', () => + [otelNativeInstance, edotInstance].flatMap((instance) => instanceSpans(instance)) + ) + ), + ]; + }, + setupPipeline: ({ apmEsClient }) => { + apmEsClient.setPipeline(apmEsClient.resolvePipelineType(ApmSynthtracePipelineSchema.Otel)); + }, + }; +}; + +export default scenario; diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_contextual_insight.test.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_contextual_insight.test.tsx new file mode 100644 index 0000000000000..adcd32f26f6ec --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_contextual_insight.test.tsx @@ -0,0 +1,60 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import { ErrorSampleContextualInsight } from './error_sample_contextual_insight'; +import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; + +jest.mock('../../../../context/apm_plugin/use_apm_plugin_context'); + +const mockUseApmPluginContext = useApmPluginContext as jest.MockedFunction< + typeof useApmPluginContext +>; + +describe('ErrorSampleContextualInsight', () => { + beforeEach(() => { + mockUseApmPluginContext.mockReturnValue({ + observabilityAIAssistant: undefined, + } as any); + }); + + it('renders null when error is undefined (OTel / incomplete data)', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders null when error has no service (missing service.environment case)', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('does not throw when error.service is missing', () => { + expect(() => { + render( + + ); + }).not.toThrow(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_contextual_insight.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_contextual_insight.tsx index 90ea6e750c62d..b91ea73e2e99b 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_contextual_insight.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_contextual_insight.tsx @@ -18,10 +18,10 @@ export function ErrorSampleContextualInsight({ error, transaction, }: { - error: { + error?: { [AT_TIMESTAMP]: string; error: Pick; - service: { + service?: { name: string; environment?: string; language?: { @@ -45,11 +45,11 @@ export function ErrorSampleContextualInsight({ const [exceptionStacktrace, setExceptionStacktrace] = useState(''); const messages = useMemo(() => { - const serviceName = error.service.name; - const languageName = error.service.language?.name ?? ''; - const runtimeName = error.service.runtime?.name ?? ''; - const runtimeVersion = error.service.runtime?.version ?? ''; - const transactionName = transaction?.transaction.name ?? ''; + const serviceName = error?.service?.name ?? ''; + const languageName = error?.service?.language?.name ?? ''; + const runtimeName = error?.service?.runtime?.name ?? ''; + const runtimeVersion = error?.service?.runtime?.version ?? ''; + const transactionName = transaction?.transaction?.name ?? ''; return observabilityAIAssistant?.getContextualInsightMessages({ message: `I'm looking at an exception and trying to understand what it means`, @@ -78,6 +78,10 @@ export function ErrorSampleContextualInsight({ }); }, [error, transaction, logStacktrace, exceptionStacktrace, observabilityAIAssistant]); + if (!error?.service) { + return null; + } + return observabilityAIAssistant?.ObservabilityAIAssistantContextualInsight && messages ? ( <> @@ -95,7 +99,7 @@ export function ErrorSampleContextualInsight({ }} style={{ display: 'none' }} > - {error.error.log?.message && ( + {error?.error?.log?.message && ( )} @@ -105,7 +109,7 @@ export function ErrorSampleContextualInsight({ }} style={{ display: 'none' }} > - {error.error.exception?.length && ( + {error?.error?.exception?.length && ( )} diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_detail.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_detail.tsx index edac627cf02d8..225ed2f766117 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_detail.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/error_sample_detail.tsx @@ -54,6 +54,10 @@ import { useTimeRange } from '../../../../hooks/use_time_range'; import { getComparisonEnabled } from '../../../shared/time_comparison/get_comparison_enabled'; import { buildUrl } from '../../../../utils/build_url'; import { OpenInDiscover } from '../../../shared/links/discover_links/open_in_discover'; +import { + ENVIRONMENT_NOT_DEFINED, + getEnvironmentLabel, +} from '../../../../../common/environment_filter_values'; const TransactionLinkName = styled.div` margin-left: ${({ theme }) => theme.euiTheme.size.s}; @@ -158,7 +162,7 @@ export function ErrorSampleDetails({ const tabs = getTabs(error); const currentTab = getCurrentTab(tabs, detailTab) as ErrorTab; - const urlFromError = error.error.page?.url || error.url?.full; + const urlFromError = error?.error?.page?.url || error?.url?.full; const urlFromTransaction = transaction?.transaction?.page?.url || transaction?.url?.full; const errorOrTransactionUrl = error?.url ? error : transaction; const errorOrTransactionHttp = error?.http ? error : transaction; @@ -174,9 +178,12 @@ export function ErrorSampleDetails({ const method = errorOrTransactionHttp?.http?.request?.method; const status = errorOrTransactionHttp?.http?.response?.status_code; const userAgent = errorOrTransactionUserAgent; - const errorEnvironment = error.service.environment; - const serviceVersion = error.service.version; - const isUnhandled = error.error.exception?.[0]?.handled === false; + const errorEnvironment = + error?.service?.environment ?? + transaction?.service?.environment ?? + ENVIRONMENT_NOT_DEFINED.value; + const serviceVersion = error?.service?.version ?? transaction?.service?.version ?? undefined; + const isUnhandled = error?.error?.exception?.[0]?.handled === false; return ( @@ -215,7 +222,7 @@ export function ErrorSampleDetails({ rangeTo={rangeTo} queryParams={{ kuery, - serviceName: error?.service.name, + serviceName: error?.service?.name, errorGroupId: groupId, }} /> @@ -231,7 +238,7 @@ export function ErrorSampleDetails({ , errorUrl ? ( @@ -266,17 +273,15 @@ export function ErrorSampleDetails({ ), - errorEnvironment ? ( - - - {errorEnvironment} - - - ) : null, + + + {getEnvironmentLabel(errorEnvironment)} + + , serviceVersion ? ( 0; - const logStackframes = error?.error.log?.stacktrace; + const logStackframes = error?.error?.log?.stacktrace; const isPlaintextException = hasExceptions - ? !!error.error.stack_trace && exceptions.length === 1 && !exceptions[0].stacktrace - : !!error.error.stack_trace; + ? !!error?.error?.stack_trace && exceptions.length === 1 && !exceptions[0].stacktrace + : !!error?.error?.stack_trace; switch (currentTab.key) { case ErrorTabKey.LogStackTrace: @@ -380,9 +385,9 @@ export function ErrorSampleDetailTabContent({ case ErrorTabKey.ExceptionStacktrace: return isPlaintextException ? ( ) : ( diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/sample_summary.test.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/sample_summary.test.tsx new file mode 100644 index 0000000000000..d2744f10ca6f8 --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/sample_summary.test.tsx @@ -0,0 +1,22 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import { SampleSummary } from './sample_summary'; + +describe('SampleSummary (missing service / OTel incomplete data)', () => { + it('renders nothing when error is undefined', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when error.error is undefined', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/sample_summary.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/sample_summary.tsx index 8a29a4d41a46a..1b3e39f61e01a 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/sample_summary.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/error_group_details/error_sampler/sample_summary.tsx @@ -18,14 +18,18 @@ const Label = styled.div` `; interface Props { - error: { + error?: { error: Pick; }; } export function SampleSummary({ error }: Props) { - const logMessage = error.error.log?.message; - const excMessage = error.error.exception?.[0].message || error.error.message; - const culprit = error.error.culprit; + const logMessage = error?.error?.log?.message; + const excMessage = error?.error?.exception?.[0]?.message || error?.error?.message; + const culprit = error?.error?.culprit; + + if (!error?.error) { + return null; + } return ( <> diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx index 3909583871585..e1a37bee40219 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx @@ -10,7 +10,10 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; import { useApmRouter } from '../../../../hooks/use_apm_router'; -import { getNextEnvironmentUrlParam } from '../../../../../common/environment_filter_values'; +import { + ENVIRONMENT_NOT_DEFINED, + getNextEnvironmentUrlParam, +} from '../../../../../common/environment_filter_values'; import type { Transaction as ITransaction } from '../../../../../typings/es_schemas/ui/transaction'; import { TransactionDetailLink } from '../../../shared/links/apm/transaction_detail_link'; import type { IWaterfall } from './waterfall_container/waterfall/waterfall_helpers/waterfall_helpers'; @@ -91,7 +94,7 @@ export function MaybeViewTraceLink({ const rootTransaction = rootWaterfallTransaction.doc; const isRoot = transaction.transaction.id === rootWaterfallTransaction.id; const nextEnvironment = getNextEnvironmentUrlParam({ - requestedEnvironment: rootTransaction.service.environment, + requestedEnvironment: rootTransaction.service?.environment ?? ENVIRONMENT_NOT_DEFINED.value, currentEnvironmentUrlParam: environment, }); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/flyout_top_level_properties.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/flyout_top_level_properties.tsx index de0c21aa31b6f..99aa208303ed1 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/flyout_top_level_properties.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/flyout_top_level_properties.tsx @@ -9,7 +9,11 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { useApmRouter } from '../../../../../../hooks/use_apm_router'; import { SERVICE_NAME, TRANSACTION_NAME } from '../../../../../../../common/es_fields/apm'; -import { getNextEnvironmentUrlParam } from '../../../../../../../common/environment_filter_values'; +import { + ENVIRONMENT_ALL, + ENVIRONMENT_NOT_DEFINED, + getNextEnvironmentUrlParam, +} from '../../../../../../../common/environment_filter_values'; import { LatencyAggregationType } from '../../../../../../../common/latency_aggregation_types'; import type { Transaction } from '../../../../../../../typings/es_schemas/ui/transaction'; import { useAnyOfApmParams } from '../../../../../../hooks/use_apm_params'; @@ -40,8 +44,8 @@ export function FlyoutTopLevelProperties({ transaction }: Props) { } const nextEnvironment = getNextEnvironmentUrlParam({ - requestedEnvironment: transaction.service.environment, - currentEnvironmentUrlParam: query.environment, + requestedEnvironment: transaction.service?.environment ?? ENVIRONMENT_NOT_DEFINED.value, + currentEnvironmentUrlParam: query?.environment ?? ENVIRONMENT_ALL.value, }); const stickyProperties = [ diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/sticky_span_properties.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/sticky_span_properties.tsx index 6c79c6ddf3d9a..846e4658a287b 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/sticky_span_properties.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/span_flyout/sticky_span_properties.tsx @@ -15,7 +15,10 @@ import { SPAN_NAME, TRANSACTION_NAME, } from '../../../../../../../../common/es_fields/apm'; -import { getNextEnvironmentUrlParam } from '../../../../../../../../common/environment_filter_values'; +import { + ENVIRONMENT_NOT_DEFINED, + getNextEnvironmentUrlParam, +} from '../../../../../../../../common/environment_filter_values'; import { NOT_AVAILABLE_LABEL } from '../../../../../../../../common/i18n'; import type { Span } from '../../../../../../../../typings/es_schemas/ui/span'; import type { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; @@ -51,7 +54,7 @@ export function StickySpanProperties({ span, transaction }: Props) { const trackEvent = useUiTracker(); const nextEnvironment = getNextEnvironmentUrlParam({ - requestedEnvironment: transaction?.service.environment, + requestedEnvironment: transaction?.service?.environment ?? ENVIRONMENT_NOT_DEFINED.value, currentEnvironmentUrlParam: environment, }); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/links/apm/agent_configuration_links.test.ts b/x-pack/solutions/observability/plugins/apm/public/components/shared/links/apm/agent_configuration_links.test.ts new file mode 100644 index 0000000000000..4a26f5d85fd66 --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/links/apm/agent_configuration_links.test.ts @@ -0,0 +1,45 @@ +/* + * 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. + */ + +/* + * 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 it except in compliance with the Elastic License 2.0. + */ + +import { editAgentConfigurationHref } from './agent_configuration_links'; +import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values'; + +const mockBasePath = { + prepend: (path: string) => `/base${path}`, +} as any; + +describe('editAgentConfigurationHref', () => { + it('uses ENVIRONMENT_NOT_DEFINED when configService is undefined', () => { + const href = editAgentConfigurationHref(undefined as any, '', mockBasePath); + expect(href).toContain(`environment=${encodeURIComponent(ENVIRONMENT_NOT_DEFINED.value)}`); + }); + + it('uses ENVIRONMENT_NOT_DEFINED when configService.environment is missing', () => { + const href = editAgentConfigurationHref({ name: 'my-service' }, '', mockBasePath); + expect(href).toContain(`environment=${encodeURIComponent(ENVIRONMENT_NOT_DEFINED.value)}`); + }); + + it('uses configService.environment when present', () => { + const href = editAgentConfigurationHref( + { name: 'my-service', environment: 'production' }, + '', + mockBasePath + ); + expect(href).toContain('environment=production'); + }); + + it('does not throw when configService is undefined and returns valid path', () => { + const href = editAgentConfigurationHref(undefined as any, '', mockBasePath); + expect(href).toContain('/app/apm/settings/agent-configuration/edit'); + }); +}); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/shared/links/apm/agent_configuration_links.tsx b/x-pack/solutions/observability/plugins/apm/public/components/shared/links/apm/agent_configuration_links.tsx index 7cd0779e19cc2..592c115456371 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/shared/links/apm/agent_configuration_links.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/shared/links/apm/agent_configuration_links.tsx @@ -7,6 +7,7 @@ import type { IBasePath } from '@kbn/core/public'; import type { AgentConfigurationIntake } from '../../../../../common/agent_configuration/configuration_types'; +import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values'; import { getLegacyApmHref } from './apm_link_hooks'; export function editAgentConfigurationHref( @@ -21,8 +22,8 @@ export function editAgentConfigurationHref( query: { // ignoring because `name` has not been added to url params. Related: https://github.com/elastic/kibana/issues/51963 // @ts-expect-error - name: configService.name, - environment: configService.environment, + name: configService?.name, + environment: configService?.environment ?? ENVIRONMENT_NOT_DEFINED.value, }, }); }