[APM][OTel] Add url.full fallback (#215397)

## Summary

This PR fixes the missing URL in the transaction summary 

## Testing [_UPDATED_]
- [SOLVED  ⬇️ ] ~~This is tricky to test ( I am trying to create a
serverless instance from this PR and it should make it easier)~~
- Testing on serverless (the env linked in the PR) 
   - EDOT service (I run locally in Docker and connect to the env):  
<img width="1904" alt="image"
src="https://github.com/user-attachments/assets/c3a7ab56-5b8f-42a5-8033-55ccbb915b40"
/>

  - Other generated service (from the env):

![image](https://github.com/user-attachments/assets/e76f2901-050a-4ee3-b536-a057d45454e2)
   
- In the meantime 
   - using synthtrace: Case to run/expectation
- `node scripts/synthtrace otel_edot_simple_trace.ts` / The trace
summary should be visible
      

![image](https://github.com/user-attachments/assets/b9ad8a8b-f89a-449c-a053-a5628c2fa620)

- `node scripts/synthtrace simple_trace.ts` / The trace summary should
still be visible (using `url.full` in this case)
      

![image](https://github.com/user-attachments/assets/93ffac41-9f79-4d09-ab69-ae5c8e782750)
This commit is contained in:
jennypavlova 2025-04-01 11:50:28 +02:00 committed by GitHub
parent c5e0b05454
commit a987209d3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 248 additions and 11 deletions

View file

@ -144,6 +144,8 @@ class OtelEdot extends Serializable<OtelEdotDocument> {
'app.ads.count': 1,
'event.outcome': 'success',
'event.success_count': 1,
'http.request.method': 'GET',
'http.response.status_code': 200,
'network.peer.address': '10.12.9.56',
'network.peer.port': 41208,
'network.type': 'ipv4',
@ -166,6 +168,8 @@ class OtelEdot extends Serializable<OtelEdotDocument> {
'transaction.root': false,
'transaction.sampled': true,
'transaction.type': 'request',
'url.path': '/some/path',
'url.scheme': 'https',
},
data_stream: {
dataset: 'generic.otel',

View file

@ -51,6 +51,8 @@ export interface OtelEdotTransactionDocument extends OtelEdotDocument {
'session.id'?: string;
'thread.id'?: number;
'thread.name'?: string;
'url.path'?: string;
'url.scheme'?: string;
};
status?: {
code?: string;

View file

@ -189,13 +189,19 @@ export const METRIC_OTEL_JVM_SYSTEM_CPU_PERCENT = 'process.runtime.jvm.system.cp
export const METRIC_OTEL_JVM_GC_DURATION = 'process.runtime.jvm.gc.duration';
export const VALUE_OTEL_JVM_PROCESS_MEMORY_HEAP = 'heap';
export const VALUE_OTEL_JVM_PROCESS_MEMORY_NON_HEAP = 'non_heap';
// OpenTelemetry semconv fields for AgentName https://opentelemetry.io/docs/specs/semconv/resource/#telemetry-sdk
export const TELEMETRY_SDK_NAME = 'telemetry.sdk.name';
export const TELEMETRY_SDK_LANGUAGE = 'telemetry.sdk.language';
export const TELEMETRY_SDK_VERSION = 'telemetry.sdk.version';
// OpenTelemetry span links
// OpenTelemetry semconv fields for HTTP server https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server-semantic-conventions
export const URL_PATH = 'url.path';
export const URL_SCHEME = 'url.scheme';
export const SERVER_ADDRESS = 'server.address';
export const SERVER_PORT = 'server.port';
// OpenTelemetry span links
export const LINKS_SPAN_ID = 'links.span_id';
export const LINKS_TRACE_ID = 'links.trace_id';

View file

@ -19,6 +19,7 @@ import {
Url,
User,
} from './fields';
import { Server } from './fields/server';
export interface Processor {
name: 'error';
@ -70,5 +71,6 @@ export interface ErrorRaw extends APMBaseDoc {
process?: Process;
service: Service;
url?: Url;
server?: Server;
user?: User;
}

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export interface Server {
address?: string;
port?: string;
}

View file

@ -9,4 +9,6 @@ export interface Url {
domain?: string;
full?: string;
original?: string;
scheme?: string;
path?: string;
}

View file

@ -8,6 +8,7 @@
import { APMBaseDoc } from './apm_base_doc';
import { EventOutcome } from './fields/event_outcome';
import { Http } from './fields/http';
import { Server } from './fields/server';
import { SpanLink } from './fields/span_links';
import { Stackframe } from './fields/stackframe';
import { TimestampUs } from './fields/timestamp_us';
@ -76,4 +77,5 @@ export interface SpanRaw extends APMBaseDoc {
};
http?: Http;
url?: Url;
server?: Server;
}

View file

@ -21,6 +21,7 @@ import { User } from './fields/user';
import { UserAgent } from './fields/user_agent';
import { Faas } from './fields/faas';
import { SpanLink } from './fields/span_links';
import { Server } from './fields/server';
interface Processor {
name: 'transaction';
@ -64,6 +65,7 @@ export interface TransactionRaw extends APMBaseDoc {
ecs?: { version?: string };
host?: Host;
http?: Http;
server?: Server;
kubernetes?: Kubernetes;
process?: Process;
service: Service;

View file

@ -256,6 +256,10 @@ exports[`Error PROCESSOR_EVENT 1`] = `"error"`;
exports[`Error PROCESSOR_NAME 1`] = `"error"`;
exports[`Error SERVER_ADDRESS 1`] = `undefined`;
exports[`Error SERVER_PORT 1`] = `undefined`;
exports[`Error SERVICE 1`] = `
Object {
"language": Object {
@ -372,6 +376,10 @@ exports[`Error TRANSACTION_TYPE 1`] = `"request"`;
exports[`Error URL_FULL 1`] = `undefined`;
exports[`Error URL_PATH 1`] = `undefined`;
exports[`Error URL_SCHEME 1`] = `undefined`;
exports[`Error USER_AGENT_NAME 1`] = `undefined`;
exports[`Error USER_AGENT_ORIGINAL 1`] = `undefined`;
@ -627,6 +635,10 @@ exports[`Span PROCESSOR_EVENT 1`] = `"span"`;
exports[`Span PROCESSOR_NAME 1`] = `"transaction"`;
exports[`Span SERVER_ADDRESS 1`] = `undefined`;
exports[`Span SERVER_PORT 1`] = `undefined`;
exports[`Span SERVICE 1`] = `
Object {
"name": "service name",
@ -739,6 +751,10 @@ exports[`Span TRANSACTION_TYPE 1`] = `undefined`;
exports[`Span URL_FULL 1`] = `undefined`;
exports[`Span URL_PATH 1`] = `undefined`;
exports[`Span URL_SCHEME 1`] = `undefined`;
exports[`Span USER_AGENT_NAME 1`] = `undefined`;
exports[`Span USER_AGENT_ORIGINAL 1`] = `undefined`;
@ -1008,6 +1024,10 @@ exports[`Transaction PROCESSOR_EVENT 1`] = `"transaction"`;
exports[`Transaction PROCESSOR_NAME 1`] = `"transaction"`;
exports[`Transaction SERVER_ADDRESS 1`] = `undefined`;
exports[`Transaction SERVER_PORT 1`] = `undefined`;
exports[`Transaction SERVICE 1`] = `
Object {
"language": Object {
@ -1124,6 +1144,10 @@ exports[`Transaction TRANSACTION_TYPE 1`] = `"transaction type"`;
exports[`Transaction URL_FULL 1`] = `"http://www.elastic.co"`;
exports[`Transaction URL_PATH 1`] = `undefined`;
exports[`Transaction URL_SCHEME 1`] = `undefined`;
exports[`Transaction USER_AGENT_NAME 1`] = `"Other"`;
exports[`Transaction USER_AGENT_ORIGINAL 1`] = `"test original"`;

View file

@ -20,6 +20,12 @@ const baseUrl = url.format({
query: { rangeFrom: start, rangeTo: end },
});
const transactionTabPath = '/app/apm/services/adservice-edot-synth/transactions/view';
const transactionUrl = url.format({
pathname: transactionTabPath,
query: { rangeFrom: start, rangeTo: end, transactionName: 'oteldemo.AdServiceEdotSynth/GetAds' },
});
describe('Service Overview', () => {
before(() => {
synthtraceOtel.index(
@ -98,6 +104,19 @@ describe('Service Overview', () => {
cy.contains('a', 'oteldemo.AdServiceEdotSynth/GetAds').click();
cy.contains('h5', 'oteldemo.AdServiceEdotSynth/GetAds');
});
it('shows transaction summary', () => {
cy.visitKibana(transactionUrl);
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');
});
});
describe('errors', () => {

View file

@ -64,7 +64,7 @@ export function WaterfallWithSummary<TSample extends {}>({
waterfallFetchStatus === FETCH_STATUS.LOADING ||
traceSamplesFetchStatus === FETCH_STATUS.LOADING;
// When traceId is not present, call to waterfallFetchResult will not be initiated
const isSucceded =
const isSucceeded =
(waterfallFetchStatus === FETCH_STATUS.SUCCESS ||
waterfallFetchStatus === FETCH_STATUS.NOT_INITIATED) &&
traceSamplesFetchStatus === FETCH_STATUS.SUCCESS;
@ -91,7 +91,7 @@ export function WaterfallWithSummary<TSample extends {}>({
const { entryTransaction } = waterfallFetchResult;
if (!entryTransaction && traceSamples?.length === 0 && isSucceded) {
if (!entryTransaction && traceSamples?.length === 0 && isSucceeded) {
return (
<EuiEmptyPrompt
title={

View file

@ -23,7 +23,7 @@ const urlStyles = css`
interface HttpInfoProps {
method?: string;
status?: number;
url: string;
url?: string;
}
export function HttpInfoSummaryItem({ status, method, url }: HttpInfoProps) {
@ -54,11 +54,13 @@ export function HttpInfoSummaryItem({ status, method, url }: HttpInfoProps) {
<span data-test-subj="apmHttpInfoRequestMethod">{method.toUpperCase()}</span>
</EuiToolTip>
)}{' '}
<EuiToolTip content={url}>
<span data-test-subj="apmHttpInfoUrl" css={urlStyles}>
{url}
</span>
</EuiToolTip>
{url && (
<EuiToolTip content={url}>
<span data-test-subj="apmHttpInfoUrl" css={urlStyles}>
{url}
</span>
</EuiToolTip>
)}
</EuiBadge>
{status && <HttpStatusBadge status={status} />}
</span>

View file

@ -15,6 +15,7 @@ import { HttpInfoSummaryItem } from './http_info_summary_item';
import { TransactionResultSummaryItem } from './transaction_result_summary_item';
import { UserAgentSummaryItem } from './user_agent_summary_item';
import { ColdStartBadge } from '../../app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/badge/cold_start_badge';
import { buildUrl } from '../../../utils/build_url';
interface Props {
transaction: Transaction;
@ -25,12 +26,13 @@ interface Props {
function getTransactionResultSummaryItem(transaction: Transaction) {
const result = transaction.transaction.result;
const url = transaction.url?.full || transaction.transaction?.page?.url;
const urlFull = transaction.url?.full || transaction.transaction?.page?.url;
const url = urlFull ?? buildUrl(transaction);
if (url) {
const method = transaction.http?.request?.method;
const status = transaction.http?.response?.status_code;
return <HttpInfoSummaryItem method={method} status={status} url={url} />;
}

View file

@ -0,0 +1,115 @@
/*
* 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 { buildUrl } from './build_url';
import type { Transaction } from '../../typings/es_schemas/ui/transaction';
describe('buildUrl', () => {
it('should return a full URL when all fields are provided', () => {
const item = {
url: {
scheme: 'ftp',
path: '/some/path',
},
server: {
address: 'example.com',
port: 443,
},
};
const result = buildUrl(item as unknown as Transaction);
expect(result).toBe('ftp://example.com:443/some/path');
});
it('should return a URL without a port if the port is not provided', () => {
const item = {
url: {
scheme: 'http',
path: '/another/path',
},
server: {
address: 'example.org',
},
};
const result = buildUrl(item as Transaction);
expect(result).toBe('http://example.org/another/path');
});
it('should return a URL without a path if the path is not provided', () => {
const item = {
url: {
scheme: 'https',
},
server: {
address: 'example.net',
port: 8443,
},
};
const result = buildUrl(item as unknown as Transaction);
expect(result).toBe('https://example.net:8443/');
});
it('should return undefined if the scheme is missing', () => {
const item = {
url: {
path: '/missing/scheme',
},
server: {
address: 'example.com',
port: 8080,
},
};
const result = buildUrl(item as unknown as Transaction);
expect(result).toBeUndefined();
});
it('should return undefined if the server address is missing', () => {
const item = {
url: {
scheme: 'https',
path: '/missing/address',
},
server: {
port: 8080,
},
};
const result = buildUrl(item as unknown as Transaction);
expect(result).toBeUndefined();
});
it('should return undefined and log an error if the port is invalid', () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const item = {
url: {
scheme: 'https',
path: '/invalid/port',
},
server: {
address: 'example.com',
port: 'invalid-port',
},
};
const result = buildUrl(item as unknown as Transaction);
expect(result).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to build URL',
expect.objectContaining({
message: 'Invalid base URL: https://example.com:invalid-port',
})
);
consoleErrorSpy.mockRestore();
});
it('should handle an empty object gracefully', () => {
const item = {};
const result = buildUrl(item as Transaction);
expect(result).toBeUndefined();
});
});

View file

@ -0,0 +1,32 @@
/*
* 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 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 const buildUrl = (item: Transaction | Span | APMError) => {
// URL fields from Otel
const urlScheme = item?.url?.scheme;
const urlPath = item?.url?.path;
const serverAddress = item?.server?.address;
const serverPort = item?.server?.port;
const hasURLFromFields = urlScheme && serverAddress;
const urlServerPort = serverPort ? `:${serverPort}` : '';
try {
const url = hasURLFromFields
? new URL(urlPath ?? '', `${urlScheme}://${serverAddress}${urlServerPort}`).toString()
: undefined;
return url;
} catch (e) {
console.error('Failed to build URL', e);
return undefined;
}
};

View file

@ -33,6 +33,10 @@ Object {
"http.response.status_code",
"http.request.method",
"user_agent.name",
"url.path",
"url.scheme",
"server.address",
"server.port",
"user_agent.version",
],
"query": Object {

View file

@ -30,6 +30,10 @@ import {
HTTP_RESPONSE_STATUS_CODE,
TRANSACTION_PAGE_URL,
USER_AGENT_NAME,
URL_PATH,
URL_SCHEME,
SERVER_ADDRESS,
SERVER_PORT,
USER_AGENT_VERSION,
} from '../../../../common/es_fields/apm';
import { asMutableArray } from '../../../../common/utils/as_mutable_array';
@ -72,6 +76,10 @@ export async function getTransaction({
HTTP_RESPONSE_STATUS_CODE,
HTTP_REQUEST_METHOD,
USER_AGENT_NAME,
URL_PATH,
URL_SCHEME,
SERVER_ADDRESS,
SERVER_PORT,
USER_AGENT_VERSION,
] as const);