Skip to content

Commit

Permalink
add new DocumentLoadInstrumentation (#43)
Browse files Browse the repository at this point in the history
* add DocumentLoadInstrumentation with ServerTiming

* add CustomIdGenerator

* adjust instrumentation

* add traceID member variable in IdGenerator

* fix transactionIdGeneration

* save transactionSpanId

* use documentload-span as root-span

* add patched user-interaction-instrumentation

* add delay to beforeunload-event

* patch xhr version 1

* patch xhr version 2

* patch fetch

* add delay configuration

* add startNewTransaction()

* little refactor

* try patchTracer()

* refactor startSpan() and remove patched instrumentations

* little refactor

* user server-timing-span-id to create transaction-span

* outsource transaction management

* fix spanId generation
  • Loading branch information
EddeCCC authored Sep 18, 2023
1 parent ea73329 commit 859555d
Show file tree
Hide file tree
Showing 7 changed files with 380 additions and 23 deletions.
150 changes: 150 additions & 0 deletions src/impl/documentLoadInstrumentation.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
}
73 changes: 57 additions & 16 deletions src/impl/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -69,6 +72,8 @@ export default class OpenTelemetryTracingImpl {
instrument_document_load: {
enabled: false,
path: "",
recordTransaction: false,
exporterDelay: 20
},
instrument_user_interaction: {
enabled: false,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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;
};
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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));
Expand All @@ -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;
};

Expand Down
26 changes: 26 additions & 0 deletions src/impl/servertiming.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
58 changes: 58 additions & 0 deletions src/impl/transactionIdGeneration.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
}
Loading

0 comments on commit 859555d

Please sign in to comment.