Skip to content

Commit 859555d

Browse files
authored
add new DocumentLoadInstrumentation (#43)
* 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
1 parent ea73329 commit 859555d

7 files changed

Lines changed: 380 additions & 23 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Also see: https://github.com/signalfx/splunk-otel-js-web/blob/main/packages/web/src/SplunkDocumentLoadInstrumentation.ts
2+
import { InstrumentationConfig } from '@opentelemetry/instrumentation';
3+
import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load';
4+
import * as api from '@opentelemetry/api';
5+
import { captureTraceParentFromPerformanceEntries } from './servertiming';
6+
import { PerformanceEntries } from '@opentelemetry/sdk-trace-web';
7+
import { Span, Tracer } from '@opentelemetry/sdk-trace-base';
8+
9+
import { isTracingSuppressed } from '@opentelemetry/core/build/src/trace/suppress-tracing'
10+
import { sanitizeAttributes } from '@opentelemetry/core/build/src/common/attributes';
11+
import { TransactionSpanManager } from './transactionSpanManager';
12+
13+
export interface DocumentLoadServerTimingInstrumentationConfig extends InstrumentationConfig {
14+
recordTransaction: boolean;
15+
exporterDelay: number;
16+
}
17+
18+
/**
19+
* Patch the Tracer class to use the transaction span as root span
20+
*/
21+
export function patchTracer() {
22+
// Overwrite startSpan in Tracer class
23+
// Copy of the original startSpan()-function with additional logic inside the function to determine the parentContext
24+
Tracer.prototype.startSpan = function (
25+
name: string,
26+
options: api.SpanOptions = {},
27+
context = api.context.active()
28+
) {
29+
30+
if (isTracingSuppressed(context)) {
31+
api.diag.debug('Instrumentation suppressed, returning Noop Span');
32+
return api.trace.wrapSpanContext(api.INVALID_SPAN_CONTEXT);
33+
}
34+
35+
let parentContext; //getParent(options, context);
36+
if(options.root) parentContext = undefined;
37+
else parentContext = api.trace.getSpanContext(context);
38+
39+
if(!parentContext) {
40+
const transactionSpan = TransactionSpanManager.getTransactionSpan();
41+
if(transactionSpan)
42+
parentContext = transactionSpan.spanContext();
43+
}
44+
45+
// Use transaction span-ID for documentLoadSpan, if existing
46+
let spanId = this._idGenerator.generateSpanId();
47+
if(name == "documentLoad") {
48+
const transactionSpanId = TransactionSpanManager.getTransactionSpanId();
49+
if(transactionSpanId) spanId = transactionSpanId;
50+
}
51+
52+
let traceId;
53+
let traceState;
54+
let parentSpanId;
55+
if (!parentContext || !api.trace.isSpanContextValid(parentContext)) {
56+
// New root span.
57+
traceId = this._idGenerator.generateTraceId();
58+
} else {
59+
// New child span.
60+
traceId = parentContext.traceId;
61+
traceState = parentContext.traceState;
62+
parentSpanId = parentContext.spanId;
63+
}
64+
65+
const spanKind = options.kind ?? api.SpanKind.INTERNAL;
66+
const links = options.links ?? [];
67+
const attributes = sanitizeAttributes(options.attributes);
68+
// make sampling decision
69+
const samplingResult = this._sampler.shouldSample(
70+
options.root
71+
? api.trace.setSpanContext(context, api.INVALID_SPAN_CONTEXT)
72+
: context,
73+
traceId,
74+
name,
75+
spanKind,
76+
attributes,
77+
links
78+
);
79+
80+
const traceFlags =
81+
samplingResult.decision === api.SamplingDecision.RECORD_AND_SAMPLED
82+
? api.TraceFlags.SAMPLED
83+
: api.TraceFlags.NONE;
84+
const spanContext = { traceId, spanId, traceFlags, traceState };
85+
if (samplingResult.decision === api.SamplingDecision.NOT_RECORD) {
86+
api.diag.debug('Recording is off, propagating context in a non-recording span');
87+
return api.trace.wrapSpanContext(spanContext);
88+
}
89+
90+
const span = new Span(
91+
this,
92+
context,
93+
name,
94+
spanContext,
95+
spanKind,
96+
parentSpanId,
97+
links,
98+
options.startTime
99+
);
100+
// Set default attributes
101+
span.setAttributes(Object.assign(attributes, samplingResult.attributes));
102+
return span;
103+
}
104+
}
105+
106+
type PerformanceEntriesWithServerTiming = PerformanceEntries & {serverTiming?: ReadonlyArray<({name: string, duration: number, description: string})>}
107+
type ExposedDocumentLoadSuper = {
108+
_startSpan(spanName: string, performanceName: string, entries: PerformanceEntries, parentSpan?: Span): api.Span | undefined;
109+
_endSpan(span: api.Span | undefined, performanceName: string, entries: PerformanceEntries): void;
110+
}
111+
112+
export class DocumentLoadServerTimingInstrumentation extends DocumentLoadInstrumentation {
113+
readonly component: string = 'document-load-server-timing';
114+
moduleName = this.component;
115+
116+
constructor(config: DocumentLoadServerTimingInstrumentationConfig) {
117+
super(config);
118+
const exposedSuper = this as any as ExposedDocumentLoadSuper;
119+
const _superStartSpan: ExposedDocumentLoadSuper['_startSpan'] = exposedSuper._startSpan.bind(this);
120+
const _superEndSpan: ExposedDocumentLoadSuper['_endSpan'] = exposedSuper._endSpan.bind(this);
121+
122+
exposedSuper._startSpan = (spanName, performanceName, entries, parentSpan) => {
123+
if (!(entries as PerformanceEntriesWithServerTiming).serverTiming && performance.getEntriesByType) {
124+
const navEntries = performance.getEntriesByType('navigation');
125+
// @ts-ignore
126+
if (navEntries[0]?.serverTiming) {
127+
// @ts-ignore
128+
(entries as PerformanceEntriesWithServerTiming).serverTiming = navEntries[0].serverTiming;
129+
}
130+
}
131+
132+
captureTraceParentFromPerformanceEntries(entries);
133+
const span = _superStartSpan(spanName, performanceName, entries, parentSpan);
134+
const exposedSpan = span as any as Span;
135+
if(exposedSpan.name == "documentLoad") TransactionSpanManager.setTransactionSpan(span);
136+
137+
return span;
138+
}
139+
140+
exposedSuper._endSpan = (span, performanceName, entries) => {
141+
142+
const transactionSpan = TransactionSpanManager.getTransactionSpan();
143+
// Don't close transactionSpan
144+
// transactionSpan will be closed through "beforeunload"-event
145+
if(transactionSpan && transactionSpan == span) return;
146+
147+
return _superEndSpan(span, performanceName, entries);
148+
};
149+
}
150+
}

src/impl/index.ts

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ import { UserInteractionInstrumentation } from '@opentelemetry/instrumentation-u
3131
import { PluginProperties, ContextFunction, PropagationHeader } from '../types';
3232
import { patchExporter, patchExporterClass } from './patchCollectorPrototype';
3333
import { MultiSpanProcessor, CustomSpanProcessor } from './spanProcessing';
34+
import { DocumentLoadServerTimingInstrumentation, patchTracer } from './documentLoadInstrumentation';
35+
import { CustomIdGenerator } from './transactionIdGeneration';
36+
import { TransactionSpanManager } from './transactionSpanManager';
3437

3538
/**
3639
* TODOs:
@@ -69,6 +72,8 @@ export default class OpenTelemetryTracingImpl {
6972
instrument_document_load: {
7073
enabled: false,
7174
path: "",
75+
recordTransaction: false,
76+
exporterDelay: 20
7277
},
7378
instrument_user_interaction: {
7479
enabled: false,
@@ -99,6 +104,7 @@ export default class OpenTelemetryTracingImpl {
99104
private traceProvider: WebTracerProvider;
100105

101106
private customSpanProcessor = new CustomSpanProcessor();
107+
private customIdGenerator = new CustomIdGenerator();
102108

103109
public register = () => {
104110
// return if already initialized
@@ -112,6 +118,7 @@ export default class OpenTelemetryTracingImpl {
112118
// the configuration used by the tracer
113119
const tracerConfiguration: WebTracerConfig = {
114120
sampler: this.resolveSampler(),
121+
idGenerator: this.customIdGenerator
115122
};
116123

117124
// create provider
@@ -169,6 +176,21 @@ export default class OpenTelemetryTracingImpl {
169176
// store the webtracer
170177
this.traceProvider = providerWithZone;
171178

179+
// If recordTransaction is enabled, patch the Tracer to always use the transaction span as root span
180+
// and initialize the transaction data storage
181+
if(this.isTransactionRecordingEnabled()) {
182+
patchTracer();
183+
const delay = this.props.plugins_config?.instrument_document_load?.exporterDelay;
184+
TransactionSpanManager.initialize(true, this.customIdGenerator);
185+
186+
window.addEventListener("beforeunload", (event) => {
187+
TransactionSpanManager.getTransactionSpan().end();
188+
this.traceProvider.forceFlush();
189+
//Synchronous blocking is necessary, so the span can be exported successfully
190+
this.sleep(delay);
191+
});
192+
}
193+
172194
// mark plugin initalized
173195
this.initialized = true;
174196
};
@@ -189,10 +211,26 @@ export default class OpenTelemetryTracingImpl {
189211
this.customSpanProcessor.addCustomAttribute(key,value);
190212
}
191213

214+
public startNewTransaction = (spanName: string) => {
215+
TransactionSpanManager.startNewTransaction(spanName);
216+
}
217+
192218
public setBeaconUrl = (url: string) => {
193219
this.beaconUrl = url;
194220
};
195221

222+
private isTransactionRecordingEnabled = (): boolean => {
223+
return this.props.plugins_config?.instrument_document_load?.recordTransaction;
224+
}
225+
226+
private sleep = (delay: number) => {
227+
//Use 20 ms as default
228+
if(!delay) delay = 20;
229+
230+
const start = new Date().getTime();
231+
while (new Date().getTime() < start + delay);
232+
}
233+
196234
/**
197235
* @returns Returns the configured context propagator for injecting the trace context into HTTP request headers.
198236
*/
@@ -267,6 +305,25 @@ export default class OpenTelemetryTracingImpl {
267305
const { plugins, corsUrls, plugins_config } = this.props;
268306
const instrumentations: any = [];
269307

308+
// Instrumentation for the document on load (initial request)
309+
if (plugins_config?.instrument_document_load?.enabled !== false) {
310+
if(this.isTransactionRecordingEnabled())
311+
instrumentations.push(new DocumentLoadServerTimingInstrumentation(plugins_config.instrument_document_load));
312+
else
313+
instrumentations.push(new DocumentLoadInstrumentation(plugins_config.instrument_document_load));
314+
}
315+
else if (plugins?.instrument_document_load !== false) {
316+
instrumentations.push(new DocumentLoadInstrumentation());
317+
}
318+
319+
// Instrumentation for user interactions
320+
if (plugins_config?.instrument_user_interaction?.enabled !== false) {
321+
instrumentations.push(new UserInteractionInstrumentation(plugins_config.instrument_user_interaction));
322+
}
323+
else if (plugins?.instrument_user_interaction !== false) {
324+
instrumentations.push(new UserInteractionInstrumentation());
325+
}
326+
270327
// XMLHttpRequest Instrumentation for web plugin
271328
if (plugins_config?.instrument_xhr?.enabled !== false) {
272329
instrumentations.push(new XMLHttpRequestInstrumentation(plugins_config.instrument_xhr));
@@ -287,22 +344,6 @@ export default class OpenTelemetryTracingImpl {
287344
instrumentations.push(new FetchInstrumentation());
288345
}
289346

290-
// Instrumentation for the document on load (initial request)
291-
if (plugins_config?.instrument_document_load?.enabled !== false) {
292-
instrumentations.push(new DocumentLoadInstrumentation(plugins_config.instrument_document_load));
293-
}
294-
else if (plugins?.instrument_document_load !== false) {
295-
instrumentations.push(new DocumentLoadInstrumentation());
296-
}
297-
298-
// Instrumentation for user interactions
299-
if (plugins_config?.instrument_user_interaction?.enabled !== false) {
300-
instrumentations.push(new UserInteractionInstrumentation(plugins_config.instrument_user_interaction));
301-
}
302-
else if (plugins?.instrument_user_interaction !== false) {
303-
instrumentations.push(new UserInteractionInstrumentation());
304-
}
305-
306347
return instrumentations;
307348
};
308349

src/impl/servertiming.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Also see: https://github.com/signalfx/splunk-otel-js-web/blob/main/packages/web/src/servertiming.ts
2+
import { PerformanceEntries } from '@opentelemetry/sdk-trace-web';
3+
import { TransactionSpanManager } from './transactionSpanManager';
4+
5+
function setTransactionIds(match: RegExpMatchArray): void {
6+
if (match && match[1] && match[2]) {
7+
const traceId = match[1];
8+
const spanId = match[2];
9+
TransactionSpanManager.setTransactionTraceId(traceId);
10+
TransactionSpanManager.setTransactionSpanId(spanId);
11+
}
12+
}
13+
14+
const ValueRegex = new RegExp('00-([0-9a-f]{32})-([0-9a-f]{16})-01');
15+
16+
export function captureTraceParentFromPerformanceEntries(entries: PerformanceEntries): void {
17+
if (!(entries as any).serverTiming) {
18+
return;
19+
}
20+
for(const st of (entries as any).serverTiming) {
21+
if (st.name === 'traceparent' && st.description) {
22+
const match = st.description.match(ValueRegex);
23+
setTransactionIds(match);
24+
}
25+
}
26+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { IdGenerator } from '@opentelemetry/core';
2+
import { TransactionSpanManager } from './transactionSpanManager';
3+
4+
const SPAN_ID_BYTES = 8;
5+
const TRACE_ID_BYTES = 16;
6+
const SHARED_BUFFER = Buffer.allocUnsafe(TRACE_ID_BYTES);
7+
8+
// Copy of RandomIdGenerator (@opentelemetry/core) with additional getTransactionTraceId()-function
9+
export class CustomIdGenerator implements IdGenerator {
10+
11+
/**
12+
* Returns a random 16-byte trace ID formatted/encoded as a 32 lowercase hex
13+
* characters corresponding to 128 bits.
14+
*/
15+
get generateTraceId(): () => string {
16+
return this.getTransactionTraceId();
17+
}
18+
19+
/**
20+
* Returns a random 8-byte span ID formatted/encoded as a 16 lowercase hex
21+
* characters corresponding to 64 bits.
22+
*/
23+
get generateSpanId(): () => string {
24+
return this.getIdGenerator(SPAN_ID_BYTES);
25+
}
26+
27+
/**
28+
* If there is a transaction-trace-id, use it
29+
* Otherwise, generate a new trace-id the ordinary way
30+
*/
31+
getTransactionTraceId(): () => string {
32+
const transactionTraceId = TransactionSpanManager.getTransactionTraceId();
33+
// Use current transaction trace ID, if existing
34+
if(transactionTraceId) return () => transactionTraceId;
35+
else return this.getIdGenerator(TRACE_ID_BYTES);
36+
}
37+
38+
getIdGenerator(bytes: number): () => string {
39+
return function generateId() {
40+
for (let i = 0; i < bytes / 4; i++) {
41+
// unsigned right shift drops decimal part of the number
42+
// 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
43+
SHARED_BUFFER.writeUInt32BE((Math.random() * 2 ** 32) >>> 0, i * 4);
44+
}
45+
46+
// If buffer is all 0, set the last byte to 1 to guarantee a valid w3c id is generated
47+
for (let i = 0; i < bytes; i++) {
48+
if (SHARED_BUFFER[i] > 0) {
49+
break;
50+
} else if (i === bytes - 1) {
51+
SHARED_BUFFER[bytes - 1] = 1;
52+
}
53+
}
54+
55+
return SHARED_BUFFER.toString('hex', 0, bytes);
56+
};
57+
}
58+
}

0 commit comments

Comments
 (0)