[Discover][APM] Fix Duration widget & Trace summary for unprocessed OTEL spans (#224697)

## Summary

This PR fixes some code paths/requests for calculating Duration
histograms and Trace summaries to account for unprocessed OTEL data. It
isn't the nicest way of doing things, because deep in APM, there is of
course an assumption that data is being dealt with in ECS format.
Probably longer term, this should be refactored, but this at least adds
in support for unprocessed OTEL for the duration widget.

![Screenshot 2025-06-20 at 15-31-29 Discover -
Elastic](https://github.com/user-attachments/assets/2ffc4769-bc87-42e4-ae38-409bf320cf85)

Related to #221521

## How to test

* Add the following to your `kibana.dev.yml` file:
```yaml
discover.experimental.enabledProfiles:
  - observability-traces-data-source-profile
  - observability-traces-transaction-document-profile
  - observability-traces-span-document-profile
```
* Set up a source of unprocessed OTEL data to feed into ES (Open
telemetry demo, etc)
* Set your space to Observability mode, and go to Discover.
* Query for `traces-*` and open any record for the document viewer
* The Span Overview waterfall should show the duration histogram that
match the document's formatted duration in the Table/JSON tabs, as well
as the trace summary information (trace id & span name)

---------

Co-authored-by: Milosz Marcinkowski <38698566+miloszmarcinkowski@users.noreply.github.com>
This commit is contained in:
Gonçalo Rica Pais da Silva 2025-06-24 02:28:54 +02:00 committed by GitHub
parent 63134aa8eb
commit 1c2995447f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 379 additions and 231 deletions

View file

@ -25,6 +25,7 @@ export function Duration({ duration, parent }: DurationProps) {
if (!parent) {
<EuiText size="xs">{asDuration(duration)}</EuiText>;
}
return (
<EuiText size="xs">
{asDuration(duration)} &nbsp;

View file

@ -8,7 +8,6 @@
*/
import React, { useMemo } from 'react';
import { flatten } from 'lodash';
import type {
BrushEndListener,
@ -69,6 +68,7 @@ interface DurationDistributionChartProps {
dataTestSubPrefix?: string;
showAxisTitle?: boolean;
showLegend?: boolean;
isOtelData?: boolean;
}
const getAnnotationsStyle = (color = 'gray'): LineAnnotationStyle => ({
@ -115,6 +115,7 @@ export function DurationDistributionChart({
dataTestSubPrefix,
showAxisTitle = true,
showLegend = true,
isOtelData = false,
}: DurationDistributionChartProps) {
const chartThemes = useChartThemes();
const { euiTheme } = useEuiTheme();
@ -136,8 +137,8 @@ export function DurationDistributionChart({
);
// This will create y axis ticks for 1, 10, 100, 1000 ...
const { yTicks, yAxisMaxDomain, yAxisDomain } = useMemo(() => {
const yMax = Math.max(...flatten(data.map((d) => d.histogram)).map((d) => d.doc_count)) ?? 0;
const { yTicks, yAxisMaxDomain, yAxisDomain, formattedData } = useMemo(() => {
const yMax = Math.max(...data.flatMap((d) => d.histogram).map((d) => d.doc_count)) ?? 0;
const computedYTicks = Math.max(1, Math.ceil(Math.log10(yMax)));
const computedMaxDomain = Math.pow(10, computedYTicks);
@ -148,8 +149,14 @@ export function DurationDistributionChart({
min: Y_AXIS_MIN_DOMAIN,
max: computedMaxDomain,
},
formattedData: isOtelData
? data.map((d) => ({
...d,
histogram: d.histogram.map((hist) => ({ ...hist, key: hist.key * 0.001 })),
}))
: data,
};
}, [data]);
}, [isOtelData, data]);
const selectionAnnotation = useMemo(() => {
return selection !== undefined
@ -169,11 +176,11 @@ export function DurationDistributionChart({
const chartData = useMemo(
() =>
data.map((d) => ({
formattedData.map((d) => ({
...d,
histogram: replaceHistogramZerosWithMinimumDomainValue(d.histogram),
})),
[data]
[formattedData]
);
return (

View file

@ -33,6 +33,7 @@ export const SPAN_SUBTYPE_FIELD = 'span.subtype';
export const SPAN_DESTINATION_SERVICE_RESOURCE_FIELD = 'span.destination.service.resource';
export const PROCESSOR_EVENT_FIELD = 'processor.event';
export const OTEL_SPAN_KIND = 'kind';
export const OTEL_DURATION = 'duration';
export const LOG_FILE_PATH_FIELD = 'log.file.path';
export const DATASTREAM_NAMESPACE_FIELD = 'data_stream.namespace';

View file

@ -126,6 +126,8 @@ export interface SpanDocumentOverview
UserAgentFields {
'transaction.id'?: string;
'transaction.name'?: string;
duration?: number;
kind?: string;
}
export interface TraceFields {

View file

@ -34,6 +34,8 @@ const fields: Array<keyof SpanDocumentOverview> = [
fieldConstants.USER_AGENT_NAME_FIELD,
fieldConstants.USER_AGENT_VERSION_FIELD,
fieldConstants.PROCESSOR_EVENT_FIELD,
fieldConstants.OTEL_DURATION,
fieldConstants.OTEL_SPAN_KIND,
];
export function getSpanDocumentOverview(

View file

@ -90,7 +90,7 @@ describe('spanDocumentProfileProvider', () => {
).toEqual(RESOLUTION_MISMATCH);
});
it('matches records with the correct data stream type and any OTEL `kind` field', () => {
it('matches records with the correct data stream type and any OTEL `kind` field (unprocessed spans)', () => {
expect(
spanDocumentProfileProvider.resolve({
rootContext: getRootContext({ profileId }),
@ -103,7 +103,7 @@ describe('spanDocumentProfileProvider', () => {
).toEqual(RESOLUTION_MATCH);
});
it('defaults to matching records with the correct data stream type but no processor event field', () => {
it('defaults to matching records with the correct data stream type but no processor event field (unprocessed spans)', () => {
expect(
spanDocumentProfileProvider.resolve({
rootContext: getRootContext({ profileId }),

View file

@ -8,12 +8,7 @@
*/
import type { DataTableRecord } from '@kbn/discover-utils';
import {
DATASTREAM_TYPE_FIELD,
getFieldValue,
OTEL_SPAN_KIND,
PROCESSOR_EVENT_FIELD,
} from '@kbn/discover-utils';
import { DATASTREAM_TYPE_FIELD, getFieldValue, PROCESSOR_EVENT_FIELD } from '@kbn/discover-utils';
import { TRACES_PRODUCT_FEATURE_ID } from '../../../../../../common/constants';
import type { DocumentProfileProvider } from '../../../../profiles';
import { DocumentType, SolutionType } from '../../../../profiles';
@ -70,10 +65,9 @@ const getIsSpanRecord = ({ record }: { record: DataTableRecord }) => {
const isSpanDocument = (record: DataTableRecord) => {
const dataStreamType = getFieldValue(record, DATASTREAM_TYPE_FIELD);
const processorEvent = getFieldValue(record, PROCESSOR_EVENT_FIELD);
const spanKind = getFieldValue(record, OTEL_SPAN_KIND);
const isApmSpan = processorEvent === 'span';
const isOtelSpan = spanKind != null || processorEvent == null;
const isOtelSpan = processorEvent == null;
return dataStreamType === 'traces' && (isApmSpan || isOtelSpan);
};

View file

@ -0,0 +1,185 @@
/*
* 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 createContainer from 'constate';
import { useState, useEffect } from 'react';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { lastValueFrom } from 'rxjs';
import { i18n } from '@kbn/i18n';
import {
OTEL_DURATION,
PARENT_ID_FIELD,
PROCESSOR_EVENT_FIELD,
SPAN_DURATION_FIELD,
SPAN_NAME_FIELD,
TRACE_ID_FIELD,
TRANSACTION_DURATION_FIELD,
TRANSACTION_ID_FIELD,
TRANSACTION_NAME_FIELD,
} from '@kbn/discover-utils';
import { getUnifiedDocViewerServices } from '../../../../../../plugin';
interface UseTransactionPrams {
traceId?: string;
transactionId?: string;
indexPattern: string;
}
interface GetTransactionParams {
traceId: string;
transactionId?: string;
indexPattern: string;
data: DataPublicPluginStart;
signal: AbortSignal;
}
async function getRootSpanData({
traceId,
transactionId,
indexPattern,
data,
signal,
}: GetTransactionParams) {
return lastValueFrom(
data.search.search(
{
params: {
index: indexPattern,
size: 1,
body: {
timeout: '20s',
fields: [
TRANSACTION_NAME_FIELD,
TRANSACTION_DURATION_FIELD,
SPAN_NAME_FIELD,
SPAN_DURATION_FIELD,
OTEL_DURATION,
],
query: {
bool: transactionId
? {
must: [
{
term: {
[TRANSACTION_ID_FIELD]: transactionId,
},
},
{
term: {
[PROCESSOR_EVENT_FIELD]: 'transaction',
},
},
],
}
: {
must: [
{
term: {
[TRACE_ID_FIELD]: traceId,
},
},
],
must_not: [
{ exists: { field: PARENT_ID_FIELD } },
{ exists: { field: 'parent_span_id' } },
],
},
},
},
},
},
{ abortSignal: signal }
)
);
}
export interface Trace {
name: string;
duration: number;
}
const useRootSpan = ({ traceId, transactionId, indexPattern }: UseTransactionPrams) => {
const { data, core } = getUnifiedDocViewerServices();
const [trace, setTrace] = useState<Trace | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
if (!traceId) {
setTrace(null);
setLoading(false);
return;
}
const controller = new AbortController();
const { signal } = controller;
const fetchData = async () => {
try {
setLoading(true);
const result = await getRootSpanData({
traceId,
transactionId,
indexPattern,
data,
signal,
});
const fields = result.rawResponse.hits.hits[0]?.fields;
const name = fields?.[TRANSACTION_NAME_FIELD] || fields?.[SPAN_NAME_FIELD];
const duration = resolveDuration(fields);
if (name && duration) {
setTrace({
name,
duration,
});
}
} catch (err) {
if (!signal.aborted) {
const error = err as Error;
core.notifications.toasts.addDanger({
title: i18n.translate('unifiedDocViewer.docViewerSpanOverview.useTrace.error', {
defaultMessage: 'An error occurred while fetching the trace',
}),
text: error.message,
});
setTrace(null);
}
} finally {
setLoading(false);
}
};
fetchData();
return function onUnmount() {
controller.abort();
};
}, [core.notifications.toasts, data, indexPattern, traceId, transactionId]);
return { loading, trace };
};
export const [RootSpanProvider, useRootSpanContext] = createContainer(useRootSpan);
function resolveDuration(fields?: Record<string, any>): number | null {
const duration = fields?.[TRANSACTION_DURATION_FIELD];
if (duration) {
return duration;
}
const otelDuration = fields?.[OTEL_DURATION];
if (otelDuration) {
return otelDuration * 0.001;
}
return null;
}

View file

@ -11,7 +11,7 @@ import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { lastValueFrom } from 'rxjs';
import { getUnifiedDocViewerServices } from '../../../../../../plugin';
import { TransactionProvider, useTransactionContext } from '.';
import { RootSpanProvider, useRootSpanContext } from '.';
import { TRANSACTION_DURATION_FIELD, TRANSACTION_NAME_FIELD } from '@kbn/discover-utils';
jest.mock('../../../../../../plugin', () => ({
@ -50,24 +50,24 @@ beforeEach(() => {
lastValueFromMock.mockReset();
});
describe('useTransaction hook', () => {
describe('useRootSpan hook', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<TransactionProvider transactionId="test-transaction" indexPattern="test-index">
<RootSpanProvider traceId="test-trace" transactionId="transaction-id" indexPattern="test-index">
{children}
</TransactionProvider>
</RootSpanProvider>
);
it('should start with loading true and transaction as null', async () => {
it('should start with loading true and trace as null', async () => {
lastValueFromMock.mockResolvedValue({});
const { result } = renderHook(() => useTransactionContext(), { wrapper });
const { result } = renderHook(() => useRootSpanContext(), { wrapper });
expect(result.current.loading).toBe(true);
expect(lastValueFrom).toHaveBeenCalledTimes(1);
});
it('should update transaction when data is fetched successfully', async () => {
const transactionName = 'Test Transaction';
it('should update trace when data is fetched successfully', async () => {
const transactionName = 'Test Trace';
const transactionDuration = 1;
lastValueFromMock.mockResolvedValue({
rawResponse: {
@ -84,50 +84,84 @@ describe('useTransaction hook', () => {
},
});
const { result } = renderHook(() => useTransactionContext(), { wrapper });
const { result } = renderHook(() => useRootSpanContext(), { wrapper });
await waitFor(() => !result.current.loading);
expect(result.current.loading).toBe(false);
expect(result.current.transaction?.name).toBe(transactionName);
expect(result.current.transaction?.duration).toBe(transactionDuration);
expect(result.current.trace?.name).toBe(transactionName);
expect(result.current.trace?.duration).toBe(transactionDuration);
expect(lastValueFrom).toHaveBeenCalledTimes(1);
});
it('should handle errors and set transaction to null, and show a toast error', async () => {
const errorMessage = 'Search error';
lastValueFromMock.mockRejectedValue(new Error(errorMessage));
it('should update trace when OTEL data is fetched successfully', async () => {
const newWrapper = ({ children }: { children: React.ReactNode }) => (
<RootSpanProvider traceId="test-trace" indexPattern="test-index">
{children}
</RootSpanProvider>
);
const { result } = renderHook(() => useTransactionContext(), { wrapper });
const transactionName = 'Test Trace';
const transactionDuration = 1;
lastValueFromMock.mockResolvedValue({
rawResponse: {
hits: {
hits: [
{
fields: {
[TRANSACTION_NAME_FIELD]: transactionName,
[TRANSACTION_DURATION_FIELD]: transactionDuration,
},
},
],
},
},
});
const { result } = renderHook(() => useRootSpanContext(), { wrapper: newWrapper });
await waitFor(() => !result.current.loading);
expect(result.current.loading).toBe(false);
expect(result.current.transaction).toBeNull();
expect(result.current.trace?.name).toBe(transactionName);
expect(result.current.trace?.duration).toBe(transactionDuration);
expect(lastValueFrom).toHaveBeenCalledTimes(1);
});
it('should handle errors and set trace to null, and show a toast error', async () => {
const errorMessage = 'Search error';
lastValueFromMock.mockRejectedValue(new Error(errorMessage));
const { result } = renderHook(() => useRootSpanContext(), { wrapper });
await waitFor(() => !result.current.loading);
expect(result.current.loading).toBe(false);
expect(result.current.trace).toBeNull();
expect(lastValueFrom).toHaveBeenCalledTimes(1);
expect(mockAddDanger).toHaveBeenCalledWith(
expect.objectContaining({
title: 'An error occurred while fetching the transaction',
title: 'An error occurred while fetching the trace',
text: errorMessage,
})
);
});
it('should set transaction to null and stop loading when transactionId is not provided', async () => {
const wrapperWithoutTransactionId = ({ children }: { children: React.ReactNode }) => (
<TransactionProvider transactionId={undefined} indexPattern="test-index">
it('should set trace to null and stop loading when traceId is not provided', async () => {
const wrapperWithoutTraceId = ({ children }: { children: React.ReactNode }) => (
<RootSpanProvider traceId={undefined} indexPattern="test-index">
{children}
</TransactionProvider>
</RootSpanProvider>
);
const { result } = renderHook(() => useTransactionContext(), {
wrapper: wrapperWithoutTransactionId,
const { result } = renderHook(() => useRootSpanContext(), {
wrapper: wrapperWithoutTraceId,
});
await waitFor(() => !result.current.loading);
expect(result.current.loading).toBe(false);
expect(result.current.transaction).toBeNull();
expect(result.current.trace).toBeNull();
expect(lastValueFrom).not.toHaveBeenCalled();
});
});

View file

@ -46,6 +46,7 @@ interface GetLatencyChartParams {
signal: AbortSignal;
spanName: string;
serviceName: string;
isOtelSpan: boolean;
}
const getSpanLatencyChart = ({
@ -53,6 +54,7 @@ const getSpanLatencyChart = ({
signal,
spanName,
serviceName,
isOtelSpan,
}: GetLatencyChartParams): Promise<{
overallHistogram?: HistogramItem[];
percentileThresholdValue?: number;
@ -65,6 +67,7 @@ const getSpanLatencyChart = ({
spanName,
serviceName,
chartType: 'spanLatency',
isOtel: isOtelSpan,
end: timeFilter.to,
environment: 'ENVIRONMENT_ALL',
kuery: '',
@ -83,9 +86,14 @@ interface SpanLatencyChartData {
interface UseSpanLatencyChartParams {
spanName: string;
serviceName: string;
isOtelSpan?: boolean;
}
export const useSpanLatencyChart = ({ spanName, serviceName }: UseSpanLatencyChartParams) => {
export const useSpanLatencyChart = ({
spanName,
serviceName,
isOtelSpan = false,
}: UseSpanLatencyChartParams) => {
const { core } = getUnifiedDocViewerServices();
const { euiTheme } = useEuiTheme();
@ -100,6 +108,7 @@ export const useSpanLatencyChart = ({ spanName, serviceName }: UseSpanLatencyCha
signal,
spanName,
serviceName,
isOtelSpan,
});
return {

View file

@ -1,133 +0,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", 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 createContainer from 'constate';
import { useState, useEffect } from 'react';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { lastValueFrom } from 'rxjs';
import { i18n } from '@kbn/i18n';
import {
PROCESSOR_EVENT_FIELD,
TRANSACTION_DURATION_FIELD,
TRANSACTION_ID_FIELD,
TRANSACTION_NAME_FIELD,
} from '@kbn/discover-utils';
import { getUnifiedDocViewerServices } from '../../../../../../plugin';
interface UseTransactionPrams {
transactionId?: string;
indexPattern: string;
}
interface GetTransactionParams {
transactionId: string;
indexPattern: string;
data: DataPublicPluginStart;
signal: AbortSignal;
}
async function getTransactionData({
transactionId,
indexPattern,
data,
signal,
}: GetTransactionParams) {
return lastValueFrom(
data.search.search(
{
params: {
index: indexPattern,
size: 1,
body: {
timeout: '20s',
fields: [TRANSACTION_NAME_FIELD, TRANSACTION_DURATION_FIELD],
query: {
bool: {
must: [
{
term: {
[TRANSACTION_ID_FIELD]: transactionId,
},
},
{
term: {
[PROCESSOR_EVENT_FIELD]: 'transaction',
},
},
],
},
},
},
},
},
{ abortSignal: signal }
)
);
}
export interface Transaction {
name: string;
duration: number;
}
const useTransaction = ({ transactionId, indexPattern }: UseTransactionPrams) => {
const { data, core } = getUnifiedDocViewerServices();
const [transaction, setTransaction] = useState<Transaction | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
if (!transactionId) {
setTransaction(null);
setLoading(false);
return;
}
const controller = new AbortController();
const { signal } = controller;
const fetchData = async () => {
try {
setLoading(true);
const result = await getTransactionData({ transactionId, indexPattern, data, signal });
const fields = result.rawResponse.hits.hits[0]?.fields;
const transactionName = fields?.[TRANSACTION_NAME_FIELD];
const transactionDuration = fields?.[TRANSACTION_DURATION_FIELD];
setTransaction({
name: transactionName || null,
duration: transactionDuration || null,
});
} catch (err) {
if (!signal.aborted) {
const error = err as Error;
core.notifications.toasts.addDanger({
title: i18n.translate('unifiedDocViewer.docViewerSpanOverview.useTransaction.error', {
defaultMessage: 'An error occurred while fetching the transaction',
}),
text: error.message,
});
setTransaction(null);
}
} finally {
setLoading(false);
}
};
fetchData();
return function onUnmount() {
controller.abort();
};
}, [core.notifications.toasts, data, indexPattern, transactionId]);
return { loading, transaction };
};
export const [TransactionProvider, useTransactionContext] = createContainer(useTransaction);

View file

@ -9,6 +9,7 @@
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui';
import {
OTEL_DURATION,
SERVICE_NAME_FIELD,
SPAN_DURATION_FIELD,
SPAN_ID_FIELD,
@ -23,7 +24,7 @@ import React, { useMemo } from 'react';
import { FieldActionsProvider } from '../../../../hooks/use_field_actions';
import { getUnifiedDocViewerServices } from '../../../../plugin';
import { Trace } from '../components/trace';
import { TransactionProvider } from './hooks/use_transaction';
import { RootSpanProvider } from './hooks/use_root_span';
import { spanFields } from './resources/fields';
import { getSpanFieldConfiguration } from './resources/get_span_field_configuration';
import { SpanDurationSummary } from './sub_components/span_duration_summary';
@ -68,16 +69,24 @@ export function SpanOverview({
[formattedDoc, flattenedDoc]
);
const spanDuration = flattenedDoc[SPAN_DURATION_FIELD];
const isOtelSpan =
flattenedDoc[SPAN_DURATION_FIELD] == null && flattenedDoc[OTEL_DURATION] != null;
const spanDuration = isOtelSpan
? flattenedDoc[OTEL_DURATION]! * 0.001
: flattenedDoc[SPAN_DURATION_FIELD];
const traceId = flattenedDoc[TRACE_ID_FIELD];
const transactionId = flattenedDoc[TRANSACTION_ID_FIELD];
return (
<DataSourcesProvider indexes={indexes}>
<RootTransactionProvider
traceId={flattenedDoc[TRACE_ID_FIELD]}
indexPattern={indexes.apm.traces}
>
<TransactionProvider transactionId={transactionId} indexPattern={indexes.apm.traces}>
<RootTransactionProvider traceId={traceId} indexPattern={indexes.apm.traces}>
<RootSpanProvider
traceId={traceId}
transactionId={transactionId}
indexPattern={indexes.apm.traces}
>
<FieldActionsProvider
columns={columns}
filter={filter}
@ -114,6 +123,7 @@ export function SpanOverview({
spanDuration={spanDuration}
spanName={flattenedDoc[SPAN_NAME_FIELD]}
serviceName={flattenedDoc[SERVICE_NAME_FIELD]}
isOtelSpan={isOtelSpan}
/>
</EuiFlexItem>
)}
@ -132,7 +142,7 @@ export function SpanOverview({
</EuiFlexGroup>
</EuiPanel>
</FieldActionsProvider>
</TransactionProvider>
</RootSpanProvider>
</RootTransactionProvider>
</DataSourcesProvider>
);

View file

@ -12,7 +12,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiText, EuiTitle } from
import { i18n } from '@kbn/i18n';
import { Duration, DurationDistributionChart } from '@kbn/apm-ui-shared';
import { ProcessorEvent } from '@kbn/apm-types-shared';
import { useTransactionContext } from '../../hooks/use_transaction';
import { useRootSpanContext } from '../../hooks/use_root_span';
import { useSpanLatencyChart } from '../../hooks/use_span_latency_chart';
import { FieldWithoutActions } from '../../../components/field_without_actions';
import { Section } from '../../../components/section';
@ -21,14 +21,16 @@ export interface SpanDurationSummaryProps {
spanDuration: number;
spanName: string;
serviceName: string;
isOtelSpan: boolean;
}
export function SpanDurationSummary({
spanDuration,
spanName,
serviceName,
isOtelSpan,
}: SpanDurationSummaryProps) {
const { transaction, loading } = useTransactionContext();
const { trace, loading } = useRootSpanContext();
const {
data: latencyChartData,
loading: latencyChartLoading,
@ -36,6 +38,7 @@ export function SpanDurationSummary({
} = useSpanLatencyChart({
spanName,
serviceName,
isOtelSpan,
});
return (
@ -65,7 +68,7 @@ export function SpanDurationSummary({
<EuiText size="xs">
<Duration
duration={spanDuration}
parent={{ loading, duration: transaction?.duration, type: 'transaction' }}
parent={{ loading, duration: trace?.duration, type: 'transaction' }}
/>
</EuiText>
</FieldWithoutActions>
@ -95,6 +98,7 @@ export function SpanDurationSummary({
eventType={ProcessorEvent.span}
showAxisTitle={false}
showLegend={false}
isOtelData={isOtelSpan}
dataTestSubPrefix="docViewerSpanOverview"
/>
)}

View file

@ -11,7 +11,7 @@ import { TRANSACTION_NAME_FIELD } from '@kbn/discover-utils';
import { EuiHorizontalRule } from '@elastic/eui';
import React, { useState, useEffect } from 'react';
import { FieldWithActions } from '../../components/field_with_actions/field_with_actions';
import { useTransactionContext } from '../hooks/use_transaction';
import { useRootSpanContext } from '../hooks/use_root_span';
import { FieldConfiguration } from '../../resources/get_field_configuration';
export interface SpanSummaryFieldProps {
fieldId: string;
@ -24,16 +24,16 @@ export function SpanSummaryField({
fieldId,
showActions = true,
}: SpanSummaryFieldProps) {
const { transaction, loading } = useTransactionContext();
const { trace, loading } = useRootSpanContext();
const [fieldValue, setFieldValue] = useState(fieldConfiguration.value);
const isTransactionNameField = fieldId === TRANSACTION_NAME_FIELD;
const isTransactionNameFieldWithoutValue = isTransactionNameField && !fieldValue;
useEffect(() => {
if (isTransactionNameField && !fieldValue && transaction?.name && !loading) {
setFieldValue(transaction.name);
if (isTransactionNameField && !fieldValue && trace?.name && !loading) {
setFieldValue(trace.name);
}
}, [transaction?.name, loading, fieldValue, isTransactionNameField]);
}, [trace?.name, loading, fieldValue, isTransactionNameField]);
if (
(!isTransactionNameFieldWithoutValue && !fieldValue) ||

View file

@ -33,12 +33,14 @@ export const fetchDurationHistogramRangeSteps = async ({
searchMetrics,
durationMinOverride,
durationMaxOverride,
isOtel = false,
}: CommonCorrelationsQueryParams & {
chartType: LatencyDistributionChartType;
apmEventClient: APMEventClient;
searchMetrics: boolean;
durationMinOverride?: number;
durationMaxOverride?: number;
isOtel?: boolean;
}): Promise<{
durationMin?: number;
durationMax?: number;
@ -58,7 +60,7 @@ export const fetchDurationHistogramRangeSteps = async ({
};
}
const durationField = getDurationField(chartType, searchMetrics);
const durationField = getDurationField(chartType, searchMetrics, isOtel);
// when using metrics data, ensure we filter by docs with the appropriate duration field
const filteredQuery = searchMetrics
@ -69,24 +71,28 @@ export const fetchDurationHistogramRangeSteps = async ({
}
: query;
const resp = await apmEventClient.search('get_duration_histogram_range_steps', {
apm: {
events: [getEventType(chartType, searchMetrics)],
const resp = await apmEventClient.search(
'get_duration_histogram_range_steps',
{
apm: {
events: [getEventType(chartType, searchMetrics)],
},
track_total_hits: 1,
size: 0,
query: getCommonCorrelationsQuery({
start,
end,
environment,
kuery,
query: filteredQuery,
}),
aggs: {
duration_min: { min: { field: durationField } },
duration_max: { max: { field: durationField } },
},
},
track_total_hits: 1,
size: 0,
query: getCommonCorrelationsQuery({
start,
end,
environment,
kuery,
query: filteredQuery,
}),
aggs: {
duration_min: { min: { field: durationField } },
duration_max: { max: { field: durationField } },
},
});
{ skipProcessorEventFilter: isOtel }
);
if (resp.hits.total.value === 0) {
return { rangeSteps: getHistogramRangeSteps(0, 1, 100) };

View file

@ -23,11 +23,13 @@ export const fetchDurationPercentiles = async ({
query,
percents,
searchMetrics,
isOtel = false,
}: CommonCorrelationsQueryParams & {
chartType: LatencyDistributionChartType;
apmEventClient: APMEventClient;
percents?: number[];
searchMetrics: boolean;
isOtel?: boolean;
}): Promise<{
totalDocs: number;
percentiles: Record<string, number>;
@ -58,13 +60,15 @@ export const fetchDurationPercentiles = async ({
hdr: {
number_of_significant_value_digits: SIGNIFICANT_VALUE_DIGITS,
},
field: getDurationField(chartType, searchMetrics),
field: getDurationField(chartType, searchMetrics, isOtel),
...(Array.isArray(percents) ? { percents } : {}),
},
},
},
};
const response = await apmEventClient.search('get_duration_percentiles', params);
const response = await apmEventClient.search('get_duration_percentiles', params, {
skipProcessorEventFilter: isOtel,
});
// return early with no results if the search didn't return any documents
if (!response.aggregations || response.hits.total.value === 0) {

View file

@ -24,6 +24,7 @@ export const fetchDurationRanges = async ({
query,
chartType,
searchMetrics,
isOtel = false,
}: {
rangeSteps: number[];
apmEventClient: APMEventClient;
@ -34,6 +35,7 @@ export const fetchDurationRanges = async ({
query: estypes.QueryDslQueryContainer;
chartType: LatencyDistributionChartType;
searchMetrics: boolean;
isOtel?: boolean;
}): Promise<{
totalDocCount: number;
durationRanges: Array<{ key: number; doc_count: number }>;
@ -59,28 +61,32 @@ export const fetchDurationRanges = async ({
ranges.push({ from: ranges[ranges.length - 1].to });
}
const resp = await apmEventClient.search('get_duration_ranges', {
apm: {
events: [getEventType(chartType, searchMetrics)],
},
track_total_hits: false,
size: 0,
query: getCommonCorrelationsQuery({
start,
end,
environment,
kuery,
query: filteredQuery,
}),
aggs: {
logspace_ranges: {
range: {
field: getDurationField(chartType, searchMetrics),
ranges,
const resp = await apmEventClient.search(
'get_duration_ranges',
{
apm: {
events: [getEventType(chartType, searchMetrics)],
},
track_total_hits: false,
size: 0,
query: getCommonCorrelationsQuery({
start,
end,
environment,
kuery,
query: filteredQuery,
}),
aggs: {
logspace_ranges: {
range: {
field: getDurationField(chartType, searchMetrics, isOtel),
ranges,
},
},
},
},
});
{ skipProcessorEventFilter: isOtel }
);
const durationRanges =
resp.aggregations?.logspace_ranges.buckets

View file

@ -6,6 +6,7 @@
*/
import {
DURATION,
SPAN_DURATION,
TRANSACTION_DURATION,
TRANSACTION_DURATION_HISTOGRAM,
@ -20,7 +21,11 @@ const {
spanLatency,
} = LatencyDistributionChartType;
export function getDurationField(chartType: LatencyDistributionChartType, searchMetrics: boolean) {
export function getDurationField(
chartType: LatencyDistributionChartType,
searchMetrics: boolean,
isOtel: boolean
) {
switch (chartType) {
case transactionLatency:
if (searchMetrics) {
@ -33,7 +38,7 @@ export function getDurationField(chartType: LatencyDistributionChartType, search
return TRANSACTION_DURATION;
case dependencyLatency:
case spanLatency:
return SPAN_DURATION;
return isOtel ? DURATION : SPAN_DURATION;
default:
return TRANSACTION_DURATION;
}

View file

@ -27,6 +27,7 @@ export async function getOverallLatencyDistribution({
durationMinOverride,
durationMaxOverride,
searchMetrics,
isOtel = false,
}: {
chartType: LatencyDistributionChartType;
apmEventClient: APMEventClient;
@ -39,6 +40,7 @@ export async function getOverallLatencyDistribution({
durationMinOverride?: number;
durationMaxOverride?: number;
searchMetrics: boolean;
isOtel?: boolean;
}) {
return withApmSpan('get_overall_latency_distribution', async () => {
const overallLatencyDistribution: OverallLatencyDistributionResponse = {};
@ -54,6 +56,7 @@ export async function getOverallLatencyDistribution({
query,
percentileThreshold,
searchMetrics,
isOtel,
});
// finish early if we weren't able to identify the percentileThresholdValue.
@ -73,6 +76,7 @@ export async function getOverallLatencyDistribution({
searchMetrics,
durationMinOverride,
durationMaxOverride,
isOtel,
});
if (!rangeSteps) {
@ -90,6 +94,7 @@ export async function getOverallLatencyDistribution({
query,
rangeSteps,
searchMetrics,
isOtel,
});
overallLatencyDistribution.durationMin = durationMin;

View file

@ -20,11 +20,13 @@ export async function getPercentileThresholdValue({
query,
percentileThreshold,
searchMetrics,
isOtel,
}: CommonCorrelationsQueryParams & {
apmEventClient: APMEventClient;
chartType: LatencyDistributionChartType;
percentileThreshold: number;
searchMetrics: boolean;
isOtel: boolean;
}) {
const durationPercentiles = await fetchDurationPercentiles({
apmEventClient,
@ -35,6 +37,7 @@ export async function getPercentileThresholdValue({
kuery,
query,
searchMetrics,
isOtel,
});
return durationPercentiles.percentiles[`${percentileThreshold}.0`];

View file

@ -33,6 +33,7 @@ const latencyOverallSpanDistributionRoute = createApmServerRoute({
),
durationMin: toNumberRt,
durationMax: toNumberRt,
isOtel: t.boolean,
}),
environmentRt,
kueryRt,
@ -59,6 +60,7 @@ const latencyOverallSpanDistributionRoute = createApmServerRoute({
durationMax,
termFilters,
chartType,
isOtel = false,
} = resources.params.body;
return getOverallLatencyDistribution({
@ -83,6 +85,7 @@ const latencyOverallSpanDistributionRoute = createApmServerRoute({
durationMinOverride: durationMin,
durationMaxOverride: durationMax,
searchMetrics: false,
isOtel,
});
},
});