|
| 1 | +import { URL } from 'url'; |
| 2 | +import { defineIntegration, getCurrentScope } from '@sentry/core'; |
| 3 | +import type { Contexts, Event, EventHint, IntegrationFn } from '@sentry/types'; |
| 4 | +import { dynamicRequire, logger } from '@sentry/utils'; |
| 5 | +import type { Worker, WorkerOptions } from 'worker_threads'; |
| 6 | +import { NODE_MAJOR, NODE_VERSION } from '../../nodeVersion'; |
| 7 | +import type { NodeClient } from '../../sdk/client'; |
| 8 | +import type { AnrIntegrationOptions, WorkerStartData } from './common'; |
| 9 | +import { base64WorkerScript } from './worker-script'; |
| 10 | + |
| 11 | +const DEFAULT_INTERVAL = 50; |
| 12 | +const DEFAULT_HANG_THRESHOLD = 5000; |
| 13 | + |
| 14 | +type WorkerNodeV14 = Worker & { new (filename: string | URL, options?: WorkerOptions): Worker }; |
| 15 | + |
| 16 | +type WorkerThreads = { |
| 17 | + Worker: WorkerNodeV14; |
| 18 | +}; |
| 19 | + |
| 20 | +function log(message: string, ...args: unknown[]): void { |
| 21 | + logger.log(`[ANR] ${message}`, ...args); |
| 22 | +} |
| 23 | + |
| 24 | +/** |
| 25 | + * We need to use dynamicRequire because worker_threads is not available in node < v12 and webpack error will when |
| 26 | + * targeting those versions |
| 27 | + */ |
| 28 | +function getWorkerThreads(): WorkerThreads { |
| 29 | + return dynamicRequire(module, 'worker_threads'); |
| 30 | +} |
| 31 | + |
| 32 | +/** |
| 33 | + * Gets contexts by calling all event processors. This relies on being called after all integrations are setup |
| 34 | + */ |
| 35 | +async function getContexts(client: NodeClient): Promise<Contexts> { |
| 36 | + let event: Event | null = { message: 'ANR' }; |
| 37 | + const eventHint: EventHint = {}; |
| 38 | + |
| 39 | + for (const processor of client.getEventProcessors()) { |
| 40 | + if (event === null) break; |
| 41 | + event = await processor(event, eventHint); |
| 42 | + } |
| 43 | + |
| 44 | + return event?.contexts || {}; |
| 45 | +} |
| 46 | + |
| 47 | +interface InspectorApi { |
| 48 | + open: (port: number) => void; |
| 49 | + url: () => string | undefined; |
| 50 | +} |
| 51 | + |
| 52 | +const INTEGRATION_NAME = 'Anr'; |
| 53 | + |
| 54 | +const _anrIntegration = ((options: Partial<AnrIntegrationOptions> = {}) => { |
| 55 | + return { |
| 56 | + name: INTEGRATION_NAME, |
| 57 | + setup(client: NodeClient) { |
| 58 | + if (NODE_MAJOR < 16 || (NODE_MAJOR === 16 && (NODE_VERSION.minor || 0) < 17)) { |
| 59 | + throw new Error('ANR detection requires Node 16.17.0 or later'); |
| 60 | + } |
| 61 | + |
| 62 | + // setImmediate is used to ensure that all other integrations have been setup |
| 63 | + setImmediate(() => _startWorker(client, options)); |
| 64 | + }, |
| 65 | + }; |
| 66 | +}) satisfies IntegrationFn; |
| 67 | + |
| 68 | +export const anrIntegration = defineIntegration(_anrIntegration); |
| 69 | + |
| 70 | +/** |
| 71 | + * Starts the ANR worker thread |
| 72 | + */ |
| 73 | +async function _startWorker(client: NodeClient, _options: Partial<AnrIntegrationOptions>): Promise<void> { |
| 74 | + const contexts = await getContexts(client); |
| 75 | + const dsn = client.getDsn(); |
| 76 | + |
| 77 | + if (!dsn) { |
| 78 | + return; |
| 79 | + } |
| 80 | + |
| 81 | + // These will not be accurate if sent later from the worker thread |
| 82 | + delete contexts.app?.app_memory; |
| 83 | + delete contexts.device?.free_memory; |
| 84 | + |
| 85 | + const initOptions = client.getOptions(); |
| 86 | + |
| 87 | + const sdkMetadata = client.getSdkMetadata() || {}; |
| 88 | + if (sdkMetadata.sdk) { |
| 89 | + sdkMetadata.sdk.integrations = initOptions.integrations.map(i => i.name); |
| 90 | + } |
| 91 | + |
| 92 | + const options: WorkerStartData = { |
| 93 | + debug: logger.isEnabled(), |
| 94 | + dsn, |
| 95 | + environment: initOptions.environment || 'production', |
| 96 | + release: initOptions.release, |
| 97 | + dist: initOptions.dist, |
| 98 | + sdkMetadata, |
| 99 | + appRootPath: _options.appRootPath, |
| 100 | + pollInterval: _options.pollInterval || DEFAULT_INTERVAL, |
| 101 | + anrThreshold: _options.anrThreshold || DEFAULT_HANG_THRESHOLD, |
| 102 | + captureStackTrace: !!_options.captureStackTrace, |
| 103 | + staticTags: _options.staticTags || {}, |
| 104 | + contexts, |
| 105 | + }; |
| 106 | + |
| 107 | + if (options.captureStackTrace) { |
| 108 | + // eslint-disable-next-line @typescript-eslint/no-var-requires |
| 109 | + const inspector: InspectorApi = require('inspector'); |
| 110 | + if (!inspector.url()) { |
| 111 | + inspector.open(0); |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + const { Worker } = getWorkerThreads(); |
| 116 | + |
| 117 | + const worker = new Worker(new URL(`data:application/javascript;base64,${base64WorkerScript}`), { |
| 118 | + workerData: options, |
| 119 | + }); |
| 120 | + |
| 121 | + process.on('exit', () => { |
| 122 | + // eslint-disable-next-line @typescript-eslint/no-floating-promises |
| 123 | + worker.terminate(); |
| 124 | + }); |
| 125 | + |
| 126 | + const timer = setInterval(() => { |
| 127 | + try { |
| 128 | + const currentSession = getCurrentScope().getSession(); |
| 129 | + // We need to copy the session object and remove the toJSON method so it can be sent to the worker |
| 130 | + // serialized without making it a SerializedSession |
| 131 | + const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; |
| 132 | + // message the worker to tell it the main event loop is still running |
| 133 | + worker.postMessage({ session }); |
| 134 | + } catch (_) { |
| 135 | + // |
| 136 | + } |
| 137 | + }, options.pollInterval); |
| 138 | + // Timer should not block exit |
| 139 | + timer.unref(); |
| 140 | + |
| 141 | + worker.on('message', (msg: string) => { |
| 142 | + if (msg === 'session-ended') { |
| 143 | + log('ANR event sent from ANR worker. Clearing session in this thread.'); |
| 144 | + getCurrentScope().setSession(undefined); |
| 145 | + } |
| 146 | + }); |
| 147 | + |
| 148 | + worker.once('error', (err: Error) => { |
| 149 | + clearInterval(timer); |
| 150 | + log('ANR worker error', err); |
| 151 | + }); |
| 152 | + |
| 153 | + worker.once('exit', (code: number) => { |
| 154 | + clearInterval(timer); |
| 155 | + log('ANR worker exit', code); |
| 156 | + }); |
| 157 | + |
| 158 | + // Ensure this thread can't block app exit |
| 159 | + worker.unref(); |
| 160 | +} |
0 commit comments