import type { ContextManager } from '@openobserve/browser-core' import { objectEntries, shallowClone, getType, isMatchOption, matchList, TraceContextInjection, } from '@openobserve/browser-core' import type { RumConfiguration } from '../configuration' import type { RumFetchResolveContext, RumFetchStartContext, RumXhrCompleteContext, RumXhrStartContext, } from '../requestCollection' import type { RumSessionManager } from '../rumSessionManager' import { isSampled } from '../sampler/sampler' import type { PropagatorType, TracingOption } from './tracer.types' import type { SpanIdentifier, TraceIdentifier } from './identifier' import { createSpanIdentifier, createTraceIdentifier, toPaddedHexadecimalString } from './identifier' export interface Tracer { traceFetch: (context: Partial) => void traceXhr: (context: Partial, xhr: XMLHttpRequest) => void clearTracingIfNeeded: (context: RumFetchResolveContext | RumXhrCompleteContext) => void } interface TracingHeaders { [key: string]: string } export function isTracingOption(item: unknown): item is TracingOption { const expectedItem = item as TracingOption return ( getType(expectedItem) === 'object' && isMatchOption(expectedItem.match) && Array.isArray(expectedItem.propagatorTypes) ) } /** * Clear tracing information to avoid incomplete traces. Ideally, we should do it when the * request did not reach the server, but the browser does not expose this. So, we clear tracing * information if the request ended with status 0 without being aborted by the application. * * Reasoning: * * * Applications are usually aborting requests after a bit of time, for example when the user is * typing (autocompletion) or navigating away (in a SPA). With a performant device and good * network conditions, the request is likely to reach the server before being canceled. * * * Requests aborted otherwise (ex: lack of internet, CORS issue, blocked by a privacy extension) * are likely to finish quickly and without reaching the server. * * Of course, it might not be the case every time, but it should limit having incomplete traces a * bit. * */ export function clearTracingIfNeeded(context: RumFetchResolveContext | RumXhrCompleteContext) { if (context.status === 0 && !context.isAborted) { context.traceId = undefined context.spanId = undefined context.traceSampled = undefined } } export function startTracer( configuration: RumConfiguration, sessionManager: RumSessionManager, userContext: ContextManager, accountContext: ContextManager ): Tracer { return { clearTracingIfNeeded, traceFetch: (context) => injectHeadersIfTracingAllowed( configuration, context, sessionManager, userContext, accountContext, (tracingHeaders: TracingHeaders) => { if (context.input instanceof Request && !context.init?.headers) { context.input = new Request(context.input) Object.keys(tracingHeaders).forEach((key) => { ;(context.input as Request).headers.append(key, tracingHeaders[key]) }) } else { context.init = shallowClone(context.init) const headers: Array<[string, string]> = [] if (context.init.headers instanceof Headers) { context.init.headers.forEach((value, key) => { headers.push([key, value]) }) } else if (Array.isArray(context.init.headers)) { context.init.headers.forEach((header) => { headers.push(header) }) } else if (context.init.headers) { Object.keys(context.init.headers).forEach((key) => { headers.push([key, (context.init!.headers as Record)[key]]) }) } context.init.headers = headers.concat(objectEntries(tracingHeaders)) } } ), traceXhr: (context, xhr) => injectHeadersIfTracingAllowed( configuration, context, sessionManager, userContext, accountContext, (tracingHeaders: TracingHeaders) => { Object.keys(tracingHeaders).forEach((name) => { xhr.setRequestHeader(name, tracingHeaders[name]) }) } ), } } function injectHeadersIfTracingAllowed( configuration: RumConfiguration, context: Partial, sessionManager: RumSessionManager, userContext: ContextManager, accountContext: ContextManager, inject: (tracingHeaders: TracingHeaders) => void ) { const session = sessionManager.findTrackedSession() if (!session) { return } const tracingOption = configuration.allowedTracingUrls.find((tracingOption) => matchList([tracingOption.match], context.url!, true) ) if (!tracingOption) { return } const traceSampled = isSampled(session.id, configuration.traceSampleRate) const shouldInjectHeaders = traceSampled || configuration.traceContextInjection === TraceContextInjection.ALL if (!shouldInjectHeaders) { return } context.traceSampled = traceSampled context.traceId = createTraceIdentifier() context.spanId = createSpanIdentifier() inject( makeTracingHeaders( context.traceId, context.spanId, context.traceSampled, session.id, tracingOption.propagatorTypes, userContext, accountContext, configuration ) ) } /** * When trace is not sampled, set priority to '0' instead of not adding the tracing headers * to prepare the implementation for sampling delegation. */ function makeTracingHeaders( traceId: TraceIdentifier, spanId: SpanIdentifier, traceSampled: boolean, sessionId: string, propagatorTypes: PropagatorType[], userContext: ContextManager, accountContext: ContextManager, configuration: RumConfiguration ): TracingHeaders { const tracingHeaders: TracingHeaders = {} propagatorTypes.forEach((propagatorType) => { switch (propagatorType) { // https://www.w3.org/TR/trace-context/ case 'tracecontext': { Object.assign(tracingHeaders, { traceparent: `00-0000000000000000${toPaddedHexadecimalString(traceId)}-${toPaddedHexadecimalString(spanId)}-0${ traceSampled ? '1' : '0' }`, tracestate: `oo=s:${traceSampled ? '1' : '0'};o:rum`, }) break } // https://github.com/openzipkin/b3-propagation case 'b3': { Object.assign(tracingHeaders, { b3: `${toPaddedHexadecimalString(traceId)}-${toPaddedHexadecimalString(spanId)}-${traceSampled ? '1' : '0'}`, }) break } case 'b3multi': { Object.assign(tracingHeaders, { 'X-B3-TraceId': toPaddedHexadecimalString(traceId), 'X-B3-SpanId': toPaddedHexadecimalString(spanId), 'X-B3-Sampled': traceSampled ? '1' : '0', }) break } } }) if (configuration.propagateTraceBaggage) { const baggageItems: Record = { 'session.id': sessionId, } const userId = userContext.getContext().id if (typeof userId === 'string') { baggageItems['user.id'] = userId } const accountId = accountContext.getContext().id if (typeof accountId === 'string') { baggageItems['account.id'] = accountId } const baggageHeader = Object.entries(baggageItems) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join(',') if (baggageHeader) { tracingHeaders['baggage'] = baggageHeader } } return tracingHeaders }