@multiplayer-app/session-recorder-browser
Version:
Multiplayer Fullstack Session Recorder for Browser
252 lines • 12.6 kB
JavaScript
import { resourceFromAttributes } from '@opentelemetry/resources';
import { W3CTraceContextPropagator } from '@opentelemetry/core';
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import * as SemanticAttributes from '@opentelemetry/semantic-conventions';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { getWebAutoInstrumentations } from '@opentelemetry/auto-instrumentations-web';
import { SessionType, ATTR_MULTIPLAYER_SESSION_ID, SessionRecorderIdGenerator, SessionRecorderBrowserTraceExporter, SessionRecorderTraceIdRatioBasedSampler, } from '@multiplayer-app/session-recorder-common';
import { trace, SpanStatusCode, context } from '@opentelemetry/api';
import { OTEL_IGNORE_URLS } from '../config';
import { processHttpPayload, headersToObject, extractResponseBody, getExporterEndpoint, getElementTextContent, getElementInnerText, } from './helpers';
export class TracerBrowserSDK {
constructor() {
this.sessionId = '';
this.globalErrorListenersRegistered = false;
}
setSessionId(sessionId, sessionType = SessionType.PLAIN) {
this.sessionId = sessionId;
this.idGenerator.setSessionId(sessionId, sessionType);
}
init(options) {
this.config = options;
const { application, version, environment } = this.config;
this.idGenerator = new SessionRecorderIdGenerator();
this.exporter = new SessionRecorderBrowserTraceExporter({
apiKey: options.apiKey,
url: getExporterEndpoint(options.exporterEndpoint),
usePostMessageFallback: options.usePostMessageFallback,
});
this.tracerProvider = new WebTracerProvider({
resource: resourceFromAttributes({
[SemanticAttributes.SEMRESATTRS_SERVICE_NAME]: application,
[SemanticAttributes.SEMRESATTRS_SERVICE_VERSION]: version,
[SemanticAttributes.SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: environment,
}),
idGenerator: this.idGenerator,
sampler: new SessionRecorderTraceIdRatioBasedSampler(this.config.sampleTraceRatio),
spanProcessors: [
this._getSpanSessionIdProcessor(),
new BatchSpanProcessor(this.exporter),
],
});
this.tracerProvider.register({
// contextManager: new ZoneContextManager(),
propagator: new W3CTraceContextPropagator(),
});
registerInstrumentations({
tracerProvider: this.tracerProvider,
instrumentations: [
getWebAutoInstrumentations({
'@opentelemetry/instrumentation-xml-http-request': {
clearTimingResources: true,
ignoreUrls: [
...OTEL_IGNORE_URLS,
...(this.config.ignoreUrls || []),
],
propagateTraceHeaderCorsUrls: options.propagateTraceHeaderCorsUrls,
applyCustomAttributesOnSpan: (span, xhr) => {
if (!this.config)
return;
const { captureBody, captureHeaders } = this.config;
try {
if (!captureBody && !captureHeaders) {
return;
}
// @ts-ignore
const networkRequest = xhr.networkRequest;
const requestBody = networkRequest.requestBody;
const responseBody = networkRequest.responseBody;
const requestHeaders = networkRequest.requestHeaders || {};
const responseHeaders = networkRequest.responseHeaders || {};
const payload = {
requestBody,
responseBody,
requestHeaders,
responseHeaders,
};
processHttpPayload(payload, this.config, span);
}
catch (error) {
// eslint-disable-next-line
console.error('[MULTIPLAYER_SESSION_RECORDER] Failed to capture xml-http payload', error);
}
},
},
'@opentelemetry/instrumentation-fetch': {
clearTimingResources: true,
ignoreUrls: [
...OTEL_IGNORE_URLS,
...(this.config.ignoreUrls || []),
],
propagateTraceHeaderCorsUrls: options.propagateTraceHeaderCorsUrls,
applyCustomAttributesOnSpan: async (span, request, response) => {
if (!this.config)
return;
const { captureBody, captureHeaders } = this.config;
try {
if (!captureBody && !captureHeaders) {
return;
}
// Try to get data from our fetch wrapper first
// @ts-ignore
const networkRequest = response === null || response === void 0 ? void 0 : response.networkRequest;
let requestBody = null;
let responseBody = null;
let requestHeaders = {};
let responseHeaders = {};
if (networkRequest) {
// Use data captured by our fetch wrapper
requestBody = networkRequest.requestBody;
responseBody = networkRequest.responseBody;
requestHeaders = networkRequest.requestHeaders || {};
responseHeaders = networkRequest.responseHeaders || {};
}
else {
// Fallback to original OpenTelemetry approach
requestBody = request.body;
requestHeaders = headersToObject(request.headers);
responseHeaders = headersToObject(response instanceof Response ? response.headers : undefined);
if (response instanceof Response && response.body) {
responseBody = await extractResponseBody(response);
}
}
const payload = {
requestBody,
responseBody,
requestHeaders,
responseHeaders,
};
processHttpPayload(payload, this.config, span);
}
catch (error) {
// eslint-disable-next-line
console.error('[MULTIPLAYER_SESSION_RECORDER] Failed to capture fetch payload', error);
}
},
},
'@opentelemetry/instrumentation-user-interaction': {
shouldPreventSpanCreation: (_event, element, span) => {
if (span['parentSpanContext']) {
return true;
}
span.setAttribute('target.innerText', getElementInnerText(element));
span.setAttribute('target.textContent', getElementTextContent(element));
Array.from(element.attributes).forEach(attribute => {
span.setAttribute(`target.attribute.${attribute.name}`, attribute.value);
});
return false;
},
},
}),
],
});
this._registerGlobalErrorListeners();
}
start(sessionId, sessionType) {
if (!this.tracerProvider) {
throw new Error('Configuration not initialized. Call init() before start().');
}
this.setSessionId(sessionId, sessionType);
}
stop() {
if (!this.tracerProvider) {
throw new Error('Configuration not initialized. Call init() before start().');
}
this.setSessionId('');
}
setApiKey(apiKey) {
if (!this.exporter) {
throw new Error('Configuration not initialized. Call init() before setApiKey().');
}
this.exporter.setApiKey(apiKey);
}
/**
* Capture an exception as an error span/event.
* If there is an active span, the exception will be recorded on it.
* Otherwise, a short-lived span will be created to hold the exception event.
*/
captureException(error, errorInfo) {
if (!error)
return;
// Prefer attaching to the active span to keep correlation intact
try {
const activeSpan = trace.getSpan(context.active());
if (activeSpan) {
this._recordException(activeSpan, error, errorInfo);
return;
}
// eslint-disable-next-line
}
catch (_ignored) { }
// Fallback: create a short-lived span to hold the exception details
try {
const tracer = trace.getTracer('exception');
const span = tracer.startSpan(error.name || 'Error');
this._recordException(span, error, errorInfo);
span.end();
// eslint-disable-next-line
}
catch (_ignored) { }
}
_recordException(span, error, errorInfo) {
span.recordException(error);
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
span.setAttribute('exception.type', error.name || 'Error');
span.setAttribute('exception.message', error.message);
span.setAttribute('exception.stacktrace', error.stack || '');
if (errorInfo) {
Object.entries(errorInfo).forEach(([key, value]) => {
span.setAttribute(`error_info.${key}`, value);
});
}
}
_getSpanSessionIdProcessor() {
return {
forceFlush: () => Promise.resolve(),
onEnd: () => { },
shutdown: () => Promise.resolve(),
onStart: (span) => {
var _a;
if ((_a = this.sessionId) === null || _a === void 0 ? void 0 : _a.length) {
span.setAttribute(ATTR_MULTIPLAYER_SESSION_ID, this.sessionId);
}
},
};
}
_registerGlobalErrorListeners() {
if (this.globalErrorListenersRegistered)
return;
if (typeof window === 'undefined')
return;
// eslint-disable-next-line
const errorHandler = (event) => {
const err = (event === null || event === void 0 ? void 0 : event.error) instanceof Error
? event.error
: new Error((event === null || event === void 0 ? void 0 : event.message) || 'Script error');
this.captureException(err);
};
// eslint-disable-next-line
const rejectionHandler = (event) => {
const reason = (event && 'reason' in event) ? event.reason : undefined;
const err = reason instanceof Error
? reason
: new Error(typeof reason === 'string' ? reason : 'Unhandled promise rejection');
this.captureException(err);
};
window.addEventListener('error', errorHandler);
window.addEventListener('unhandledrejection', rejectionHandler);
this.globalErrorListenersRegistered = true;
}
}
//# sourceMappingURL=index.js.map