[APM] Trace sample performance improvements (#183802)

Fixes #178985 
## Summary

This PR changes the frontend logic to render the trace waterfall
component. Instead of recursively rendering transactions/spans and their
child transactions/spans, which causes high memory usage depending on
the amount of data/how complex the trace to be rendered is, it now uses
tree data structure and BFS/DFS algorithms.

Besides that, the trace sample component can render a very long list. To
avoid rendering too many elements in the DOM, this PR changes it to use
a virtual list


### Memory consumption

15-minutes worth of data

| before | after |
|-------|-------|
|<img width="590" alt="image"
src="45746f12-3119-4641-9d68-a725a1fff6ac">|<img
width="590" alt="image"
src="64e7e5f2-8dda-40eb-8abc-f1974aeb7072">|


30-minutes worth of data

| before | after |
|-------|-------|
|<img width="590" alt="image"
src="a0b32774-4bb9-4d8c-a088-b4baea0c204a">|<img
width="590" alt="image"
src="b09188e3-2fa9-4d38-b344-f3dd3656bde8">|


1-hour worth of data
| before | after |
|-------|-------|
|<img width="590" alt="image"
src="c33f61ff-e7f8-4f1c-ac49-28bb4c819303">|<img
width="590" alt="image"
src="ad5299cd-7a72-43e1-aa4a-407c99acb107">|

### Extra

Sticky header fix


632485ee-80c5-486d-aaa2-c34047b9c11e

### How to test

The best way to test is to connect to an oblt cluster
- Navigate to APM > Dependencies
- Go into `cartService`
- Click on `Operations` tab and click on `POST /nodejs/addToCart`
operation.
- Select different date ranges and services

### For reviewers

There is a problem with positioning the trace elements in the grid when
rendering data for large date ranges
https://github.com/elastic/kibana/issues/178985#issuecomment-2137480777.
This won't be addressed in this PR

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Carlos Crespo 2024-06-06 16:36:02 +02:00 committed by GitHub
parent 15424370e1
commit 620359f893
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1601 additions and 512 deletions

View file

@ -8,7 +8,7 @@
import { ApmFields } from './apm_fields';
import { Serializable } from '../serializable';
import { generateLongId, generateShortId } from '../utils/generate_id';
import { generateLongIdWithSeed, generateShortId, generateLongId } from '../utils/generate_id';
export class ApmError extends Serializable<ApmFields> {
constructor(fields: ApmFields) {
@ -21,10 +21,13 @@ export class ApmError extends Serializable<ApmFields> {
}
serialize() {
const errorMessage =
this.fields['error.grouping_name'] || this.fields['error.exception']?.[0]?.message;
const [data] = super.serialize();
data['error.grouping_key'] = generateLongId(
this.fields['error.grouping_name'] || this.fields['error.exception']?.[0]?.message
);
data['error.grouping_key'] = errorMessage
? generateLongIdWithSeed(errorMessage)
: generateLongId();
return [data];
}

View file

@ -11,7 +11,7 @@ import { Span } from './span';
import { Transaction } from './transaction';
import { Event } from './event';
import { ApmApplicationMetricFields, ApmFields, GeoLocation, SpanParams } from './apm_fields';
import { generateLongId } from '../utils/generate_id';
import { generateLongIdWithSeed, generateLongId } from '../utils/generate_id';
import { Metricset } from './metricset';
import { ApmError } from './apm_error';
@ -259,7 +259,7 @@ export class MobileDevice extends Entity<ApmFields> {
return new ApmError({
...this.fields,
'error.type': 'crash',
'error.id': generateLongId(message),
'error.id': generateLongIdWithSeed(message),
'error.exception': [{ message, ...{ type: 'crash' } }],
'error.grouping_name': groupingName || message,
});

View file

@ -7,16 +7,33 @@
*/
let seq = 0;
const pid = String(process.pid);
function generateId(seed?: string, length: number = 32) {
const str = seed ?? String(seq++);
return str.padStart(length, '0');
const LONG_ID_LENGTH = 32;
const SHORT_ID_LENGTH = 16;
function generateId(length: number = LONG_ID_LENGTH) {
const id = String(seq++);
const generatedId = pid + id.padStart(length - pid.length, '0');
if (generatedId.length > length) {
throw new Error(`generated id is longer than ${length} characters: ${generatedId.length}`);
}
return generatedId;
}
export function generateShortId(seed?: string) {
return generateId(seed, 16);
function generateIdWithSeed(seed: string, length: number = LONG_ID_LENGTH) {
return seed?.padStart(length, '0');
}
export function generateLongId(seed?: string) {
return generateId(seed, 32);
export function generateShortId() {
return generateId(SHORT_ID_LENGTH);
}
export function generateLongId() {
return generateId(LONG_ID_LENGTH);
}
export function generateLongIdWithSeed(seed: string) {
return generateIdWithSeed(seed, LONG_ID_LENGTH);
}

View file

@ -48,7 +48,24 @@ describe('simple trace', () => {
// TODO this is not entirely factual, since id's are generated of a global sequence number
it('generates the same data every time', () => {
expect(events).toMatchSnapshot();
expect(events).toMatchSnapshot(
events.map((event) => {
const matchers: Record<string, any> = {};
if (event['transaction.id']) {
matchers['transaction.id'] = expect.any(String);
}
if (event['trace.id']) {
matchers['trace.id'] = expect.any(String);
}
if (event['span.id']) {
matchers['span.id'] = expect.any(String);
}
if (event['parent.id']) {
matchers['parent.id'] = expect.any(String);
}
return matchers;
})
);
});
it('generates 15 transaction events', () => {
@ -83,9 +100,9 @@ describe('simple trace', () => {
'service.name': 'opbeans-java',
'service.node.name': 'instance-1',
'timestamp.us': 1609459200000000,
'trace.id': '00000000000000000000000000000241',
'trace.id': expect.stringContaining('00000000000000000000000241'),
'transaction.duration.us': 1000000,
'transaction.id': '0000000000000240',
'transaction.id': expect.stringContaining('0000000240'),
'transaction.name': 'GET /api/product/list',
'transaction.type': 'request',
'transaction.sampled': true,
@ -95,26 +112,28 @@ describe('simple trace', () => {
it('outputs span events', () => {
const [, span] = events;
expect(span).toEqual({
'@timestamp': 1609459200050,
'agent.name': 'java',
'container.id': 'instance-1',
'event.outcome': 'success',
'host.name': 'instance-1',
'parent.id': '0000000000000300',
'processor.event': 'span',
'processor.name': 'transaction',
'service.environment': 'production',
'service.name': 'opbeans-java',
'service.node.name': 'instance-1',
'span.duration.us': 900000,
'span.id': '0000000000000302',
'span.name': 'GET apm-*/_search',
'span.subtype': 'elasticsearch',
'span.type': 'db',
'timestamp.us': 1609459200050000,
'trace.id': '00000000000000000000000000000301',
'transaction.id': '0000000000000300',
});
expect(span).toEqual(
expect.objectContaining({
'@timestamp': 1609459200050,
'agent.name': 'java',
'container.id': 'instance-1',
'event.outcome': 'success',
'host.name': 'instance-1',
'parent.id': expect.stringContaining('0000000300'),
'processor.event': 'span',
'processor.name': 'transaction',
'service.environment': 'production',
'service.name': 'opbeans-java',
'service.node.name': 'instance-1',
'span.duration.us': 900000,
'span.id': expect.stringContaining('0000000302'),
'span.name': 'GET apm-*/_search',
'span.subtype': 'elasticsearch',
'span.type': 'db',
'timestamp.us': 1609459200050000,
'trace.id': expect.stringContaining('00000000000000000000000301'),
'transaction.id': expect.stringContaining('0000000300'),
})
);
});
});

View file

@ -14,9 +14,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459200000000,
"trace.id": "00000000000000000000000000000001",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000000",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -27,20 +27,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000000",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000002",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459200050000,
"trace.id": "00000000000000000000000000000001",
"transaction.id": "0000000000000000",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459200000,
@ -95,9 +95,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459260000000,
"trace.id": "00000000000000000000000000000005",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000004",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -108,20 +108,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000004",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000006",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459260050000,
"trace.id": "00000000000000000000000000000005",
"transaction.id": "0000000000000004",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459260000,
@ -176,9 +176,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459320000000,
"trace.id": "00000000000000000000000000000009",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000008",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -189,20 +189,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000008",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000010",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459320050000,
"trace.id": "00000000000000000000000000000009",
"transaction.id": "0000000000000008",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459320000,
@ -257,9 +257,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459380000000,
"trace.id": "00000000000000000000000000000013",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000012",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -270,20 +270,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000012",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000014",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459380050000,
"trace.id": "00000000000000000000000000000013",
"transaction.id": "0000000000000012",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459380000,
@ -338,9 +338,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459440000000,
"trace.id": "00000000000000000000000000000017",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000016",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -351,20 +351,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000016",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000018",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459440050000,
"trace.id": "00000000000000000000000000000017",
"transaction.id": "0000000000000016",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459440000,
@ -419,9 +419,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459500000000,
"trace.id": "00000000000000000000000000000021",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000020",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -432,20 +432,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000020",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000022",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459500050000,
"trace.id": "00000000000000000000000000000021",
"transaction.id": "0000000000000020",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459500000,
@ -500,9 +500,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459560000000,
"trace.id": "00000000000000000000000000000025",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000024",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -513,20 +513,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000024",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000026",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459560050000,
"trace.id": "00000000000000000000000000000025",
"transaction.id": "0000000000000024",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459560000,
@ -581,9 +581,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459620000000,
"trace.id": "00000000000000000000000000000029",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000028",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -594,20 +594,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000028",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000030",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459620050000,
"trace.id": "00000000000000000000000000000029",
"transaction.id": "0000000000000028",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459620000,
@ -662,9 +662,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459680000000,
"trace.id": "00000000000000000000000000000033",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000032",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -675,20 +675,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000032",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000034",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459680050000,
"trace.id": "00000000000000000000000000000033",
"transaction.id": "0000000000000032",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459680000,
@ -743,9 +743,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459740000000,
"trace.id": "00000000000000000000000000000037",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000036",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -756,20 +756,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000036",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000038",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459740050000,
"trace.id": "00000000000000000000000000000037",
"transaction.id": "0000000000000036",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459740000,
@ -824,9 +824,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459800000000,
"trace.id": "00000000000000000000000000000041",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000040",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -837,20 +837,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000040",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000042",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459800050000,
"trace.id": "00000000000000000000000000000041",
"transaction.id": "0000000000000040",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459800000,
@ -905,9 +905,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459860000000,
"trace.id": "00000000000000000000000000000045",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000044",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -918,20 +918,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000044",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000046",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459860050000,
"trace.id": "00000000000000000000000000000045",
"transaction.id": "0000000000000044",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459860000,
@ -986,9 +986,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459920000000,
"trace.id": "00000000000000000000000000000049",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000048",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -999,20 +999,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000048",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000050",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459920050000,
"trace.id": "00000000000000000000000000000049",
"transaction.id": "0000000000000048",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459920000,
@ -1067,9 +1067,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609459980000000,
"trace.id": "00000000000000000000000000000053",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000052",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -1080,20 +1080,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000052",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000054",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609459980050000,
"trace.id": "00000000000000000000000000000053",
"transaction.id": "0000000000000052",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609459980000,
@ -1148,9 +1148,9 @@ Array [
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"timestamp.us": 1609460040000000,
"trace.id": "00000000000000000000000000000057",
"trace.id": Any<String>,
"transaction.duration.us": 1000000,
"transaction.id": "0000000000000056",
"transaction.id": Any<String>,
"transaction.name": "GET /api/product/list",
"transaction.sampled": true,
"transaction.type": "request",
@ -1161,20 +1161,20 @@ Array [
"container.id": "instance-1",
"event.outcome": "success",
"host.name": "instance-1",
"parent.id": "0000000000000056",
"parent.id": Any<String>,
"processor.event": "span",
"processor.name": "transaction",
"service.environment": "production",
"service.name": "opbeans-java",
"service.node.name": "instance-1",
"span.duration.us": 900000,
"span.id": "0000000000000058",
"span.id": Any<String>,
"span.name": "GET apm-*/_search",
"span.subtype": "elasticsearch",
"span.type": "db",
"timestamp.us": 1609460040050000,
"trace.id": "00000000000000000000000000000057",
"transaction.id": "0000000000000056",
"trace.id": Any<String>,
"transaction.id": Any<String>,
},
Object {
"@timestamp": 1609460040000,

View file

@ -6,7 +6,7 @@
*/
import { getErrorGroupingKey } from '@kbn/apm-synthtrace-client/src/lib/apm/instance';
import { generateLongId } from '@kbn/apm-synthtrace-client/src/lib/utils/generate_id';
import { generateLongIdWithSeed } from '@kbn/apm-synthtrace-client/src/lib/utils/generate_id';
import url from 'url';
import { synthtrace } from '../../../synthtrace';
@ -71,7 +71,7 @@ describe('Error details', () => {
});
describe('when error has data', () => {
const errorGroupingKey = generateLongId('Error 1');
const errorGroupingKey = generateLongIdWithSeed('Error 1');
const errorGroupingKeyShort = errorGroupingKey.slice(0, 5);
const errorDetailsPageHref = url.format({
pathname: `/app/apm/services/opbeans-java/errors/${errorGroupingKey}`,

View file

@ -42,7 +42,9 @@ describe('Large Trace in waterfall', () => {
});
it('renders waterfall items', () => {
cy.getByTestSubj('waterfallItem').should('have.length.greaterThan', 200);
// it renders a virtual list, so the number of items rendered is not the same as the number of items in the trace
cy.getByTestSubj('waterfallItem').should('have.length.at.least', 39);
cy.getByTestSubj('waterfall').should('have.css', 'height').and('eq', '10011px');
});
it('shows warning about trace size', () => {
@ -70,7 +72,9 @@ describe('Large Trace in waterfall', () => {
});
it('renders waterfall items', () => {
cy.getByTestSubj('waterfallItem').should('have.length.greaterThan', 400);
// it renders a virtual list, so the number of items rendered is not the same as the number of items in the trace
cy.getByTestSubj('waterfallItem').should('have.length.at.least', 39);
cy.getByTestSubj('waterfall').should('have.css', 'height').and('eq', '10011px');
});
it('does not show the warning about trace size', () => {

View file

@ -7,7 +7,7 @@
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { omit, orderBy } from 'lodash';
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useHistory } from 'react-router-dom';
import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context';
import { useApmParams } from '../../../hooks/use_apm_params';
@ -20,6 +20,7 @@ import { ResettingHeightRetainer } from '../../shared/height_retainer/resetting_
import { push, replace } from '../../shared/links/url_helpers';
import { useWaterfallFetcher } from '../transaction_details/use_waterfall_fetcher';
import { WaterfallWithSummary } from '../transaction_details/waterfall_with_summary';
import { TransactionTab } from '../transaction_details/waterfall_with_summary/transaction_tabs';
import { DependencyOperationDistributionChart } from './dependency_operation_distribution_chart';
import { DetailViewHeader } from './detail_view_header';
import { maybeRedirectToAvailableSpanSample } from './maybe_redirect_to_available_span_sample';
@ -115,9 +116,37 @@ export function DependencyOperationDetailView() {
const isWaterfallLoading =
spanFetch.status === FETCH_STATUS.NOT_INITIATED ||
(spanFetch.status === FETCH_STATUS.LOADING && samples.length === 0) ||
waterfallFetch.status === FETCH_STATUS.LOADING ||
!waterfallFetch.waterfall.entryWaterfallTransaction;
(waterfallFetch.status === FETCH_STATUS.LOADING &&
!waterfallFetch.waterfall.entryWaterfallTransaction);
const onSampleClick = useCallback(
(sample: any) => {
push(history, { query: { spanId: sample.spanId } });
},
[history]
);
const onTabClick = useCallback(
(nextDetailTab: TransactionTab) => {
push(history, {
query: {
detailTab: nextDetailTab,
},
});
},
[history]
);
const onShowCriticalPathChange = useCallback(
(nextShowCriticalPath: boolean) => {
push(history, {
query: {
showCriticalPath: nextShowCriticalPath ? 'true' : 'false',
},
});
},
[history]
);
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
@ -147,31 +176,18 @@ export function DependencyOperationDetailView() {
<ResettingHeightRetainer reset={!isWaterfallLoading}>
<WaterfallWithSummary
environment={environment}
waterfallFetchResult={waterfallFetch}
waterfallFetchResult={waterfallFetch.waterfall}
waterfallFetchStatus={waterfallFetch.status}
traceSamples={samples}
traceSamplesFetchStatus={spanFetch.status}
onSampleClick={(sample) => {
push(history, { query: { spanId: sample.spanId } });
}}
onTabClick={(tab) => {
push(history, {
query: {
detailTab: tab,
},
});
}}
onSampleClick={onSampleClick}
onTabClick={onTabClick}
serviceName={waterfallFetch.waterfall.entryWaterfallTransaction?.doc.service.name}
waterfallItemId={waterfallItemId}
detailTab={detailTab}
selectedSample={selectedSample || null}
showCriticalPath={showCriticalPath}
onShowCriticalPathChange={(nextShowCriticalPath) => {
push(history, {
query: {
showCriticalPath: nextShowCriticalPath ? 'true' : 'false',
},
});
}}
onShowCriticalPathChange={onShowCriticalPathChange}
/>
</ResettingHeightRetainer>
</EuiPanel>

View file

@ -4,14 +4,17 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import { FETCH_STATUS } from '@kbn/observability-shared-plugin/public';
import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useTimeRange } from '../../../hooks/use_time_range';
import { useTraceExplorerSamples } from '../../../hooks/use_trace_explorer_samples';
import { ResettingHeightRetainer } from '../../shared/height_retainer/resetting_height_container';
import { push, replace } from '../../shared/links/url_helpers';
import { useWaterfallFetcher } from '../transaction_details/use_waterfall_fetcher';
import { WaterfallWithSummary } from '../transaction_details/waterfall_with_summary';
import { TransactionTab } from '../transaction_details/waterfall_with_summary/transaction_tabs';
export function TraceExplorerWaterfall() {
const history = useHistory();
@ -52,39 +55,61 @@ export function TraceExplorerWaterfall() {
end,
});
const onSampleClick = useCallback(
(sample: any) => {
push(history, {
query: {
traceId: sample.traceId,
transactionId: sample.transactionId,
waterfallItemId: '',
},
});
},
[history]
);
const onTabClick = useCallback(
(nextDetailTab: TransactionTab) => {
push(history, {
query: {
detailTab: nextDetailTab,
},
});
},
[history]
);
const onShowCriticalPathChange = useCallback(
(nextShowCriticalPath: boolean) => {
push(history, {
query: {
showCriticalPath: nextShowCriticalPath ? 'true' : 'false',
},
});
},
[history]
);
const isWaterfallLoading =
waterfallFetchResult.status === FETCH_STATUS.LOADING &&
!waterfallFetchResult.waterfall.entryWaterfallTransaction;
return (
<WaterfallWithSummary
waterfallFetchResult={waterfallFetchResult}
traceSamples={traceSamplesFetchResult.data.traceSamples}
traceSamplesFetchStatus={traceSamplesFetchResult.status}
environment={environment}
onSampleClick={(sample) => {
push(history, {
query: {
traceId: sample.traceId,
transactionId: sample.transactionId,
waterfallItemId: '',
},
});
}}
onTabClick={(nextDetailTab) => {
push(history, {
query: {
detailTab: nextDetailTab,
},
});
}}
detailTab={detailTab}
waterfallItemId={waterfallItemId}
serviceName={waterfallFetchResult.waterfall.entryWaterfallTransaction?.doc.service.name}
showCriticalPath={showCriticalPath}
onShowCriticalPathChange={(nextShowCriticalPath) => {
push(history, {
query: {
showCriticalPath: nextShowCriticalPath ? 'true' : 'false',
},
});
}}
/>
<ResettingHeightRetainer reset={!isWaterfallLoading}>
<WaterfallWithSummary
waterfallFetchResult={waterfallFetchResult.waterfall}
waterfallFetchStatus={waterfallFetchResult.status}
traceSamples={traceSamplesFetchResult.data.traceSamples}
traceSamplesFetchStatus={traceSamplesFetchResult.status}
environment={environment}
onSampleClick={onSampleClick}
onTabClick={onTabClick}
detailTab={detailTab}
waterfallItemId={waterfallItemId}
serviceName={waterfallFetchResult.waterfall.entryWaterfallTransaction?.doc.service.name}
showCriticalPath={showCriticalPath}
onShowCriticalPathChange={onShowCriticalPathChange}
/>
</ResettingHeightRetainer>
);
}

View file

@ -92,6 +92,20 @@ export function TransactionDistribution({
[history]
);
const onSampleClick = useCallback(
(sample: { transactionId: string; traceId: string }) => {
history.push({
...history.location,
search: fromQuery({
...toQuery(history.location.search),
transactionId: sample.transactionId,
traceId: sample.traceId,
}),
});
},
[history]
);
return (
<ResettingHeightRetainer reset={!traceId}>
<div data-test-subj="apmTransactionDistributionTabContent">
@ -111,21 +125,13 @@ export function TransactionDistribution({
<EuiSpacer size="s" />
<WaterfallWithSummary
environment={environment}
onSampleClick={(sample) => {
history.push({
...history.location,
search: fromQuery({
...toQuery(history.location.search),
transactionId: sample.transactionId,
traceId: sample.traceId,
}),
});
}}
onSampleClick={onSampleClick}
onTabClick={onTabClick}
serviceName={serviceName}
waterfallItemId={waterfallItemId}
detailTab={detailTab as TransactionTab | undefined}
waterfallFetchResult={waterfallFetchResult}
waterfallFetchResult={waterfallFetchResult.waterfall}
waterfallFetchStatus={waterfallFetchResult.status}
traceSamplesFetchStatus={traceSamplesFetchResult.status}
traceSamples={traceSamplesFetchResult.data?.traceSamples}
showCriticalPath={showCriticalPath}

View file

@ -25,9 +25,10 @@ import { FETCH_STATUS } from '../../../../hooks/use_fetcher';
import { WaterfallFetchResult } from '../use_waterfall_fetcher';
interface Props<TSample extends {}> {
waterfallFetchResult: WaterfallFetchResult;
waterfallFetchResult: WaterfallFetchResult['waterfall'];
traceSamples?: TSample[];
traceSamplesFetchStatus: FETCH_STATUS;
waterfallFetchStatus: FETCH_STATUS;
environment: Environment;
onSampleClick: (sample: TSample) => void;
onTabClick: (tab: TransactionTab) => void;
@ -41,6 +42,7 @@ interface Props<TSample extends {}> {
export function WaterfallWithSummary<TSample extends {}>({
waterfallFetchResult,
waterfallFetchStatus,
traceSamples,
traceSamplesFetchStatus,
environment,
@ -58,12 +60,12 @@ export function WaterfallWithSummary<TSample extends {}>({
const isControlled = selectedSample !== undefined;
const isLoading =
waterfallFetchResult.status === FETCH_STATUS.LOADING ||
waterfallFetchStatus === FETCH_STATUS.LOADING ||
traceSamplesFetchStatus === FETCH_STATUS.LOADING;
// When traceId is not present, call to waterfallFetchResult will not be initiated
const isSucceded =
(waterfallFetchResult.status === FETCH_STATUS.SUCCESS ||
waterfallFetchResult.status === FETCH_STATUS.NOT_INITIATED) &&
(waterfallFetchStatus === FETCH_STATUS.SUCCESS ||
waterfallFetchStatus === FETCH_STATUS.NOT_INITIATED) &&
traceSamplesFetchStatus === FETCH_STATUS.SUCCESS;
useEffect(() => {
@ -86,7 +88,7 @@ export function WaterfallWithSummary<TSample extends {}>({
: 0
: sampleActivePage;
const { entryTransaction } = waterfallFetchResult.waterfall;
const { entryTransaction } = waterfallFetchResult;
if (!entryTransaction && traceSamples?.length === 0 && isSucceded) {
return (
@ -136,7 +138,7 @@ export function WaterfallWithSummary<TSample extends {}>({
<MaybeViewTraceLink
isLoading={isLoading}
transaction={entryTransaction}
waterfall={waterfallFetchResult.waterfall}
waterfall={waterfallFetchResult}
environment={environment}
/>
</EuiFlexItem>
@ -153,8 +155,8 @@ export function WaterfallWithSummary<TSample extends {}>({
) : (
<EuiFlexItem grow={false}>
<TransactionSummary
errorCount={waterfallFetchResult.waterfall.totalErrorsCount}
totalDuration={waterfallFetchResult.waterfall.rootWaterfallTransaction?.duration}
errorCount={waterfallFetchResult.totalErrorsCount}
totalDuration={waterfallFetchResult.rootWaterfallTransaction?.duration}
transaction={entryTransaction}
/>
</EuiFlexItem>
@ -167,7 +169,7 @@ export function WaterfallWithSummary<TSample extends {}>({
serviceName={serviceName}
waterfallItemId={waterfallItemId}
onTabClick={onTabClick}
waterfall={waterfallFetchResult.waterfall}
waterfall={waterfallFetchResult}
isLoading={isLoading}
showCriticalPath={showCriticalPath}
onShowCriticalPathChange={onShowCriticalPathChange}

View file

@ -15,20 +15,24 @@ import {
EuiToolTip,
} from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { groupBy } from 'lodash';
import { transparentize } from 'polished';
import React, { useState } from 'react';
import React, { useEffect, useRef } from 'react';
import { WindowScroller, AutoSizer } from 'react-virtualized';
import { areEqual, ListChildComponentProps, VariableSizeList as List } from 'react-window';
import { asBigNumber } from '../../../../../../../common/utils/formatters';
import { getCriticalPath } from '../../../../../../../common/critical_path/get_critical_path';
import { useTheme } from '../../../../../../hooks/use_theme';
import { Margins } from '../../../../../shared/charts/timeline';
import { IWaterfall, IWaterfallSpanOrTransaction } from './waterfall_helpers/waterfall_helpers';
import {
IWaterfallNodeFlatten,
IWaterfall,
IWaterfallSpanOrTransaction,
} from './waterfall_helpers/waterfall_helpers';
import { WaterfallItem } from './waterfall_item';
import { WaterfallContextProvider } from './context/waterfall_context';
import { useWaterfallContext } from './context/use_waterfall';
interface AccordionWaterfallProps {
isOpen: boolean;
item: IWaterfallSpanOrTransaction;
level: number;
duration: IWaterfall['duration'];
waterfallItemId?: string;
waterfall: IWaterfall;
@ -38,24 +42,27 @@ interface AccordionWaterfallProps {
maxLevelOpen: number;
}
const ACCORDION_HEIGHT = '48px';
type WaterfallProps = Omit<
AccordionWaterfallProps,
'item' | 'maxLevelOpen' | 'showCriticalPath' | 'waterfall' | 'isOpen'
>;
interface WaterfallNodeProps extends WaterfallProps {
node: IWaterfallNodeFlatten;
}
const ACCORDION_HEIGHT = 48;
const StyledAccordion = euiStyled(EuiAccordion).withConfig({
shouldForwardProp: (prop) => !['childrenCount', 'marginLeftLevel', 'hasError'].includes(prop),
shouldForwardProp: (prop) => !['marginLeftLevel', 'hasError'].includes(prop),
})<
EuiAccordionProps & {
childrenCount: number;
marginLeftLevel: number;
hasError: boolean;
}
>`
.waterfall_accordion {
border-top: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
}
.euiAccordion__childWrapper {
transition: none;
}
border-top: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
${(props) => {
const borderLeft = props.hasError
@ -63,7 +70,7 @@ const StyledAccordion = euiStyled(EuiAccordion).withConfig({
: `1px solid ${props.theme.eui.euiColorLightShade};`;
return `.button_${props.id} {
width: 100%;
height: ${ACCORDION_HEIGHT};
height: ${ACCORDION_HEIGHT}px;
margin-left: ${props.marginLeftLevel}px;
border-left: ${borderLeft}
&:hover {
@ -78,111 +85,166 @@ const StyledAccordion = euiStyled(EuiAccordion).withConfig({
}
`;
export function AccordionWaterfall(props: AccordionWaterfallProps) {
const {
item,
level,
duration,
waterfall,
waterfallItemId,
timelineMargins,
onClickWaterfallItem,
showCriticalPath,
maxLevelOpen,
} = props;
export function AccordionWaterfall({
maxLevelOpen,
showCriticalPath,
waterfall,
isOpen,
...props
}: AccordionWaterfallProps) {
return (
<WaterfallContextProvider
maxLevelOpen={maxLevelOpen}
showCriticalPath={showCriticalPath}
waterfall={waterfall}
isOpen={isOpen}
>
<Waterfall {...props} />
</WaterfallContextProvider>
);
}
function Waterfall(props: WaterfallProps) {
const listRef = useRef<List>(null);
const rowSizeMapRef = useRef(new Map<number, number>());
const { traceList } = useWaterfallContext();
const onRowLoad = (index: number, size: number) => {
rowSizeMapRef.current.set(index, size);
};
const getRowSize = (index: number) => {
// adds 1px for the border top
return rowSizeMapRef.current.get(index) || ACCORDION_HEIGHT + 1;
};
const onScroll = ({ scrollTop }: { scrollTop: number }) => {
listRef.current?.scrollTo(scrollTop);
};
return (
<WindowScroller onScroll={onScroll}>
{({ registerChild }) => (
<AutoSizer disableHeight>
{({ width }) => (
<div data-test-subj="waterfall" ref={registerChild}>
<List
ref={listRef}
style={{ height: '100%' }}
itemCount={traceList.length}
itemSize={getRowSize}
height={window.innerHeight}
width={width}
itemData={{ ...props, traceList, onLoad: onRowLoad }}
>
{VirtualRow}
</List>
</div>
)}
</AutoSizer>
)}
</WindowScroller>
);
}
const VirtualRow = React.memo(
({
index,
style,
data,
}: ListChildComponentProps<
Omit<WaterfallNodeProps, 'node'> & {
traceList: IWaterfallNodeFlatten[];
onLoad: (index: number, size: number) => void;
}
>) => {
const { onLoad, traceList, ...props } = data;
const ref = React.useRef<HTMLDivElement | null>(null);
useEffect(() => {
onLoad(index, ref.current?.getBoundingClientRect().height ?? ACCORDION_HEIGHT);
}, [index, onLoad]);
return (
<div style={style} ref={ref}>
<WaterfallNode {...props} node={traceList[index]} />
</div>
);
},
areEqual
);
const WaterfallNode = React.memo((props: WaterfallNodeProps) => {
const theme = useTheme();
const { duration, waterfallItemId, onClickWaterfallItem, timelineMargins, node } = props;
const { criticalPathSegmentsById, getErrorCount, updateTreeNode, showCriticalPath } =
useWaterfallContext();
const [isOpen, setIsOpen] = useState(props.isOpen);
const displayedColor = showCriticalPath ? transparentize(0.5, node.item.color) : node.item.color;
const marginLeftLevel = 8 * node.level;
const hasToggle = !!node.childrenToLoad;
const errorCount = getErrorCount(node.item.id);
let children = waterfall.childrenByParentId[item.id] || [];
const segments = criticalPathSegmentsById[node.item.id]
?.filter((segment) => segment.self)
.map((segment) => ({
id: segment.item.id,
color: theme.eui.euiColorAccent,
left: (segment.offset - node.item.offset - node.item.skew) / node.item.duration,
width: segment.duration / node.item.duration,
}));
const criticalPath = showCriticalPath ? getCriticalPath(waterfall) : undefined;
const toggleAccordion = () => {
updateTreeNode({ ...node, expanded: !node.expanded });
};
const criticalPathSegmentsById = groupBy(criticalPath?.segments, (segment) => segment.item.id);
let displayedColor = item.color;
if (showCriticalPath) {
children = children.filter((child) => criticalPathSegmentsById[child.id]?.length);
displayedColor = transparentize(0.5, item.color);
}
const errorCount = waterfall.getErrorCount(item.id);
// To indent the items creating the parent/child tree
const marginLeftLevel = 8 * level;
function toggleAccordion() {
setIsOpen((isCurrentOpen) => !isCurrentOpen);
}
const hasToggle = !!children.length;
const onWaterfallItemClick = (flyoutDetailTab: string) => {
onClickWaterfallItem(node.item, flyoutDetailTab);
};
return (
<StyledAccordion
data-test-subj="waterfallItem"
className="waterfall_accordion"
style={{ position: 'relative' }}
buttonClassName={`button_${item.id}`}
key={item.id}
id={item.id}
hasError={item.doc.event?.outcome === 'failure'}
buttonClassName={`button_${node.item.id}`}
id={node.item.id}
hasError={node.item.doc.event?.outcome === 'failure'}
marginLeftLevel={marginLeftLevel}
childrenCount={children.length}
buttonContentClassName="accordion__buttonContent"
buttonContent={
<EuiFlexGroup gutterSize="none">
<EuiFlexGroup gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>
<ToggleAccordionButton
show={hasToggle}
isOpen={isOpen}
childrenCount={children.length}
isOpen={node.expanded}
childrenCount={node.childrenToLoad}
onClick={toggleAccordion}
/>
</EuiFlexItem>
<EuiFlexItem>
<WaterfallItem
key={item.id}
key={node.item.id}
timelineMargins={timelineMargins}
color={displayedColor}
item={item}
item={node.item}
hasToggle={hasToggle}
totalDuration={duration}
isSelected={item.id === waterfallItemId}
isSelected={node.item.id === waterfallItemId}
errorCount={errorCount}
marginLeftLevel={marginLeftLevel}
onClick={(flyoutDetailTab: string) => {
onClickWaterfallItem(item, flyoutDetailTab);
}}
segments={criticalPathSegmentsById[item.id]
?.filter((segment) => segment.self)
.map((segment) => ({
color: theme.eui.euiColorAccent,
left: (segment.offset - item.offset - item.skew) / item.duration,
width: segment.duration / item.duration,
}))}
onClick={onWaterfallItemClick}
segments={segments}
/>
</EuiFlexItem>
</EuiFlexGroup>
}
arrowDisplay="none"
initialIsOpen={true}
forceState={isOpen ? 'open' : 'closed'}
initialIsOpen
forceState={node.expanded ? 'open' : 'closed'}
onToggle={toggleAccordion}
>
{isOpen &&
children.map((child) => (
<AccordionWaterfall
{...props}
key={child.id}
isOpen={maxLevelOpen > level}
level={level + 1}
item={child}
/>
))}
</StyledAccordion>
/>
);
}
});
function ToggleAccordionButton({
show,
@ -206,7 +268,7 @@ function ToggleAccordionButton({
display: 'flex',
}}
>
<EuiFlexGroup gutterSize="xs" alignItems="center" justifyContent="center">
<EuiFlexGroup gutterSize="xs" alignItems="center" justifyContent="center" responsive={false}>
<EuiFlexItem grow={false}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
<div

View file

@ -0,0 +1,13 @@
/*
* 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 { useContext } from 'react';
import { WaterfallContext } from './waterfall_context';
export function useWaterfallContext() {
return useContext(WaterfallContext);
}

View file

@ -0,0 +1,118 @@
/*
* 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 { Dictionary, groupBy } from 'lodash';
import React, { PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react';
import { CriticalPathSegment } from '../../../../../../../../common/critical_path/types';
import { getCriticalPath } from '../../../../../../../../common/critical_path/get_critical_path';
import {
buildTraceTree,
convertTreeToList,
IWaterfall,
IWaterfallNode,
IWaterfallNodeFlatten,
updateTraceTreeNode,
} from '../waterfall_helpers/waterfall_helpers';
export interface WaterfallContextProps {
waterfall: IWaterfall;
showCriticalPath: boolean;
maxLevelOpen: number;
isOpen: boolean;
}
export const WaterfallContext = React.createContext<
{
criticalPathSegmentsById: Dictionary<CriticalPathSegment[]>;
showCriticalPath: boolean;
traceList: IWaterfallNodeFlatten[];
getErrorCount: (waterfallItemId: string) => number;
updateTreeNode: (newTree: IWaterfallNodeFlatten) => void;
} & Pick<WaterfallContextProps, 'showCriticalPath'>
>({
criticalPathSegmentsById: {} as Dictionary<CriticalPathSegment[]>,
showCriticalPath: false,
traceList: [],
getErrorCount: () => 0,
updateTreeNode: () => undefined,
});
export function WaterfallContextProvider({
showCriticalPath,
waterfall,
maxLevelOpen,
children,
isOpen,
}: PropsWithChildren<WaterfallContextProps>) {
const [tree, setTree] = useState<IWaterfallNode | null>(null);
const criticalPathSegmentsById = useMemo(() => {
if (!showCriticalPath) {
return {};
}
const criticalPath = getCriticalPath(waterfall);
return groupBy(criticalPath.segments, (segment) => segment.item.id);
}, [showCriticalPath, waterfall]);
const traceList = useMemo(() => {
return convertTreeToList(tree);
}, [tree]);
const getErrorCount = useCallback(
(waterfallItemId) => waterfall.getErrorCount(waterfallItemId),
[waterfall]
);
const updateTreeNode = useCallback(
(updatedNode: IWaterfallNodeFlatten) => {
if (!tree) return;
const newTree = updateTraceTreeNode({
root: tree,
updatedNode,
waterfall,
path: {
criticalPathSegmentsById,
showCriticalPath,
},
});
if (newTree) {
setTree(newTree);
}
},
[criticalPathSegmentsById, showCriticalPath, tree, waterfall]
);
useEffect(() => {
const root = buildTraceTree({
waterfall,
maxLevelOpen,
isOpen,
path: {
criticalPathSegmentsById,
showCriticalPath,
},
});
setTree(root);
}, [criticalPathSegmentsById, isOpen, maxLevelOpen, showCriticalPath, waterfall]);
return (
<WaterfallContext.Provider
value={{
showCriticalPath,
criticalPathSegmentsById,
getErrorCount,
traceList,
updateTreeNode,
}}
>
{children}
</WaterfallContext.Provider>
);
}

View file

@ -11,7 +11,12 @@ import { History } from 'history';
import React, { useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { Timeline } from '../../../../../shared/charts/timeline';
import { css } from '@emotion/react';
import { useTheme } from '../../../../../../hooks/use_theme';
import {
VerticalLinesContainer,
TimelineAxisContainer,
} from '../../../../../shared/charts/timeline';
import { fromQuery, toQuery } from '../../../../../shared/links/url_helpers';
import { getAgentMarks } from '../marks/get_agent_marks';
import { getErrorMarks } from '../marks/get_error_marks';
@ -22,7 +27,6 @@ import { IWaterfall, IWaterfallItem } from './waterfall_helpers/waterfall_helper
const Container = euiStyled.div`
transition: 0.1s padding ease;
position: relative;
overflow: hidden;
`;
const toggleFlyout = ({
@ -59,35 +63,35 @@ function getWaterfallMaxLevel(waterfall: IWaterfall) {
if (!entryId) {
return 0;
}
let maxLevel = 1;
function countLevels(id: string, currentLevel: number) {
const visited = new Set<string>();
const queue: Array<{ id: string; level: number }> = [{ id: entryId, level: 1 }];
while (queue.length > 0) {
const { id, level } = queue.shift()!;
const children = waterfall.childrenByParentId[id] || [];
if (children.length) {
children.forEach((child) => {
// Skip processing when a child node has the same ID as its parent
// to prevent infinite loop
if (child.id !== id) {
countLevels(child.id, currentLevel + 1);
}
});
} else {
if (maxLevel < currentLevel) {
maxLevel = currentLevel;
maxLevel = Math.max(maxLevel, level);
visited.add(id);
children.forEach((child) => {
if (child.id !== id && !visited.has(child.id)) {
queue.push({ id: child.id, level: level + 1 });
visited.add(child.id);
}
}
});
}
countLevels(entryId, 1);
return maxLevel;
}
// level starts with 0
const maxLevelOpen = 2;
const MAX_DEPTH_OPEN_LIMIT = 2;
export function Waterfall({ waterfall, waterfallItemId, showCriticalPath }: Props) {
const history = useHistory();
const theme = useTheme();
const [isAccordionOpen, setIsAccordionOpen] = useState(true);
const itemContainerHeight = 58; // TODO: This is a nasty way to calculate the height of the svg element. A better approach should be found
const waterfallHeight = itemContainerHeight * waterfall.items.length;
const { duration } = waterfall;
@ -124,47 +128,59 @@ export function Waterfall({ waterfall, waterfallItemId, showCriticalPath }: Prop
})}
/>
)}
<div>
<div style={{ display: 'flex' }}>
<EuiButtonEmpty
data-test-subj="apmWaterfallButton"
style={{ zIndex: 3, position: 'absolute' }}
iconType={isAccordionOpen ? 'fold' : 'unfold'}
onClick={() => {
setIsAccordionOpen((isOpen) => !isOpen);
}}
/>
<Timeline
marks={[...agentMarks, ...errorMarks]}
xMax={duration}
height={waterfallHeight}
margins={timelineMargins}
/>
</div>
<WaterfallItemsContainer>
{!waterfall.entryWaterfallTransaction ? null : (
<AccordionWaterfall
// used to recreate the entire tree when `isAccordionOpen` changes, collapsing or expanding all elements.
key={`accordion_state_${isAccordionOpen}`}
isOpen={isAccordionOpen}
item={waterfall.entryWaterfallTransaction}
level={0}
waterfallItemId={waterfallItemId}
duration={duration}
waterfall={waterfall}
timelineMargins={timelineMargins}
onClickWaterfallItem={(item: IWaterfallItem, flyoutDetailTab: string) =>
toggleFlyout({ history, item, flyoutDetailTab })
}
showCriticalPath={showCriticalPath}
maxLevelOpen={
waterfall.traceDocsTotal > 500 ? maxLevelOpen : waterfall.traceDocsTotal
}
/>
)}
</WaterfallItemsContainer>
<div
css={css`
display: flex;
position: sticky;
top: var(--euiFixedHeadersOffset, 0);
z-index: ${theme.eui.euiZLevel2};
background-color: ${theme.eui.euiColorEmptyShade};
border-bottom: 1px solid ${theme.eui.euiColorMediumShade};
`}
>
<EuiButtonEmpty
data-test-subj="apmWaterfallButton"
css={css`
position: absolute;
z-index: ${theme.eui.euiZLevel2};
`}
iconType={isAccordionOpen ? 'fold' : 'unfold'}
onClick={() => {
setIsAccordionOpen((isOpen) => !isOpen);
}}
/>
<TimelineAxisContainer
marks={[...agentMarks, ...errorMarks]}
xMax={duration}
margins={timelineMargins}
/>
</div>
<VerticalLinesContainer
marks={[...agentMarks, ...errorMarks]}
xMax={duration}
margins={timelineMargins}
/>
<WaterfallItemsContainer>
{!waterfall.entryWaterfallTransaction ? null : (
<AccordionWaterfall
isOpen={isAccordionOpen}
waterfallItemId={waterfallItemId}
duration={duration}
waterfall={waterfall}
timelineMargins={timelineMargins}
onClickWaterfallItem={(item: IWaterfallItem, flyoutDetailTab: string) =>
toggleFlyout({ history, item, flyoutDetailTab })
}
showCriticalPath={showCriticalPath}
maxLevelOpen={
waterfall.traceDocsTotal > 500 ? MAX_DEPTH_OPEN_LIMIT : waterfall.traceDocsTotal
}
/>
)}
</WaterfallItemsContainer>
<WaterfallFlyout
waterfallItemId={waterfallItemId}
waterfall={waterfall}

View file

@ -17,6 +17,11 @@ import {
IWaterfallError,
IWaterfallSpanOrTransaction,
getOrphanTraceItemsCount,
buildTraceTree,
convertTreeToList,
updateTraceTreeNode,
IWaterfallNode,
IWaterfallNodeFlatten,
} from './waterfall_helpers';
import { APMError } from '../../../../../../../../typings/es_schemas/ui/apm_error';
import {
@ -25,113 +30,113 @@ import {
} from '../../../../../../../../common/waterfall/typings';
describe('waterfall_helpers', () => {
describe('getWaterfall', () => {
const hits = [
{
processor: { event: 'transaction' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-node' },
transaction: {
duration: { us: 49660 },
name: 'GET /api',
id: 'myTransactionId1',
},
timestamp: { us: 1549324795784006 },
} as Transaction,
{
parent: { id: 'mySpanIdA' },
processor: { event: 'span' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-ruby' },
transaction: { id: 'myTransactionId2' },
timestamp: { us: 1549324795825633 },
span: {
duration: { us: 481 },
name: 'SELECT FROM products',
id: 'mySpanIdB',
},
} as Span,
{
parent: { id: 'myTransactionId2' },
processor: { event: 'span' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-ruby' },
transaction: { id: 'myTransactionId2' },
span: {
duration: { us: 6161 },
name: 'Api::ProductsController#index',
id: 'mySpanIdA',
},
timestamp: { us: 1549324795824504 },
} as Span,
{
parent: { id: 'mySpanIdA' },
processor: { event: 'span' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-ruby' },
transaction: { id: 'myTransactionId2' },
span: {
duration: { us: 532 },
name: 'SELECT FROM product',
id: 'mySpanIdC',
},
timestamp: { us: 1549324795827905 },
} as Span,
{
parent: { id: 'myTransactionId1' },
processor: { event: 'span' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-node' },
transaction: { id: 'myTransactionId1' },
span: {
duration: { us: 47557 },
name: 'GET opbeans-ruby:3000/api/products',
id: 'mySpanIdD',
},
timestamp: { us: 1549324795785760 },
} as Span,
{
parent: { id: 'mySpanIdD' },
processor: { event: 'transaction' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-ruby' },
transaction: {
duration: { us: 8634 },
name: 'Api::ProductsController#index',
id: 'myTransactionId2',
marks: {
agent: {
domInteractive: 382,
domComplete: 383,
timeToFirstByte: 14,
},
const hits = [
{
processor: { event: 'transaction' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-node' },
transaction: {
duration: { us: 49660 },
name: 'GET /api',
id: 'myTransactionId1',
},
timestamp: { us: 1549324795784006 },
} as Transaction,
{
parent: { id: 'mySpanIdA' },
processor: { event: 'span' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-ruby' },
transaction: { id: 'myTransactionId2' },
timestamp: { us: 1549324795825633 },
span: {
duration: { us: 481 },
name: 'SELECT FROM products',
id: 'mySpanIdB',
},
} as Span,
{
parent: { id: 'myTransactionId2' },
processor: { event: 'span' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-ruby' },
transaction: { id: 'myTransactionId2' },
span: {
duration: { us: 6161 },
name: 'Api::ProductsController#index',
id: 'mySpanIdA',
},
timestamp: { us: 1549324795824504 },
} as Span,
{
parent: { id: 'mySpanIdA' },
processor: { event: 'span' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-ruby' },
transaction: { id: 'myTransactionId2' },
span: {
duration: { us: 532 },
name: 'SELECT FROM product',
id: 'mySpanIdC',
},
timestamp: { us: 1549324795827905 },
} as Span,
{
parent: { id: 'myTransactionId1' },
processor: { event: 'span' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-node' },
transaction: { id: 'myTransactionId1' },
span: {
duration: { us: 47557 },
name: 'GET opbeans-ruby:3000/api/products',
id: 'mySpanIdD',
},
timestamp: { us: 1549324795785760 },
} as Span,
{
parent: { id: 'mySpanIdD' },
processor: { event: 'transaction' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-ruby' },
transaction: {
duration: { us: 8634 },
name: 'Api::ProductsController#index',
id: 'myTransactionId2',
marks: {
agent: {
domInteractive: 382,
domComplete: 383,
timeToFirstByte: 14,
},
},
timestamp: { us: 1549324795823304 },
} as unknown as Transaction,
];
const errorDocs = [
{
processor: { event: 'error' },
parent: { id: 'myTransactionId1' },
timestamp: { us: 1549324795810000 },
trace: { id: 'myTraceId' },
transaction: { id: 'myTransactionId1' },
error: {
id: 'error1',
grouping_key: 'errorGroupingKey1',
log: {
message: 'error message',
},
},
timestamp: { us: 1549324795823304 },
} as unknown as Transaction,
];
const errorDocs = [
{
processor: { event: 'error' },
parent: { id: 'myTransactionId1' },
timestamp: { us: 1549324795810000 },
trace: { id: 'myTraceId' },
transaction: { id: 'myTransactionId1' },
error: {
id: 'error1',
grouping_key: 'errorGroupingKey1',
log: {
message: 'error message',
},
service: { name: 'opbeans-ruby' },
agent: {
name: 'ruby',
version: '2',
},
} as unknown as APMError,
];
},
service: { name: 'opbeans-ruby' },
agent: {
name: 'ruby',
version: '2',
},
} as unknown as APMError,
];
describe('getWaterfall', () => {
it('should return full waterfall', () => {
const apiResp = {
traceItems: {
@ -750,4 +755,229 @@ describe('waterfall_helpers', () => {
expect(getOrphanTraceItemsCount(traceItems)).toBe(1);
});
});
describe('#trace tree', () => {
const waterfall = getWaterfall({
traceItems: {
traceDocs: hits,
errorDocs,
exceedsMax: false,
spanLinksCountById: {},
traceDocsTotal: hits.length,
maxTraceItems: 5000,
},
entryTransaction: {
processor: { event: 'transaction' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-node' },
transaction: {
duration: { us: 49660 },
name: 'GET /api',
id: 'myTransactionId1',
},
timestamp: { us: 1549324795784006 },
} as Transaction,
});
const tree: IWaterfallNode = {
id: 'myTransactionId1',
item: {
docType: 'transaction',
doc: {
agent: { name: 'nodejs' },
processor: { event: 'transaction' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-node' },
transaction: {
duration: { us: 49660 },
name: 'GET /api',
id: 'myTransactionId1',
type: 'request',
},
timestamp: { us: 1549324795784006 },
},
id: 'myTransactionId1',
duration: 49660,
offset: 0,
skew: 0,
legendValues: { serviceName: 'opbeans-node', spanType: '' },
color: '',
spanLinksCount: { linkedParents: 0, linkedChildren: 0 },
},
children: [
{
id: '0-mySpanIdD-0',
item: {
docType: 'span',
doc: {
agent: { name: 'nodejs' },
parent: { id: 'myTransactionId1' },
processor: { event: 'span' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-node' },
transaction: { id: 'myTransactionId1' },
span: {
duration: { us: 47557 },
name: 'GET opbeans-ruby:3000/api/products',
id: 'mySpanIdD',
type: 'request',
},
timestamp: { us: 1549324795785760 },
},
id: 'mySpanIdD',
parentId: 'myTransactionId1',
duration: 47557,
offset: 1754,
skew: 0,
legendValues: { serviceName: 'opbeans-node', spanType: '' },
color: '',
spanLinksCount: { linkedParents: 0, linkedChildren: 0 },
},
children: [],
childrenToLoad: 1,
level: 1,
expanded: false,
hasInitializedChildren: false,
},
],
level: 0,
childrenToLoad: 1,
expanded: true,
hasInitializedChildren: true,
};
describe('buildTraceTree', () => {
it('should build the trace tree correctly', () => {
const result = buildTraceTree({
waterfall,
path: {
criticalPathSegmentsById: {},
showCriticalPath: false,
},
maxLevelOpen: 1,
isOpen: true,
});
expect(result).toEqual(
expect.objectContaining({
item: expect.objectContaining({ id: 'myTransactionId1' }),
level: 0,
expanded: true,
hasInitializedChildren: true,
})
);
expect(result?.children[0]).toEqual(
expect.objectContaining({
item: expect.objectContaining({ id: 'mySpanIdD' }),
level: 1,
expanded: false,
childrenToLoad: 1,
children: [],
hasInitializedChildren: false,
})
);
});
});
describe('convertTreeToList', () => {
it('should convert the trace tree to a list correctly', () => {
const result = convertTreeToList(tree);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
item: expect.objectContaining({ id: 'myTransactionId1' }),
level: 0,
expanded: true,
hasInitializedChildren: true,
childrenToLoad: 1,
}),
expect.objectContaining({
item: expect.objectContaining({ id: 'mySpanIdD' }),
level: 1,
expanded: false,
hasInitializedChildren: false,
childrenToLoad: 1,
}),
])
);
});
});
describe('updateTraceTreeNode', () => {
it('should update the "mySpanIdD" node setting "expanded" to true', () => {
const updatedNode: IWaterfallNodeFlatten = {
id: '0-mySpanIdD-0',
item: {
docType: 'span',
doc: {
agent: { name: 'nodejs' },
parent: { id: 'myTransactionId1' },
processor: { event: 'span' },
trace: { id: 'myTraceId' },
service: { name: 'opbeans-node' },
transaction: { id: 'myTransactionId1' },
span: {
duration: { us: 47557 },
name: 'GET opbeans-ruby:3000/api/products',
id: 'mySpanIdD',
type: 'request',
},
timestamp: { us: 1549324795785760 },
},
id: 'mySpanIdD',
parentId: 'myTransactionId1',
duration: 47557,
offset: 1754,
skew: 0,
legendValues: { serviceName: 'opbeans-node', spanType: '' },
color: '',
spanLinksCount: { linkedParents: 0, linkedChildren: 0 },
},
childrenToLoad: 1,
level: 1,
expanded: true,
hasInitializedChildren: false,
};
const result = updateTraceTreeNode({
root: tree,
updatedNode,
waterfall,
path: {
criticalPathSegmentsById: {},
showCriticalPath: false,
},
});
expect(result).toEqual(
expect.objectContaining({
item: expect.objectContaining({ id: 'myTransactionId1' }),
level: 0,
expanded: true,
hasInitializedChildren: true,
})
);
expect(result?.children[0]).toEqual(
expect.objectContaining({
item: expect.objectContaining({ id: 'mySpanIdD' }),
level: 1,
expanded: true,
hasInitializedChildren: true,
})
);
expect(result?.children[0].children[0]).toEqual(
expect.objectContaining({
item: expect.objectContaining({ id: 'myTransactionId2' }),
level: 2,
expanded: false,
hasInitializedChildren: false,
})
);
});
});
});
});

View file

@ -6,8 +6,9 @@
*/
import { euiPaletteColorBlind } from '@elastic/eui';
import { first, flatten, groupBy, isEmpty, sortBy, uniq } from 'lodash';
import { Dictionary, first, flatten, groupBy, isEmpty, sortBy, uniq } from 'lodash';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import { CriticalPathSegment } from '../../../../../../../../common/critical_path/types';
import type { APIReturnType } from '../../../../../../../services/rest/create_call_apm_api';
import type { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction';
import {
@ -93,6 +94,23 @@ export interface IWaterfallLegend {
color: string;
}
export interface IWaterfallNode {
id: string;
item: IWaterfallItem;
// children that are loaded
children: IWaterfallNode[];
// total number of children that needs to be loaded
childrenToLoad: number;
// collapsed or expanded state
expanded: boolean;
// level in the tree
level: number;
// flag to indicate if children are loaded
hasInitializedChildren: boolean;
}
export type IWaterfallNodeFlatten = Omit<IWaterfallNode, 'children'>;
function getLegendValues(transactionOrSpan: WaterfallTransaction | WaterfallSpan) {
return {
[WaterfallLegendType.ServiceName]: transactionOrSpan.service.name,
@ -462,3 +480,186 @@ export function getWaterfall(apiResponse: TraceAPIResponse): IWaterfall {
orphanTraceItemsCount,
};
}
function getChildren({
path,
waterfall,
waterfallItemId,
}: {
waterfallItemId: string;
waterfall: IWaterfall;
path: {
criticalPathSegmentsById: Dictionary<CriticalPathSegment[]>;
showCriticalPath: boolean;
};
}) {
const children = waterfall.childrenByParentId[waterfallItemId] ?? [];
return path.showCriticalPath
? children.filter((child) => path.criticalPathSegmentsById[child.id]?.length)
: children;
}
function buildTree({
root,
waterfall,
maxLevelOpen,
path,
}: {
root: IWaterfallNode;
waterfall: IWaterfall;
maxLevelOpen: number;
path: {
criticalPathSegmentsById: Dictionary<CriticalPathSegment[]>;
showCriticalPath: boolean;
};
}) {
const tree = { ...root };
const queue: IWaterfallNode[] = [tree];
for (let queueIndex = 0; queueIndex < queue.length; queueIndex++) {
const node = queue[queueIndex];
const children = getChildren({ path, waterfall, waterfallItemId: node.item.id });
// Set childrenToLoad for all nodes enqueued.
// this allows lazy loading of child nodes
node.childrenToLoad = children.length;
if (maxLevelOpen > node.level) {
children.forEach((child, index) => {
const level = node.level + 1;
const currentNode: IWaterfallNode = {
id: `${level}-${child.id}-${index}`,
item: child,
children: [],
level,
expanded: level < maxLevelOpen,
childrenToLoad: 0,
hasInitializedChildren: false,
};
node.children.push(currentNode);
queue.push(currentNode);
});
node.hasInitializedChildren = true;
}
}
return tree;
}
export function buildTraceTree({
waterfall,
maxLevelOpen,
isOpen,
path,
}: {
waterfall: IWaterfall;
maxLevelOpen: number;
isOpen: boolean;
path: {
criticalPathSegmentsById: Dictionary<CriticalPathSegment[]>;
showCriticalPath: boolean;
};
}): IWaterfallNode | null {
const entry = waterfall.entryWaterfallTransaction;
if (!entry) {
return null;
}
const root: IWaterfallNode = {
id: entry.id,
item: entry,
children: [],
level: 0,
expanded: isOpen,
childrenToLoad: 0,
hasInitializedChildren: false,
};
return buildTree({ root, maxLevelOpen, waterfall, path });
}
export const convertTreeToList = (root: IWaterfallNode | null): IWaterfallNodeFlatten[] => {
if (!root) {
return [];
}
const result: IWaterfallNodeFlatten[] = [];
const stack: IWaterfallNode[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
const { children, ...nodeWithoutChildren } = node;
result.push(nodeWithoutChildren);
if (node.expanded) {
for (let i = node.children.length - 1; i >= 0; i--) {
stack.push(node.children[i]);
}
}
}
return result;
};
export const updateTraceTreeNode = ({
root,
updatedNode,
waterfall,
path,
}: {
root: IWaterfallNode;
updatedNode: IWaterfallNodeFlatten;
waterfall: IWaterfall;
path: {
criticalPathSegmentsById: Dictionary<CriticalPathSegment[]>;
showCriticalPath: boolean;
};
}) => {
if (!root) {
return;
}
const tree = { ...root };
const stack: Array<{ parent: IWaterfallNode | null; index: number; node: IWaterfallNode }> = [
{ parent: null, index: 0, node: root },
];
while (stack.length > 0) {
const { parent, index, node } = stack.pop()!;
if (node.id === updatedNode.id) {
Object.assign(node, updatedNode);
if (updatedNode.expanded && !updatedNode.hasInitializedChildren) {
Object.assign(
node,
buildTree({
root: node,
waterfall,
maxLevelOpen: node.level + 1, // Only one level above the current node will be loaded
path,
})
);
}
if (parent) {
parent.children[index] = node;
} else {
Object.assign(tree, node);
}
return tree;
}
for (let i = node.children.length - 1; i >= 0; i--) {
stack.push({ parent: node, index: i, node: node.children[i] });
}
}
return tree;
};

View file

@ -7,7 +7,7 @@
import { EuiBadge, EuiIcon, EuiText, EuiTitle, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { ReactNode, useRef, useState, useEffect } from 'react';
import React, { ReactNode, useRef, useEffect, useState } from 'react';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { useTheme } from '../../../../../../hooks/use_theme';
import { isMobileAgentName, isRumAgentName } from '../../../../../../../common/agent_name';
@ -115,6 +115,7 @@ interface IWaterfallItemProps {
errorCount: number;
marginLeftLevel: number;
segments?: Array<{
id: string;
left: number;
width: number;
color: string;
@ -267,6 +268,7 @@ export function WaterfallItem({
<CriticalPathItemBar>
{segments?.map((segment) => (
<CriticalPathItemSegment
key={segment.id}
color={segment.color}
left={segment.left}
width={segment.width}

View file

@ -1,6 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Timeline should render with data 1`] = `
exports[`Timeline TimelineAxisContainer should render with data 1`] = `
.c0 {
position: absolute;
bottom: 0;
}
<div
style={
Object {
@ -8,5 +13,350 @@ exports[`Timeline should render with data 1`] = `
"width": "100%",
}
}
/>
>
<div
style={
Object {
"height": 100,
"width": "100%",
}
}
>
<svg
height={100}
style={
Object {
"left": 0,
"position": "absolute",
"top": 0,
}
}
width={0}
>
<g
transform="translate(0 80)"
>
<text
fill="#69707d"
fontSize={11}
textAnchor="middle"
x={50}
y={0}
>
0 μs
</text>
<text
fill="#69707d"
fontSize={11}
textAnchor="middle"
x={30}
y={0}
>
200 μs
</text>
<text
fill="#69707d"
fontSize={11}
textAnchor="middle"
x={10}
y={0}
>
400 μs
</text>
<text
fill="#69707d"
fontSize={11}
textAnchor="middle"
x={-10}
y={0}
>
600 μs
</text>
<text
fill="#69707d"
fontSize={11}
textAnchor="middle"
x={-30}
y={0}
>
800 μs
</text>
<text
fill="#343741"
textAnchor="middle"
x={-50}
y={0}
>
1,000 μs
</text>
</g>
</svg>
<div
className="c0"
style={
Object {
"left": -9955.5,
}
}
>
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
font-size: 14px;
color: #69707d;
cursor: pointer;
opacity: 1;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.c1 {
width: 11px;
height: 11px;
margin-right: 0;
background: #98a2b3;
border-radius: 100%;
}
<span
className="euiToolTipAnchor emotion-euiToolTipAnchor-inlineBlock"
onKeyDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<div
className="c0"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
>
<span
className="c1"
color="#98a2b3"
shape="circle"
/>
</div>
</span>
</div>
<div
className="c0"
style={
Object {
"left": -10955.5,
}
}
>
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
font-size: 14px;
color: #69707d;
cursor: pointer;
opacity: 1;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.c1 {
width: 11px;
height: 11px;
margin-right: 0;
background: #98a2b3;
border-radius: 100%;
}
<span
className="euiToolTipAnchor emotion-euiToolTipAnchor-inlineBlock"
onKeyDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<div
className="c0"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
>
<span
className="c1"
color="#98a2b3"
shape="circle"
/>
</div>
</span>
</div>
<div
className="c0"
style={
Object {
"left": -18955.5,
}
}
>
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
font-size: 14px;
color: #69707d;
cursor: pointer;
opacity: 1;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.c1 {
width: 11px;
height: 11px;
margin-right: 0;
background: #98a2b3;
border-radius: 100%;
}
<span
className="euiToolTipAnchor emotion-euiToolTipAnchor-inlineBlock"
onKeyDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
>
<div
className="c0"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
>
<span
className="c1"
color="#98a2b3"
shape="circle"
/>
</div>
</span>
</div>
</div>
</div>
`;
exports[`Timeline VerticalLinesContainer should render with data 1`] = `
<div
style={
Object {
"height": "100%",
"width": "100%",
}
}
>
<svg
height="100%"
style={
Object {
"left": 0,
"position": "absolute",
"top": 0,
}
}
width={0}
>
<g
transform="translate(0 100)"
>
<line
stroke="#f5f7fa"
x1={50}
x2={50}
y1={0}
y2="100%"
/>
<line
stroke="#f5f7fa"
x1={30}
x2={30}
y1={0}
y2="100%"
/>
<line
stroke="#f5f7fa"
x1={10}
x2={10}
y1={0}
y2="100%"
/>
<line
stroke="#f5f7fa"
x1={-10}
x2={-10}
y1={0}
y2="100%"
/>
<line
stroke="#f5f7fa"
x1={-30}
x2={-30}
y1={0}
y2="100%"
/>
<line
stroke="#f5f7fa"
x1={-50}
x2={-50}
y1={0}
y2="100%"
/>
<line
stroke="#98a2b3"
x1={-9950}
x2={-9950}
y1={0}
y2="100%"
/>
<line
stroke="#98a2b3"
x1={-10950}
x2={-10950}
y1={0}
y2="100%"
/>
<line
stroke="#98a2b3"
x1={-18950}
x2={-18950}
y1={0}
y2="100%"
/>
<line
stroke="#98a2b3"
x1={-50}
x2={-50}
y1={0}
y2="100%"
/>
</g>
</svg>
</div>
`;

View file

@ -22,39 +22,59 @@ export interface Margins {
left: number;
}
interface TimelineProps {
export interface TimelineProps {
marks?: Mark[];
xMin?: number;
xMax?: number;
height: number;
margins: Margins;
width?: number;
}
function TimeLineContainer({ width, xMin, xMax, height, marks, margins }: TimelineProps) {
if (xMax == null || !width) {
export function TimelineAxisContainer({ xMax, xMin, margins, marks }: TimelineProps) {
const [width, setWidth] = useState(0);
if (xMax === undefined) {
return null;
}
const plotValues = getPlotValues({ width, xMin, xMax, height, margins });
const topTraceDuration = xMax - (xMin ?? 0);
return (
<>
<TimelineAxis plotValues={plotValues} marks={marks} topTraceDuration={topTraceDuration} />
<VerticalLines plotValues={plotValues} marks={marks} topTraceDuration={topTraceDuration} />
</>
);
}
export function Timeline(props: TimelineProps) {
const [width, setWidth] = useState(0);
return (
<EuiResizeObserver onResize={(size) => setWidth(size.width)}>
{(resizeRef) => (
<div style={{ width: '100%', height: '100%' }} ref={resizeRef}>
<TimeLineContainer {...props} width={width} />
</div>
)}
{(resizeRef) => {
const plotValues = getPlotValues({ width, xMin, xMax, margins });
const topTraceDuration = xMax - (xMin ?? 0);
return (
<div style={{ width: '100%', height: '100%' }} ref={resizeRef}>
<TimelineAxis
plotValues={plotValues}
marks={marks}
topTraceDuration={topTraceDuration}
/>
</div>
);
}}
</EuiResizeObserver>
);
}
export function VerticalLinesContainer({ xMax, xMin, margins, marks }: TimelineProps) {
const [width, setWidth] = useState(0);
if (xMax == null) {
return null;
}
return (
<EuiResizeObserver onResize={(size) => setWidth(size.width)}>
{(resizeRef) => {
const plotValues = getPlotValues({ width, xMin, xMax, margins });
const topTraceDuration = xMax - (xMin ?? 0);
return (
<div style={{ width: '100%', height: '100%' }} ref={resizeRef}>
<VerticalLines
plotValues={plotValues}
marks={marks}
topTraceDuration={topTraceDuration}
/>
</div>
);
}}
</EuiResizeObserver>
);
}

View file

@ -14,13 +14,11 @@ export function getPlotValues({
width,
xMin = 0,
xMax,
height,
margins,
}: {
width: number;
xMin?: number;
xMax: number;
height: number;
margins: Margins;
}) {
const xScale = scaleLinear()
@ -28,7 +26,6 @@ export function getPlotValues({
.range([margins.left, width - margins.right]);
return {
height,
margins,
tickValues: xScale.ticks(7),
width,

View file

@ -12,10 +12,10 @@ import {
mockMoment,
toJson,
} from '../../../../utils/test_helpers';
import { Timeline } from '.';
import { TimelineAxisContainer, TimelineProps, VerticalLinesContainer } from '.';
import { AgentMark } from '../../../app/transaction_details/waterfall_with_summary/waterfall_container/marks/get_agent_marks';
describe('Timeline', () => {
describe.each([[TimelineAxisContainer], [VerticalLinesContainer]])(`Timeline`, (Component) => {
let consoleMock: jest.SpyInstance;
beforeAll(() => {
@ -27,19 +27,15 @@ describe('Timeline', () => {
consoleMock.mockRestore();
});
it('should render with data', () => {
const props = {
traceRootDuration: 200000,
width: 1000,
duration: 200000,
height: 116,
it(`${Component.name} should render with data`, () => {
const props: TimelineProps = {
xMax: 1000,
margins: {
top: 100,
left: 50,
right: 50,
bottom: 0,
},
animation: null,
marks: [
{
id: 'timeToFirstByte',
@ -62,17 +58,14 @@ describe('Timeline', () => {
] as AgentMark[],
};
const wrapper = mountWithTheme(<Timeline {...props} />);
const wrapper = mountWithTheme(<Component {...props} />);
expect(toJson(wrapper)).toMatchSnapshot();
});
it('should not crash if traceRootDuration is 0', () => {
const props = {
traceRootDuration: 0,
width: 1000,
it(`${Component.name} should not crash if traceRootDuration is 0`, () => {
const props: TimelineProps = {
xMax: 0,
height: 116,
margins: {
top: 100,
left: 50,
@ -81,7 +74,7 @@ describe('Timeline', () => {
},
};
const mountTimeline = () => mountWithTheme(<Timeline {...props} />);
const mountTimeline = () => mountWithTheme(<Component {...props} />);
expect(mountTimeline).not.toThrow();
});

View file

@ -6,7 +6,7 @@
*/
import { inRange } from 'lodash';
import React, { ReactNode } from 'react';
import React from 'react';
import { getDurationFormatter } from '../../../../../common/utils/formatters';
import { useTheme } from '../../../../hooks/use_theme';
import { Mark } from '.';
@ -30,7 +30,6 @@ const getXAxisTickValues = (tickValues: number[], topTraceDuration?: number) =>
};
interface TimelineAxisProps {
header?: ReactNode;
plotValues: PlotValues;
marks?: Mark[];
topTraceDuration: number;
@ -54,11 +53,7 @@ export function TimelineAxis({ plotValues, marks = [], topTraceDuration }: Timel
return (
<div
style={{
position: 'sticky',
top: 0,
borderBottom: `1px solid ${theme.eui.euiColorMediumShade}`,
height: margins.top,
zIndex: 2,
width: '100%',
}}
>

View file

@ -17,7 +17,7 @@ interface VerticalLinesProps {
}
export function VerticalLines({ topTraceDuration, plotValues, marks = [] }: VerticalLinesProps) {
const { width, height, margins, tickValues, xScale } = plotValues;
const { width, margins, tickValues, xScale } = plotValues;
const markTimes = marks.filter((mark) => mark.verticalLine).map(({ offset }) => offset);
@ -38,7 +38,7 @@ export function VerticalLines({ topTraceDuration, plotValues, marks = [] }: Vert
return (
<svg
width={width}
height={height + margins.top}
height="100%"
style={{
position: 'absolute',
top: 0,
@ -52,7 +52,7 @@ export function VerticalLines({ topTraceDuration, plotValues, marks = [] }: Vert
x1={position}
x2={position}
y1={0}
y2={height}
y2="100%"
stroke={theme.eui.euiColorLightestShade}
/>
))}
@ -62,7 +62,7 @@ export function VerticalLines({ topTraceDuration, plotValues, marks = [] }: Vert
x1={position}
x2={position}
y1={0}
y2={height}
y2="100%"
stroke={theme.eui.euiColorMediumShade}
/>
))}
@ -72,7 +72,7 @@ export function VerticalLines({ topTraceDuration, plotValues, marks = [] }: Vert
x1={topTraceDurationPosition}
x2={topTraceDurationPosition}
y1={0}
y2={height}
y2="100%"
stroke={theme.eui.euiColorMediumShade}
/>
)}