[APM] Span link (#126630)

* adding span links data generation

* fixing span links synthtrace

* adding span links

* span links route

* fixing span links new scenario

* adding span links

* improved tab structure

* span links table

* adding span links data generation

* fixing span links synthtrace

* adding span links

* span links route

* fixing span links new scenario

* adding span links

* span links table

* improved tab structure

* adjusting table

* fixing ts issue

* filtering data within timerange

* fixing ts

* fixing ci

* disabling select option when no link available

* adding api tests

* fixing tests

* e2e tests

* fixing too_many_nested_clauses issue

* refactoring apis

* api tests

* fixing e2e tests

* fixing links

* renaming link

* fixing tests

* addressing PR comments

* fixing test

* fixing ci

* fixing ci

* addressing pr comments

* passing processor event to incoming links API

* updating api tests

* renaming incoming and outgoing

* wrapping type into details property

* renaming incoming/outgoing

* pr comments

* adding processor event to query

* renaming

* new API tests

* import fix

* renaming

* adding e2e tests

* addressing pr changes

* changing link

* Adding filter on children fetch

* renaming services on test

* renaming

Co-authored-by: Boris Kirov <borisasenovkirov@gmail.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cauê Marcondes 2022-05-16 14:40:51 -04:00 committed by GitHub
parent 3f5197a598
commit d3b9f3285e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 3354 additions and 1247 deletions

View file

@ -85,6 +85,10 @@ export type ApmFields = Fields &
'span.destination.service.response_time.count': number;
'span.self_time.count': number;
'span.self_time.sum.us': number;
'span.links': Array<{
trace: { id: string };
span: { id: string };
}>;
'cloud.provider': string;
'cloud.project.name': string;
'cloud.service.name': string;

View file

@ -0,0 +1,114 @@
/*
* 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 { compact, shuffle } from 'lodash';
import { apm, ApmFields, EntityArrayIterable, timerange } from '../..';
import { generateLongId, generateShortId } from '../../lib/utils/generate_id';
import { Scenario } from '../scenario';
function generateExternalSpanLinks() {
// randomly creates external span links 0 - 10
return Array(Math.floor(Math.random() * 11))
.fill(0)
.map(() => ({ span: { id: generateLongId() }, trace: { id: generateShortId() } }));
}
function getSpanLinksFromEvents(events: ApmFields[]) {
return compact(
events.map((event) => {
const spanId = event['span.id'];
return spanId ? { span: { id: spanId }, trace: { id: event['trace.id']! } } : undefined;
})
);
}
const scenario: Scenario<ApmFields> = async () => {
return {
generate: ({ from, to }) => {
const producerInternalOnlyInstance = apm
.service('producer-internal-only', 'production', 'go')
.instance('instance-a');
const producerInternalOnlyEvents = timerange(
new Date('2022-04-25T19:00:00.000Z'),
new Date('2022-04-25T19:01:00.000Z')
)
.interval('1m')
.rate(1)
.generator((timestamp) => {
return producerInternalOnlyInstance
.transaction('Transaction A')
.timestamp(timestamp)
.duration(1000)
.success()
.children(
producerInternalOnlyInstance
.span('Span A', 'custom')
.timestamp(timestamp + 50)
.duration(100)
.success()
);
});
const producerInternalOnlyApmFields = producerInternalOnlyEvents.toArray();
const spanASpanLink = getSpanLinksFromEvents(producerInternalOnlyApmFields);
const producerConsumerInstance = apm
.service('producer-consumer', 'production', 'java')
.instance('instance-b');
const producerConsumerEvents = timerange(from, to)
.interval('1m')
.rate(1)
.generator((timestamp) => {
return producerConsumerInstance
.transaction('Transaction B')
.timestamp(timestamp)
.duration(1000)
.success()
.children(
producerConsumerInstance
.span('Span B', 'external')
.defaults({
'span.links': shuffle([...generateExternalSpanLinks(), ...spanASpanLink]),
})
.timestamp(timestamp + 50)
.duration(900)
.success()
);
});
const producerConsumerApmFields = producerConsumerEvents.toArray();
const spanBSpanLink = getSpanLinksFromEvents(producerConsumerApmFields);
const consumerInstance = apm.service('consumer', 'production', 'ruby').instance('instance-c');
const consumerEvents = timerange(from, to)
.interval('1m')
.rate(1)
.generator((timestamp) => {
return consumerInstance
.transaction('Transaction C')
.timestamp(timestamp)
.duration(1000)
.success()
.children(
consumerInstance
.span('Span C', 'external')
.defaults({ 'span.links': spanBSpanLink })
.timestamp(timestamp + 50)
.duration(900)
.success()
);
});
return new EntityArrayIterable(producerInternalOnlyApmFields)
.merge(consumerEvents)
.merge(new EntityArrayIterable(producerConsumerApmFields));
},
};
};
export default scenario;

View file

@ -203,6 +203,12 @@ exports[`Error SPAN_DURATION 1`] = `undefined`;
exports[`Error SPAN_ID 1`] = `undefined`;
exports[`Error SPAN_LINKS 1`] = `undefined`;
exports[`Error SPAN_LINKS_SPAN_ID 1`] = `undefined`;
exports[`Error SPAN_LINKS_TRACE_ID 1`] = `undefined`;
exports[`Error SPAN_NAME 1`] = `undefined`;
exports[`Error SPAN_SELF_TIME_SUM 1`] = `undefined`;
@ -446,6 +452,12 @@ exports[`Span SPAN_DURATION 1`] = `1337`;
exports[`Span SPAN_ID 1`] = `"span id"`;
exports[`Span SPAN_LINKS 1`] = `undefined`;
exports[`Span SPAN_LINKS_SPAN_ID 1`] = `undefined`;
exports[`Span SPAN_LINKS_TRACE_ID 1`] = `undefined`;
exports[`Span SPAN_NAME 1`] = `"span name"`;
exports[`Span SPAN_SELF_TIME_SUM 1`] = `undefined`;
@ -703,6 +715,12 @@ exports[`Transaction SPAN_DURATION 1`] = `undefined`;
exports[`Transaction SPAN_ID 1`] = `undefined`;
exports[`Transaction SPAN_LINKS 1`] = `undefined`;
exports[`Transaction SPAN_LINKS_SPAN_ID 1`] = `undefined`;
exports[`Transaction SPAN_LINKS_TRACE_ID 1`] = `undefined`;
exports[`Transaction SPAN_NAME 1`] = `undefined`;
exports[`Transaction SPAN_SELF_TIME_SUM 1`] = `undefined`;

View file

@ -74,6 +74,10 @@ export const SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT =
export const SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM =
'span.destination.service.response_time.sum.us';
export const SPAN_LINKS = 'span.links';
export const SPAN_LINKS_TRACE_ID = 'span.links.trace.id';
export const SPAN_LINKS_SPAN_ID = 'span.links.span.id';
// Parent ID for a transaction or span
export const PARENT_ID = 'parent.id';

View file

@ -0,0 +1,24 @@
/*
* 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 { AgentName } from '../typings/es_schemas/ui/fields/agent';
import { Environment } from './environment_rt';
export interface SpanLinkDetails {
traceId: string;
spanId: string;
details?: {
agentName: AgentName;
serviceName: string;
duration: number;
environment: Environment;
transactionId?: string;
spanName?: string;
spanSubtype?: string;
spanType?: string;
};
}

View file

@ -628,8 +628,7 @@
{
"type": "index",
"value": {
"aliases": {
},
"aliases": {},
"index": ".ml-config",
"mappings": {
"_meta": {
@ -15510,6 +15509,26 @@
"type": {
"ignore_above": 1024,
"type": "keyword"
},
"links": {
"properties": {
"span": {
"properties": {
"id": {
"type": "keyword",
"ignore_above": 1024
}
}
},
"trace": {
"properties": {
"id": {
"type": "keyword",
"ignore_above": 1024
}
}
}
}
}
}
},
@ -20620,6 +20639,26 @@
"type": {
"ignore_above": 1024,
"type": "keyword"
},
"links": {
"properties": {
"span": {
"properties": {
"id": {
"type": "keyword",
"ignore_above": 1024
}
}
},
"trace": {
"properties": {
"id": {
"type": "keyword",
"ignore_above": 1024
}
}
}
}
}
}
},

View file

@ -0,0 +1,326 @@
/*
* 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 { apm, EntityArrayIterable, timerange } from '@elastic/apm-synthtrace';
import { synthtrace } from '../../../../synthtrace';
import { SpanLink } from '../../../../../typings/es_schemas/raw/fields/span_links';
function getProducerInternalOnly() {
const producerInternalOnlyInstance = apm
.service('producer-internal-only', 'production', 'go')
.instance('instance a');
const events = timerange(
new Date('2022-01-01T00:00:00.000Z'),
new Date('2022-01-01T00:01:00.000Z')
)
.interval('1m')
.rate(1)
.generator((timestamp) => {
return producerInternalOnlyInstance
.transaction(`Transaction A`)
.timestamp(timestamp)
.duration(1000)
.success()
.children(
producerInternalOnlyInstance
.span(`Span A`, 'external', 'http')
.timestamp(timestamp + 50)
.duration(100)
.success()
);
});
const apmFields = events.toArray();
const transactionA = apmFields.find(
(item) => item['processor.event'] === 'transaction'
);
const spanA = apmFields.find((item) => item['processor.event'] === 'span');
const ids =
spanA && transactionA
? {
transactionAId: transactionA['transaction.id']!,
traceId: spanA['trace.id']!,
spanAId: spanA['span.id']!,
}
: {};
const spanASpanLink = spanA
? { trace: { id: spanA['trace.id']! }, span: { id: spanA['span.id']! } }
: undefined;
return {
ids,
spanASpanLink,
apmFields,
};
}
function getProducerExternalOnly() {
const producerExternalOnlyInstance = apm
.service('producer-external-only', 'production', 'java')
.instance('instance b');
const events = timerange(
new Date('2022-01-01T00:02:00.000Z'),
new Date('2022-01-01T00:03:00.000Z')
)
.interval('1m')
.rate(1)
.generator((timestamp) => {
return producerExternalOnlyInstance
.transaction(`Transaction B`)
.timestamp(timestamp)
.duration(1000)
.success()
.children(
producerExternalOnlyInstance
.span(`Span B`, 'external', 'http')
.defaults({
'span.links': [
{ trace: { id: 'trace#1' }, span: { id: 'span#1' } },
],
})
.timestamp(timestamp + 50)
.duration(100)
.success(),
producerExternalOnlyInstance
.span(`Span B.1`, 'external', 'http')
.timestamp(timestamp + 50)
.duration(100)
.success()
);
});
const apmFields = events.toArray();
const transactionB = apmFields.find(
(item) => item['processor.event'] === 'transaction'
);
const spanB = apmFields.find(
(item) =>
item['processor.event'] === 'span' && item['span.name'] === 'Span B'
);
const ids =
spanB && transactionB
? {
traceId: spanB['trace.id']!,
transactionBId: transactionB['transaction.id']!,
spanBId: spanB['span.id']!,
}
: {};
const spanBSpanLink = spanB
? {
trace: { id: spanB['trace.id']! },
span: { id: spanB['span.id']! },
}
: undefined;
return {
ids,
spanBSpanLink,
apmFields,
};
}
function getProducerConsumer({
producerInternalOnlySpanASpanLink,
}: {
producerInternalOnlySpanASpanLink?: SpanLink;
}) {
const producerConsumerInstance = apm
.service('producer-consumer', 'production', 'ruby')
.instance('instance c');
const events = timerange(
new Date('2022-01-01T00:04:00.000Z'),
new Date('2022-01-01T00:05:00.000Z')
)
.interval('1m')
.rate(1)
.generator((timestamp) => {
return producerConsumerInstance
.transaction(`Transaction C`)
.defaults({
'span.links': producerInternalOnlySpanASpanLink
? [producerInternalOnlySpanASpanLink]
: [],
})
.timestamp(timestamp)
.duration(1000)
.success()
.children(
producerConsumerInstance
.span(`Span C`, 'external', 'http')
.timestamp(timestamp + 50)
.duration(100)
.success()
);
});
const apmFields = events.toArray();
const transactionC = apmFields.find(
(item) => item['processor.event'] === 'transaction'
);
const transactionCSpanLink = transactionC
? {
trace: { id: transactionC['trace.id']! },
span: { id: transactionC['transaction.id']! },
}
: undefined;
const spanC = apmFields.find(
(item) =>
item['processor.event'] === 'span' || item['span.name'] === 'Span C'
);
const spanCSpanLink = spanC
? {
trace: { id: spanC['trace.id']! },
span: { id: spanC['span.id']! },
}
: undefined;
const ids =
spanC && transactionC
? {
traceId: transactionC['trace.id']!,
transactionCId: transactionC['transaction.id']!,
spanCId: spanC['span.id']!,
}
: {};
return {
transactionCSpanLink,
spanCSpanLink,
ids,
apmFields,
};
}
function getConsumerMultiple({
producerInternalOnlySpanASpanLink,
producerExternalOnlySpanBSpanLink,
producerConsumerSpanCSpanLink,
producerConsumerTransactionCSpanLink,
}: {
producerInternalOnlySpanASpanLink?: SpanLink;
producerExternalOnlySpanBSpanLink?: SpanLink;
producerConsumerSpanCSpanLink?: SpanLink;
producerConsumerTransactionCSpanLink?: SpanLink;
}) {
const consumerMultipleInstance = apm
.service('consumer-multiple', 'production', 'nodejs')
.instance('instance d');
const events = timerange(
new Date('2022-01-01T00:06:00.000Z'),
new Date('2022-01-01T00:07:00.000Z')
)
.interval('1m')
.rate(1)
.generator((timestamp) => {
return consumerMultipleInstance
.transaction(`Transaction D`)
.defaults({
'span.links':
producerInternalOnlySpanASpanLink && producerConsumerSpanCSpanLink
? [
producerInternalOnlySpanASpanLink,
producerConsumerSpanCSpanLink,
]
: [],
})
.timestamp(timestamp)
.duration(1000)
.success()
.children(
consumerMultipleInstance
.span(`Span E`, 'external', 'http')
.defaults({
'span.links':
producerExternalOnlySpanBSpanLink &&
producerConsumerTransactionCSpanLink
? [
producerExternalOnlySpanBSpanLink,
producerConsumerTransactionCSpanLink,
]
: [],
})
.timestamp(timestamp + 50)
.duration(100)
.success()
);
});
const apmFields = events.toArray();
const transactionD = apmFields.find(
(item) => item['processor.event'] === 'transaction'
);
const spanE = apmFields.find((item) => item['processor.event'] === 'span');
const ids =
transactionD && spanE
? {
traceId: transactionD['trace.id']!,
transactionDId: transactionD['transaction.id']!,
spanEId: spanE['span.id']!,
}
: {};
return {
ids,
apmFields,
};
}
/**
* Data ingestion summary:
*
* producer-internal-only (go)
* --Transaction A
* ----Span A
*
* producer-external-only (java)
* --Transaction B
* ----Span B
* ------span.links=external link
* ----Span B1
*
* producer-consumer (ruby)
* --Transaction C
* ------span.links=producer-internal-only / Span A
* ----Span C
*
* consumer-multiple (nodejs)
* --Transaction D
* ------span.links= producer-consumer / Span C | producer-internal-only / Span A
* ----Span E
* ------span.links= producer-external-only / Span B | producer-consumer / Transaction C
*/
export async function generateSpanLinksData() {
const producerInternalOnly = getProducerInternalOnly();
const producerExternalOnly = getProducerExternalOnly();
const producerConsumer = getProducerConsumer({
producerInternalOnlySpanASpanLink: producerInternalOnly.spanASpanLink,
});
const producerMultiple = getConsumerMultiple({
producerInternalOnlySpanASpanLink: producerInternalOnly.spanASpanLink,
producerConsumerSpanCSpanLink: producerConsumer.spanCSpanLink,
producerConsumerTransactionCSpanLink: producerConsumer.transactionCSpanLink,
producerExternalOnlySpanBSpanLink: producerExternalOnly.spanBSpanLink,
});
await synthtrace.index(
new EntityArrayIterable(producerInternalOnly.apmFields).merge(
new EntityArrayIterable(producerExternalOnly.apmFields),
new EntityArrayIterable(producerConsumer.apmFields),
new EntityArrayIterable(producerMultiple.apmFields)
)
);
return {
producerInternalOnlyIds: producerInternalOnly.ids,
producerExternalOnlyIds: producerExternalOnly.ids,
producerConsumerIds: producerConsumer.ids,
producerMultipleIds: producerMultiple.ids,
};
}

View file

@ -0,0 +1,301 @@
/*
* 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 url from 'url';
import { synthtrace } from '../../../../synthtrace';
import { generateSpanLinksData } from './generate_span_links_data';
const start = '2022-01-01T00:00:00.000Z';
const end = '2022-01-01T00:15:00.000Z';
function getServiceInventoryUrl({ serviceName }: { serviceName: string }) {
return url.format({
pathname: `/app/apm/services/${serviceName}`,
query: {
rangeFrom: start,
rangeTo: end,
environment: 'ENVIRONMENT_ALL',
kuery: '',
serviceGroup: '',
transactionType: 'request',
comparisonEnabled: true,
offset: '1d',
},
});
}
describe('Span links', () => {
beforeEach(() => {
cy.loginAsReadOnlyUser();
});
describe('when data is loaded', () => {
let ids: Awaited<ReturnType<typeof generateSpanLinksData>>;
before(async () => {
ids = await generateSpanLinksData();
});
after(async () => {
await synthtrace.clean();
});
describe('span links count on trace waterfall', () => {
it('Shows two children and no parents on producer-internal-only Span A', () => {
cy.visit(
getServiceInventoryUrl({ serviceName: 'producer-internal-only' })
);
cy.contains('Transaction A').click();
cy.contains('2 Span links');
cy.get(
`[data-test-subj="spanLinksBadge_${ids.producerInternalOnlyIds.spanAId}"]`
).realHover();
cy.contains('2 Span links found');
cy.contains('2 incoming');
cy.contains('0 outgoing');
});
it('Shows one parent and one children on producer-external-only Span B', () => {
cy.visit(
getServiceInventoryUrl({ serviceName: 'producer-external-only' })
);
cy.contains('Transaction B').click();
cy.contains('2 Span links');
cy.get(
`[data-test-subj="spanLinksBadge_${ids.producerExternalOnlyIds.spanBId}"]`
).realHover();
cy.contains('2 Span links found');
cy.contains('1 incoming');
cy.contains('1 outgoing');
});
it('Shows one parent and one children on producer-consumer Transaction C', () => {
cy.visit(getServiceInventoryUrl({ serviceName: 'producer-consumer' }));
cy.contains('Transaction C').click();
cy.contains('2 Span links');
cy.get(
`[data-test-subj="spanLinksBadge_${ids.producerConsumerIds.transactionCId}"]`
).realHover();
cy.contains('2 Span links found');
cy.contains('1 incoming');
cy.contains('1 outgoing');
});
it('Shows no parent and one children on producer-consumer Span C', () => {
cy.visit(getServiceInventoryUrl({ serviceName: 'producer-consumer' }));
cy.contains('Transaction C').click();
cy.contains('1 Span link');
cy.get(
`[data-test-subj="spanLinksBadge_${ids.producerConsumerIds.spanCId}"]`
).realHover();
cy.contains('1 Span link found');
cy.contains('1 incoming');
cy.contains('0 outgoing');
});
it('Shows two parents and one children on consumer-multiple Transaction D', () => {
cy.visit(getServiceInventoryUrl({ serviceName: 'consumer-multiple' }));
cy.contains('Transaction D').click();
cy.contains('2 Span links');
cy.get(
`[data-test-subj="spanLinksBadge_${ids.producerMultipleIds.transactionDId}"]`
).realHover();
cy.contains('2 Span links found');
cy.contains('0 incoming');
cy.contains('2 outgoing');
});
it('Shows two parents and one children on consumer-multiple Span E', () => {
cy.visit(getServiceInventoryUrl({ serviceName: 'consumer-multiple' }));
cy.contains('Transaction D').click();
cy.contains('2 Span links');
cy.get(
`[data-test-subj="spanLinksBadge_${ids.producerMultipleIds.spanEId}"]`
).realHover();
cy.contains('2 Span links found');
cy.contains('0 incoming');
cy.contains('2 outgoing');
});
});
describe('span link flyout', () => {
it('Shows children details on producer-internal-only Span A', () => {
cy.visit(
getServiceInventoryUrl({ serviceName: 'producer-internal-only' })
);
cy.contains('Transaction A').click();
cy.contains('Span A').click();
cy.get('[data-test-subj="spanLinksTab"]').click();
cy.contains('producer-consumer')
.should('have.attr', 'href')
.and('include', '/services/producer-consumer/overview');
cy.contains('Transaction C')
.should('have.attr', 'href')
.and(
'include',
`/link-to/transaction/${ids.producerConsumerIds.transactionCId}?waterfallItemId=${ids.producerConsumerIds.transactionCId}`
);
cy.contains('consumer-multiple')
.should('have.attr', 'href')
.and('include', '/services/consumer-multiple/overview');
cy.contains('Transaction D')
.should('have.attr', 'href')
.and(
'include',
`link-to/transaction/${ids.producerMultipleIds.transactionDId}?waterfallItemId=${ids.producerMultipleIds.transactionDId}`
);
cy.get('[data-test-subj="spanLinkTypeSelect"]').should(
'contain.text',
'Outgoing links (0)'
);
});
it('Shows children and parents details on producer-external-only Span B', () => {
cy.visit(
getServiceInventoryUrl({ serviceName: 'producer-external-only' })
);
cy.contains('Transaction B').click();
cy.contains('Span B').click();
cy.get('[data-test-subj="spanLinksTab"]').click();
cy.contains('consumer-multiple')
.should('have.attr', 'href')
.and('include', '/services/consumer-multiple/overview');
cy.contains('Span E')
.should('have.attr', 'href')
.and(
'include',
`link-to/transaction/${ids.producerMultipleIds.transactionDId}?waterfallItemId=${ids.producerMultipleIds.spanEId}`
);
cy.get('[data-test-subj="spanLinkTypeSelect"]').select(
'Outgoing links (1)'
);
cy.contains('Unknown');
cy.contains('trace#1-span#1');
});
it('Shows children and parents details on producer-consumer Transaction C', () => {
cy.visit(getServiceInventoryUrl({ serviceName: 'producer-consumer' }));
cy.contains('Transaction C').click();
cy.get(
`[aria-controls="${ids.producerConsumerIds.transactionCId}"]`
).click();
cy.get('[data-test-subj="spanLinksTab"]').click();
cy.contains('consumer-multiple')
.should('have.attr', 'href')
.and('include', '/services/consumer-multiple/overview');
cy.contains('Span E')
.should('have.attr', 'href')
.and(
'include',
`link-to/transaction/${ids.producerMultipleIds.transactionDId}?waterfallItemId=${ids.producerMultipleIds.spanEId}`
);
cy.get('[data-test-subj="spanLinkTypeSelect"]').select(
'Outgoing links (1)'
);
cy.contains('producer-internal-only')
.should('have.attr', 'href')
.and('include', '/services/producer-internal-only/overview');
cy.contains('Span A')
.should('have.attr', 'href')
.and(
'include',
`link-to/transaction/${ids.producerInternalOnlyIds.transactionAId}?waterfallItemId=${ids.producerInternalOnlyIds.spanAId}`
);
});
it('Shows children and parents details on producer-consumer Span C', () => {
cy.visit(getServiceInventoryUrl({ serviceName: 'producer-consumer' }));
cy.contains('Transaction C').click();
cy.contains('Span C').click();
cy.get('[data-test-subj="spanLinksTab"]').click();
cy.contains('consumer-multiple')
.should('have.attr', 'href')
.and('include', '/services/consumer-multiple/overview');
cy.contains('Transaction D')
.should('have.attr', 'href')
.and(
'include',
`link-to/transaction/${ids.producerMultipleIds.transactionDId}?waterfallItemId=${ids.producerMultipleIds.transactionDId}`
);
cy.get('[data-test-subj="spanLinkTypeSelect"]').should(
'contain.text',
'Outgoing links (0)'
);
});
it('Shows children and parents details on consumer-multiple Transaction D', () => {
cy.visit(getServiceInventoryUrl({ serviceName: 'consumer-multiple' }));
cy.contains('Transaction D').click();
cy.get(
`[aria-controls="${ids.producerMultipleIds.transactionDId}"]`
).click();
cy.get('[data-test-subj="spanLinksTab"]').click();
cy.contains('producer-consumer')
.should('have.attr', 'href')
.and('include', '/services/producer-consumer/overview');
cy.contains('Span C')
.should('have.attr', 'href')
.and(
'include',
`link-to/transaction/${ids.producerConsumerIds.transactionCId}?waterfallItemId=${ids.producerConsumerIds.spanCId}`
);
cy.contains('producer-internal-only')
.should('have.attr', 'href')
.and('include', '/services/producer-internal-only/overview');
cy.contains('Span A')
.should('have.attr', 'href')
.and(
'include',
`link-to/transaction/${ids.producerInternalOnlyIds.transactionAId}?waterfallItemId=${ids.producerInternalOnlyIds.spanAId}`
);
cy.get('[data-test-subj="spanLinkTypeSelect"]').should(
'contain.text',
'Incoming links (0)'
);
});
it('Shows children and parents details on consumer-multiple Span E', () => {
cy.visit(getServiceInventoryUrl({ serviceName: 'consumer-multiple' }));
cy.contains('Transaction D').click();
cy.contains('Span E').click();
cy.get('[data-test-subj="spanLinksTab"]').click();
cy.contains('producer-external-only')
.should('have.attr', 'href')
.and('include', '/services/producer-external-only/overview');
cy.contains('Span B')
.should('have.attr', 'href')
.and(
'include',
`link-to/transaction/${ids.producerExternalOnlyIds.transactionBId}?waterfallItemId=${ids.producerExternalOnlyIds.spanBId}`
);
cy.contains('producer-consumer')
.should('have.attr', 'href')
.and('include', '/services/producer-consumer/overview');
cy.contains('Transaction C')
.should('have.attr', 'href')
.and(
'include',
`link-to/transaction/${ids.producerConsumerIds.transactionCId}?waterfallItemId=${ids.producerConsumerIds.transactionCId}`
);
cy.get('[data-test-subj="spanLinkTypeSelect"]').should(
'contain.text',
'Incoming links (0)'
);
});
});
});
});

View file

@ -34,7 +34,7 @@ describe('getRedirectToTransactionDetailPageUrl', () => {
it('formats url correctly', () => {
expect(url).toBe(
'/services/opbeans-node/transactions/view?traceId=trace_id&transactionId=transaction_id&transactionName=transaction_name&transactionType=request&rangeFrom=2020-01-01T00%3A00%3A00.000Z&rangeTo=2020-01-01T00%3A05%3A00.000Z'
'/services/opbeans-node/transactions/view?traceId=trace_id&transactionId=transaction_id&transactionName=transaction_name&transactionType=request&rangeFrom=2020-01-01T00%3A00%3A00.000Z&rangeTo=2020-01-01T00%3A05%3A00.000Z&waterfallItemId='
);
});
});
@ -48,7 +48,7 @@ describe('getRedirectToTransactionDetailPageUrl', () => {
it('uses timerange provided', () => {
expect(url).toBe(
'/services/opbeans-node/transactions/view?traceId=trace_id&transactionId=transaction_id&transactionName=transaction_name&transactionType=request&rangeFrom=2020-01-01T00%3A02%3A00.000Z&rangeTo=2020-01-01T00%3A17%3A59.999Z'
'/services/opbeans-node/transactions/view?traceId=trace_id&transactionId=transaction_id&transactionName=transaction_name&transactionType=request&rangeFrom=2020-01-01T00%3A02%3A00.000Z&rangeTo=2020-01-01T00%3A17%3A59.999Z&waterfallItemId='
);
});
});

View file

@ -12,10 +12,12 @@ export const getRedirectToTransactionDetailPageUrl = ({
transaction,
rangeFrom,
rangeTo,
waterfallItemId,
}: {
transaction: Transaction;
rangeFrom?: string;
rangeTo?: string;
waterfallItemId?: string;
}) => {
return format({
pathname: `/services/${transaction.service.name}/transactions/view`,
@ -37,6 +39,7 @@ export const getRedirectToTransactionDetailPageUrl = ({
diff: transaction.transaction.duration.us / 1000,
direction: 'up',
}),
waterfallItemId,
},
});
};

View file

@ -122,7 +122,7 @@ describe('TraceLink', () => {
const component = shallow(<TraceLink />);
expect(component.prop('to')).toEqual(
'/services/foo/transactions/view?traceId=123&transactionId=456&transactionName=bar&transactionType=GET&rangeFrom=now-24h&rangeTo=now'
'/services/foo/transactions/view?traceId=123&transactionId=456&transactionName=bar&transactionType=GET&rangeFrom=now-24h&rangeTo=now&waterfallItemId='
);
});
});

View file

@ -10,12 +10,14 @@ import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_
import { useApmParams } from '../../../hooks/use_apm_params';
import { useFetcher } from '../../../hooks/use_fetcher';
import { useTimeRange } from '../../../hooks/use_time_range';
import { APIReturnType } from '../../../services/rest/create_call_apm_api';
import { getWaterfall } from './waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers';
const INITIAL_DATA = {
const INITIAL_DATA: APIReturnType<'GET /internal/apm/traces/{traceId}'> = {
errorDocs: [],
traceDocs: [],
exceedsMax: false,
linkedChildrenOfSpanCountBySpanId: {},
};
export function useWaterfallFetcher() {

View file

@ -0,0 +1,59 @@
/*
* 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 { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { SpanLinksCount } from '../waterfall_helpers/waterfall_helpers';
type Props = SpanLinksCount & { id: string };
export function SpanLinksBadge({ linkedParents, linkedChildren, id }: Props) {
if (!linkedParents && !linkedChildren) {
return null;
}
const total = linkedParents + linkedChildren;
return (
<EuiToolTip
title={i18n.translate('xpack.apm.waterfall.spanLinks.tooltip.title', {
defaultMessage:
'{total} {total, plural, one {Span link} other {Span links}} found',
values: { total },
})}
content={
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
{i18n.translate(
'xpack.apm.waterfall.spanLinks.tooltip.linkedChildren',
{
defaultMessage: '{linkedChildren} incoming',
values: { linkedChildren },
}
)}
</EuiFlexItem>
<EuiFlexItem>
{i18n.translate(
'xpack.apm.waterfall.spanLinks.tooltip.linkedParents',
{
defaultMessage: '{linkedParents} outgoing',
values: { linkedParents },
}
)}
</EuiFlexItem>
</EuiFlexGroup>
}
>
<EuiBadge data-test-subj={`spanLinksBadge_${id}`}>
{i18n.translate('xpack.apm.waterfall.spanLinks.badge', {
defaultMessage:
'{total} {total, plural, one {Span link} other {Span links}}',
values: { total },
})}
</EuiBadge>
</EuiToolTip>
);
}

View file

@ -20,24 +20,27 @@ import {
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { Fragment } from 'react';
import { isEmpty } from 'lodash';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { CompositeSpanDurationSummaryItem } from '../../../../../../shared/summary/composite_span_duration_summary_item';
import { isEmpty } from 'lodash';
import React, { Fragment } from 'react';
import { Span } from '../../../../../../../../typings/es_schemas/ui/span';
import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction';
import { DiscoverSpanLink } from '../../../../../../shared/links/discover_links/discover_span_link';
import { SpanMetadata } from '../../../../../../shared/metadata_table/span_metadata';
import { getSpanLinksTabContent } from '../../../../../../shared/span_links/span_links_tab_content';
import { Stacktrace } from '../../../../../../shared/stacktrace';
import { Summary } from '../../../../../../shared/summary';
import { CompositeSpanDurationSummaryItem } from '../../../../../../shared/summary/composite_span_duration_summary_item';
import { DurationSummaryItem } from '../../../../../../shared/summary/duration_summary_item';
import { HttpInfoSummaryItem } from '../../../../../../shared/summary/http_info_summary_item';
import { TimestampTooltip } from '../../../../../../shared/timestamp_tooltip';
import { ResponsiveFlyout } from '../responsive_flyout';
import { SyncBadge } from '../badge/sync_badge';
import { FailureBadge } from '../failure_badge';
import { ResponsiveFlyout } from '../responsive_flyout';
import { SpanLinksCount } from '../waterfall_helpers/waterfall_helpers';
import { SpanDatabase } from './span_db';
import { StickySpanProperties } from './sticky_span_properties';
import { FailureBadge } from '../failure_badge';
import { ProcessorEvent } from '../../../../../../../../common/processor_event';
function formatType(type: string) {
switch (type) {
@ -86,6 +89,7 @@ interface Props {
parentTransaction?: Transaction;
totalDuration?: number;
onClose: () => void;
spanLinksCount: SpanLinksCount;
}
export function SpanFlyout({
@ -93,6 +97,7 @@ export function SpanFlyout({
parentTransaction,
totalDuration,
onClose,
spanLinksCount,
}: Props) {
if (!span) {
return null;
@ -107,6 +112,13 @@ export function SpanFlyout({
const spanHttpUrl = span.url?.original || span.span?.http?.url?.original;
const spanHttpMethod = span.http?.request?.method || span.span?.http?.method;
const spanLinksTabContent = getSpanLinksTabContent({
spanLinksCount,
traceId: span.trace.id,
spanId: span.span.id,
processorEvent: ProcessorEvent.span,
});
return (
<EuiPortal>
<ResponsiveFlyout onClose={onClose} size="m" ownFocus={true}>
@ -254,6 +266,7 @@ export function SpanFlyout({
},
]
: []),
...(spanLinksTabContent ? [spanLinksTabContent] : []),
]}
/>
</EuiFlyoutBody>

View file

@ -10,19 +10,23 @@ import {
EuiFlexItem,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiHorizontalRule,
EuiPortal,
EuiSpacer,
EuiTabbedContent,
EuiTitle,
EuiHorizontalRule,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ProcessorEvent } from '../../../../../../../../common/processor_event';
import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction';
import { TransactionActionMenu } from '../../../../../../shared/transaction_action_menu/transaction_action_menu';
import { TransactionMetadata } from '../../../../../../shared/metadata_table/transaction_metadata';
import { getSpanLinksTabContent } from '../../../../../../shared/span_links/span_links_tab_content';
import { TransactionSummary } from '../../../../../../shared/summary/transaction_summary';
import { TransactionActionMenu } from '../../../../../../shared/transaction_action_menu/transaction_action_menu';
import { FlyoutTopLevelProperties } from '../flyout_top_level_properties';
import { ResponsiveFlyout } from '../responsive_flyout';
import { TransactionMetadata } from '../../../../../../shared/metadata_table/transaction_metadata';
import { SpanLinksCount } from '../waterfall_helpers/waterfall_helpers';
import { DroppedSpansWarning } from './dropped_spans_warning';
interface Props {
@ -30,21 +34,7 @@ interface Props {
transaction?: Transaction;
errorCount?: number;
rootTransactionDuration?: number;
}
function TransactionPropertiesTable({
transaction,
}: {
transaction: Transaction;
}) {
return (
<div>
<EuiTitle size="s">
<h4>Metadata</h4>
</EuiTitle>
<TransactionMetadata transaction={transaction} />
</div>
);
spanLinksCount: SpanLinksCount;
}
export function TransactionFlyout({
@ -52,11 +42,19 @@ export function TransactionFlyout({
onClose,
errorCount = 0,
rootTransactionDuration,
spanLinksCount,
}: Props) {
if (!transactionDoc) {
return null;
}
const spanLinksTabContent = getSpanLinksTabContent({
spanLinksCount,
traceId: transactionDoc.trace.id,
spanId: transactionDoc.transaction.id,
processorEvent: ProcessorEvent.transaction,
});
return (
<EuiPortal>
<ResponsiveFlyout onClose={onClose} ownFocus={true} maxWidth={false}>
@ -94,7 +92,26 @@ export function TransactionFlyout({
/>
<EuiHorizontalRule margin="m" />
<DroppedSpansWarning transactionDoc={transactionDoc} />
<TransactionPropertiesTable transaction={transactionDoc} />
<EuiTabbedContent
tabs={[
{
id: 'metadata',
name: i18n.translate(
'xpack.apm.propertiesTable.tabs.metadataLabel',
{
defaultMessage: 'Metadata',
}
),
content: (
<>
<EuiSpacer size="m" />
<TransactionMetadata transaction={transactionDoc} />
</>
),
},
...(spanLinksTabContent ? [spanLinksTabContent] : []),
]}
/>
</EuiFlyoutBody>
</ResponsiveFlyout>
</EuiPortal>

View file

@ -45,6 +45,7 @@ export function WaterfallFlyout({
span={currentItem.doc}
parentTransaction={parentTransaction}
onClose={() => toggleFlyout({ history })}
spanLinksCount={currentItem.spanLinksCount}
/>
);
case 'transaction':
@ -56,6 +57,7 @@ export function WaterfallFlyout({
waterfall.rootTransaction?.transaction.duration.us
}
errorCount={waterfall.getErrorCount(currentItem.id)}
spanLinksCount={currentItem.spanLinksCount}
/>
);
default:

View file

@ -190,18 +190,38 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId1",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId2",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdA",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -389,18 +409,38 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId1",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId2",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdA",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
],
"mySpanIdD": Array [
@ -516,12 +556,24 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId1",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
],
"myTransactionId1": Array [
@ -596,9 +648,17 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId1",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
],
"myTransactionId2": Array [
@ -751,15 +811,31 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId1",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId2",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
],
"root": Array [
@ -797,6 +873,10 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
],
},
@ -835,6 +915,10 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"errorItems": Array [
Object {
@ -907,6 +991,10 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId1",
"skew": 0,
@ -948,6 +1036,10 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -1020,9 +1112,17 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId1",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -1136,12 +1236,24 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId1",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -1292,15 +1404,31 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId1",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId2",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -1488,18 +1616,38 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId1",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId2",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdA",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -1687,18 +1835,38 @@ Object {
"parent": undefined,
"parentId": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId1",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId2",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdA",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
],
"legends": Array [
@ -1869,12 +2037,24 @@ Object {
"parent": undefined,
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId2",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdA",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -1994,12 +2174,24 @@ Object {
"parent": undefined,
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId2",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdA",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
],
"mySpanIdD": Array [
@ -2047,6 +2239,10 @@ Object {
"parent": undefined,
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
],
"myTransactionId2": Array [
@ -2131,9 +2327,17 @@ Object {
"parent": undefined,
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId2",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
],
},
@ -2182,6 +2386,10 @@ Object {
"parent": undefined,
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"errorItems": Array [],
"getErrorCount": [Function],
@ -2230,6 +2438,10 @@ Object {
"parent": undefined,
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -2312,9 +2524,17 @@ Object {
"parent": undefined,
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId2",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -2434,12 +2654,24 @@ Object {
"parent": undefined,
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId2",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdA",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -2559,12 +2791,24 @@ Object {
"parent": undefined,
"parentId": "mySpanIdD",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "myTransactionId2",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "mySpanIdA",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
],
"legends": Array [
@ -2687,6 +2931,10 @@ Array [
"offset": 0,
"parent": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -2740,9 +2988,17 @@ Array [
"offset": 0,
"parent": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "a",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -2796,9 +3052,17 @@ Array [
"offset": 0,
"parent": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "a",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -2877,12 +3141,24 @@ Array [
"offset": 0,
"parent": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "a",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "b",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
Object {
"color": "",
@ -2989,15 +3265,31 @@ Array [
"offset": 0,
"parent": undefined,
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "a",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "b",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
"parentId": "c",
"skew": 0,
"spanLinksCount": Object {
"linkedChildren": 0,
"linkedParents": 0,
},
},
]
`;

View file

@ -133,6 +133,7 @@ describe('waterfall_helpers', () => {
traceDocs: hits,
errorDocs,
exceedsMax: false,
linkedChildrenOfSpanCountBySpanId: {},
};
const waterfall = getWaterfall(apiResp, entryTransactionId);
const { apiResponse, ...waterfallRest } = waterfall;
@ -151,6 +152,7 @@ describe('waterfall_helpers', () => {
traceDocs: hits,
errorDocs,
exceedsMax: false,
linkedChildrenOfSpanCountBySpanId: {},
};
const waterfall = getWaterfall(apiResp, entryTransactionId);
@ -236,6 +238,7 @@ describe('waterfall_helpers', () => {
traceDocs: traceItems,
errorDocs: [],
exceedsMax: false,
linkedChildrenOfSpanCountBySpanId: {},
},
entryTransactionId
);
@ -342,6 +345,7 @@ describe('waterfall_helpers', () => {
traceDocs: traceItems,
errorDocs: [],
exceedsMax: false,
linkedChildrenOfSpanCountBySpanId: {},
},
entryTransactionId
);
@ -404,6 +408,10 @@ describe('waterfall_helpers', () => {
skew: 0,
legendValues,
color: '',
spanLinksCount: {
linkedChildren: 0,
linkedParents: 0,
},
},
{
docType: 'span',
@ -426,6 +434,10 @@ describe('waterfall_helpers', () => {
skew: 0,
legendValues,
color: '',
spanLinksCount: {
linkedChildren: 0,
linkedParents: 0,
},
},
{
docType: 'span',
@ -448,6 +460,10 @@ describe('waterfall_helpers', () => {
skew: 0,
legendValues,
color: '',
spanLinksCount: {
linkedChildren: 0,
linkedParents: 0,
},
},
{
docType: 'transaction',
@ -464,6 +480,10 @@ describe('waterfall_helpers', () => {
skew: 0,
legendValues,
color: '',
spanLinksCount: {
linkedChildren: 0,
linkedParents: 0,
},
},
{
docType: 'transaction',
@ -481,6 +501,10 @@ describe('waterfall_helpers', () => {
skew: 0,
legendValues,
color: '',
spanLinksCount: {
linkedChildren: 0,
linkedParents: 0,
},
},
];

View file

@ -7,10 +7,11 @@
import { euiPaletteColorBlind } from '@elastic/eui';
import { first, flatten, groupBy, isEmpty, sortBy, uniq } from 'lodash';
import { APIReturnType } from '../../../../../../../services/rest/create_call_apm_api';
import { APMError } from '../../../../../../../../typings/es_schemas/ui/apm_error';
import { Span } from '../../../../../../../../typings/es_schemas/ui/span';
import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction';
import type { APIReturnType } from '../../../../../../../services/rest/create_call_apm_api';
import type { APMError } from '../../../../../../../../typings/es_schemas/ui/apm_error';
import type { Span } from '../../../../../../../../typings/es_schemas/ui/span';
import type { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction';
import { ProcessorEvent } from '../../../../../../../../common/processor_event';
type TraceAPIResponse = APIReturnType<'GET /internal/apm/traces/{traceId}'>;
@ -20,6 +21,11 @@ interface IWaterfallGroup {
const ROOT_ID = 'root';
export interface SpanLinksCount {
linkedChildren: number;
linkedParents: number;
}
export enum WaterfallLegendType {
ServiceName = 'serviceName',
SpanType = 'spanType',
@ -48,6 +54,7 @@ interface IWaterfallSpanItemBase<TDocument, TDoctype>
*/
duration: number;
legendValues: Record<WaterfallLegendType, string>;
spanLinksCount: SpanLinksCount;
}
interface IWaterfallItemBase<TDocument, TDoctype> {
@ -93,13 +100,17 @@ function getLegendValues(transactionOrSpan: Transaction | Span) {
return {
[WaterfallLegendType.ServiceName]: transactionOrSpan.service.name,
[WaterfallLegendType.SpanType]:
'span' in transactionOrSpan
? transactionOrSpan.span.subtype || transactionOrSpan.span.type
transactionOrSpan.processor.event === ProcessorEvent.span
? (transactionOrSpan as Span).span.subtype ||
(transactionOrSpan as Span).span.type
: '',
};
}
function getTransactionItem(transaction: Transaction): IWaterfallTransaction {
function getTransactionItem(
transaction: Transaction,
linkedChildrenCount: number = 0
): IWaterfallTransaction {
return {
docType: 'transaction',
doc: transaction,
@ -110,10 +121,17 @@ function getTransactionItem(transaction: Transaction): IWaterfallTransaction {
skew: 0,
legendValues: getLegendValues(transaction),
color: '',
spanLinksCount: {
linkedParents: transaction.span?.links?.length ?? 0,
linkedChildren: linkedChildrenCount,
},
};
}
function getSpanItem(span: Span): IWaterfallSpan {
function getSpanItem(
span: Span,
linkedChildrenCount: number = 0
): IWaterfallSpan {
return {
docType: 'span',
doc: span,
@ -124,6 +142,10 @@ function getSpanItem(span: Span): IWaterfallSpan {
skew: 0,
legendValues: getLegendValues(span),
color: '',
spanLinksCount: {
linkedParents: span.span.links?.length ?? 0,
linkedChildren: linkedChildrenCount,
},
};
}
@ -265,14 +287,26 @@ const getWaterfallDuration = (waterfallItems: IWaterfallItem[]) =>
0
);
const getWaterfallItems = (items: TraceAPIResponse['traceDocs']) =>
const getWaterfallItems = (
items: TraceAPIResponse['traceDocs'],
linkedChildrenOfSpanCountBySpanId: TraceAPIResponse['linkedChildrenOfSpanCountBySpanId']
) =>
items.map((item) => {
const docType: 'span' | 'transaction' = item.processor.event;
switch (docType) {
case 'span':
return getSpanItem(item as Span);
case 'span': {
const span = item as Span;
return getSpanItem(
span,
linkedChildrenOfSpanCountBySpanId[span.span.id]
);
}
case 'transaction':
return getTransactionItem(item as Transaction);
const transaction = item as Transaction;
return getTransactionItem(
transaction,
linkedChildrenOfSpanCountBySpanId[transaction.transaction.id]
);
}
});
@ -396,7 +430,8 @@ export function getWaterfall(
const errorCountByParentId = getErrorCountByParentId(apiResponse.errorDocs);
const waterfallItems: IWaterfallSpanOrTransaction[] = getWaterfallItems(
apiResponse.traceDocs
apiResponse.traceDocs,
apiResponse.linkedChildrenOfSpanCountBySpanId
);
const childrenByParentId = getChildrenGroupedByParentId(

View file

@ -19,6 +19,7 @@ import { asDuration } from '../../../../../../../common/utils/formatters';
import { Margins } from '../../../../../shared/charts/timeline';
import { TruncateWithTooltip } from '../../../../../shared/truncate_with_tooltip';
import { SyncBadge } from './badge/sync_badge';
import { SpanLinksBadge } from './badge/span_links_badge';
import { ColdStartBadge } from './badge/cold_start_badge';
import { IWaterfallSpanOrTransaction } from './waterfall_helpers/waterfall_helpers';
import { FailureBadge } from './failure_badge';
@ -237,6 +238,11 @@ export function WaterfallItem({
agentName={item.doc.agent.name}
/>
)}
<SpanLinksBadge
linkedParents={item.spanLinksCount.linkedParents}
linkedChildren={item.spanLinksCount.linkedChildren}
id={item.id}
/>
{isServerlessColdstart && <ColdStartBadge />}
</ItemText>
</Container>

View file

@ -530,6 +530,7 @@ export const simpleTrace = {
],
exceedsMax: false,
errorDocs: [],
linkedChildrenOfSpanCountBySpanId: {},
} as TraceAPIResponse;
export const manyChildrenWithSameLength = {
@ -4126,6 +4127,7 @@ export const manyChildrenWithSameLength = {
},
},
],
linkedChildrenOfSpanCountBySpanId: {},
} as TraceAPIResponse;
export const traceWithErrors = {
@ -4716,6 +4718,7 @@ export const traceWithErrors = {
},
},
],
linkedChildrenOfSpanCountBySpanId: {},
} as unknown as TraceAPIResponse;
export const traceChildStartBeforeParent = {
@ -5211,6 +5214,7 @@ export const traceChildStartBeforeParent = {
],
exceedsMax: false,
errorDocs: [],
linkedChildrenOfSpanCountBySpanId: {},
} as TraceAPIResponse;
export const inferredSpans = {
@ -5865,4 +5869,5 @@ export const inferredSpans = {
],
exceedsMax: false,
errorDocs: [],
linkedChildrenOfSpanCountBySpanId: {},
} as TraceAPIResponse;

View file

@ -21,7 +21,7 @@ const CentralizedContainer = euiStyled.div`
export function TransactionLink() {
const {
path: { transactionId },
query: { rangeFrom, rangeTo },
query: { rangeFrom, rangeTo, waterfallItemId },
} = useApmParams('/link-to/transaction/{transactionId}');
const { data = { transaction: null }, status } = useFetcher(
@ -46,6 +46,7 @@ export function TransactionLink() {
transaction: data.transaction,
rangeFrom,
rangeTo,
waterfallItemId,
})}
/>
);

View file

@ -43,6 +43,7 @@ const apmRoutes = {
query: t.partial({
rangeFrom: t.string,
rangeTo: t.string,
waterfallItemId: t.string,
}),
}),
]),

View file

@ -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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiSelect,
EuiSelectOption,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo, useState } from 'react';
import { useApmParams } from '../../../hooks/use_apm_params';
import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher';
import { useTimeRange } from '../../../hooks/use_time_range';
import { SpanLinksCount } from '../../app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers';
import { KueryBar } from '../kuery_bar';
import { SpanLinksCallout } from './span_links_callout';
import { SpanLinksTable } from './span_links_table';
import { ProcessorEvent } from '../../../../common/processor_event';
import { useLocalStorage } from '../../../hooks/use_local_storage';
interface Props {
spanLinksCount: SpanLinksCount;
traceId: string;
spanId: string;
processorEvent: ProcessorEvent;
}
type LinkType = 'children' | 'parents';
export function SpanLinks({
spanLinksCount,
traceId,
spanId,
processorEvent,
}: Props) {
const {
query: { rangeFrom, rangeTo },
} = useApmParams('/services/{serviceName}/transactions/view');
const { start, end } = useTimeRange({ rangeFrom, rangeTo });
const [selectedLinkType, setSelectedLinkType] = useState<LinkType>(
spanLinksCount.linkedChildren ? 'children' : 'parents'
);
const [spanLinksCalloutDismissed, setSpanLinksCalloutDismissed] =
useLocalStorage('apm.spanLinksCalloutDismissed', false);
const [kuery, setKuery] = useState('');
const { data, status } = useFetcher(
(callApmApi) => {
if (selectedLinkType === 'children') {
return callApmApi(
'GET /internal/apm/traces/{traceId}/span_links/{spanId}/children',
{
params: {
path: { traceId, spanId },
query: { kuery, start, end },
},
}
);
}
return callApmApi(
'GET /internal/apm/traces/{traceId}/span_links/{spanId}/parents',
{
params: {
path: { traceId, spanId },
query: { kuery, start, end, processorEvent },
},
}
);
},
[selectedLinkType, kuery, traceId, spanId, start, end, processorEvent]
);
const selectOptions: EuiSelectOption[] = useMemo(
() => [
{
value: 'children',
text: i18n.translate('xpack.apm.spanLinks.combo.childrenLinks', {
defaultMessage: 'Incoming links ({linkedChildren})',
values: { linkedChildren: spanLinksCount.linkedChildren },
}),
disabled: !spanLinksCount.linkedChildren,
},
{
value: 'parents',
text: i18n.translate('xpack.apm.spanLinks.combo.parentsLinks', {
defaultMessage: 'Outgoing links ({linkedParents})',
values: { linkedParents: spanLinksCount.linkedParents },
}),
disabled: !spanLinksCount.linkedParents,
},
],
[spanLinksCount]
);
if (
!data ||
status === FETCH_STATUS.LOADING ||
status === FETCH_STATUS.NOT_INITIATED
) {
return (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<EuiLoadingSpinner />
</div>
);
}
return (
<EuiFlexGroup direction="column" gutterSize="m">
{!spanLinksCalloutDismissed && (
<EuiFlexItem>
<SpanLinksCallout
dismissCallout={() => {
setSpanLinksCalloutDismissed(true);
}}
/>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<KueryBar
onSubmit={(value) => {
setKuery(value);
}}
value={kuery}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSelect
data-test-subj="spanLinkTypeSelect"
options={selectOptions}
value={selectedLinkType}
onChange={(e) => {
setSelectedLinkType(e.target.value as LinkType);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<SpanLinksTable items={data.spanLinksDetails} />
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,41 @@
/*
* 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 { EuiButton, EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
interface Props {
dismissCallout: () => void;
}
export function SpanLinksCallout({ dismissCallout }: Props) {
return (
<EuiCallOut
title={i18n.translate('xpack.apm.spanLinks.callout.title', {
defaultMessage: 'Span links',
})}
iconType="iInCircle"
>
<p>
<FormattedMessage
id="xpack.apm.spanLinks.callout.description"
defaultMessage="A Link is a pointer from the current span to another span in the same trace or in a different trace. For example. this can be used in batching operations, where a single batch handler processes multiple requests from different traces or when the handler receives a request from a different project."
/>
</p>
<EuiButton
onClick={() => {
dismissCallout();
}}
>
{i18n.translate('xpack.apm.spanLinks.callout.dimissButton', {
defaultMessage: 'Dismiss',
})}
</EuiButton>
</EuiCallOut>
);
}

View file

@ -0,0 +1,58 @@
/*
* 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 { EuiNotificationBadge, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SpanLinks } from '.';
import { SpanLinksCount } from '../../app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/waterfall_helpers/waterfall_helpers';
import { ProcessorEvent } from '../../../../common/processor_event';
interface Props {
spanLinksCount: SpanLinksCount;
traceId: string;
spanId: string;
processorEvent: ProcessorEvent;
}
export function getSpanLinksTabContent({
spanLinksCount,
traceId,
spanId,
processorEvent,
}: Props) {
if (!spanLinksCount.linkedChildren && !spanLinksCount.linkedParents) {
return undefined;
}
return {
id: 'span_links',
'data-test-subj': 'spanLinksTab',
name: (
<>
{i18n.translate('xpack.apm.propertiesTable.tabs.spanLinks', {
defaultMessage: 'Span links',
})}
</>
),
append: (
<EuiNotificationBadge color="subdued">
{spanLinksCount.linkedChildren + spanLinksCount.linkedParents}
</EuiNotificationBadge>
),
content: (
<>
<EuiSpacer size="m" />
<SpanLinks
spanLinksCount={spanLinksCount}
traceId={traceId}
spanId={spanId}
processorEvent={processorEvent}
/>
</>
),
};
}

View file

@ -0,0 +1,234 @@
/*
* 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 {
EuiBasicTableColumn,
EuiButtonEmpty,
EuiButtonIcon,
EuiCopy,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiInMemoryTable,
EuiLink,
EuiPopover,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { SpanLinkDetails } from '../../../../common/span_links';
import { asDuration } from '../../../../common/utils/formatters';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useApmRouter } from '../../../hooks/use_apm_router';
import { ServiceLink } from '../service_link';
import { getSpanIcon } from '../span_icon/get_span_icon';
interface Props {
items: SpanLinkDetails[];
}
export function SpanLinksTable({ items }: Props) {
const router = useApmRouter();
const {
query: { rangeFrom, rangeTo, comparisonEnabled },
} = useApmParams('/services/{serviceName}/transactions/view');
const [idActionMenuOpen, setIdActionMenuOpen] = useState<
string | undefined
>();
const columns: Array<EuiBasicTableColumn<SpanLinkDetails>> = [
{
field: 'serviceName',
name: i18n.translate('xpack.apm.spanLinks.table.serviceName', {
defaultMessage: 'Service name',
}),
sortable: true,
render: (_, { details }) => {
if (details) {
return (
<ServiceLink
serviceName={details.serviceName}
agentName={details.agentName}
query={{
rangeFrom,
rangeTo,
kuery: '',
serviceGroup: '',
comparisonEnabled,
environment: details.environment || 'ENVIRONMENT_ALL',
}}
/>
);
}
return (
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIcon type="stopSlash" size="m" color="subdued" />
</EuiFlexItem>
<EuiFlexItem>
{i18n.translate('xpack.apm.spanLinks.table.serviceName.unknown', {
defaultMessage: 'Unknown',
})}
</EuiFlexItem>
</EuiFlexGroup>
);
},
},
{
field: 'spanId',
name: i18n.translate('xpack.apm.spanLinks.table.span', {
defaultMessage: 'Span',
}),
sortable: true,
render: (_, { spanId, traceId, details }) => {
if (details && details.transactionId) {
return (
<EuiFlexGroup
alignItems="center"
gutterSize="xs"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiIcon
type={getSpanIcon(details.spanType, details.spanSubtype)}
size="l"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiLink
href={router.link('/link-to/transaction/{transactionId}', {
path: { transactionId: details.transactionId },
query: { waterfallItemId: spanId },
})}
>
{details.spanName}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return `${traceId}-${spanId}`;
},
},
{
field: 'duration',
name: i18n.translate('xpack.apm.spanLinks.table.spanDuration', {
defaultMessage: 'Span duration',
}),
sortable: true,
width: '150',
render: (_, { details }) => {
return (
<EuiText size="s" color="subdued">
{asDuration(details?.duration)}
</EuiText>
);
},
},
{
field: 'actions',
name: 'Actions',
width: '100',
render: (_, { spanId, traceId, details }) => {
const id = `${traceId}:${spanId}`;
return (
<EuiPopover
button={
<EuiButtonIcon
aria-label="Edit"
iconType="boxesHorizontal"
onClick={() => {
setIdActionMenuOpen(id);
}}
/>
}
isOpen={idActionMenuOpen === id}
closePopover={() => {
setIdActionMenuOpen(undefined);
}}
>
<EuiFlexGroup direction="column" gutterSize="s">
{details?.transactionId && (
<EuiFlexItem>
<EuiLink
href={router.link('/link-to/transaction/{transactionId}', {
path: { transactionId: details.transactionId },
})}
>
{i18n.translate(
'xpack.apm.spanLinks.table.actions.goToTraceDetails',
{ defaultMessage: 'Go to trace' }
)}
</EuiLink>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiCopy textToCopy={traceId}>
{(copy) => (
<EuiButtonEmpty
onClick={() => {
copy();
setIdActionMenuOpen(undefined);
}}
flush="both"
>
{i18n.translate(
'xpack.apm.spanLinks.table.actions.copyParentTraceId',
{ defaultMessage: 'Copy parent trace id' }
)}
</EuiButtonEmpty>
)}
</EuiCopy>
</EuiFlexItem>
{details?.transactionId && (
<EuiFlexItem>
<EuiLink
href={router.link('/link-to/transaction/{transactionId}', {
path: { transactionId: details.transactionId },
query: { waterfallItemId: spanId },
})}
>
{i18n.translate(
'xpack.apm.spanLinks.table.actions.goToSpanDetails',
{ defaultMessage: 'Go to span details' }
)}
</EuiLink>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiCopy textToCopy={spanId}>
{(copy) => (
<EuiButtonEmpty
onClick={() => {
copy();
setIdActionMenuOpen(undefined);
}}
flush="both"
>
{i18n.translate(
'xpack.apm.spanLinks.table.actions.copySpanId',
{ defaultMessage: 'Copy span id' }
)}
</EuiButtonEmpty>
)}
</EuiCopy>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopover>
);
},
},
];
return (
<EuiInMemoryTable
items={items}
columns={columns}
sorting={true}
pagination={true}
/>
);
}

View file

@ -37,6 +37,7 @@ import { historicalDataRouteRepository } from '../historical_data/route';
import { eventMetadataRouteRepository } from '../event_metadata/route';
import { suggestionsRouteRepository } from '../suggestions/route';
import { agentKeysRouteRepository } from '../agent_keys/route';
import { spanLinksRouteRepository } from '../span_links/route';
function getTypedGlobalApmServerRouteRepository() {
const repository = {
@ -67,6 +68,7 @@ function getTypedGlobalApmServerRouteRepository() {
...historicalDataRouteRepository,
...eventMetadataRouteRepository,
...agentKeysRouteRepository,
...spanLinksRouteRepository,
};
return repository;

View file

@ -0,0 +1,141 @@
/*
* 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 { rangeQuery } from '@kbn/observability-plugin/server';
import { isEmpty } from 'lodash';
import {
PROCESSOR_EVENT,
SPAN_ID,
SPAN_LINKS,
SPAN_LINKS_TRACE_ID,
SPAN_LINKS_SPAN_ID,
TRACE_ID,
TRANSACTION_ID,
} from '../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../common/processor_event';
import type { SpanRaw } from '../../../typings/es_schemas/raw/span_raw';
import type { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw';
import { Setup } from '../../lib/helpers/setup_request';
import { getBufferedTimerange } from './utils';
async function fetchLinkedChildrenOfSpan({
traceId,
setup,
start,
end,
spanId,
}: {
traceId: string;
setup: Setup;
start: number;
end: number;
spanId?: string;
}) {
const { apmEventClient } = setup;
const { startWithBuffer, endWithBuffer } = getBufferedTimerange({
start,
end,
});
const response = await apmEventClient.search(
'fetch_linked_children_of_span',
{
apm: {
events: [ProcessorEvent.span, ProcessorEvent.transaction],
},
_source: [SPAN_LINKS, TRACE_ID, SPAN_ID, PROCESSOR_EVENT, TRANSACTION_ID],
body: {
size: 1000,
query: {
bool: {
filter: [
...rangeQuery(startWithBuffer, endWithBuffer),
{ term: { [SPAN_LINKS_TRACE_ID]: traceId } },
...(spanId ? [{ term: { [SPAN_LINKS_SPAN_ID]: spanId } }] : []),
],
},
},
},
}
);
// Filter out documents that don't have any span.links that match the combination of traceId and spanId
return response.hits.hits.filter(({ _source: source }) => {
const spanLinks = source.span?.links?.filter((spanLink) => {
return (
spanLink.trace.id === traceId &&
(spanId ? spanLink.span.id === spanId : true)
);
});
return !isEmpty(spanLinks);
});
}
function getSpanId(source: TransactionRaw | SpanRaw) {
return source.processor.event === ProcessorEvent.span
? (source as SpanRaw).span.id
: (source as TransactionRaw).transaction?.id;
}
export async function getLinkedChildrenCountBySpanId({
traceId,
setup,
start,
end,
}: {
traceId: string;
setup: Setup;
start: number;
end: number;
}) {
const linkedChildren = await fetchLinkedChildrenOfSpan({
traceId,
setup,
start,
end,
});
return linkedChildren.reduce<Record<string, number>>(
(acc, { _source: source }) => {
source.span?.links?.forEach((link) => {
// Ignores span links that don't belong to this trace
if (link.trace.id === traceId) {
acc[link.span.id] = (acc[link.span.id] || 0) + 1;
}
});
return acc;
},
{}
);
}
export async function getLinkedChildrenOfSpan({
traceId,
spanId,
setup,
start,
end,
}: {
traceId: string;
spanId: string;
setup: Setup;
start: number;
end: number;
}) {
const linkedChildren = await fetchLinkedChildrenOfSpan({
traceId,
spanId,
setup,
start,
end,
});
return linkedChildren.map(({ _source: source }) => {
return {
trace: { id: source.trace.id },
span: { id: getSpanId(source) },
};
});
}

View file

@ -0,0 +1,63 @@
/*
* 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 { rangeQuery } from '@kbn/observability-plugin/server';
import {
SPAN_ID,
SPAN_LINKS,
TRACE_ID,
TRANSACTION_ID,
PROCESSOR_EVENT,
} from '../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../common/processor_event';
import { SpanRaw } from '../../../typings/es_schemas/raw/span_raw';
import { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw';
import { Setup } from '../../lib/helpers/setup_request';
export async function getLinkedParentsOfSpan({
setup,
traceId,
spanId,
start,
end,
processorEvent,
}: {
traceId: string;
spanId: string;
setup: Setup;
start: number;
end: number;
processorEvent: ProcessorEvent;
}) {
const { apmEventClient } = setup;
const response = await apmEventClient.search('get_linked_parents_of_span', {
apm: {
events: [processorEvent],
},
_source: [SPAN_LINKS],
body: {
size: 1,
query: {
bool: {
filter: [
...rangeQuery(start, end),
{ term: { [TRACE_ID]: traceId } },
{ exists: { field: SPAN_LINKS } },
{ term: { [PROCESSOR_EVENT]: processorEvent } },
...(processorEvent === ProcessorEvent.transaction
? [{ term: { [TRANSACTION_ID]: spanId } }]
: [{ term: { [SPAN_ID]: spanId } }]),
],
},
},
},
});
const source = response.hits.hits?.[0]?._source as TransactionRaw | SpanRaw;
return source?.span?.links || [];
}

View file

@ -0,0 +1,212 @@
/*
* 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 { kqlQuery, rangeQuery } from '@kbn/observability-plugin/server';
import { chunk, compact, isEmpty, keyBy } from 'lodash';
import {
SERVICE_NAME,
SPAN_ID,
SPAN_NAME,
TRACE_ID,
TRANSACTION_ID,
TRANSACTION_NAME,
TRANSACTION_DURATION,
SPAN_DURATION,
PROCESSOR_EVENT,
SPAN_SUBTYPE,
SPAN_TYPE,
AGENT_NAME,
} from '../../../common/elasticsearch_fieldnames';
import { Environment } from '../../../common/environment_rt';
import { ProcessorEvent } from '../../../common/processor_event';
import { SpanLinkDetails } from '../../../common/span_links';
import { SpanLink } from '../../../typings/es_schemas/raw/fields/span_links';
import { SpanRaw } from '../../../typings/es_schemas/raw/span_raw';
import { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw';
import { Setup } from '../../lib/helpers/setup_request';
import { getBufferedTimerange } from './utils';
async function fetchSpanLinksDetails({
setup,
kuery,
spanLinks,
start,
end,
}: {
setup: Setup;
kuery: string;
spanLinks: SpanLink[];
start: number;
end: number;
}) {
const { apmEventClient } = setup;
const { startWithBuffer, endWithBuffer } = getBufferedTimerange({
start,
end,
});
const response = await apmEventClient.search('get_span_links_details', {
apm: {
events: [ProcessorEvent.span, ProcessorEvent.transaction],
},
_source: [
TRACE_ID,
SPAN_ID,
TRANSACTION_ID,
SERVICE_NAME,
SPAN_NAME,
TRANSACTION_NAME,
TRANSACTION_DURATION,
SPAN_DURATION,
PROCESSOR_EVENT,
SPAN_SUBTYPE,
SPAN_TYPE,
AGENT_NAME,
],
body: {
size: 1000,
query: {
bool: {
filter: [
...rangeQuery(startWithBuffer, endWithBuffer),
...kqlQuery(kuery),
{
bool: {
should: spanLinks.map((item) => {
return {
bool: {
filter: [
{ term: { [TRACE_ID]: item.trace.id } },
{
bool: {
should: [
{ term: { [SPAN_ID]: item.span.id } },
{ term: { [TRANSACTION_ID]: item.span.id } },
],
minimum_should_match: 1,
},
},
],
},
};
}),
minimum_should_match: 1,
},
},
],
},
},
},
});
const spanIdsMap = keyBy(spanLinks, 'span.id');
return response.hits.hits.filter(({ _source: source }) => {
// The above query might return other spans from the same transaction because siblings spans share the same transaction.id
// so, if it is a span we need to guarantee that the span.id is the same as the span links ids
if (source.processor.event === ProcessorEvent.span) {
const span = source as SpanRaw;
const hasSpanId = spanIdsMap[span.span.id] || false;
return hasSpanId;
}
return true;
});
}
export async function getSpanLinksDetails({
setup,
spanLinks,
kuery,
start,
end,
}: {
setup: Setup;
spanLinks: SpanLink[];
kuery: string;
start: number;
end: number;
}): Promise<SpanLinkDetails[]> {
if (!spanLinks.length) {
return [];
}
// chunk span links to avoid too_many_nested_clauses problem
const spanLinksChunks = chunk(spanLinks, 500);
const chunkedResponses = await Promise.all(
spanLinksChunks.map((spanLinksChunk) =>
fetchSpanLinksDetails({
setup,
kuery,
spanLinks: spanLinksChunk,
start,
end,
})
)
);
const linkedSpans = chunkedResponses.flat();
// Creates a map for all span links details found
const spanLinksDetailsMap = linkedSpans.reduce<
Record<string, SpanLinkDetails>
>((acc, { _source: source }) => {
const commonDetails = {
serviceName: source.service.name,
agentName: source.agent.name,
environment: source.service.environment as Environment,
transactionId: source.transaction?.id,
};
if (source.processor.event === ProcessorEvent.transaction) {
const transaction = source as TransactionRaw;
const key = `${transaction.trace.id}:${transaction.transaction.id}`;
acc[key] = {
traceId: source.trace.id,
spanId: transaction.transaction.id,
details: {
...commonDetails,
spanName: transaction.transaction.name,
duration: transaction.transaction.duration.us,
},
};
} else {
const span = source as SpanRaw;
const key = `${span.trace.id}:${span.span.id}`;
acc[key] = {
traceId: source.trace.id,
spanId: span.span.id,
details: {
...commonDetails,
spanName: span.span.name,
duration: span.span.duration.us,
spanSubtype: span.span.subtype,
spanType: span.span.type,
},
};
}
return acc;
}, {});
// It's important to keep the original order of the span links,
// so loops trough the original list merging external links and links with details.
// external links are links that the details were not found in the ES query.
return compact(
spanLinks.map((item) => {
const key = `${item.trace.id}:${item.span.id}`;
const details = spanLinksDetailsMap[key];
if (details) {
return details;
}
// When kuery is not set, returns external links, if not hides this item.
return isEmpty(kuery)
? { traceId: item.trace.id, spanId: item.span.id }
: undefined;
})
);
}

View file

@ -0,0 +1,103 @@
/*
* 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 * as t from 'io-ts';
import { setupRequest } from '../../lib/helpers/setup_request';
import { createApmServerRoute } from '../apm_routes/create_apm_server_route';
import { getSpanLinksDetails } from './get_span_links_details';
import { getLinkedChildrenOfSpan } from './get_linked_children';
import { kueryRt, rangeRt } from '../default_api_types';
import { SpanLinkDetails } from '../../../common/span_links';
import { processorEventRt } from '../../../common/processor_event';
import { getLinkedParentsOfSpan } from './get_linked_parents';
const linkedParentsRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/traces/{traceId}/span_links/{spanId}/parents',
params: t.type({
path: t.type({
traceId: t.string,
spanId: t.string,
}),
query: t.intersection([
kueryRt,
rangeRt,
t.type({ processorEvent: processorEventRt }),
]),
}),
options: { tags: ['access:apm'] },
handler: async (
resources
): Promise<{
spanLinksDetails: SpanLinkDetails[];
}> => {
const {
params: { query, path },
} = resources;
const setup = await setupRequest(resources);
const linkedParents = await getLinkedParentsOfSpan({
setup,
traceId: path.traceId,
spanId: path.spanId,
start: query.start,
end: query.end,
processorEvent: query.processorEvent,
});
return {
spanLinksDetails: await getSpanLinksDetails({
setup,
spanLinks: linkedParents,
kuery: query.kuery,
start: query.start,
end: query.end,
}),
};
},
});
const linkedChildrenRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/traces/{traceId}/span_links/{spanId}/children',
params: t.type({
path: t.type({
traceId: t.string,
spanId: t.string,
}),
query: t.intersection([kueryRt, rangeRt]),
}),
options: { tags: ['access:apm'] },
handler: async (
resources
): Promise<{
spanLinksDetails: SpanLinkDetails[];
}> => {
const {
params: { query, path },
} = resources;
const setup = await setupRequest(resources);
const linkedChildren = await getLinkedChildrenOfSpan({
setup,
traceId: path.traceId,
spanId: path.spanId,
start: query.start,
end: query.end,
});
return {
spanLinksDetails: await getSpanLinksDetails({
setup,
spanLinks: linkedChildren,
kuery: query.kuery,
start: query.start,
end: query.end,
}),
};
},
});
export const spanLinksRouteRepository = {
...linkedParentsRoute,
...linkedChildrenRoute,
};

View file

@ -0,0 +1,23 @@
/*
* 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 moment from 'moment';
export function getBufferedTimerange({
start,
end,
bufferSize = 4,
}: {
start: number;
end: number;
bufferSize?: number;
}) {
return {
startWithBuffer: moment(start).subtract(bufferSize, 'days').valueOf(),
endWithBuffer: moment(end).add(bufferSize, 'days').valueOf(),
};
}

View file

@ -10,15 +10,16 @@ import {
Sort,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { rangeQuery } from '@kbn/observability-plugin/server';
import { ProcessorEvent } from '../../../common/processor_event';
import {
ERROR_LOG_LEVEL,
PARENT_ID,
SPAN_DURATION,
TRACE_ID,
TRANSACTION_DURATION,
SPAN_DURATION,
PARENT_ID,
ERROR_LOG_LEVEL,
} from '../../../common/elasticsearch_fieldnames';
import { ProcessorEvent } from '../../../common/processor_event';
import { Setup } from '../../lib/helpers/setup_request';
import { getLinkedChildrenCountBySpanId } from '../span_links/get_linked_children';
export async function getTraceItems(
traceId: string,
@ -74,12 +75,21 @@ export async function getTraceItems(
},
});
const errorResponse = await errorResponsePromise;
const traceResponse = await traceResponsePromise;
const [errorResponse, traceResponse, linkedChildrenOfSpanCountBySpanId] =
await Promise.all([
errorResponsePromise,
traceResponsePromise,
getLinkedChildrenCountBySpanId({ traceId, setup, start, end }),
]);
const exceedsMax = traceResponse.hits.total.value > maxTraceItems;
const traceDocs = traceResponse.hits.hits.map((hit) => hit._source);
const errorDocs = errorResponse.hits.hits.map((hit) => hit._source);
return { exceedsMax, traceDocs, errorDocs };
return {
exceedsMax,
traceDocs,
errorDocs,
linkedChildrenOfSpanCountBySpanId,
};
}

View file

@ -82,6 +82,7 @@ const tracesByIdRoute = createApmServerRoute({
errorDocs: Array<
import('./../../../typings/es_schemas/ui/apm_error').APMError
>;
linkedChildrenOfSpanCountBySpanId: Record<string, number>;
}> => {
const setup = await setupRequest(resources);
const { params } = resources;

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 SpanLink {
trace: { id: string };
span: { id: 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 { SpanLink } from './fields/span_links';
import { Stackframe } from './fields/stackframe';
import { TimestampUs } from './fields/timestamp_us';
import { Url } from './fields/url';
@ -63,6 +64,7 @@ export interface SpanRaw extends APMBaseDoc {
sum: { us: number };
compression_strategy: string;
};
links?: SpanLink[];
};
timestamp: TimestampUs;
transaction?: {

View file

@ -20,6 +20,7 @@ import { Url } from './fields/url';
import { User } from './fields/user';
import { UserAgent } from './fields/user_agent';
import { Faas } from './fields/faas';
import { SpanLink } from './fields/span_links';
interface Processor {
name: 'transaction';
@ -71,4 +72,7 @@ export interface TransactionRaw extends APMBaseDoc {
user_agent?: UserAgent;
cloud?: Cloud;
faas?: Faas;
span?: {
links?: SpanLink[];
};
}

View file

@ -628,8 +628,7 @@
{
"type": "index",
"value": {
"aliases": {
},
"aliases": {},
"index": ".ml-config",
"mappings": {
"_meta": {
@ -15510,6 +15509,26 @@
"type": {
"ignore_above": 1024,
"type": "keyword"
},
"links": {
"properties": {
"span": {
"properties": {
"id": {
"type": "keyword",
"ignore_above": 1024
}
}
},
"trace": {
"properties": {
"id": {
"type": "keyword",
"ignore_above": 1024
}
}
}
}
}
}
},
@ -20620,6 +20639,26 @@
"type": {
"ignore_above": 1024,
"type": "keyword"
},
"links": {
"properties": {
"span": {
"properties": {
"id": {
"type": "keyword",
"ignore_above": 1024
}
}
},
"trace": {
"properties": {
"id": {
"type": "keyword",
"ignore_above": 1024
}
}
}
}
}
}
},

View file

@ -0,0 +1,300 @@
/*
* 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 { apm, timerange } from '@elastic/apm-synthtrace';
import { SpanLink } from '@kbn/apm-plugin/typings/es_schemas/raw/fields/span_links';
import uuid from 'uuid';
function getProducerInternalOnly() {
const producerInternalOnlyInstance = apm
.service('producer-internal-only', 'production', 'go')
.instance('instance a');
const events = timerange(
new Date('2022-01-01T00:00:00.000Z'),
new Date('2022-01-01T00:01:00.000Z')
)
.interval('1m')
.rate(1)
.generator((timestamp) => {
return producerInternalOnlyInstance
.transaction(`Transaction A`)
.timestamp(timestamp)
.duration(1000)
.success()
.children(
producerInternalOnlyInstance
.span(`Span A`, 'external', 'http')
.timestamp(timestamp + 50)
.duration(100)
.success()
);
});
const apmFields = events.toArray();
const transactionA = apmFields.find((item) => item['processor.event'] === 'transaction');
const spanA = apmFields.find((item) => item['processor.event'] === 'span');
const ids = {
transactionAId: transactionA?.['transaction.id']!,
traceId: spanA?.['trace.id']!,
spanAId: spanA?.['span.id']!,
};
const spanASpanLink = {
trace: { id: spanA?.['trace.id']! },
span: { id: spanA?.['span.id']! },
};
return {
ids,
spanASpanLink,
apmFields,
};
}
function getProducerExternalOnly() {
const producerExternalOnlyInstance = apm
.service('producer-external-only', 'production', 'java')
.instance('instance b');
const events = timerange(
new Date('2022-01-01T00:02:00.000Z'),
new Date('2022-01-01T00:03:00.000Z')
)
.interval('1m')
.rate(1)
.generator((timestamp) => {
return producerExternalOnlyInstance
.transaction(`Transaction B`)
.timestamp(timestamp)
.duration(1000)
.success()
.children(
producerExternalOnlyInstance
.span(`Span B`, 'external', 'http')
.defaults({
'span.links': [{ trace: { id: 'trace#1' }, span: { id: 'span#1' } }],
})
.timestamp(timestamp + 50)
.duration(100)
.success(),
producerExternalOnlyInstance
.span(`Span B.1`, 'external', 'http')
.timestamp(timestamp + 50)
.duration(100)
.success()
);
});
const apmFields = events.toArray();
const transactionB = apmFields.find((item) => item['processor.event'] === 'transaction');
const spanB = apmFields.find(
(item) => item['processor.event'] === 'span' && item['span.name'] === 'Span B'
);
const ids = {
traceId: spanB?.['trace.id']!,
transactionBId: transactionB?.['transaction.id']!,
spanBId: spanB?.['span.id']!,
};
const spanBSpanLink = {
trace: { id: spanB?.['trace.id']! },
span: { id: spanB?.['span.id']! },
};
const transactionBSpanLink = {
trace: { id: transactionB?.['trace.id']! },
span: { id: transactionB?.['transaction.id']! },
};
return {
ids,
spanBSpanLink,
transactionBSpanLink,
apmFields,
};
}
function getProducerConsumer({
producerInternalOnlySpanASpanLink,
producerExternalOnlySpanBLink,
producerExternalOnlyTransactionBLink,
}: {
producerInternalOnlySpanASpanLink: SpanLink;
producerExternalOnlySpanBLink: SpanLink;
producerExternalOnlyTransactionBLink: SpanLink;
}) {
const externalTraceId = uuid.v4();
const producerConsumerInstance = apm
.service('producer-consumer', 'production', 'ruby')
.instance('instance c');
const events = timerange(
new Date('2022-01-01T00:04:00.000Z'),
new Date('2022-01-01T00:05:00.000Z')
)
.interval('1m')
.rate(1)
.generator((timestamp) => {
return producerConsumerInstance
.transaction(`Transaction C`)
.defaults({
'span.links': [
producerInternalOnlySpanASpanLink,
producerExternalOnlyTransactionBLink,
{ trace: { id: externalTraceId }, span: { id: producerExternalOnlySpanBLink.span.id } },
],
})
.timestamp(timestamp)
.duration(1000)
.success()
.children(
producerConsumerInstance
.span(`Span C`, 'external', 'http')
.timestamp(timestamp + 50)
.duration(100)
.success()
);
});
const apmFields = events.toArray();
const transactionC = apmFields.find((item) => item['processor.event'] === 'transaction');
const transactionCSpanLink = {
trace: { id: transactionC?.['trace.id']! },
span: { id: transactionC?.['transaction.id']! },
};
const spanC = apmFields.find(
(item) => item['processor.event'] === 'span' || item['span.name'] === 'Span C'
);
const spanCSpanLink = {
trace: { id: spanC?.['trace.id']! },
span: { id: spanC?.['span.id']! },
};
const ids = {
traceId: transactionC?.['trace.id']!,
transactionCId: transactionC?.['transaction.id']!,
spanCId: spanC?.['span.id']!,
externalTraceId,
};
return {
transactionCSpanLink,
spanCSpanLink,
ids,
apmFields,
};
}
function getConsumerMultiple({
producerInternalOnlySpanALink,
producerExternalOnlySpanBLink,
producerConsumerSpanCLink,
producerConsumerTransactionCLink,
}: {
producerInternalOnlySpanALink: SpanLink;
producerExternalOnlySpanBLink: SpanLink;
producerConsumerSpanCLink: SpanLink;
producerConsumerTransactionCLink: SpanLink;
}) {
const consumerMultipleInstance = apm
.service('consumer-multiple', 'production', 'nodejs')
.instance('instance d');
const events = timerange(
new Date('2022-01-01T00:06:00.000Z'),
new Date('2022-01-01T00:07:00.000Z')
)
.interval('1m')
.rate(1)
.generator((timestamp) => {
return consumerMultipleInstance
.transaction(`Transaction D`)
.defaults({ 'span.links': [producerInternalOnlySpanALink, producerConsumerSpanCLink] })
.timestamp(timestamp)
.duration(1000)
.success()
.children(
consumerMultipleInstance
.span(`Span E`, 'external', 'http')
.defaults({
'span.links': [producerExternalOnlySpanBLink, producerConsumerTransactionCLink],
})
.timestamp(timestamp + 50)
.duration(100)
.success()
);
});
const apmFields = events.toArray();
const transactionD = apmFields.find((item) => item['processor.event'] === 'transaction');
const spanE = apmFields.find((item) => item['processor.event'] === 'span');
const ids = {
traceId: transactionD?.['trace.id']!,
transactionDId: transactionD?.['transaction.id']!,
spanEId: spanE?.['span.id']!,
};
return {
ids,
apmFields,
};
}
/**
* Data ingestion summary:
*
* producer-internal-only (go)
* --Transaction A
* ----Span A
*
* producer-external-only (java)
* --Transaction B
* ----Span B
* ------span.links=external link
* ----Span B1
*
* producer-consumer (ruby)
* --Transaction C
* ------span.links=Service A / Span A
* ------span.links=Service B / Transaction B
* ------span.links=External ID / Span B
* ----Span C
*
* consumer-multiple (nodejs)
* --Transaction D
* ------span.links= Service C / Span C | Service A / Span A
* ----Span E
* ------span.links= Service B / Span B | Service C / Transaction C
*/
export function generateSpanLinksData() {
const producerInternalOnly = getProducerInternalOnly();
const producerExternalOnly = getProducerExternalOnly();
const producerConsumer = getProducerConsumer({
producerInternalOnlySpanASpanLink: producerInternalOnly.spanASpanLink,
producerExternalOnlySpanBLink: producerExternalOnly.spanBSpanLink,
producerExternalOnlyTransactionBLink: producerExternalOnly.transactionBSpanLink,
});
const producerMultiple = getConsumerMultiple({
producerInternalOnlySpanALink: producerInternalOnly.spanASpanLink,
producerExternalOnlySpanBLink: producerExternalOnly.spanBSpanLink,
producerConsumerSpanCLink: producerConsumer.spanCSpanLink,
producerConsumerTransactionCLink: producerConsumer.transactionCSpanLink,
});
return {
apmFields: {
producerInternalOnly: producerInternalOnly.apmFields,
producerExternalOnly: producerExternalOnly.apmFields,
producerConsumer: producerConsumer.apmFields,
producerMultiple: producerMultiple.apmFields,
},
ids: {
producerInternalOnly: producerInternalOnly.ids,
producerExternalOnly: producerExternalOnly.ids,
producerConsumer: producerConsumer.ids,
producerMultiple: producerMultiple.ids,
},
};
}

View file

@ -0,0 +1,496 @@
/*
* 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 { EntityArrayIterable } from '@elastic/apm-synthtrace';
import { ProcessorEvent } from '@kbn/apm-plugin/common/processor_event';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { generateSpanLinksData } from './data_generator';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const synthtraceEsClient = getService('synthtraceEsClient');
const start = new Date('2022-01-01T00:00:00.000Z').getTime();
const end = new Date('2022-01-01T00:15:00.000Z').getTime() - 1;
registry.when(
'contains linked children',
{ config: 'basic', archives: ['apm_mappings_only_8.0.0'] },
() => {
let ids: ReturnType<typeof generateSpanLinksData>['ids'];
before(async () => {
const spanLinksData = generateSpanLinksData();
ids = spanLinksData.ids;
await synthtraceEsClient.index(
new EntityArrayIterable(spanLinksData.apmFields.producerInternalOnly).merge(
new EntityArrayIterable(spanLinksData.apmFields.producerExternalOnly),
new EntityArrayIterable(spanLinksData.apmFields.producerConsumer),
new EntityArrayIterable(spanLinksData.apmFields.producerMultiple)
)
);
});
after(() => synthtraceEsClient.clean());
describe('Span links count on traces', () => {
async function fetchTraces({ traceId }: { traceId: string }) {
return await apmApiClient.readUser({
endpoint: `GET /internal/apm/traces/{traceId}`,
params: {
path: { traceId },
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
},
},
});
}
describe('producer-internal-only trace', () => {
let traces: Awaited<ReturnType<typeof fetchTraces>>['body'];
before(async () => {
const tracesResponse = await fetchTraces({ traceId: ids.producerInternalOnly.traceId });
traces = tracesResponse.body;
});
it('contains two children link on Span A', () => {
expect(Object.values(traces.linkedChildrenOfSpanCountBySpanId).length).to.equal(1);
expect(
traces.linkedChildrenOfSpanCountBySpanId[ids.producerInternalOnly.spanAId]
).to.equal(2);
});
});
describe('producer-external-only trace', () => {
let traces: Awaited<ReturnType<typeof fetchTraces>>['body'];
before(async () => {
const tracesResponse = await fetchTraces({ traceId: ids.producerExternalOnly.traceId });
traces = tracesResponse.body;
});
it('contains two children link on Span B', () => {
expect(Object.values(traces.linkedChildrenOfSpanCountBySpanId).length).to.equal(2);
expect(
traces.linkedChildrenOfSpanCountBySpanId[ids.producerExternalOnly.spanBId]
).to.equal(1);
expect(
traces.linkedChildrenOfSpanCountBySpanId[ids.producerExternalOnly.transactionBId]
).to.equal(1);
});
});
describe('producer-consumer trace', () => {
let traces: Awaited<ReturnType<typeof fetchTraces>>['body'];
before(async () => {
const tracesResponse = await fetchTraces({ traceId: ids.producerConsumer.traceId });
traces = tracesResponse.body;
});
it('contains one children link on transaction C and two on span C', () => {
expect(Object.values(traces.linkedChildrenOfSpanCountBySpanId).length).to.equal(2);
expect(
traces.linkedChildrenOfSpanCountBySpanId[ids.producerConsumer.transactionCId]
).to.equal(1);
expect(traces.linkedChildrenOfSpanCountBySpanId[ids.producerConsumer.spanCId]).to.equal(
1
);
});
});
describe('consumer-multiple trace', () => {
let traces: Awaited<ReturnType<typeof fetchTraces>>['body'];
before(async () => {
const tracesResponse = await fetchTraces({ traceId: ids.producerMultiple.traceId });
traces = tracesResponse.body;
});
it('contains no children', () => {
expect(Object.values(traces.linkedChildrenOfSpanCountBySpanId).length).to.equal(0);
expect(
traces.linkedChildrenOfSpanCountBySpanId[ids.producerMultiple.transactionDId]
).to.equal(undefined);
expect(traces.linkedChildrenOfSpanCountBySpanId[ids.producerMultiple.spanEId]).to.equal(
undefined
);
});
});
});
describe('Span links details', () => {
async function fetchChildrenAndParentsDetails({
kuery,
traceId,
spanId,
processorEvent,
}: {
kuery: string;
traceId: string;
spanId: string;
processorEvent: ProcessorEvent;
}) {
const [childrenLinksResponse, parentsLinksResponse] = await Promise.all([
await apmApiClient.readUser({
endpoint: 'GET /internal/apm/traces/{traceId}/span_links/{spanId}/children',
params: {
path: { traceId, spanId },
query: {
kuery,
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
},
},
}),
apmApiClient.readUser({
endpoint: 'GET /internal/apm/traces/{traceId}/span_links/{spanId}/parents',
params: {
path: { traceId, spanId },
query: {
kuery,
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
processorEvent,
},
},
}),
]);
return {
childrenLinks: childrenLinksResponse.body,
parentsLinks: parentsLinksResponse.body,
};
}
describe('producer-internal-only span links details', () => {
let transactionALinksDetails: Awaited<ReturnType<typeof fetchChildrenAndParentsDetails>>;
let spanALinksDetails: Awaited<ReturnType<typeof fetchChildrenAndParentsDetails>>;
before(async () => {
const [transactionALinksDetailsResponse, spanALinksDetailsResponse] = await Promise.all(
[
fetchChildrenAndParentsDetails({
kuery: '',
traceId: ids.producerInternalOnly.traceId,
spanId: ids.producerInternalOnly.transactionAId,
processorEvent: ProcessorEvent.transaction,
}),
fetchChildrenAndParentsDetails({
kuery: '',
traceId: ids.producerInternalOnly.traceId,
spanId: ids.producerInternalOnly.spanAId,
processorEvent: ProcessorEvent.span,
}),
]
);
transactionALinksDetails = transactionALinksDetailsResponse;
spanALinksDetails = spanALinksDetailsResponse;
});
it('returns no links for transaction A', () => {
expect(transactionALinksDetails.childrenLinks.spanLinksDetails).to.eql([]);
expect(transactionALinksDetails.parentsLinks.spanLinksDetails).to.eql([]);
});
it('returns no parents on Span A', () => {
expect(spanALinksDetails.parentsLinks.spanLinksDetails).to.eql([]);
});
it('returns two children on Span A', () => {
expect(spanALinksDetails.childrenLinks.spanLinksDetails.length).to.eql(2);
const serviceCDetails = spanALinksDetails.childrenLinks.spanLinksDetails.find(
(childDetails) => {
return (
childDetails.traceId === ids.producerConsumer.traceId &&
childDetails.spanId === ids.producerConsumer.transactionCId
);
}
);
expect(serviceCDetails?.details).to.eql({
serviceName: 'producer-consumer',
agentName: 'ruby',
transactionId: ids.producerConsumer.transactionCId,
spanName: 'Transaction C',
duration: 1000000,
});
const serviceDDetails = spanALinksDetails.childrenLinks.spanLinksDetails.find(
(childDetails) => {
return (
childDetails.traceId === ids.producerMultiple.traceId &&
childDetails.spanId === ids.producerMultiple.transactionDId
);
}
);
expect(serviceDDetails?.details).to.eql({
serviceName: 'consumer-multiple',
agentName: 'nodejs',
transactionId: ids.producerMultiple.transactionDId,
spanName: 'Transaction D',
duration: 1000000,
});
});
});
describe('producer-external-only span links details', () => {
let transactionBLinksDetails: Awaited<ReturnType<typeof fetchChildrenAndParentsDetails>>;
let spanBLinksDetails: Awaited<ReturnType<typeof fetchChildrenAndParentsDetails>>;
before(async () => {
const [transactionALinksDetailsResponse, spanALinksDetailsResponse] = await Promise.all(
[
fetchChildrenAndParentsDetails({
kuery: '',
traceId: ids.producerExternalOnly.traceId,
spanId: ids.producerExternalOnly.transactionBId,
processorEvent: ProcessorEvent.transaction,
}),
fetchChildrenAndParentsDetails({
kuery: '',
traceId: ids.producerExternalOnly.traceId,
spanId: ids.producerExternalOnly.spanBId,
processorEvent: ProcessorEvent.span,
}),
]
);
transactionBLinksDetails = transactionALinksDetailsResponse;
spanBLinksDetails = spanALinksDetailsResponse;
});
it('returns producer-consumer as children of transaction B', () => {
expect(transactionBLinksDetails.childrenLinks.spanLinksDetails.length).to.be(1);
});
it('returns no parent for transaction B', () => {
expect(transactionBLinksDetails.parentsLinks.spanLinksDetails).to.eql([]);
});
it('returns external parent on Span B', () => {
expect(spanBLinksDetails.parentsLinks.spanLinksDetails.length).to.be(1);
expect(spanBLinksDetails.parentsLinks.spanLinksDetails).to.eql([
{ traceId: 'trace#1', spanId: 'span#1' },
]);
});
it('returns consumer-multiple as child on Span B', () => {
expect(spanBLinksDetails.childrenLinks.spanLinksDetails.length).to.be(1);
expect(spanBLinksDetails.childrenLinks.spanLinksDetails).to.eql([
{
traceId: ids.producerMultiple.traceId,
spanId: ids.producerMultiple.spanEId,
details: {
serviceName: 'consumer-multiple',
agentName: 'nodejs',
transactionId: ids.producerMultiple.transactionDId,
spanName: 'Span E',
duration: 100000,
spanSubtype: 'http',
spanType: 'external',
},
},
]);
});
});
describe('producer-consumer span links details', () => {
let transactionCLinksDetails: Awaited<ReturnType<typeof fetchChildrenAndParentsDetails>>;
let spanCLinksDetails: Awaited<ReturnType<typeof fetchChildrenAndParentsDetails>>;
before(async () => {
const [transactionALinksDetailsResponse, spanALinksDetailsResponse] = await Promise.all(
[
fetchChildrenAndParentsDetails({
kuery: '',
traceId: ids.producerConsumer.traceId,
spanId: ids.producerConsumer.transactionCId,
processorEvent: ProcessorEvent.transaction,
}),
fetchChildrenAndParentsDetails({
kuery: '',
traceId: ids.producerConsumer.traceId,
spanId: ids.producerConsumer.spanCId,
processorEvent: ProcessorEvent.span,
}),
]
);
transactionCLinksDetails = transactionALinksDetailsResponse;
spanCLinksDetails = spanALinksDetailsResponse;
});
it('returns producer-internal-only Span A, producer-external-only Transaction B, and External link as parents of Transaction C', () => {
expect(transactionCLinksDetails.parentsLinks.spanLinksDetails.length).to.be(3);
expect(transactionCLinksDetails.parentsLinks.spanLinksDetails).to.eql([
{
traceId: ids.producerInternalOnly.traceId,
spanId: ids.producerInternalOnly.spanAId,
details: {
serviceName: 'producer-internal-only',
agentName: 'go',
transactionId: ids.producerInternalOnly.transactionAId,
spanName: 'Span A',
duration: 100000,
spanSubtype: 'http',
spanType: 'external',
},
},
{
traceId: ids.producerExternalOnly.traceId,
spanId: ids.producerExternalOnly.transactionBId,
details: {
serviceName: 'producer-external-only',
agentName: 'java',
transactionId: ids.producerExternalOnly.transactionBId,
duration: 1000000,
spanName: 'Transaction B',
},
},
{
traceId: ids.producerConsumer.externalTraceId,
spanId: ids.producerExternalOnly.spanBId,
},
]);
});
it('returns consumer-multiple Span E as child of Transaction C', () => {
expect(transactionCLinksDetails.childrenLinks.spanLinksDetails.length).to.be(1);
expect(transactionCLinksDetails.childrenLinks.spanLinksDetails).to.eql([
{
traceId: ids.producerMultiple.traceId,
spanId: ids.producerMultiple.spanEId,
details: {
serviceName: 'consumer-multiple',
agentName: 'nodejs',
transactionId: ids.producerMultiple.transactionDId,
spanName: 'Span E',
duration: 100000,
spanSubtype: 'http',
spanType: 'external',
},
},
]);
});
it('returns no child on Span C', () => {
expect(spanCLinksDetails.parentsLinks.spanLinksDetails.length).to.be(0);
});
it('returns consumer-multiple as Child on producer-consumer', () => {
expect(spanCLinksDetails.childrenLinks.spanLinksDetails.length).to.be(1);
expect(spanCLinksDetails.childrenLinks.spanLinksDetails).to.eql([
{
traceId: ids.producerMultiple.traceId,
spanId: ids.producerMultiple.transactionDId,
details: {
serviceName: 'consumer-multiple',
agentName: 'nodejs',
transactionId: ids.producerMultiple.transactionDId,
spanName: 'Transaction D',
duration: 1000000,
},
},
]);
});
});
describe('consumer-multiple span links details', () => {
let transactionDLinksDetails: Awaited<ReturnType<typeof fetchChildrenAndParentsDetails>>;
let spanELinksDetails: Awaited<ReturnType<typeof fetchChildrenAndParentsDetails>>;
before(async () => {
const [transactionALinksDetailsResponse, spanALinksDetailsResponse] = await Promise.all(
[
fetchChildrenAndParentsDetails({
kuery: '',
traceId: ids.producerMultiple.traceId,
spanId: ids.producerMultiple.transactionDId,
processorEvent: ProcessorEvent.transaction,
}),
fetchChildrenAndParentsDetails({
kuery: '',
traceId: ids.producerMultiple.traceId,
spanId: ids.producerMultiple.spanEId,
processorEvent: ProcessorEvent.span,
}),
]
);
transactionDLinksDetails = transactionALinksDetailsResponse;
spanELinksDetails = spanALinksDetailsResponse;
});
it('returns producer-internal-only Span A and producer-consumer Span C as parents of Transaction D', () => {
expect(transactionDLinksDetails.parentsLinks.spanLinksDetails.length).to.be(2);
expect(transactionDLinksDetails.parentsLinks.spanLinksDetails).to.eql([
{
traceId: ids.producerInternalOnly.traceId,
spanId: ids.producerInternalOnly.spanAId,
details: {
serviceName: 'producer-internal-only',
agentName: 'go',
transactionId: ids.producerInternalOnly.transactionAId,
spanName: 'Span A',
duration: 100000,
spanSubtype: 'http',
spanType: 'external',
},
},
{
traceId: ids.producerConsumer.traceId,
spanId: ids.producerConsumer.spanCId,
details: {
serviceName: 'producer-consumer',
agentName: 'ruby',
transactionId: ids.producerConsumer.transactionCId,
spanName: 'Span C',
duration: 100000,
spanSubtype: 'http',
spanType: 'external',
},
},
]);
});
it('returns no children on Transaction D', () => {
expect(transactionDLinksDetails.childrenLinks.spanLinksDetails.length).to.be(0);
});
it('returns producer-external-only Span B and producer-consumer Transaction C as parents of Span E', () => {
expect(spanELinksDetails.parentsLinks.spanLinksDetails.length).to.be(2);
expect(spanELinksDetails.parentsLinks.spanLinksDetails).to.eql([
{
traceId: ids.producerExternalOnly.traceId,
spanId: ids.producerExternalOnly.spanBId,
details: {
serviceName: 'producer-external-only',
agentName: 'java',
transactionId: ids.producerExternalOnly.transactionBId,
spanName: 'Span B',
duration: 100000,
spanSubtype: 'http',
spanType: 'external',
},
},
{
traceId: ids.producerConsumer.traceId,
spanId: ids.producerConsumer.transactionCId,
details: {
serviceName: 'producer-consumer',
agentName: 'ruby',
transactionId: ids.producerConsumer.transactionCId,
spanName: 'Transaction C',
duration: 1000000,
},
},
]);
});
it('returns no children on Span E', () => {
expect(spanELinksDetails.childrenLinks.spanLinksDetails.length).to.be(0);
});
});
});
}
);
}

View file

@ -4,84 +4,120 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { apm, EntityArrayIterable, timerange } from '@elastic/apm-synthtrace';
import expect from '@kbn/expect';
import archives_metadata from '../../common/fixtures/es_archiver/archives_metadata';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { SupertestReturnType } from '../../common/apm_api_supertest';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
const synthtraceEsClient = getService('synthtraceEsClient');
const archiveName = 'apm_8.0.0';
const metadata = archives_metadata[archiveName];
const { start, end } = metadata;
const start = new Date('2022-01-01T00:00:00.000Z').getTime();
const end = new Date('2022-01-01T00:15:00.000Z').getTime() - 1;
async function fetchTraces({
traceId,
query,
}: {
traceId: string;
query: { start: string; end: string; _inspect?: boolean };
}) {
return await apmApiClient.readUser({
endpoint: `GET /internal/apm/traces/{traceId}`,
params: {
path: { traceId },
query,
},
});
}
registry.when('Trace does not exist', { config: 'basic', archives: [] }, () => {
it('handles empty state', async () => {
const response = await apmApiClient.readUser({
endpoint: `GET /internal/apm/traces/{traceId}`,
params: {
path: { traceId: 'foo' },
query: { start, end },
const response = await fetchTraces({
traceId: 'foo',
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
},
});
expect(response.status).to.be(200);
expect(response.body).to.eql({ exceedsMax: false, traceDocs: [], errorDocs: [] });
expect(response.body).to.eql({
exceedsMax: false,
traceDocs: [],
errorDocs: [],
linkedChildrenOfSpanCountBySpanId: {},
});
});
});
registry.when('Trace exists', { config: 'basic', archives: [archiveName] }, () => {
let response: SupertestReturnType<`GET /internal/apm/traces/{traceId}`>;
registry.when('Trace exists', { config: 'basic', archives: ['apm_mappings_only_8.0.0'] }, () => {
let serviceATraceId: string;
before(async () => {
response = await apmApiClient.readUser({
endpoint: `GET /internal/apm/traces/{traceId}`,
params: {
path: { traceId: '64d0014f7530df24e549dd17cc0a8895' },
query: { start, end },
},
const instanceJava = apm.service('synth-apple', 'production', 'java').instance('instance-b');
const events = timerange(start, end)
.interval('1m')
.rate(1)
.generator((timestamp) => {
return [
instanceJava
.transaction('GET /apple 🍏')
.timestamp(timestamp)
.duration(1000)
.failure()
.errors(
instanceJava
.error('[ResponseError] index_not_found_exception')
.timestamp(timestamp + 50)
)
.children(
instanceJava
.span('get_green_apple_🍏', 'db', 'elasticsearch')
.timestamp(timestamp + 50)
.duration(900)
.success()
),
];
});
const entities = events.toArray();
serviceATraceId = entities.slice(0, 1)[0]['trace.id']!;
await synthtraceEsClient.index(new EntityArrayIterable(entities));
});
after(() => synthtraceEsClient.clean());
describe('return trace', () => {
let traces: Awaited<ReturnType<typeof fetchTraces>>['body'];
before(async () => {
const response = await fetchTraces({
traceId: serviceATraceId,
query: { start: new Date(start).toISOString(), end: new Date(end).toISOString() },
});
expect(response.status).to.eql(200);
traces = response.body;
});
it('returns some errors', () => {
expect(traces.errorDocs.length).to.be.greaterThan(0);
expect(traces.errorDocs[0].error.exception?.[0].message).to.eql(
'[ResponseError] index_not_found_exception'
);
});
});
it('returns the correct status code', async () => {
expect(response.status).to.be(200);
});
it('returns the correct number of buckets', async () => {
expectSnapshot(response.body.errorDocs.map((doc) => doc.error?.exception?.[0]?.message))
.toMatchInline(`
Array [
"Test CaptureError",
"Uncaught Error: Test Error in dashboard",
]
`);
expectSnapshot(
response.body.traceDocs.map((doc) =>
'span' in doc ? `${doc.span.name} (span)` : `${doc.transaction.name} (transaction)`
)
).toMatchInline(`
Array [
"/dashboard (transaction)",
"GET /api/stats (transaction)",
"APIRestController#topProducts (transaction)",
"Parsing the document, executing sync. scripts (span)",
"GET /api/products/top (span)",
"GET /api/stats (span)",
"Requesting and receiving the document (span)",
"SELECT FROM customers (span)",
"SELECT FROM order_lines (span)",
"http://opbeans-frontend:3000/static/css/main.7bd7c5e8.css (span)",
"SELECT FROM products (span)",
"SELECT FROM orders (span)",
"SELECT FROM order_lines (span)",
"Making a connection to the server (span)",
"Fire \\"load\\" event (span)",
"empty query (span)",
]
`);
expectSnapshot(response.body.exceedsMax).toMatchInline(`false`);
it('returns some trace docs', () => {
expect(traces.traceDocs.length).to.be.greaterThan(0);
expect(
traces.traceDocs.map((item) => {
if (item.span && 'name' in item.span) {
return item.span.name;
}
if (item.transaction && 'name' in item.transaction) {
return item.transaction.name;
}
})
).to.eql(['GET /apple 🍏', 'get_green_apple_🍏']);
});
});
});
}