diff --git a/src/impl/documentLoadInstrumentation.ts b/src/impl/documentLoadInstrumentation.ts new file mode 100644 index 0000000..8f0d701 --- /dev/null +++ b/src/impl/documentLoadInstrumentation.ts @@ -0,0 +1,150 @@ +// Also see: https://github.com/signalfx/splunk-otel-js-web/blob/main/packages/web/src/SplunkDocumentLoadInstrumentation.ts +import { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load'; +import * as api from '@opentelemetry/api'; +import { captureTraceParentFromPerformanceEntries } from './servertiming'; +import { PerformanceEntries } from '@opentelemetry/sdk-trace-web'; +import { Span, Tracer } from '@opentelemetry/sdk-trace-base'; + +import { isTracingSuppressed } from '@opentelemetry/core/build/src/trace/suppress-tracing' +import { sanitizeAttributes } from '@opentelemetry/core/build/src/common/attributes'; +import { TransactionSpanManager } from './transactionSpanManager'; + +export interface DocumentLoadServerTimingInstrumentationConfig extends InstrumentationConfig { + recordTransaction: boolean; + exporterDelay: number; +} + +/** + * Patch the Tracer class to use the transaction span as root span + */ +export function patchTracer() { + // Overwrite startSpan in Tracer class + // Copy of the original startSpan()-function with additional logic inside the function to determine the parentContext + Tracer.prototype.startSpan = function ( + name: string, + options: api.SpanOptions = {}, + context = api.context.active() + ) { + + if (isTracingSuppressed(context)) { + api.diag.debug('Instrumentation suppressed, returning Noop Span'); + return api.trace.wrapSpanContext(api.INVALID_SPAN_CONTEXT); + } + + let parentContext; //getParent(options, context); + if(options.root) parentContext = undefined; + else parentContext = api.trace.getSpanContext(context); + + if(!parentContext) { + const transactionSpan = TransactionSpanManager.getTransactionSpan(); + if(transactionSpan) + parentContext = transactionSpan.spanContext(); + } + + // Use transaction span-ID for documentLoadSpan, if existing + let spanId = this._idGenerator.generateSpanId(); + if(name == "documentLoad") { + const transactionSpanId = TransactionSpanManager.getTransactionSpanId(); + if(transactionSpanId) spanId = transactionSpanId; + } + + let traceId; + let traceState; + let parentSpanId; + if (!parentContext || !api.trace.isSpanContextValid(parentContext)) { + // New root span. + traceId = this._idGenerator.generateTraceId(); + } else { + // New child span. + traceId = parentContext.traceId; + traceState = parentContext.traceState; + parentSpanId = parentContext.spanId; + } + + const spanKind = options.kind ?? api.SpanKind.INTERNAL; + const links = options.links ?? []; + const attributes = sanitizeAttributes(options.attributes); + // make sampling decision + const samplingResult = this._sampler.shouldSample( + options.root + ? api.trace.setSpanContext(context, api.INVALID_SPAN_CONTEXT) + : context, + traceId, + name, + spanKind, + attributes, + links + ); + + const traceFlags = + samplingResult.decision === api.SamplingDecision.RECORD_AND_SAMPLED + ? api.TraceFlags.SAMPLED + : api.TraceFlags.NONE; + const spanContext = { traceId, spanId, traceFlags, traceState }; + if (samplingResult.decision === api.SamplingDecision.NOT_RECORD) { + api.diag.debug('Recording is off, propagating context in a non-recording span'); + return api.trace.wrapSpanContext(spanContext); + } + + const span = new Span( + this, + context, + name, + spanContext, + spanKind, + parentSpanId, + links, + options.startTime + ); + // Set default attributes + span.setAttributes(Object.assign(attributes, samplingResult.attributes)); + return span; + } +} + +type PerformanceEntriesWithServerTiming = PerformanceEntries & {serverTiming?: ReadonlyArray<({name: string, duration: number, description: string})>} +type ExposedDocumentLoadSuper = { + _startSpan(spanName: string, performanceName: string, entries: PerformanceEntries, parentSpan?: Span): api.Span | undefined; + _endSpan(span: api.Span | undefined, performanceName: string, entries: PerformanceEntries): void; +} + +export class DocumentLoadServerTimingInstrumentation extends DocumentLoadInstrumentation { + readonly component: string = 'document-load-server-timing'; + moduleName = this.component; + + constructor(config: DocumentLoadServerTimingInstrumentationConfig) { + super(config); + const exposedSuper = this as any as ExposedDocumentLoadSuper; + const _superStartSpan: ExposedDocumentLoadSuper['_startSpan'] = exposedSuper._startSpan.bind(this); + const _superEndSpan: ExposedDocumentLoadSuper['_endSpan'] = exposedSuper._endSpan.bind(this); + + exposedSuper._startSpan = (spanName, performanceName, entries, parentSpan) => { + if (!(entries as PerformanceEntriesWithServerTiming).serverTiming && performance.getEntriesByType) { + const navEntries = performance.getEntriesByType('navigation'); + // @ts-ignore + if (navEntries[0]?.serverTiming) { + // @ts-ignore + (entries as PerformanceEntriesWithServerTiming).serverTiming = navEntries[0].serverTiming; + } + } + + captureTraceParentFromPerformanceEntries(entries); + const span = _superStartSpan(spanName, performanceName, entries, parentSpan); + const exposedSpan = span as any as Span; + if(exposedSpan.name == "documentLoad") TransactionSpanManager.setTransactionSpan(span); + + return span; + } + + exposedSuper._endSpan = (span, performanceName, entries) => { + + const transactionSpan = TransactionSpanManager.getTransactionSpan(); + // Don't close transactionSpan + // transactionSpan will be closed through "beforeunload"-event + if(transactionSpan && transactionSpan == span) return; + + return _superEndSpan(span, performanceName, entries); + }; + } +} diff --git a/src/impl/index.ts b/src/impl/index.ts index be0d50e..9ee4c74 100644 --- a/src/impl/index.ts +++ b/src/impl/index.ts @@ -31,6 +31,9 @@ import { UserInteractionInstrumentation } from '@opentelemetry/instrumentation-u import { PluginProperties, ContextFunction, PropagationHeader } from '../types'; import { patchExporter, patchExporterClass } from './patchCollectorPrototype'; import { MultiSpanProcessor, CustomSpanProcessor } from './spanProcessing'; +import { DocumentLoadServerTimingInstrumentation, patchTracer } from './documentLoadInstrumentation'; +import { CustomIdGenerator } from './transactionIdGeneration'; +import { TransactionSpanManager } from './transactionSpanManager'; /** * TODOs: @@ -69,6 +72,8 @@ export default class OpenTelemetryTracingImpl { instrument_document_load: { enabled: false, path: "", + recordTransaction: false, + exporterDelay: 20 }, instrument_user_interaction: { enabled: false, @@ -99,6 +104,7 @@ export default class OpenTelemetryTracingImpl { private traceProvider: WebTracerProvider; private customSpanProcessor = new CustomSpanProcessor(); + private customIdGenerator = new CustomIdGenerator(); public register = () => { // return if already initialized @@ -112,6 +118,7 @@ export default class OpenTelemetryTracingImpl { // the configuration used by the tracer const tracerConfiguration: WebTracerConfig = { sampler: this.resolveSampler(), + idGenerator: this.customIdGenerator }; // create provider @@ -169,6 +176,21 @@ export default class OpenTelemetryTracingImpl { // store the webtracer this.traceProvider = providerWithZone; + // If recordTransaction is enabled, patch the Tracer to always use the transaction span as root span + // and initialize the transaction data storage + if(this.isTransactionRecordingEnabled()) { + patchTracer(); + const delay = this.props.plugins_config?.instrument_document_load?.exporterDelay; + TransactionSpanManager.initialize(true, this.customIdGenerator); + + window.addEventListener("beforeunload", (event) => { + TransactionSpanManager.getTransactionSpan().end(); + this.traceProvider.forceFlush(); + //Synchronous blocking is necessary, so the span can be exported successfully + this.sleep(delay); + }); + } + // mark plugin initalized this.initialized = true; }; @@ -189,10 +211,26 @@ export default class OpenTelemetryTracingImpl { this.customSpanProcessor.addCustomAttribute(key,value); } + public startNewTransaction = (spanName: string) => { + TransactionSpanManager.startNewTransaction(spanName); + } + public setBeaconUrl = (url: string) => { this.beaconUrl = url; }; + private isTransactionRecordingEnabled = (): boolean => { + return this.props.plugins_config?.instrument_document_load?.recordTransaction; + } + + private sleep = (delay: number) => { + //Use 20 ms as default + if(!delay) delay = 20; + + const start = new Date().getTime(); + while (new Date().getTime() < start + delay); + } + /** * @returns Returns the configured context propagator for injecting the trace context into HTTP request headers. */ @@ -267,6 +305,25 @@ export default class OpenTelemetryTracingImpl { const { plugins, corsUrls, plugins_config } = this.props; const instrumentations: any = []; + // Instrumentation for the document on load (initial request) + if (plugins_config?.instrument_document_load?.enabled !== false) { + if(this.isTransactionRecordingEnabled()) + instrumentations.push(new DocumentLoadServerTimingInstrumentation(plugins_config.instrument_document_load)); + else + instrumentations.push(new DocumentLoadInstrumentation(plugins_config.instrument_document_load)); + } + else if (plugins?.instrument_document_load !== false) { + instrumentations.push(new DocumentLoadInstrumentation()); + } + + // Instrumentation for user interactions + if (plugins_config?.instrument_user_interaction?.enabled !== false) { + instrumentations.push(new UserInteractionInstrumentation(plugins_config.instrument_user_interaction)); + } + else if (plugins?.instrument_user_interaction !== false) { + instrumentations.push(new UserInteractionInstrumentation()); + } + // XMLHttpRequest Instrumentation for web plugin if (plugins_config?.instrument_xhr?.enabled !== false) { instrumentations.push(new XMLHttpRequestInstrumentation(plugins_config.instrument_xhr)); @@ -287,22 +344,6 @@ export default class OpenTelemetryTracingImpl { instrumentations.push(new FetchInstrumentation()); } - // Instrumentation for the document on load (initial request) - if (plugins_config?.instrument_document_load?.enabled !== false) { - instrumentations.push(new DocumentLoadInstrumentation(plugins_config.instrument_document_load)); - } - else if (plugins?.instrument_document_load !== false) { - instrumentations.push(new DocumentLoadInstrumentation()); - } - - // Instrumentation for user interactions - if (plugins_config?.instrument_user_interaction?.enabled !== false) { - instrumentations.push(new UserInteractionInstrumentation(plugins_config.instrument_user_interaction)); - } - else if (plugins?.instrument_user_interaction !== false) { - instrumentations.push(new UserInteractionInstrumentation()); - } - return instrumentations; }; diff --git a/src/impl/servertiming.ts b/src/impl/servertiming.ts new file mode 100644 index 0000000..f9cde47 --- /dev/null +++ b/src/impl/servertiming.ts @@ -0,0 +1,26 @@ +// Also see: https://github.com/signalfx/splunk-otel-js-web/blob/main/packages/web/src/servertiming.ts +import { PerformanceEntries } from '@opentelemetry/sdk-trace-web'; +import { TransactionSpanManager } from './transactionSpanManager'; + +function setTransactionIds(match: RegExpMatchArray): void { + if (match && match[1] && match[2]) { + const traceId = match[1]; + const spanId = match[2]; + TransactionSpanManager.setTransactionTraceId(traceId); + TransactionSpanManager.setTransactionSpanId(spanId); + } +} + +const ValueRegex = new RegExp('00-([0-9a-f]{32})-([0-9a-f]{16})-01'); + +export function captureTraceParentFromPerformanceEntries(entries: PerformanceEntries): void { + if (!(entries as any).serverTiming) { + return; + } + for(const st of (entries as any).serverTiming) { + if (st.name === 'traceparent' && st.description) { + const match = st.description.match(ValueRegex); + setTransactionIds(match); + } + } +} diff --git a/src/impl/transactionIdGeneration.ts b/src/impl/transactionIdGeneration.ts new file mode 100644 index 0000000..e34e117 --- /dev/null +++ b/src/impl/transactionIdGeneration.ts @@ -0,0 +1,58 @@ +import { IdGenerator } from '@opentelemetry/core'; +import { TransactionSpanManager } from './transactionSpanManager'; + +const SPAN_ID_BYTES = 8; +const TRACE_ID_BYTES = 16; +const SHARED_BUFFER = Buffer.allocUnsafe(TRACE_ID_BYTES); + +// Copy of RandomIdGenerator (@opentelemetry/core) with additional getTransactionTraceId()-function +export class CustomIdGenerator implements IdGenerator { + + /** + * Returns a random 16-byte trace ID formatted/encoded as a 32 lowercase hex + * characters corresponding to 128 bits. + */ + get generateTraceId(): () => string { + return this.getTransactionTraceId(); + } + + /** + * Returns a random 8-byte span ID formatted/encoded as a 16 lowercase hex + * characters corresponding to 64 bits. + */ + get generateSpanId(): () => string { + return this.getIdGenerator(SPAN_ID_BYTES); + } + + /** + * If there is a transaction-trace-id, use it + * Otherwise, generate a new trace-id the ordinary way + */ + getTransactionTraceId(): () => string { + const transactionTraceId = TransactionSpanManager.getTransactionTraceId(); + // Use current transaction trace ID, if existing + if(transactionTraceId) return () => transactionTraceId; + else return this.getIdGenerator(TRACE_ID_BYTES); + } + + getIdGenerator(bytes: number): () => string { + return function generateId() { + for (let i = 0; i < bytes / 4; i++) { + // unsigned right shift drops decimal part of the number + // it is required because if a number between 2**32 and 2**32 - 1 is generated, an out of range error is thrown by writeUInt32BE + SHARED_BUFFER.writeUInt32BE((Math.random() * 2 ** 32) >>> 0, i * 4); + } + + // If buffer is all 0, set the last byte to 1 to guarantee a valid w3c id is generated + for (let i = 0; i < bytes; i++) { + if (SHARED_BUFFER[i] > 0) { + break; + } else if (i === bytes - 1) { + SHARED_BUFFER[bytes - 1] = 1; + } + } + + return SHARED_BUFFER.toString('hex', 0, bytes); + }; + } +} diff --git a/src/impl/transactionSpanManager.ts b/src/impl/transactionSpanManager.ts new file mode 100644 index 0000000..684ade1 --- /dev/null +++ b/src/impl/transactionSpanManager.ts @@ -0,0 +1,81 @@ +import api, { Span } from '@opentelemetry/api'; +import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; +import { CustomIdGenerator } from './transactionIdGeneration'; + +/** + * Manager, that stores the transaction-span and provides Getter- and Setter-functions + */ +export class TransactionSpanManager { + + private static readonly openTelemetryVersion = "0.25.0"; + private static readonly documentLoadTracerName = "@opentelemetry/instrumentation-document-load"; + + // Store trace-id, before transactionSpan was created + private static transactionTraceId: string; + // Store span-id, before transactionSpan was created + private static transactionSpanId: string; + private static transactionSpan: Span; + + // Disabled, by default + private static isTransactionRecordingEnabled = false; + + private static idGenerator: CustomIdGenerator; + + public static initialize = (isTransactionRecordingEnabled: boolean, + idGenerator: CustomIdGenerator) => { + TransactionSpanManager.isTransactionRecordingEnabled = isTransactionRecordingEnabled; + TransactionSpanManager.idGenerator = idGenerator; + } + + public static getTransactionTraceId = () => { + return TransactionSpanManager.transactionTraceId; + } + + public static setTransactionTraceId = (traceId: string) => { + if(TransactionSpanManager.isTransactionRecordingEnabled) + TransactionSpanManager.transactionTraceId = traceId; + } + + public static getTransactionSpanId = () => { + return TransactionSpanManager.transactionSpanId; + } + + public static setTransactionSpanId = (spanId: string) => { + if(TransactionSpanManager.isTransactionRecordingEnabled) + TransactionSpanManager.transactionSpanId = spanId; + } + + public static getTransactionSpan = () => { + return TransactionSpanManager.transactionSpan; + } + + public static setTransactionSpan = (span: Span) => { + if(TransactionSpanManager.isTransactionRecordingEnabled) + TransactionSpanManager.transactionSpan = span; + } + + public static startNewTransaction = (spanName: string) => { + // Check if transactions should be recorded, otherwise don't start transaction + if(!TransactionSpanManager.isTransactionRecordingEnabled) { + console.warn("No Transaction started: Transaction recording is disabled"); + return; + } + + const currentTransactionSpan = TransactionSpanManager.getTransactionSpan(); + if(currentTransactionSpan) TransactionSpanManager.transactionSpan.end(); + // Delete current transaction span, after closing it + TransactionSpanManager.setTransactionSpan(null); + + // Delete current transaction IDs, so the IdGenerator cannot use it + TransactionSpanManager.setTransactionTraceId(null); + TransactionSpanManager.setTransactionSpanId(null); + // Generate new random transaction trace ID + const newTraceId = TransactionSpanManager.idGenerator.generateTraceId(); + TransactionSpanManager.setTransactionTraceId(newTraceId); + + // Just use any existing tracer, for example document-load + const tracer = api.trace.getTracer(TransactionSpanManager.documentLoadTracerName, TransactionSpanManager.openTelemetryVersion); + const newTransactionSpan = tracer.startSpan(spanName, {root: true}); + TransactionSpanManager.setTransactionSpan(newTransactionSpan); + } +} diff --git a/src/index.ts b/src/index.ts index 1cb87c8..11f04e9 100755 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,9 @@ if (currentEntriesFn) { if(addToBeacon) window.BOOMR.addVar(key, value); }, + // Starts a new transaction, if document_load.recordTransaction is enabled + startNewTransaction: impl.startNewTransaction, + is_complete: () => { // This method should determine if the plugin has completed doing what it // needs to do and return true if so or false otherwise diff --git a/src/types.d.ts b/src/types.d.ts index 5d9f366..1677b73 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,11 +1,9 @@ import { PropagateTraceHeaderCorsUrls } from '@opentelemetry/sdk-trace-web'; import { CollectorExporterNodeConfigBase } from '@opentelemetry/exporter-collector'; -import {FetchInstrumentation, FetchInstrumentationConfig} from "@opentelemetry/instrumentation-fetch"; -import { - XMLHttpRequestInstrumentation, - XMLHttpRequestInstrumentationConfig -} from "@opentelemetry/instrumentation-xml-http-request"; -import {InstrumentationConfig} from "@opentelemetry/instrumentation"; +import { FetchInstrumentationConfig } from "@opentelemetry/instrumentation-fetch"; +import { XMLHttpRequestInstrumentationConfig } from "@opentelemetry/instrumentation-xml-http-request"; +import { DocumentLoadServerTimingInstrumentationConfig } from './impl/documentLoadInstrumentation'; +import { InstrumentationConfig } from "@opentelemetry/instrumentation"; export interface PluginProperties { samplingRate: number; @@ -41,7 +39,7 @@ export interface OTPluginProperties { export interface OTPluginConfig { instrument_fetch: FetchInstrumentationConfig; instrument_xhr: XMLHttpRequestInstrumentationConfig; - instrument_document_load: InstrumentationConfig; + instrument_document_load: DocumentLoadServerTimingInstrumentationConfig; instrument_user_interaction: InstrumentationConfig; }