diff --git a/__tests__/plugins/apm.test.ts b/__tests__/plugins/apm.test.ts new file mode 100644 index 00000000..d6ed23de --- /dev/null +++ b/__tests__/plugins/apm.test.ts @@ -0,0 +1,96 @@ +/** + * MIT License + * + * Copyright (c) 2020-present, Elastic NV + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +import { journey, runner, monitor } from '../../src/core'; +import { Gatherer } from '../../src/core/gatherer'; +import { NetworkManager } from '../../src/plugins'; +import { + Apm, + BAGGAGE_HEADER, + TRACE_STATE_HEADER, + genTraceStateHeader, + generateBaggageHeader, +} from '../../src/plugins/apm'; +import { Server } from '../utils/server'; +import { wsEndpoint } from '../utils/test-config'; + +describe('apm', () => { + let server: Server; + beforeAll(async () => { + server = await Server.create(); + }); + afterAll(async () => { + await server.close(); + }); + + it('propagate http header', async () => { + runner.registerJourney( + journey('j1', () => {}), + {} as any + ); + const driver = await Gatherer.setupDriver({ + wsEndpoint, + }); + const network = new NetworkManager(driver); + const apm = new Apm(driver, { traceUrls: ['**/*'] }); + await network.start(); + await apm.start(); + // visit test page + await driver.page.goto(server.TEST_PAGE); + await apm.stop(); + const [htmlReq] = await network.stop(); + await Gatherer.stop(); + + expect(htmlReq.request.headers[BAGGAGE_HEADER]).toBe( + `synthetics.monitor.id=j1;` + ); + expect(htmlReq.request.headers[TRACE_STATE_HEADER]).toBe(`es=s:1`); + }); + + it('baggage generation', () => { + const j1 = journey('j1', () => { + monitor.use({ id: 'foo' }); + }); + runner.registerJourney(j1, {} as any); + expect(generateBaggageHeader(j1)).toBe(`synthetics.monitor.id=foo;`); + + // Set Checkgroup + process.env['ELASTIC_SYNTHETICS_TRACE_ID'] = 'x-trace'; + process.env['ELASTIC_SYNTHETICS_MONITOR_ID'] = 'global-foo'; + expect(generateBaggageHeader(j1)).toBe( + `synthetics.trace.id=x-trace;synthetics.monitor.id=global-foo;` + ); + + delete process.env['ELASTIC_SYNTHETICS_TRACE_ID']; + delete process.env['ELASTIC_SYNTHETICS_MONITOR_ID']; + }); + + it('tracestate generation', () => { + expect(genTraceStateHeader(0.5)).toBe(`es=s:0.5`); + expect(genTraceStateHeader(0.921132)).toBe(`es=s:0.9211`); + expect(genTraceStateHeader(-1)).toBe(`es=s:1`); + expect(genTraceStateHeader(20)).toBe(`es=s:1`); + }); +}); diff --git a/src/common_types.ts b/src/common_types.ts index db4219fa..072347b8 100644 --- a/src/common_types.ts +++ b/src/common_types.ts @@ -237,6 +237,7 @@ export type RunOptions = BaseArgs & { environment?: string; networkConditions?: NetworkConditions; reporter?: BuiltInReporterName | ReporterInstance; + apm?: ApmOptions; }; export type PushOptions = Partial & @@ -252,6 +253,11 @@ export type ProjectSettings = { space: string; }; +export type ApmOptions = { + traceUrls: Array; + sampleRate?: number; +}; + export type PlaywrightOptions = LaunchOptions & BrowserContextOptions & { testIdAttribute?: string; @@ -264,6 +270,7 @@ export type SyntheticsConfig = { playwrightOptions?: PlaywrightOptions; monitor?: MonitorConfig; project?: ProjectSettings; + apm?: ApmOptions; }; /** Runner Payload types */ diff --git a/src/core/gatherer.ts b/src/core/gatherer.ts index 33034672..4a480d26 100644 --- a/src/core/gatherer.ts +++ b/src/core/gatherer.ts @@ -140,12 +140,13 @@ export class Gatherer { */ static async beginRecording(driver: Driver, options: RunOptions) { log('Gatherer: started recording'); - const { network, metrics } = options; + const { network, metrics, apm } = options; const pluginManager = new PluginManager(driver); pluginManager.registerAll(options); const plugins = [await pluginManager.start('browserconsole')]; network && plugins.push(await pluginManager.start('network')); metrics && plugins.push(await pluginManager.start('performance')); + apm && plugins.push(await pluginManager.start('apm')); await Promise.all(plugins); return pluginManager; } diff --git a/src/options.ts b/src/options.ts index bfd3342b..88a122c3 100644 --- a/src/options.ts +++ b/src/options.ts @@ -137,6 +137,9 @@ export function normalizeOptions( } break; } + // Enables the tracing on the journey level + options.apm = config.apm; + return options; } diff --git a/src/plugins/apm.ts b/src/plugins/apm.ts new file mode 100644 index 00000000..1c74bf50 --- /dev/null +++ b/src/plugins/apm.ts @@ -0,0 +1,127 @@ +/** + * MIT License + * + * Copyright (c) 2020-present, Elastic NV + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +import { Request, Route } from 'playwright-core'; +import { ApmOptions, Driver } from '../common_types'; +import { runner } from '../core'; +import { Journey } from '../dsl'; + +/** + * Baggage header is used to propagate user defined properties across distributed systems. + * https://www.w3.org/TR/baggage/ + */ +export const BAGGAGE_HEADER = 'baggage'; +/** + * Tracestate header is used to provide vendor specific trace identification. + * https://www.w3.org/TR/trace-context/#tracestate-header + */ +export const TRACE_STATE_HEADER = 'tracestate'; +const NAMESPACE = 'es'; +const PROPERTY_SEPARATOR = '='; + +function getValueFromEnv(key: string) { + return process.env?.[key]; +} + +/** + * Generate the tracestate header in the elastic namespace which is understood by + * all of our apm agents + * https://github.com/elastic/apm/blob/main/specs/agents/tracing-distributed-tracing.md#tracestate + * + * rate must be in the range [0,1] rounded up to 4 decimal precision (0.0001, 0.8122, ) + */ +export function genTraceStateHeader(rate = 1) { + if (isNaN(rate) || rate < 0 || rate > 1) { + rate = 1; + } else if (rate > 0 && rate < 0.001) { + rate = 0.001; + } else { + rate = Math.round(rate * 10000) / 10000; + } + return `${NAMESPACE}${PROPERTY_SEPARATOR}s:${rate}`; +} + +/** + * Generate the baggage header to be propagated to the destination routes + * + * We are interested in the following properties + * 1. Monitor ID - monitor id of the synthetics monitor + * 2. Trace id - checkgroup/exec that begins the synthetics journey + * 3. Location - location where the synthetics monitor is run from + * 4. Type - type of the synthetics monitor (browser, http, tcp, etc) + */ +export function generateBaggageHeader(journey: Journey) { + let monitorId = getValueFromEnv('ELASTIC_SYNTHETICS_MONITOR_ID'); + if (!monitorId) { + monitorId = journey?.monitor.config.id; + } + + const baggageObj = { + 'synthetics.trace.id': getValueFromEnv('ELASTIC_SYNTHETICS_TRACE_ID'), + 'synthetics.monitor.id': monitorId, + 'synthetics.monitor.type': getValueFromEnv( + 'ELASTIC_SYNTHETICS_MONITOR_TYPE' + ), + 'synthetics.monitor.location': getValueFromEnv( + 'ELASTIC_SYNTHETICS_MONITOR_LOCATION' + ), + }; + + let baggage = ''; + for (const key of Object.keys(baggageObj)) { + if (baggageObj[key]) { + baggage += `${key}${PROPERTY_SEPARATOR}${baggageObj[key]};`; + } + } + + return baggage; +} + +export class Apm { + constructor(private driver: Driver, private options: ApmOptions) {} + + async traceHandler(route: Route, request: Request) { + // Propagate baggae headers to the urls + route.continue({ + headers: { + ...request.headers(), + [BAGGAGE_HEADER]: generateBaggageHeader(runner.currentJourney), + [TRACE_STATE_HEADER]: genTraceStateHeader(this.options.sampleRate), + }, + }); + } + + async start() { + for (const url of this.options.traceUrls) { + await this.driver.context.route(url, this.traceHandler.bind(this)); + } + } + + async stop() { + for (const url of this.options.traceUrls) { + await this.driver.context.unroute(url, this.traceHandler.bind(this)); + } + } +} diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 9de1b9b6..3725f3ad 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -28,3 +28,4 @@ export * from './network'; export * from './performance'; export * from './tracing'; export * from './browser-console'; +export * from './apm'; diff --git a/src/plugins/plugin-manager.ts b/src/plugins/plugin-manager.ts index 740bcb0b..69f64bb7 100644 --- a/src/plugins/plugin-manager.ts +++ b/src/plugins/plugin-manager.ts @@ -23,8 +23,9 @@ * */ -import { PluginOutput, Driver } from '../common_types'; +import { PluginOutput, Driver, ApmOptions } from '../common_types'; import { + Apm, BrowserConsole, NetworkManager, PerformanceManager, @@ -33,9 +34,21 @@ import { } from './'; import { Step } from '../dsl'; -type PluginType = 'network' | 'trace' | 'performance' | 'browserconsole'; -type Plugin = NetworkManager | Tracing | PerformanceManager | BrowserConsole; -type PluginOptions = TraceOptions; +type PluginType = + | 'network' + | 'trace' + | 'performance' + | 'browserconsole' + | 'apm'; +type Plugin = + | NetworkManager + | Tracing + | PerformanceManager + | BrowserConsole + | Apm; +type PluginOptions = TraceOptions & { + apm?: ApmOptions; +}; export class PluginManager { protected plugins = new Map(); @@ -44,6 +57,7 @@ export class PluginManager { 'trace', 'performance', 'browserconsole', + 'apm', ]; constructor(private driver: Driver) {} @@ -62,6 +76,9 @@ export class PluginManager { case 'browserconsole': instance = new BrowserConsole(this.driver); break; + case 'apm': + instance = new Apm(this.driver, options.apm); + break; } instance && this.plugins.set(type, instance); return instance;