diff --git a/packages/elastic-apm-generator/src/lib/apm_error.ts b/packages/elastic-apm-generator/src/lib/apm_error.ts new file mode 100644 index 0000000000000..5a48093a26db2 --- /dev/null +++ b/packages/elastic-apm-generator/src/lib/apm_error.ts @@ -0,0 +1,30 @@ +/* + * 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 { Fields } from './entity'; +import { Serializable } from './serializable'; +import { generateLongId, generateShortId } from './utils/generate_id'; + +export class ApmError extends Serializable { + constructor(fields: Fields) { + super({ + ...fields, + 'processor.event': 'error', + 'processor.name': 'error', + 'error.id': generateShortId(), + }); + } + + serialize() { + const [data] = super.serialize(); + data['error.grouping_key'] = generateLongId( + this.fields['error.grouping_name'] || this.fields['error.exception']?.[0]?.message + ); + return [data]; + } +} diff --git a/packages/elastic-apm-generator/src/lib/base_span.ts b/packages/elastic-apm-generator/src/lib/base_span.ts index 6288c16d339b6..f762bf730a717 100644 --- a/packages/elastic-apm-generator/src/lib/base_span.ts +++ b/packages/elastic-apm-generator/src/lib/base_span.ts @@ -10,7 +10,7 @@ import { Fields } from './entity'; import { Serializable } from './serializable'; import { Span } from './span'; import { Transaction } from './transaction'; -import { generateTraceId } from './utils/generate_id'; +import { generateLongId } from './utils/generate_id'; export class BaseSpan extends Serializable { private readonly _children: BaseSpan[] = []; @@ -19,7 +19,7 @@ export class BaseSpan extends Serializable { super({ ...fields, 'event.outcome': 'unknown', - 'trace.id': generateTraceId(), + 'trace.id': generateLongId(), 'processor.name': 'transaction', }); } diff --git a/packages/elastic-apm-generator/src/lib/entity.ts b/packages/elastic-apm-generator/src/lib/entity.ts index 2a4beee652cf7..bf8fc10efd3a7 100644 --- a/packages/elastic-apm-generator/src/lib/entity.ts +++ b/packages/elastic-apm-generator/src/lib/entity.ts @@ -6,6 +6,19 @@ * Side Public License, v 1. */ +export type ApplicationMetricFields = Partial<{ + 'system.process.memory.size': number; + 'system.memory.actual.free': number; + 'system.memory.total': number; + 'system.cpu.total.norm.pct': number; + 'system.process.memory.rss.bytes': number; + 'system.process.cpu.total.norm.pct': number; +}>; + +export interface Exception { + message: string; +} + export type Fields = Partial<{ '@timestamp': number; 'agent.name': string; @@ -14,6 +27,10 @@ export type Fields = Partial<{ 'ecs.version': string; 'event.outcome': string; 'event.ingested': number; + 'error.id': string; + 'error.exception': Exception[]; + 'error.grouping_name': string; + 'error.grouping_key': string; 'host.name': string; 'metricset.name': string; 'observer.version': string; @@ -46,7 +63,8 @@ export type Fields = Partial<{ 'span.destination.service.response_time.count': number; 'span.self_time.count': number; 'span.self_time.sum.us': number; -}>; +}> & + ApplicationMetricFields; export class Entity { constructor(public readonly fields: Fields) { diff --git a/packages/elastic-apm-generator/src/lib/instance.ts b/packages/elastic-apm-generator/src/lib/instance.ts index 4218a9e23f4b4..3570f497c9055 100644 --- a/packages/elastic-apm-generator/src/lib/instance.ts +++ b/packages/elastic-apm-generator/src/lib/instance.ts @@ -6,7 +6,9 @@ * Side Public License, v 1. */ -import { Entity } from './entity'; +import { ApmError } from './apm_error'; +import { ApplicationMetricFields, Entity } from './entity'; +import { Metricset } from './metricset'; import { Span } from './span'; import { Transaction } from './transaction'; @@ -27,4 +29,20 @@ export class Instance extends Entity { 'span.subtype': spanSubtype, }); } + + error(message: string, type?: string, groupingName?: string) { + return new ApmError({ + ...this.fields, + 'error.exception': [{ message, ...(type ? { type } : {}) }], + 'error.grouping_name': groupingName || message, + }); + } + + appMetrics(metrics: ApplicationMetricFields) { + return new Metricset({ + ...this.fields, + 'metricset.name': 'app', + ...metrics, + }); + } } diff --git a/packages/elastic-apm-generator/src/lib/metricset.ts b/packages/elastic-apm-generator/src/lib/metricset.ts index f7abec6fde958..c1ebbea313123 100644 --- a/packages/elastic-apm-generator/src/lib/metricset.ts +++ b/packages/elastic-apm-generator/src/lib/metricset.ts @@ -6,12 +6,15 @@ * Side Public License, v 1. */ +import { Fields } from './entity'; import { Serializable } from './serializable'; -export class Metricset extends Serializable {} - -export function metricset(name: string) { - return new Metricset({ - 'metricset.name': name, - }); +export class Metricset extends Serializable { + constructor(fields: Fields) { + super({ + 'processor.event': 'metric', + 'processor.name': 'metric', + ...fields, + }); + } } diff --git a/packages/elastic-apm-generator/src/lib/output/to_elasticsearch_output.ts b/packages/elastic-apm-generator/src/lib/output/to_elasticsearch_output.ts index b4cae1b41b9a6..d90ce8e01f83d 100644 --- a/packages/elastic-apm-generator/src/lib/output/to_elasticsearch_output.ts +++ b/packages/elastic-apm-generator/src/lib/output/to_elasticsearch_output.ts @@ -25,7 +25,8 @@ export function toElasticsearchOutput(events: Fields[], versionOverride?: string const document = {}; // eslint-disable-next-line guard-for-in for (const key in values) { - set(document, key, values[key as keyof typeof values]); + const val = values[key as keyof typeof values]; + set(document, key, val); } return { _index: `apm-${versionOverride || values['observer.version']}-${values['processor.event']}`, diff --git a/packages/elastic-apm-generator/src/lib/span.ts b/packages/elastic-apm-generator/src/lib/span.ts index 36f7f44816d01..3c8d90f56b78e 100644 --- a/packages/elastic-apm-generator/src/lib/span.ts +++ b/packages/elastic-apm-generator/src/lib/span.ts @@ -8,14 +8,14 @@ import { BaseSpan } from './base_span'; import { Fields } from './entity'; -import { generateEventId } from './utils/generate_id'; +import { generateShortId } from './utils/generate_id'; export class Span extends BaseSpan { constructor(fields: Fields) { super({ ...fields, 'processor.event': 'span', - 'span.id': generateEventId(), + 'span.id': generateShortId(), }); } diff --git a/packages/elastic-apm-generator/src/lib/transaction.ts b/packages/elastic-apm-generator/src/lib/transaction.ts index f615f46710996..3a8d32e1843f8 100644 --- a/packages/elastic-apm-generator/src/lib/transaction.ts +++ b/packages/elastic-apm-generator/src/lib/transaction.ts @@ -6,22 +6,48 @@ * Side Public License, v 1. */ +import { ApmError } from './apm_error'; import { BaseSpan } from './base_span'; import { Fields } from './entity'; -import { generateEventId } from './utils/generate_id'; +import { generateShortId } from './utils/generate_id'; export class Transaction extends BaseSpan { private _sampled: boolean = true; + private readonly _errors: ApmError[] = []; constructor(fields: Fields) { super({ ...fields, 'processor.event': 'transaction', - 'transaction.id': generateEventId(), + 'transaction.id': generateShortId(), 'transaction.sampled': true, }); } + parent(span: BaseSpan) { + super.parent(span); + + this._errors.forEach((error) => { + error.fields['trace.id'] = this.fields['trace.id']; + error.fields['transaction.id'] = this.fields['transaction.id']; + error.fields['transaction.type'] = this.fields['transaction.type']; + }); + + return this; + } + + errors(...errors: ApmError[]) { + errors.forEach((error) => { + error.fields['trace.id'] = this.fields['trace.id']; + error.fields['transaction.id'] = this.fields['transaction.id']; + error.fields['transaction.type'] = this.fields['transaction.type']; + }); + + this._errors.push(...errors); + + return this; + } + duration(duration: number) { this.fields['transaction.duration.us'] = duration * 1000; return this; @@ -35,11 +61,13 @@ export class Transaction extends BaseSpan { serialize() { const [transaction, ...spans] = super.serialize(); + const errors = this._errors.flatMap((error) => error.serialize()); + const events = [transaction]; if (this._sampled) { events.push(...spans); } - return events; + return events.concat(errors); } } diff --git a/packages/elastic-apm-generator/src/lib/utils/generate_id.ts b/packages/elastic-apm-generator/src/lib/utils/generate_id.ts index 6c8b33fc19077..cc372a56209aa 100644 --- a/packages/elastic-apm-generator/src/lib/utils/generate_id.ts +++ b/packages/elastic-apm-generator/src/lib/utils/generate_id.ts @@ -12,14 +12,14 @@ let seq = 0; const namespace = 'f38d5b83-8eee-4f5b-9aa6-2107e15a71e3'; -function generateId() { - return uuidv5(String(seq++), namespace).replace(/-/g, ''); +function generateId(seed?: string) { + return uuidv5(seed ?? String(seq++), namespace).replace(/-/g, ''); } -export function generateEventId() { - return generateId().substr(0, 16); +export function generateShortId(seed?: string) { + return generateId(seed).substr(0, 16); } -export function generateTraceId() { - return generateId().substr(0, 32); +export function generateLongId(seed?: string) { + return generateId(seed).substr(0, 32); } diff --git a/packages/elastic-apm-generator/src/scripts/examples/01_simple_trace.ts b/packages/elastic-apm-generator/src/scripts/examples/01_simple_trace.ts index 7aae2986919c8..f6aad154532c2 100644 --- a/packages/elastic-apm-generator/src/scripts/examples/01_simple_trace.ts +++ b/packages/elastic-apm-generator/src/scripts/examples/01_simple_trace.ts @@ -14,11 +14,11 @@ export function simpleTrace(from: number, to: number) { const range = timerange(from, to); - const transactionName = '100rpm (80% success) failed 1000ms'; + const transactionName = '240rpm/60% 1000ms'; const successfulTraceEvents = range - .interval('30s') - .rate(40) + .interval('1s') + .rate(3) .flatMap((timestamp) => instance .transaction(transactionName) @@ -38,21 +38,39 @@ export function simpleTrace(from: number, to: number) { ); const failedTraceEvents = range - .interval('30s') - .rate(10) + .interval('1s') + .rate(1) .flatMap((timestamp) => instance .transaction(transactionName) .timestamp(timestamp) .duration(1000) .failure() + .errors( + instance.error('[ResponseError] index_not_found_exception').timestamp(timestamp + 50) + ) .serialize() ); + const metricsets = range + .interval('30s') + .rate(1) + .flatMap((timestamp) => + instance + .appMetrics({ + 'system.memory.actual.free': 800, + 'system.memory.total': 1000, + 'system.cpu.total.norm.pct': 0.6, + 'system.process.cpu.total.norm.pct': 0.7, + }) + .timestamp(timestamp) + .serialize() + ); const events = successfulTraceEvents.concat(failedTraceEvents); return [ ...events, + ...metricsets, ...getTransactionMetrics(events), ...getSpanDestinationMetrics(events), ...getBreakdownMetrics(events), diff --git a/packages/elastic-apm-generator/src/test/scenarios/05_transactions_with_errors.test.ts b/packages/elastic-apm-generator/src/test/scenarios/05_transactions_with_errors.test.ts new file mode 100644 index 0000000000000..289fdfa6cf565 --- /dev/null +++ b/packages/elastic-apm-generator/src/test/scenarios/05_transactions_with_errors.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { pick } from 'lodash'; +import { service } from '../../index'; +import { Instance } from '../../lib/instance'; + +describe('transactions with errors', () => { + let instance: Instance; + const timestamp = new Date('2021-01-01T00:00:00.000Z').getTime(); + + beforeEach(() => { + instance = service('opbeans-java', 'production', 'java').instance('instance'); + }); + it('generates error events', () => { + const events = instance + .transaction('GET /api') + .timestamp(timestamp) + .errors(instance.error('test error').timestamp(timestamp)) + .serialize(); + + const errorEvents = events.filter((event) => event['processor.event'] === 'error'); + + expect(errorEvents.length).toEqual(1); + + expect( + pick(errorEvents[0], 'processor.event', 'processor.name', 'error.exception', '@timestamp') + ).toEqual({ + 'processor.event': 'error', + 'processor.name': 'error', + '@timestamp': timestamp, + 'error.exception': [{ message: 'test error' }], + }); + }); + + it('sets the transaction and trace id', () => { + const [transaction, error] = instance + .transaction('GET /api') + .timestamp(timestamp) + .errors(instance.error('test error').timestamp(timestamp)) + .serialize(); + + const keys = ['transaction.id', 'trace.id', 'transaction.type']; + + expect(pick(error, keys)).toEqual({ + 'transaction.id': transaction['transaction.id'], + 'trace.id': transaction['trace.id'], + 'transaction.type': 'request', + }); + }); + + it('sets the error grouping key', () => { + const [, error] = instance + .transaction('GET /api') + .timestamp(timestamp) + .errors(instance.error('test error').timestamp(timestamp)) + .serialize(); + + expect(error['error.grouping_name']).toEqual('test error'); + expect(error['error.grouping_key']).toMatchInlineSnapshot(`"8b96fa10a7f85a5d960198627bf50840"`); + }); +}); diff --git a/packages/elastic-apm-generator/src/test/scenarios/06_application_metrics.test.ts b/packages/elastic-apm-generator/src/test/scenarios/06_application_metrics.test.ts new file mode 100644 index 0000000000000..59ca8f0edbe88 --- /dev/null +++ b/packages/elastic-apm-generator/src/test/scenarios/06_application_metrics.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { pick } from 'lodash'; +import { service } from '../../index'; +import { Instance } from '../../lib/instance'; + +describe('application metrics', () => { + let instance: Instance; + const timestamp = new Date('2021-01-01T00:00:00.000Z').getTime(); + + beforeEach(() => { + instance = service('opbeans-java', 'production', 'java').instance('instance'); + }); + it('generates application metricsets', () => { + const events = instance + .appMetrics({ + 'system.memory.actual.free': 80, + 'system.memory.total': 100, + }) + .timestamp(timestamp) + .serialize(); + + const appMetrics = events.filter((event) => event['processor.event'] === 'metric'); + + expect(appMetrics.length).toEqual(1); + + expect( + pick( + appMetrics[0], + '@timestamp', + 'agent.name', + 'container.id', + 'metricset.name', + 'processor.event', + 'processor.name', + 'service.environment', + 'service.name', + 'service.node.name', + 'system.memory.actual.free', + 'system.memory.total' + ) + ).toEqual({ + '@timestamp': timestamp, + 'metricset.name': 'app', + 'processor.event': 'metric', + 'processor.name': 'metric', + 'system.memory.actual.free': 80, + 'system.memory.total': 100, + ...pick( + instance.fields, + 'agent.name', + 'container.id', + 'service.environment', + 'service.name', + 'service.node.name' + ), + }); + }); +});