Skip to content

Commit

Permalink
[ObsUX] Add Missing transaction warning if there are Transactions or …
Browse files Browse the repository at this point in the history
…Spans with a parent.id that doesn't exist in the trace (#171196)

Closes #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:
- <img width="1423" alt="image"
src="https://github.com/elastic/kibana/assets/14139027/3548d1dd-d87f-4090-a028-d62cf8ec35c8">
- Check the traces: 
   - Incomplete trace (with warning):
- <img width="1401" alt="image"
src="https://github.com/elastic/kibana/assets/14139027/a75a2112-a425-43d9-bdd1-b3724ccfe6e3">

   - Complete trace (no warning):
- <img width="1387" alt="image"
src="https://github.com/elastic/kibana/assets/14139027/f33614ab-269b-447c-bd0b-bc00f9799092">
- Unit test in
[waterfall_helpers.test.ts](https://github.com/elastic/kibana/pull/171196/files#diff-6cdeaa931c0085a16353ac34f937d442a39e1227621f11b3de0608a39e949fc6)
  • Loading branch information
jennypavlova authored Nov 20, 2023
1 parent b83b495 commit 3dbe1e4
Show file tree
Hide file tree
Showing 5 changed files with 273 additions and 4 deletions.
153 changes: 153 additions & 0 deletions packages/kbn-apm-synthtrace/src/scenarios/trace_with_orphan_items.ts
Original file line number Diff line number Diff line change
@@ -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<ApmFields> = 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<Serializable<ApmFields>>;

return withClient(
apmEsClient,
Readable.from([...unserializedChanged, ...successfulTraceEvents])
);
},
};
};

export default scenario;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -108,7 +109,19 @@ export function WaterfallContainer({
</EuiFlexItem>
) : null}
<EuiFlexItem>
<WaterfallLegends legends={legendsWithFallbackLabel} type={colorBy} />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<WaterfallLegends
legends={legendsWithFallbackLabel}
type={colorBy}
/>
</EuiFlexItem>
{hasOrphanTraceItems ? (
<EuiFlexItem grow={false}>
<MissingTransactionWarning />
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<Waterfall
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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 { EuiBadge, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';

export function MissingTransactionWarning() {
return (
<EuiToolTip
position="left"
content={i18n.translate(
'xpack.apm.transactionDetails.agentMissingTransactionMessage',
{
defaultMessage:
'This trace contains spans from missing transactions. As a result these spans are not displayed in the timeline.',
}
)}
anchorClassName="eui-fullWidth"
>
<EuiBadge
iconType="warning"
color="hollow"
data-test-id="apm-missing-transaction-badge"
>
{i18n.translate(
'xpack.apm.transactionDetails.agentMissingTransactionLabel',
{
defaultMessage: 'Incomplete trace',
}
)}
</EuiBadge>
</EuiToolTip>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
IWaterfallTransaction,
IWaterfallError,
IWaterfallSpanOrTransaction,
getHasOrphanTraceItems,
} from './waterfall_helpers';
import { APMError } from '../../../../../../../../typings/es_schemas/ui/apm_error';
import {
Expand Down Expand Up @@ -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<WaterfallTransaction | WaterfallSpan> = [
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<WaterfallTransaction | WaterfallSpan> = [
myTransactionItem,
{
processor: { event: 'span' },
span: {
id: 'myOrphanSpanId',
},
parent: {
id: 'myNotExistingTransactionId1',
},
} as WaterfallSpan,
];
expect(getHasOrphanTraceItems(traceItems)).toBe(true);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface IWaterfall {
totalErrorsCount: number;
traceDocsTotal: number;
maxTraceItems: number;
hasOrphanTraceItems: boolean;
}

interface IWaterfallItemBase<TDocument, TDoctype> {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -415,6 +416,22 @@ function getErrorCountByParentId(
}, {});
}

export const getHasOrphanTraceItems = (
traceDocs: Array<WaterfallTransaction | WaterfallSpan>
) => {
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) {
Expand All @@ -429,6 +446,7 @@ export function getWaterfall(apiResponse: TraceAPIResponse): IWaterfall {
totalErrorsCount: 0,
traceDocsTotal: 0,
maxTraceItems: 0,
hasOrphanTraceItems: false,
};
}

Expand Down Expand Up @@ -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,
Expand All @@ -478,5 +498,6 @@ export function getWaterfall(apiResponse: TraceAPIResponse): IWaterfall {
totalErrorsCount: traceItems.errorDocs.length,
traceDocsTotal: traceItems.traceDocsTotal,
maxTraceItems: traceItems.maxTraceItems,
hasOrphanTraceItems,
};
}

0 comments on commit 3dbe1e4

Please sign in to comment.