From 3dbe1e434a780bd254a042ffb4b307798721dc01 Mon Sep 17 00:00:00 2001 From: jennypavlova Date: Mon, 20 Nov 2023 17:33:11 +0100 Subject: [PATCH] [ObsUX] Add Missing transaction warning if there are Transactions or Spans with a parent.id that doesn't exist in the trace (#171196) Closes https://github.com/elastic/kibana/issues/25117 ## Summary Add a missing transaction warning if there are Transactions or Spans with a `parent.id` that doesn't exist in the trace. ## Testing - Use the `trace_with_orphan_items.ts` scenario: `node scripts/synthtrace --clean trace_with_orphan_items.ts` - In APM -> Traces there are 2 traces: - image - Check the traces: - Incomplete trace (with warning): - image - Complete trace (no warning): - image - Unit test in [waterfall_helpers.test.ts](https://github.com/elastic/kibana/pull/171196/files#diff-6cdeaa931c0085a16353ac34f937d442a39e1227621f11b3de0608a39e949fc6) --- .../src/scenarios/trace_with_orphan_items.ts | 153 ++++++++++++++++++ .../waterfall_container/index.tsx | 19 ++- .../waterfall/missing_transaction_warning.tsx | 39 +++++ .../waterfall_helpers.test.ts | 43 +++++ .../waterfall_helpers/waterfall_helpers.ts | 23 ++- 5 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 packages/kbn-apm-synthtrace/src/scenarios/trace_with_orphan_items.ts create mode 100644 x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/missing_transaction_warning.tsx diff --git a/packages/kbn-apm-synthtrace/src/scenarios/trace_with_orphan_items.ts b/packages/kbn-apm-synthtrace/src/scenarios/trace_with_orphan_items.ts new file mode 100644 index 000000000000..ca853f9e7354 --- /dev/null +++ b/packages/kbn-apm-synthtrace/src/scenarios/trace_with_orphan_items.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { apm, ApmFields, httpExitSpan, Serializable } from '@kbn/apm-synthtrace-client'; +import { Readable } from 'stream'; +import { Scenario } from '../cli/scenario'; + +import { RunOptions } from '../cli/utils/parse_run_cli_flags'; +import { getSynthtraceEnvironment } from '../lib/utils/get_synthtrace_environment'; +import { withClient } from '../lib/utils/with_client'; + +const ENVIRONMENT = getSynthtraceEnvironment(__filename); + +const scenario: Scenario = async (runOptions: RunOptions) => { + return { + generate: ({ range, clients: { apmEsClient } }) => { + const transactionName = 'trace with orphans'; + const successfulTimestamps = range.interval('1s').rate(3); + + const synthRum = apm + .service({ name: 'synth-rum', environment: ENVIRONMENT, agentName: 'rum-js' }) + .instance('my-instance'); + const synthNode = apm + .service({ name: 'synth-node', environment: ENVIRONMENT, agentName: 'nodejs' }) + .instance('my-instance'); + const synthGo = apm + .service({ name: 'synth-go', environment: ENVIRONMENT, agentName: 'go' }) + .instance('my-instance'); + + const traces = successfulTimestamps.generator((timestamp) => { + // synth-rum + return synthGo + .transaction({ transactionName }) + .duration(400) + .timestamp(timestamp) + .children( + // synth-rum -> synth-node + synthRum + .span( + httpExitSpan({ + spanName: 'GET /api/products/top', + destinationUrl: 'http://synth-node:3000', + }) + ) + .duration(300) + .timestamp(timestamp) + .children( + synthRum + .transaction({ transactionName: 'Child Transaction' }) + .timestamp(timestamp) + .duration(200) + .children( + synthGo + .span({ spanName: 'custom_operation', spanType: 'custom' }) + .timestamp(timestamp) + .duration(100) + .success() + ), + // synth-node + synthNode + .transaction({ transactionName: 'Initial transaction in synth-node' }) + .duration(300) + .timestamp(timestamp) + .children( + synthNode + // synth-node -> synth-go + .span( + httpExitSpan({ + spanName: 'GET synth-go:3000', + destinationUrl: 'http://synth-go:3000', + }) + ) + .timestamp(timestamp) + .duration(400) + + .children( + // synth-go + synthGo + .transaction({ transactionName: 'Child Transaction' }) + .timestamp(timestamp) + .duration(200) + .children( + synthGo + .span({ spanName: 'custom_operation', spanType: 'custom' }) + .timestamp(timestamp) + .duration(100) + .success(), + synthGo + .span({ spanName: 'custom_new_operation', spanType: 'custom' }) + .timestamp(timestamp) + .duration(100) + .success() + ) + ) + ) + ) + ); + }); + + const successfulTraceEvents = Array.from( + successfulTimestamps.generator((timestamp) => + synthNode + .transaction({ transactionName: 'successful trace' }) + .timestamp(timestamp) + .duration(1000) + .success() + .children( + synthNode + .span({ + spanName: 'GET apm-*/_search', + spanType: 'db', + spanSubtype: 'elasticsearch', + }) + .duration(1000) + .success() + .destination('elasticsearch') + .timestamp(timestamp), + synthNode + .span({ spanName: 'custom_operation', spanType: 'custom' }) + .duration(100) + .success() + .timestamp(timestamp) + ) + ) + ); + + const unserialized = Array.from(traces); + + const serialized = unserialized + .flatMap((event) => event.serialize()) + .filter((trace) => trace['transaction.name'] !== 'Child Transaction'); + + const unserializedChanged = serialized.map((event) => ({ + fields: event, + serialize: () => { + return [event]; + }, + })) as Array>; + + return withClient( + apmEsClient, + Readable.from([...unserializedChanged, ...successfulTraceEvents]) + ); + }, + }; +}; + +export default scenario; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/index.tsx index 69ed7a63b0c5..abd6aee61f40 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/index.tsx @@ -13,10 +13,11 @@ import { useCriticalPathFeatureEnabledSetting } from '../../../../../hooks/use_c import { TechnicalPreviewBadge } from '../../../../shared/technical_preview_badge'; import { Waterfall } from './waterfall'; import { - IWaterfall, + type IWaterfall, WaterfallLegendType, } from './waterfall/waterfall_helpers/waterfall_helpers'; import { WaterfallLegends } from './waterfall_legends'; +import { MissingTransactionWarning } from './waterfall/missing_transaction_warning'; interface Props { waterfallItemId?: string; @@ -38,7 +39,7 @@ export function WaterfallContainer({ if (!waterfall) { return null; } - const { legends, items } = waterfall; + const { legends, items, hasOrphanTraceItems } = waterfall; // Service colors are needed to color the dot in the error popover const serviceLegends = legends.filter( @@ -108,7 +109,19 @@ export function WaterfallContainer({ ) : null} - + + + + + {hasOrphanTraceItems ? ( + + + + ) : null} + + + {i18n.translate( + 'xpack.apm.transactionDetails.agentMissingTransactionLabel', + { + defaultMessage: 'Incomplete trace', + } + )} + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.test.ts b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.test.ts index df028ee3c8f7..208102b415e5 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.test.ts +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.test.ts @@ -16,6 +16,7 @@ import { IWaterfallTransaction, IWaterfallError, IWaterfallSpanOrTransaction, + getHasOrphanTraceItems, } from './waterfall_helpers'; import { APMError } from '../../../../../../../../typings/es_schemas/ui/apm_error'; import { @@ -717,4 +718,46 @@ describe('waterfall_helpers', () => { expect(getClockSkew(child, parent)).toBe(0); }); }); + + describe('getHasOrphanTraceItems', () => { + const myTransactionItem = { + processor: { event: 'transaction' }, + trace: { id: 'myTrace' }, + transaction: { + id: 'myTransactionId1', + }, + } as WaterfallTransaction; + + it('should return false if there are no orphan items', () => { + const traceItems: Array = [ + myTransactionItem, + { + processor: { event: 'span' }, + span: { + id: 'mySpanId', + }, + parent: { + id: 'myTransactionId1', + }, + } as WaterfallSpan, + ]; + expect(getHasOrphanTraceItems(traceItems)).toBe(false); + }); + + it('should return true if there are orphan items', () => { + const traceItems: Array = [ + myTransactionItem, + { + processor: { event: 'span' }, + span: { + id: 'myOrphanSpanId', + }, + parent: { + id: 'myNotExistingTransactionId1', + }, + } as WaterfallSpan, + ]; + expect(getHasOrphanTraceItems(traceItems)).toBe(true); + }); + }); }); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.ts b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.ts index 62a3ddb434ee..b197859ff108 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.ts +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers.ts @@ -48,6 +48,7 @@ export interface IWaterfall { totalErrorsCount: number; traceDocsTotal: number; maxTraceItems: number; + hasOrphanTraceItems: boolean; } interface IWaterfallItemBase { @@ -191,7 +192,7 @@ export function getClockSkew( case 'error': case 'span': return parentItem.skew; - // transaction is the inital entry in a service. Calculate skew for this, and it will be propogated to all child spans + // transaction is the initial entry in a service. Calculate skew for this, and it will be propagated to all child spans case 'transaction': { const parentStart = parentItem.doc.timestamp.us + parentItem.skew; @@ -415,6 +416,22 @@ function getErrorCountByParentId( }, {}); } +export const getHasOrphanTraceItems = ( + traceDocs: Array +) => { + const waterfallItemsIds = new Set( + traceDocs.map((doc) => + doc.processor.event === 'span' + ? (doc?.span as WaterfallSpan['span']).id + : doc?.transaction?.id + ) + ); + + return traceDocs.some( + (item) => item.parent?.id && !waterfallItemsIds.has(item.parent.id) + ); +}; + export function getWaterfall(apiResponse: TraceAPIResponse): IWaterfall { const { traceItems, entryTransaction } = apiResponse; if (isEmpty(traceItems.traceDocs) || !entryTransaction) { @@ -429,6 +446,7 @@ export function getWaterfall(apiResponse: TraceAPIResponse): IWaterfall { totalErrorsCount: 0, traceDocsTotal: 0, maxTraceItems: 0, + hasOrphanTraceItems: false, }; } @@ -464,6 +482,8 @@ export function getWaterfall(apiResponse: TraceAPIResponse): IWaterfall { const duration = getWaterfallDuration(items); const legends = getLegends(items); + const hasOrphanTraceItems = getHasOrphanTraceItems(traceItems.traceDocs); + return { entryWaterfallTransaction, rootWaterfallTransaction, @@ -478,5 +498,6 @@ export function getWaterfall(apiResponse: TraceAPIResponse): IWaterfall { totalErrorsCount: traceItems.errorDocs.length, traceDocsTotal: traceItems.traceDocsTotal, maxTraceItems: traceItems.maxTraceItems, + hasOrphanTraceItems, }; }