diff --git a/packages/kbn-apm-synthtrace/src/lib/apm/apm_fields.ts b/packages/kbn-apm-synthtrace/src/lib/apm/apm_fields.ts index 7736aa8e8879b..07379b2a3002d 100644 --- a/packages/kbn-apm-synthtrace/src/lib/apm/apm_fields.ts +++ b/packages/kbn-apm-synthtrace/src/lib/apm/apm_fields.ts @@ -58,6 +58,8 @@ export type ApmFields = Fields & 'error.grouping_key': string; 'host.name': string; 'host.hostname': string; + 'http.request.method': string; + 'http.response.status_code': number; 'kubernetes.pod.uid': string; 'kubernetes.pod.name': string; 'metricset.name': string; @@ -84,14 +86,13 @@ export type ApmFields = Fields & 'service.framework.name': string; 'service.target.name': string; 'service.target.type': string; + 'span.action': string; 'span.id': string; 'span.name': string; 'span.type': string; 'span.subtype': string; 'span.duration.us': number; - 'span.destination.service.name': string; 'span.destination.service.resource': string; - 'span.destination.service.type': string; 'span.destination.service.response_time.sum.us': number; 'span.destination.service.response_time.count': number; 'span.self_time.count': number; diff --git a/packages/kbn-apm-synthtrace/src/lib/apm/base_span.ts b/packages/kbn-apm-synthtrace/src/lib/apm/base_span.ts index f8c058592a494..0cfe5940405a2 100644 --- a/packages/kbn-apm-synthtrace/src/lib/apm/base_span.ts +++ b/packages/kbn-apm-synthtrace/src/lib/apm/base_span.ts @@ -40,6 +40,10 @@ export class BaseSpan extends Serializable { return this; } + getChildren() { + return this._children; + } + children(...children: BaseSpan[]): this { children.forEach((child) => { child.parent(this); diff --git a/packages/kbn-apm-synthtrace/src/lib/apm/client/apm_synthtrace_apm_client.ts b/packages/kbn-apm-synthtrace/src/lib/apm/client/apm_synthtrace_apm_client.ts index da836cd8a2119..af200a103558e 100644 --- a/packages/kbn-apm-synthtrace/src/lib/apm/client/apm_synthtrace_apm_client.ts +++ b/packages/kbn-apm-synthtrace/src/lib/apm/client/apm_synthtrace_apm_client.ts @@ -148,9 +148,7 @@ export class ApmSynthtraceApmClient { const destination = (e.context.destination = e.context.destination ?? {}); const destinationService = (destination.service = destination.service ?? { resource: '' }); - set('span.destination.service.name', destinationService, (c, v) => (c.name = v)); set('span.destination.service.resource', destinationService, (c, v) => (c.resource = v)); - set('span.destination.service.type', destinationService, (c, v) => (c.type = v)); } if (e.kind === 'transaction') { set('transaction.name', e, (c, v) => (c.name = v)); diff --git a/packages/kbn-apm-synthtrace/src/lib/apm/instance.ts b/packages/kbn-apm-synthtrace/src/lib/apm/instance.ts index 32a81de9f307a..f69e54b3e300b 100644 --- a/packages/kbn-apm-synthtrace/src/lib/apm/instance.ts +++ b/packages/kbn-apm-synthtrace/src/lib/apm/instance.ts @@ -13,6 +13,12 @@ import { Span } from './span'; import { Transaction } from './transaction'; import { ApmApplicationMetricFields, ApmFields } from './apm_fields'; +export type SpanParams = { + spanName: string; + spanType: string; + spanSubtype?: string; +} & ApmFields; + export class Instance extends Entity { transaction({ transactionName, @@ -28,16 +34,7 @@ export class Instance extends Entity { }); } - span({ - spanName, - spanType, - spanSubtype, - ...apmFields - }: { - spanName: string; - spanType: string; - spanSubtype?: string; - } & ApmFields) { + span({ spanName, spanType, spanSubtype, ...apmFields }: SpanParams) { return new Span({ ...this.fields, ...apmFields, diff --git a/packages/kbn-apm-synthtrace/src/lib/apm/span.ts b/packages/kbn-apm-synthtrace/src/lib/apm/span.ts index a5795ae321478..99e0b1053014a 100644 --- a/packages/kbn-apm-synthtrace/src/lib/apm/span.ts +++ b/packages/kbn-apm-synthtrace/src/lib/apm/span.ts @@ -10,6 +10,7 @@ import url from 'url'; import { BaseSpan } from './base_span'; import { generateShortId } from '../utils/generate_id'; import { ApmFields } from './apm_fields'; +import { SpanParams } from './instance'; export class Span extends BaseSpan { constructor(fields: ApmFields) { @@ -25,29 +26,26 @@ export class Span extends BaseSpan { return this; } - destination(resource: string, type?: string, name?: string) { - if (!type) { - type = this.fields['span.type']; - } - - if (!name) { - name = resource; - } + destination(resource: string) { this.fields['span.destination.service.resource'] = resource; - this.fields['span.destination.service.name'] = name; - this.fields['span.destination.service.type'] = type; return this; } } +export type HttpMethod = 'GET' | 'POST' | 'DELETE' | 'PUT'; + export function httpExitSpan({ spanName, destinationUrl, + method = 'GET', + statusCode = 200, }: { spanName: string; destinationUrl: string; -}) { + method?: HttpMethod; + statusCode?: number; +}): SpanParams { // origin: 'http://opbeans-go:3000', // host: 'opbeans-go:3000', // hostname: 'opbeans-go', @@ -55,31 +53,98 @@ export function httpExitSpan({ const destination = new url.URL(destinationUrl); const spanType = 'external'; - const spanSubType = 'http'; + const spanSubtype = 'http'; return { spanName, spanType, - spanSubType, + spanSubtype, + + // http + 'span.action': method, + 'http.request.method': method, + 'http.response.status_code': statusCode, + + // destination 'destination.address': destination.hostname, 'destination.port': parseInt(destination.port, 10), 'service.target.name': destination.host, - 'span.destination.service.name': destination.origin, 'span.destination.service.resource': destination.host, - 'span.destination.service.type': 'external', }; } -export function dbExitSpan({ spanName, spanSubType }: { spanName: string; spanSubType?: string }) { +export function dbExitSpan({ spanName, spanSubtype }: { spanName: string; spanSubtype?: string }) { const spanType = 'db'; return { spanName, spanType, - spanSubType, - 'service.target.type': spanSubType, - 'span.destination.service.name': spanSubType, - 'span.destination.service.resource': spanSubType, - 'span.destination.service.type': spanType, + spanSubtype, + 'service.target.type': spanSubtype, + 'span.destination.service.resource': spanSubtype, + }; +} + +export function elasticsearchSpan(spanName: string, statement?: string): SpanParams { + const spanType = 'db'; + const spanSubtype = 'elasticsearch'; + + return { + spanName, + spanType, + spanSubtype, + + ...(statement + ? { + 'span.db.statement': statement, + 'span.db.type': 'elasticsearch', + } + : {}), + + 'service.target.type': spanSubtype, + 'destination.address': 'qwerty.us-west2.gcp.elastic-cloud.com', + 'destination.port': 443, + 'span.destination.service.resource': spanSubtype, + }; +} + +export function sqliteSpan(spanName: string, statement?: string): SpanParams { + const spanType = 'db'; + const spanSubtype = 'sqlite'; + + return { + spanName, + spanType, + spanSubtype, + + ...(statement + ? { + 'span.db.statement': statement, + 'span.db.type': 'sql', + } + : {}), + + // destination + 'service.target.type': spanSubtype, + 'destination.address': 'qwerty.us-west2.gcp.elastic-cloud.com', + 'destination.port': 443, + 'span.destination.service.resource': spanSubtype, + }; +} + +export function redisSpan(spanName: string): SpanParams { + const spanType = 'db'; + const spanSubtype = 'redis'; + + return { + spanName, + spanType, + spanSubtype, + + // destination + 'service.target.type': spanSubtype, + 'destination.address': 'qwerty.us-west2.gcp.elastic-cloud.com', + 'destination.port': 443, + 'span.destination.service.resource': spanSubtype, }; } diff --git a/packages/kbn-apm-synthtrace/src/lib/dsl/distributed_trace_client.test.ts b/packages/kbn-apm-synthtrace/src/lib/dsl/distributed_trace_client.test.ts new file mode 100644 index 0000000000000..87e508abe87db --- /dev/null +++ b/packages/kbn-apm-synthtrace/src/lib/dsl/distributed_trace_client.test.ts @@ -0,0 +1,221 @@ +/* + * 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 { apm } from '../apm'; +import { ApmFields } from '../apm/apm_fields'; +import { BaseSpan } from '../apm/base_span'; +import { DistributedTrace } from './distributed_trace_client'; + +const opbeansRum = apm + .service({ name: 'opbeans-rum', environment: 'prod', agentName: 'rum-js' }) + .instance('my-instance'); + +const opbeansNode = apm + .service({ name: 'opbeans-node', environment: 'prod', agentName: 'nodejs' }) + .instance('my-instance'); + +const opbeansGo = apm + .service({ name: 'opbeans-go', environment: 'prod', agentName: 'go' }) + .instance('my-instance'); + +describe('DistributedTrace', () => { + describe('basic scenario', () => { + it('should add latency', () => { + const dt = new DistributedTrace({ + serviceInstance: opbeansRum, + transactionName: 'Dashboard', + timestamp: 0, + children: (s) => { + s.service({ + serviceInstance: opbeansNode, + transactionName: 'GET /nodejs/products', + + children: (_) => { + _.service({ serviceInstance: opbeansGo, transactionName: 'GET /gogo' }); + _.db({ name: 'GET apm-*/_search', type: 'elasticsearch', duration: 400 }); + }, + }); + }, + }).getTransaction(); + + const traceDocs = getTraceDocs(dt); + const formattedDocs = traceDocs.map((f) => { + return { + processorEvent: f['processor.event'], + timestamp: f['@timestamp'], + duration: (f['transaction.duration.us'] ?? f['span.duration.us'])! / 1000, + name: f['transaction.name'] ?? f['span.name'], + }; + }); + + expect(formattedDocs).toMatchInlineSnapshot(` + Array [ + Object { + "duration": 400, + "name": "Dashboard", + "processorEvent": "transaction", + "timestamp": 0, + }, + Object { + "duration": 400, + "name": "GET /nodejs/products", + "processorEvent": "span", + "timestamp": 0, + }, + Object { + "duration": 400, + "name": "GET /nodejs/products", + "processorEvent": "transaction", + "timestamp": 0, + }, + Object { + "duration": 0, + "name": "GET /gogo", + "processorEvent": "span", + "timestamp": 0, + }, + Object { + "duration": 0, + "name": "GET /gogo", + "processorEvent": "transaction", + "timestamp": 0, + }, + Object { + "duration": 400, + "name": "GET apm-*/_search", + "processorEvent": "span", + "timestamp": 0, + }, + ] + `); + }); + }); + + describe('latency', () => { + it('should add latency', () => { + const traceDocs = getSimpleScenario({ latency: 500 }); + const timestamps = traceDocs.map((f) => f['@timestamp']); + expect(timestamps).toMatchInlineSnapshot(` + Array [ + 0, + 0, + 250, + 250, + 250, + 250, + ] + `); + }); + + it('should not add latency', () => { + const traceDocs = getSimpleScenario(); + const timestamps = traceDocs.map((f) => f['@timestamp']); + expect(timestamps).toMatchInlineSnapshot(` + Array [ + 0, + 0, + 0, + 0, + 0, + 0, + ] + `); + }); + }); + + describe('duration', () => { + it('should add duration', () => { + const traceDocs = getSimpleScenario({ duration: 3000 }); + const durations = traceDocs.map( + (f) => (f['transaction.duration.us'] ?? f['span.duration.us'])! / 1000 + ); + expect(durations).toMatchInlineSnapshot(` + Array [ + 3000, + 3000, + 3000, + 300, + 400, + 500, + ] + `); + }); + + it('should not add duration', () => { + const traceDocs = getSimpleScenario(); + const durations = traceDocs.map( + (f) => (f['transaction.duration.us'] ?? f['span.duration.us'])! / 1000 + ); + expect(durations).toMatchInlineSnapshot(` + Array [ + 500, + 500, + 500, + 300, + 400, + 500, + ] + `); + }); + }); + + describe('repeat', () => { + it('produces few trace documents when "repeat" is disabled', () => { + const traceDocs = getSimpleScenario({ repeat: undefined }); + expect(traceDocs.length).toBe(6); + }); + + it('produces more trace documents when "repeat" is enabled', () => { + const traceDocs = getSimpleScenario({ repeat: 20 }); + expect(traceDocs.length).toBe(101); + }); + }); +}); + +function getTraceDocs(transaction: BaseSpan): ApmFields[] { + const children = transaction.getChildren(); + if (children) { + const childFields = children.flatMap((child) => getTraceDocs(child)); + return [transaction.fields, ...childFields]; + } + + return [transaction.fields]; +} + +function getSimpleScenario({ + duration, + latency, + repeat, +}: { + duration?: number; + latency?: number; + repeat?: number; +} = {}) { + const dt = new DistributedTrace({ + serviceInstance: opbeansRum, + transactionName: 'Dashboard', + timestamp: 0, + children: (s) => { + s.service({ + serviceInstance: opbeansNode, + transactionName: 'GET /nodejs/products', + duration, + latency, + repeat, + + children: (_) => { + _.db({ name: 'GET apm-*/_search', type: 'elasticsearch', duration: 300 }); + _.db({ name: 'GET apm-*/_search', type: 'elasticsearch', duration: 400 }); + _.db({ name: 'GET apm-*/_search', type: 'elasticsearch', duration: 500 }); + }, + }); + }, + }).getTransaction(); + + return getTraceDocs(dt); +} diff --git a/packages/kbn-apm-synthtrace/src/lib/dsl/distributed_trace_client.ts b/packages/kbn-apm-synthtrace/src/lib/dsl/distributed_trace_client.ts new file mode 100644 index 0000000000000..ceeb94f871e3a --- /dev/null +++ b/packages/kbn-apm-synthtrace/src/lib/dsl/distributed_trace_client.ts @@ -0,0 +1,183 @@ +/* + * 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 { times } from 'lodash'; +import { elasticsearchSpan, httpExitSpan, HttpMethod, redisSpan, sqliteSpan } from '../apm/span'; +import { BaseSpan } from '../apm/base_span'; +import { Instance, SpanParams } from '../apm/instance'; +import { Transaction } from '../apm/transaction'; + +export class DistributedTrace { + timestamp: number; + serviceInstance: Instance; + spanEndTimes: number[] = []; + childSpans: BaseSpan[] = []; + transaction: Transaction; + + constructor({ + serviceInstance, + transactionName, + timestamp, + children, + }: { + serviceInstance: Instance; + transactionName: string; + timestamp: number; + children?: (dt: DistributedTrace) => void; + }) { + this.timestamp = timestamp; + this.serviceInstance = serviceInstance; + + if (children) { + children(this); + } + + const maxEndTime = Math.max(...this.spanEndTimes); + const duration = maxEndTime - this.timestamp; + + this.transaction = serviceInstance + .transaction({ transactionName }) + .timestamp(timestamp) + .duration(duration) + .children(...this.childSpans); + + return this; + } + + getTransaction() { + return this.transaction; + } + + service({ + serviceInstance, + transactionName, + latency = 0, + repeat = 1, + timestamp = this.timestamp, + duration, + children, + }: { + serviceInstance: Instance; + transactionName: string; + repeat?: number; + timestamp?: number; + latency?: number; + duration?: number; + children?: (dt: DistributedTrace) => unknown; + }) { + const originServiceInstance = this.serviceInstance; + + times(repeat, () => { + const dt = new DistributedTrace({ + serviceInstance, + transactionName, + timestamp: timestamp + latency / 2, + children, + }); + + const maxSpanEndTime = Math.max(...dt.spanEndTimes, timestamp + (duration ?? 0)); + this.spanEndTimes.push(maxSpanEndTime + latency / 2); + + // origin service + const exitSpanStart = timestamp; + const exitSpanDuration = (duration ?? maxSpanEndTime - exitSpanStart) + latency / 2; + + // destination service + const transactionStart = timestamp + latency / 2; + const transactionDuration = duration ?? maxSpanEndTime - transactionStart; + + const span = originServiceInstance + .span( + httpExitSpan({ + spanName: transactionName, + destinationUrl: 'http://api-gateway:3000', // TODO: this should be derived from serviceInstance + }) + ) + .duration(exitSpanDuration) + .timestamp(exitSpanStart) + .children( + dt.serviceInstance + .transaction({ transactionName }) + .timestamp(transactionStart) + .duration(transactionDuration) + .children(...(dt.childSpans ?? [])) + ); + + this.childSpans.push(span); + }); + } + + external({ + name, + url, + method, + statusCode, + duration, + timestamp = this.timestamp, + }: { + name: string; + url: string; + method?: HttpMethod; + statusCode?: number; + duration: number; + timestamp?: number; + }) { + const startTime = timestamp; + const endTime = startTime + duration; + this.spanEndTimes.push(endTime); + + const span = this.serviceInstance + .span(httpExitSpan({ spanName: name, destinationUrl: url, method, statusCode })) + .timestamp(startTime) + .duration(duration) + .success(); + + this.childSpans.push(span); + } + + db({ + name, + duration, + type, + statement, + timestamp = this.timestamp, + }: { + name: string; + duration: number; + type: 'elasticsearch' | 'sqlite' | 'redis'; + statement?: string; + timestamp?: number; + }) { + const startTime = timestamp; + const endTime = startTime + duration; + this.spanEndTimes.push(endTime); + + let dbSpan: SpanParams; + switch (type) { + case 'elasticsearch': + dbSpan = elasticsearchSpan(name, statement); + break; + + case 'sqlite': + dbSpan = sqliteSpan(name, statement); + break; + + case 'redis': + dbSpan = redisSpan(name); + break; + } + + const span = this.serviceInstance + .span(dbSpan) + .timestamp(startTime) + .duration(duration) + .success(); + + this.childSpans.push(span); + } +} diff --git a/packages/kbn-apm-synthtrace/src/scenarios/distributed_trace_long.ts b/packages/kbn-apm-synthtrace/src/scenarios/distributed_trace_long.ts new file mode 100644 index 0000000000000..32542ee2c1d49 --- /dev/null +++ b/packages/kbn-apm-synthtrace/src/scenarios/distributed_trace_long.ts @@ -0,0 +1,134 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/no-shadow */ + +import { apm, timerange } from '../..'; +import { ApmFields } from '../lib/apm/apm_fields'; +import { Scenario } from '../cli/scenario'; +import { RunOptions } from '../cli/utils/parse_run_cli_flags'; +import { getSynthtraceEnvironment } from '../lib/utils/get_synthtrace_environment'; +import { DistributedTrace } from '../lib/dsl/distributed_trace_client'; + +const ENVIRONMENT = getSynthtraceEnvironment(__filename); + +const scenario: Scenario = async (runOptions: RunOptions) => { + return { + generate: ({ from, to }) => { + const ratePerMinute = 1; + const traceDuration = 1100; + const rootTransactionName = `${ratePerMinute}rpm / ${traceDuration}ms`; + + const opbeansRum = apm + .service({ name: 'opbeans-rum', environment: ENVIRONMENT, agentName: 'rum-js' }) + .instance('my-instance'); + + const opbeansNode = apm + .service({ name: 'opbeans-node', environment: ENVIRONMENT, agentName: 'nodejs' }) + .instance('my-instance'); + + const opbeansGo = apm + .service({ name: 'opbeans-go', environment: ENVIRONMENT, agentName: 'go' }) + .instance('my-instance'); + + const opbeansDotnet = apm + .service({ name: 'opbeans-dotnet', environment: ENVIRONMENT, agentName: 'dotnet' }) + .instance('my-instance'); + + const opbeansJava = apm + .service({ name: 'opbeans-java', environment: ENVIRONMENT, agentName: 'java' }) + .instance('my-instance'); + + const traces = timerange(from, to) + .ratePerMinute(ratePerMinute) + .generator((timestamp) => { + return new DistributedTrace({ + serviceInstance: opbeansRum, + transactionName: rootTransactionName, + timestamp, + children: (_) => { + _.service({ + repeat: 10, + serviceInstance: opbeansNode, + transactionName: 'GET /nodejs/products', + latency: 100, + + children: (_) => { + _.service({ + serviceInstance: opbeansGo, + transactionName: 'GET /go', + children: (_) => { + _.service({ + repeat: 20, + serviceInstance: opbeansJava, + transactionName: 'GET /java', + children: (_) => { + _.external({ + name: 'GET telemetry.elastic.co', + url: 'https://telemetry.elastic.co/ping', + duration: 50, + }); + }, + }); + }, + }); + _.db({ name: 'GET apm-*/_search', type: 'elasticsearch', duration: 400 }); + _.db({ name: 'GET', type: 'redis', duration: 500 }); + _.db({ name: 'SELECT * FROM users', type: 'sqlite', duration: 600 }); + }, + }); + + _.service({ + serviceInstance: opbeansNode, + transactionName: 'GET /nodejs/users', + latency: 100, + repeat: 10, + children: (_) => { + _.service({ + serviceInstance: opbeansGo, + transactionName: 'GET /go/security', + latency: 50, + children: (_) => { + _.service({ + repeat: 10, + serviceInstance: opbeansDotnet, + transactionName: 'GET /dotnet/cases/4', + latency: 50, + children: (_) => + _.db({ + name: 'GET apm-*/_search', + type: 'elasticsearch', + duration: 600, + statement: JSON.stringify( + { + query: { + query_string: { + query: '(new york city) OR (big apple)', + default_field: 'content', + }, + }, + }, + null, + 2 + ), + }), + }); + }, + }); + }, + }); + }, + }).getTransaction(); + }); + + return traces; + }, + }; +}; + +export default scenario;