UNPKG

@sentry/browser

Version:
305 lines (302 loc) 10.8 kB
import { debug, uuid4, getGlobalScope, getRootSpan, getSdkMetadataForEnvelopeHeader, createEnvelope, dsnToString } from '@sentry/core/browser'; import { DEBUG_BUILD } from '../debug-build.js'; import { shouldProfileSession, setThreadAttributes, startJSSelfProfile, createProfileChunkPayload, validateProfileChunk } from './utils.js'; const CHUNK_INTERVAL_MS = 6e4; const MAX_ROOT_SPAN_PROFILE_MS = 3e5; class UIProfiler { constructor() { this._client = void 0; this._profiler = void 0; this._chunkTimer = void 0; this._profilerId = void 0; this._isRunning = false; this._sessionSampled = false; this._lifecycleMode = void 0; this._activeRootSpanIds = /* @__PURE__ */ new Set(); this._rootSpanTimeouts = /* @__PURE__ */ new Map(); } /** * Initialize the profiler with client, session sampling and lifecycle mode. */ initialize(client) { const lifecycleMode = client.getOptions().profileLifecycle; const sessionSampled = shouldProfileSession(client.getOptions()); DEBUG_BUILD && debug.log(`[Profiling] Initializing profiler (lifecycle='${lifecycleMode}').`); if (!sessionSampled) { DEBUG_BUILD && debug.log("[Profiling] Session not sampled. Skipping lifecycle profiler initialization."); } this._profilerId = uuid4(); this._client = client; this._sessionSampled = sessionSampled; this._lifecycleMode = lifecycleMode; if (lifecycleMode === "trace") { this._setupTraceLifecycleListeners(client); } client.on("spanStart", (span) => { if (this._isRunning) { setThreadAttributes(span); } }); } /** Starts UI profiling (only effective in 'manual' mode and when sampled). */ start() { if (this._lifecycleMode === "trace") { DEBUG_BUILD && debug.warn( '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.start()` are ignored in trace mode.' ); return; } if (this._isRunning) { DEBUG_BUILD && debug.warn("[Profiling] Profile session is already running, `uiProfiler.start()` is a no-op."); return; } if (!this._sessionSampled) { DEBUG_BUILD && debug.warn("[Profiling] Session is not sampled, `uiProfiler.start()` is a no-op."); return; } this._beginProfiling(); } /** Stops UI profiling (only effective in 'manual' mode). */ stop() { if (this._lifecycleMode === "trace") { DEBUG_BUILD && debug.warn( '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.stop()` are ignored in trace mode.' ); return; } if (!this._isRunning) { DEBUG_BUILD && debug.warn("[Profiling] Profiler is not running, `uiProfiler.stop()` is a no-op."); return; } this._endProfiling(); } /** Handle an already-active root span at integration setup time (used only in trace mode). */ notifyRootSpanActive(rootSpan) { if (this._lifecycleMode !== "trace" || !this._sessionSampled) { return; } const spanId = rootSpan.spanContext().spanId; if (!spanId || this._activeRootSpanIds.has(spanId)) { return; } this._registerTraceRootSpan(spanId); const rootSpanCount = this._activeRootSpanIds.size; if (rootSpanCount === 1) { DEBUG_BUILD && debug.log("[Profiling] Detected already active root span during setup. Active root spans now:", rootSpanCount); this._beginProfiling(); } if (this._isRunning) { setThreadAttributes(rootSpan); } } /** * Begin profiling if not already running. */ _beginProfiling() { if (this._isRunning) { return; } this._isRunning = true; DEBUG_BUILD && debug.log("[Profiling] Started profiling with profiler ID:", this._profilerId); getGlobalScope().setContext("profile", { profiler_id: this._profilerId }); this._startProfilerInstance(); if (!this._profiler) { DEBUG_BUILD && debug.log("[Profiling] Failed to start JS Profiler; stopping."); this._resetProfilerInfo(); return; } this._startPeriodicChunking(); } /** End profiling session; final chunk will be collected and sent. */ _endProfiling() { if (!this._isRunning) { return; } this._isRunning = false; if (this._chunkTimer) { clearTimeout(this._chunkTimer); this._chunkTimer = void 0; } this._clearAllRootSpanTimeouts(); this._collectCurrentChunk().catch((e) => { DEBUG_BUILD && debug.error("[Profiling] Failed to collect current profile chunk on `stop()`:", e); }); if (this._lifecycleMode === "manual") { getGlobalScope().setContext("profile", {}); } } /** Trace-mode: attach spanStart/spanEnd listeners. */ _setupTraceLifecycleListeners(client) { client.on("spanStart", (span) => { if (!this._sessionSampled) { DEBUG_BUILD && debug.log("[Profiling] Span not profiled because of negative sampling decision for user session."); return; } if (span !== getRootSpan(span)) { return; } if (!span.isRecording()) { DEBUG_BUILD && debug.log("[Profiling] Discarding profile because root span was not sampled."); return; } const spanId = span.spanContext().spanId; if (!spanId || this._activeRootSpanIds.has(spanId)) { return; } this._registerTraceRootSpan(spanId); const rootSpanCount = this._activeRootSpanIds.size; if (rootSpanCount === 1) { DEBUG_BUILD && debug.log( `[Profiling] Root span ${spanId} started. Profiling active while there are active root spans (count=${rootSpanCount}).` ); this._beginProfiling(); } }); client.on("spanEnd", (span) => { if (!this._sessionSampled) { return; } const spanId = span.spanContext().spanId; if (!spanId || !this._activeRootSpanIds.has(spanId)) { return; } this._activeRootSpanIds.delete(spanId); const rootSpanCount = this._activeRootSpanIds.size; DEBUG_BUILD && debug.log( `[Profiling] Root span with ID ${spanId} ended. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).` ); if (rootSpanCount === 0) { this._collectCurrentChunk().catch((e) => { DEBUG_BUILD && debug.error("[Profiling] Failed to collect current profile chunk on last `spanEnd`:", e); }); this._endProfiling(); } }); } /** * Resets profiling information from scope and resets running state (used on failure) */ _resetProfilerInfo() { this._isRunning = false; getGlobalScope().setContext("profile", {}); } /** * Clear and reset all per-root-span timeouts. */ _clearAllRootSpanTimeouts() { this._rootSpanTimeouts.forEach((timeout) => clearTimeout(timeout)); this._rootSpanTimeouts.clear(); } /** Keep track of root spans and schedule safeguard timeout (trace mode). */ _registerTraceRootSpan(spanId) { this._activeRootSpanIds.add(spanId); const timeout = setTimeout(() => this._onRootSpanTimeout(spanId), MAX_ROOT_SPAN_PROFILE_MS); this._rootSpanTimeouts.set(spanId, timeout); } /** * Start a profiler instance if needed. */ _startProfilerInstance() { if (this._profiler?.stopped === false) { return; } const profiler = startJSSelfProfile(); if (!profiler) { DEBUG_BUILD && debug.log("[Profiling] Failed to start JS Profiler."); return; } this._profiler = profiler; } /** * Schedule the next 60s chunk while running. * Each tick collects a chunk and restarts the profiler. * A chunk should be closed when there are no active root spans anymore OR when the maximum chunk interval is reached. */ _startPeriodicChunking() { if (!this._isRunning) { return; } this._chunkTimer = setTimeout(() => { this._collectCurrentChunk().catch((e) => { DEBUG_BUILD && debug.error("[Profiling] Failed to collect current profile chunk during periodic chunking:", e); }); if (this._isRunning) { this._startProfilerInstance(); if (!this._profiler) { this._resetProfilerInfo(); return; } this._startPeriodicChunking(); } }, CHUNK_INTERVAL_MS); } /** * Handle timeout for a specific root span ID to avoid indefinitely running profiler if `spanEnd` never fires. * If this was the last active root span, collect the current chunk and stop profiling. */ _onRootSpanTimeout(rootSpanId) { if (!this._rootSpanTimeouts.has(rootSpanId)) { return; } this._rootSpanTimeouts.delete(rootSpanId); if (!this._activeRootSpanIds.has(rootSpanId)) { return; } DEBUG_BUILD && debug.log( `[Profiling] Reached 5-minute timeout for root span ${rootSpanId}. You likely started a manual root span that never called \`.end()\`.` ); this._activeRootSpanIds.delete(rootSpanId); if (this._activeRootSpanIds.size === 0) { this._endProfiling(); } } /** * Stop current profiler instance, convert profile to chunk & send. */ async _collectCurrentChunk() { const prevProfiler = this._profiler; this._profiler = void 0; if (!prevProfiler) { return; } try { const profile = await prevProfiler.stop(); const chunk = createProfileChunkPayload(profile, this._client, this._profilerId); const validationReturn = validateProfileChunk(chunk); if ("reason" in validationReturn) { DEBUG_BUILD && debug.log( "[Profiling] Discarding invalid profile chunk (this is probably a bug in the SDK):", validationReturn.reason ); return; } this._sendProfileChunk(chunk); DEBUG_BUILD && debug.log("[Profiling] Collected browser profile chunk."); } catch (e) { DEBUG_BUILD && debug.log("[Profiling] Error while stopping JS Profiler for chunk:", e); } } /** * Send a profile chunk as a standalone envelope. */ _sendProfileChunk(chunk) { const client = this._client; const sdkInfo = getSdkMetadataForEnvelopeHeader(client.getSdkMetadata?.()); const dsn = client.getDsn(); const tunnel = client.getOptions().tunnel; const envelope = createEnvelope( { event_id: uuid4(), sent_at: (/* @__PURE__ */ new Date()).toISOString(), ...sdkInfo && { sdk: sdkInfo }, ...!!tunnel && dsn && { dsn: dsnToString(dsn) } }, [[{ type: "profile_chunk", platform: "javascript" }, chunk]] ); client.sendEnvelope(envelope).then(null, (reason) => { DEBUG_BUILD && debug.error("Error while sending profile chunk envelope:", reason); }); } } export { UIProfiler }; //# sourceMappingURL=UIProfiler.js.map