[APM][OTel] EDOT error summary fix (#217885)

## Summary

This PR fixes the issue with the error summary missing items using edot.
It includes e2e tests with synthtrace for both edot and otel services.

TODO 

- [x] Test with serverless (waiting for the PR to be deployed)
Tested on serverless works as expected: 

<img width="2560" alt="image"
src="https://github.com/user-attachments/assets/8dd7962e-7d66-482d-97fb-0b08882bd04f"
/>
This commit is contained in:
jennypavlova 2025-04-15 21:44:11 +02:00 committed by GitHub
parent 7ee7edb5e5
commit 7c9a3ee1f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 81 additions and 27 deletions

View file

@ -7,5 +7,5 @@
export interface Server {
address?: string;
port?: string;
port?: number;
}

View file

@ -124,12 +124,27 @@ describe('Service Overview', () => {
beforeEach(() => {
cy.loginAsViewerUser();
cy.visitKibana(baseUrl);
cy.contains('adservice-edot-synth');
cy.contains('a', 'View errors').click();
});
it('navigates to the errors page', () => {
cy.contains('adservice-edot-synth');
cy.contains('a', 'View errors').click();
cy.url().should('include', '/adservice-edot-synth/errors');
});
it('navigates to error detail page and shows error summary', () => {
cy.contains('a', '[ResponseError] index_not_found_exception').click();
cy.contains('div', '[ResponseError] index_not_found_exception');
cy.getByTestSubj('apmHttpInfoRequestMethod').should('exist');
cy.getByTestSubj('apmHttpInfoRequestMethod').contains('GET');
cy.getByTestSubj('apmHttpInfoUrl').should('exist');
cy.getByTestSubj('apmHttpInfoUrl').contains(
'https://otel-demo-blue-adservice-edot-synth:8080/some/path'
);
cy.getByTestSubj('apmHttpInfoRequestMethod').should('exist');
cy.getByTestSubj('apmHttpStatusBadge').should('exist');
cy.getByTestSubj('apmHttpStatusBadge').contains('OK');
});
});
});

View file

@ -151,9 +151,17 @@ describe('Service Overview', () => {
cy.url().should('include', '/sendotlp-otel-native-synth/errors');
});
it('navigates to error detail page', () => {
cy.contains('a', '*errors.errorString').click();
it('navigates to error detail page and checks error summary', () => {
cy.contains('a', 'boom').click();
cy.contains('div', 'boom');
cy.getByTestSubj('apmHttpInfoRequestMethod').should('exist');
cy.getByTestSubj('apmHttpInfoRequestMethod').contains('GET');
cy.getByTestSubj('apmHttpInfoUrl').should('exist');
cy.getByTestSubj('apmHttpInfoUrl').contains('https://elastic.co/');
cy.getByTestSubj('apmHttpInfoRequestMethod').should('exist');
cy.getByTestSubj('apmHttpStatusBadge').should('exist');
cy.getByTestSubj('apmHttpStatusBadge').contains('OK');
});
});
});

View file

@ -36,6 +36,15 @@ export function adserviceEdot({ from, to }: { from: number; to: number }) {
'attributes.url.scheme': 'https',
})
.timestamp(timestamp)
.failure()
.errors(
edotInstance
.error({
message: '[ResponseError] index_not_found_exception',
type: 'ResponseError',
})
.timestamp(timestamp + 50)
)
.duration(551)
.success(),
]);

View file

@ -56,6 +56,7 @@ import { ErrorTabKey, getTabs } from './error_tabs';
import { ErrorUiActionsContextMenu } from './error_ui_actions_context_menu';
import { SampleSummary } from './sample_summary';
import { ErrorSampleContextualInsight } from './error_sample_contextual_insight';
import { buildUrl } from '../../../../utils/build_url';
const TransactionLinkName = styled.div`
margin-left: ${({ theme }) => theme.euiTheme.size.s};
@ -154,11 +155,22 @@ export function ErrorSampleDetails({
const tabs = getTabs(error);
const currentTab = getCurrentTab(tabs, detailTab) as ErrorTab;
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;
const errorOrTransactionUserAgent = error?.user_agent
? error.user_agent
: transaction?.user_agent;
const errorUrl = error.error.page?.url || error.url?.full;
const method = error.http?.request?.method;
const status = error.http?.response?.status_code;
const userAgent = error?.user_agent;
// To get the error data needed for the summary we use the transaction fallback in case
// the error data is not available.
// In case of OTel the error data is not available in the error response and we need to use
// the associated root span data (which is called "transaction" here because of the APM data model).
const errorUrl = urlFromError || urlFromTransaction || buildUrl(errorOrTransactionUrl);
const method = errorOrTransactionHttp?.http?.request?.method;
const status = errorOrTransactionHttp?.http?.response?.status_code;
const userAgent = errorOrTransactionUserAgent;
const environment = error.service.environment;
const serviceVersion = error.service.version;
const isUnhandled = error.error.exception?.[0]?.handled === false;
@ -205,7 +217,7 @@ export function ErrorSampleDetails({
<EuiFlexItem>
<EuiIcon type="apmTrace" />
</EuiFlexItem>
<EuiFlexItem style={{ whiteSpace: 'nowrap' }}>
<EuiFlexItem css={{ whiteSpace: 'nowrap' }}>
{i18n.translate('xpack.apm.errorSampleDetails.viewOccurrencesInTraceExplorer', {
defaultMessage: 'Explore traces with this error',
})}
@ -223,7 +235,7 @@ export function ErrorSampleDetails({
<EuiFlexItem>
<EuiIcon type="discoverApp" />
</EuiFlexItem>
<EuiFlexItem style={{ whiteSpace: 'nowrap' }}>
<EuiFlexItem css={{ whiteSpace: 'nowrap' }}>
{i18n.translate(
'xpack.apm.errorSampleDetails.viewOccurrencesInDiscoverButtonLabel',
{
@ -247,7 +259,7 @@ export function ErrorSampleDetails({
<Summary
items={[
<TimestampTooltip time={errorData ? error.timestamp.us / 1000 : 0} />,
errorUrl && method ? (
errorUrl ? (
<HttpInfoSummaryItem url={errorUrl} method={method} status={status} />
) : null,
userAgent?.name ? <UserAgentSummaryItem {...userAgent} /> : null,

View file

@ -5,8 +5,7 @@
* 2.0.
*/
import { buildUrl } from './build_url';
import type { Transaction } from '../../typings/es_schemas/ui/transaction';
import { type ItemType, buildUrl } from './build_url';
describe('buildUrl', () => {
it('should return a full URL when all fields are provided', () => {
@ -20,7 +19,7 @@ describe('buildUrl', () => {
port: 443,
},
};
const result = buildUrl(item as unknown as Transaction);
const result = buildUrl(item);
expect(result).toBe('ftp://example.com:443/some/path');
});
@ -34,7 +33,7 @@ describe('buildUrl', () => {
address: 'example.org',
},
};
const result = buildUrl(item as Transaction);
const result = buildUrl(item);
expect(result).toBe('http://example.org/another/path');
});
@ -48,7 +47,7 @@ describe('buildUrl', () => {
port: 8443,
},
};
const result = buildUrl(item as unknown as Transaction);
const result = buildUrl(item);
expect(result).toBe('https://example.net:8443/');
});
@ -62,7 +61,7 @@ describe('buildUrl', () => {
port: 8080,
},
};
const result = buildUrl(item as unknown as Transaction);
const result = buildUrl(item);
expect(result).toBeUndefined();
});
@ -76,7 +75,7 @@ describe('buildUrl', () => {
port: 8080,
},
};
const result = buildUrl(item as unknown as Transaction);
const result = buildUrl(item);
expect(result).toBeUndefined();
});
@ -90,17 +89,17 @@ describe('buildUrl', () => {
},
server: {
address: 'example.com',
port: 'invalid-port',
port: 'invalid', // Invalid port
},
};
const result = buildUrl(item as unknown as Transaction);
const result = buildUrl(item as unknown as ItemType);
expect(result).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to build URL',
expect.objectContaining({
message: 'Invalid base URL: https://example.com:invalid-port',
message: 'Invalid base URL: https://example.com:invalid',
})
);
@ -109,7 +108,7 @@ describe('buildUrl', () => {
it('should handle an empty object gracefully', () => {
const item = {};
const result = buildUrl(item as Transaction);
const result = buildUrl(item);
expect(result).toBeUndefined();
});
});

View file

@ -4,11 +4,18 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Transaction } from '../../typings/es_schemas/ui/transaction';
import type { Span } from '../../typings/es_schemas/ui/span';
import type { APMError } from '../../typings/es_schemas/ui/apm_error';
export interface ItemType {
url?: {
scheme?: string;
path?: string;
};
server?: {
address?: string;
port?: number;
};
}
export const buildUrl = (item: Transaction | Span | APMError) => {
export const buildUrl = (item?: ItemType) => {
// URL fields from Otel
const urlScheme = item?.url?.scheme;
const urlPath = item?.url?.path;

View file

@ -126,6 +126,10 @@ export async function getTransaction({
return {
...event,
server: {
...event.server,
port: event.server?.port ? Number(event.server?.port) : undefined,
},
transaction: {
...event.transaction,
marks: source?.transaction?.marks,