|
| 1 | +/* eslint-disable max-lines */ |
| 2 | +import { |
| 3 | + AppContext, |
| 4 | + Contexts, |
| 5 | + CultureContext, |
| 6 | + DeviceContext, |
| 7 | + Event, |
| 8 | + EventProcessor, |
| 9 | + Integration, |
| 10 | + OsContext, |
| 11 | +} from '@sentry/types'; |
| 12 | +import { execFile } from 'child_process'; |
| 13 | +import { readdir, readFile } from 'fs'; |
| 14 | +import * as os from 'os'; |
| 15 | +import { join } from 'path'; |
| 16 | +import { promisify } from 'util'; |
| 17 | + |
| 18 | +// TODO: Required until we drop support for Node v8 |
| 19 | +export const readFileAsync = promisify(readFile); |
| 20 | +export const readDirAsync = promisify(readdir); |
| 21 | + |
| 22 | +interface DeviceContextOptions { |
| 23 | + cpu?: boolean; |
| 24 | + memory?: boolean; |
| 25 | +} |
| 26 | + |
| 27 | +interface ContextOptions { |
| 28 | + app?: boolean; |
| 29 | + os?: boolean; |
| 30 | + device?: DeviceContextOptions | boolean; |
| 31 | + culture?: boolean; |
| 32 | +} |
| 33 | + |
| 34 | +/** Add node modules / packages to the event */ |
| 35 | +export class Context implements Integration { |
| 36 | + /** |
| 37 | + * @inheritDoc |
| 38 | + */ |
| 39 | + public static id: string = 'Context'; |
| 40 | + |
| 41 | + /** |
| 42 | + * @inheritDoc |
| 43 | + */ |
| 44 | + public name: string = Context.id; |
| 45 | + |
| 46 | + /** |
| 47 | + * Caches contexts so they're only evaluated once |
| 48 | + */ |
| 49 | + private _cachedContexts: Contexts | undefined; |
| 50 | + |
| 51 | + public constructor(private readonly _options: ContextOptions = { app: true, os: true, device: true, culture: true }) { |
| 52 | + // |
| 53 | + } |
| 54 | + |
| 55 | + /** |
| 56 | + * @inheritDoc |
| 57 | + */ |
| 58 | + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void { |
| 59 | + addGlobalEventProcessor(event => this.addContext(event)); |
| 60 | + } |
| 61 | + |
| 62 | + /** Processes an event and adds context lines */ |
| 63 | + public async addContext(event: Event): Promise<Event> { |
| 64 | + if (this._cachedContexts === undefined) { |
| 65 | + this._cachedContexts = {}; |
| 66 | + |
| 67 | + if (this._options.os) { |
| 68 | + this._cachedContexts.os = await getOsContext(); |
| 69 | + } |
| 70 | + |
| 71 | + if (this._options.app) { |
| 72 | + this._cachedContexts.app = getAppContext(); |
| 73 | + } |
| 74 | + |
| 75 | + if (this._options.device) { |
| 76 | + this._cachedContexts.device = getDeviceContext(this._options.device); |
| 77 | + } |
| 78 | + |
| 79 | + if (this._options.culture) { |
| 80 | + const culture = getCultureContext(); |
| 81 | + |
| 82 | + if (culture) { |
| 83 | + this._cachedContexts.culture = culture; |
| 84 | + } |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + event.contexts = { ...event.contexts, ...this._cachedContexts }; |
| 89 | + |
| 90 | + return event; |
| 91 | + } |
| 92 | +} |
| 93 | + |
| 94 | +/** |
| 95 | + * Returns the operating system context. |
| 96 | + * |
| 97 | + * Based on the current platform, this uses a different strategy to provide the |
| 98 | + * most accurate OS information. Since this might involve spawning subprocesses |
| 99 | + * or accessing the file system, this should only be executed lazily and cached. |
| 100 | + * |
| 101 | + * - On macOS (Darwin), this will execute the `sw_vers` utility. The context |
| 102 | + * has a `name`, `version`, `build` and `kernel_version` set. |
| 103 | + * - On Linux, this will try to load a distribution release from `/etc` and set |
| 104 | + * the `name`, `version` and `kernel_version` fields. |
| 105 | + * - On all other platforms, only a `name` and `version` will be returned. Note |
| 106 | + * that `version` might actually be the kernel version. |
| 107 | + */ |
| 108 | +async function getOsContext(): Promise<OsContext> { |
| 109 | + const platformId = os.platform(); |
| 110 | + switch (platformId) { |
| 111 | + case 'darwin': |
| 112 | + return getDarwinInfo(); |
| 113 | + case 'linux': |
| 114 | + return getLinuxInfo(); |
| 115 | + default: |
| 116 | + return { |
| 117 | + name: PLATFORM_NAMES[platformId] || platformId, |
| 118 | + version: os.release(), |
| 119 | + }; |
| 120 | + } |
| 121 | +} |
| 122 | + |
| 123 | +function getCultureContext(): CultureContext | undefined { |
| 124 | + try { |
| 125 | + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any |
| 126 | + if ((process.versions as unknown as any).icu !== 'string') { |
| 127 | + // Node was built without ICU support |
| 128 | + return; |
| 129 | + } |
| 130 | + |
| 131 | + // Check that node was built with full Intl support. Its possible it was built without support for non-English |
| 132 | + // locales which will make resolvedOptions inaccurate |
| 133 | + // |
| 134 | + // https://nodejs.org/api/intl.html#detecting-internationalization-support |
| 135 | + const january = new Date(9e8); |
| 136 | + const spanish = new Intl.DateTimeFormat('es', { month: 'long' }); |
| 137 | + if (spanish.format(january) === 'enero') { |
| 138 | + const options = Intl.DateTimeFormat().resolvedOptions(); |
| 139 | + |
| 140 | + return { |
| 141 | + locale: options.locale, |
| 142 | + timezone: options.timeZone, |
| 143 | + }; |
| 144 | + } |
| 145 | + } catch (err) { |
| 146 | + // |
| 147 | + } |
| 148 | + |
| 149 | + return; |
| 150 | +} |
| 151 | + |
| 152 | +function getAppContext(): AppContext { |
| 153 | + const memory_used = process.memoryUsage().rss; |
| 154 | + const app_start_time = new Date(Date.now() - process.uptime() * 1000).toISOString(); |
| 155 | + |
| 156 | + return { app_start_time, memory_used }; |
| 157 | +} |
| 158 | + |
| 159 | +function getDeviceContext(deviceOpt: DeviceContextOptions | true): DeviceContext { |
| 160 | + const device: DeviceContext = {}; |
| 161 | + |
| 162 | + device.boot_time = new Date(Date.now() - os.uptime() * 1000).toISOString(); |
| 163 | + device.arch = os.arch(); |
| 164 | + |
| 165 | + if (deviceOpt === true || deviceOpt.memory) { |
| 166 | + device.memory_size = os.totalmem(); |
| 167 | + device.free_memory = os.freemem(); |
| 168 | + } |
| 169 | + |
| 170 | + if (deviceOpt === true || deviceOpt.cpu) { |
| 171 | + const cpuInfo: os.CpuInfo[] | undefined = os.cpus(); |
| 172 | + if (cpuInfo && cpuInfo.length) { |
| 173 | + const firstCpu = cpuInfo[0]; |
| 174 | + |
| 175 | + device.processor_count = cpuInfo.length; |
| 176 | + device.cpu_description = firstCpu.model; |
| 177 | + device.processor_frequency = firstCpu.speed; |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + return device; |
| 182 | +} |
| 183 | + |
| 184 | +/** Mapping of Node's platform names to actual OS names. */ |
| 185 | +const PLATFORM_NAMES: { [platform: string]: string } = { |
| 186 | + aix: 'IBM AIX', |
| 187 | + freebsd: 'FreeBSD', |
| 188 | + openbsd: 'OpenBSD', |
| 189 | + sunos: 'SunOS', |
| 190 | + win32: 'Windows', |
| 191 | +}; |
| 192 | + |
| 193 | +/** Linux version file to check for a distribution. */ |
| 194 | +interface DistroFile { |
| 195 | + /** The file name, located in `/etc`. */ |
| 196 | + name: string; |
| 197 | + /** Potential distributions to check. */ |
| 198 | + distros: string[]; |
| 199 | +} |
| 200 | + |
| 201 | +/** Mapping of linux release files located in /etc to distributions. */ |
| 202 | +const LINUX_DISTROS: DistroFile[] = [ |
| 203 | + { name: 'fedora-release', distros: ['Fedora'] }, |
| 204 | + { name: 'redhat-release', distros: ['Red Hat Linux', 'Centos'] }, |
| 205 | + { name: 'redhat_version', distros: ['Red Hat Linux'] }, |
| 206 | + { name: 'SuSE-release', distros: ['SUSE Linux'] }, |
| 207 | + { name: 'lsb-release', distros: ['Ubuntu Linux', 'Arch Linux'] }, |
| 208 | + { name: 'debian_version', distros: ['Debian'] }, |
| 209 | + { name: 'debian_release', distros: ['Debian'] }, |
| 210 | + { name: 'arch-release', distros: ['Arch Linux'] }, |
| 211 | + { name: 'gentoo-release', distros: ['Gentoo Linux'] }, |
| 212 | + { name: 'novell-release', distros: ['SUSE Linux'] }, |
| 213 | + { name: 'alpine-release', distros: ['Alpine Linux'] }, |
| 214 | +]; |
| 215 | + |
| 216 | +/** Functions to extract the OS version from Linux release files. */ |
| 217 | +const LINUX_VERSIONS: { |
| 218 | + [identifier: string]: (content: string) => string | undefined; |
| 219 | +} = { |
| 220 | + alpine: content => content, |
| 221 | + arch: content => matchFirst(/distrib_release=(.*)/, content), |
| 222 | + centos: content => matchFirst(/release ([^ ]+)/, content), |
| 223 | + debian: content => content, |
| 224 | + fedora: content => matchFirst(/release (..)/, content), |
| 225 | + mint: content => matchFirst(/distrib_release=(.*)/, content), |
| 226 | + red: content => matchFirst(/release ([^ ]+)/, content), |
| 227 | + suse: content => matchFirst(/VERSION = (.*)\n/, content), |
| 228 | + ubuntu: content => matchFirst(/distrib_release=(.*)/, content), |
| 229 | +}; |
| 230 | + |
| 231 | +/** |
| 232 | + * Executes a regular expression with one capture group. |
| 233 | + * |
| 234 | + * @param regex A regular expression to execute. |
| 235 | + * @param text Content to execute the RegEx on. |
| 236 | + * @returns The captured string if matched; otherwise undefined. |
| 237 | + */ |
| 238 | +function matchFirst(regex: RegExp, text: string): string | undefined { |
| 239 | + const match = regex.exec(text); |
| 240 | + return match ? match[1] : undefined; |
| 241 | +} |
| 242 | + |
| 243 | +/** Loads the macOS operating system context. */ |
| 244 | +async function getDarwinInfo(): Promise<OsContext> { |
| 245 | + // Default values that will be used in case no operating system information |
| 246 | + // can be loaded. The default version is computed via heuristics from the |
| 247 | + // kernel version, but the build ID is missing. |
| 248 | + const darwinInfo: OsContext = { |
| 249 | + kernel_version: os.release(), |
| 250 | + name: 'Mac OS X', |
| 251 | + version: `10.${Number(os.release().split('.')[0]) - 4}`, |
| 252 | + }; |
| 253 | + |
| 254 | + try { |
| 255 | + // We try to load the actual macOS version by executing the `sw_vers` tool. |
| 256 | + // This tool should be available on every standard macOS installation. In |
| 257 | + // case this fails, we stick with the values computed above. |
| 258 | + |
| 259 | + const output = await new Promise<string>((resolve, reject) => { |
| 260 | + execFile('/usr/bin/sw_vers', (error: Error | null, stdout: string) => { |
| 261 | + if (error) { |
| 262 | + reject(error); |
| 263 | + return; |
| 264 | + } |
| 265 | + resolve(stdout); |
| 266 | + }); |
| 267 | + }); |
| 268 | + |
| 269 | + darwinInfo.name = matchFirst(/^ProductName:\s+(.*)$/m, output); |
| 270 | + darwinInfo.version = matchFirst(/^ProductVersion:\s+(.*)$/m, output); |
| 271 | + darwinInfo.build = matchFirst(/^BuildVersion:\s+(.*)$/m, output); |
| 272 | + } catch (e) { |
| 273 | + // ignore |
| 274 | + } |
| 275 | + |
| 276 | + return darwinInfo; |
| 277 | +} |
| 278 | + |
| 279 | +/** Returns a distribution identifier to look up version callbacks. */ |
| 280 | +function getLinuxDistroId(name: string): string { |
| 281 | + return name.split(' ')[0].toLowerCase(); |
| 282 | +} |
| 283 | + |
| 284 | +/** Loads the Linux operating system context. */ |
| 285 | +async function getLinuxInfo(): Promise<OsContext> { |
| 286 | + // By default, we cannot assume anything about the distribution or Linux |
| 287 | + // version. `os.release()` returns the kernel version and we assume a generic |
| 288 | + // "Linux" name, which will be replaced down below. |
| 289 | + const linuxInfo: OsContext = { |
| 290 | + kernel_version: os.release(), |
| 291 | + name: 'Linux', |
| 292 | + }; |
| 293 | + |
| 294 | + try { |
| 295 | + // We start guessing the distribution by listing files in the /etc |
| 296 | + // directory. This is were most Linux distributions (except Knoppix) store |
| 297 | + // release files with certain distribution-dependent meta data. We search |
| 298 | + // for exactly one known file defined in `LINUX_DISTROS` and exit if none |
| 299 | + // are found. In case there are more than one file, we just stick with the |
| 300 | + // first one. |
| 301 | + const etcFiles = await readDirAsync('/etc'); |
| 302 | + const distroFile = LINUX_DISTROS.find(file => etcFiles.includes(file.name)); |
| 303 | + if (!distroFile) { |
| 304 | + return linuxInfo; |
| 305 | + } |
| 306 | + |
| 307 | + // Once that file is known, load its contents. To make searching in those |
| 308 | + // files easier, we lowercase the file contents. Since these files are |
| 309 | + // usually quite small, this should not allocate too much memory and we only |
| 310 | + // hold on to it for a very short amount of time. |
| 311 | + const distroPath = join('/etc', distroFile.name); |
| 312 | + const contents = ((await readFileAsync(distroPath, { encoding: 'utf-8' })) as string).toLowerCase(); |
| 313 | + |
| 314 | + // Some Linux distributions store their release information in the same file |
| 315 | + // (e.g. RHEL and Centos). In those cases, we scan the file for an |
| 316 | + // identifier, that basically consists of the first word of the linux |
| 317 | + // distribution name (e.g. "red" for Red Hat). In case there is no match, we |
| 318 | + // just assume the first distribution in our list. |
| 319 | + const { distros } = distroFile; |
| 320 | + linuxInfo.name = distros.find(d => contents.indexOf(getLinuxDistroId(d)) >= 0) || distros[0]; |
| 321 | + |
| 322 | + // Based on the found distribution, we can now compute the actual version |
| 323 | + // number. This is different for every distribution, so several strategies |
| 324 | + // are computed in `LINUX_VERSIONS`. |
| 325 | + const id = getLinuxDistroId(linuxInfo.name); |
| 326 | + linuxInfo.version = LINUX_VERSIONS[id](contents); |
| 327 | + } catch (e) { |
| 328 | + // ignore |
| 329 | + } |
| 330 | + |
| 331 | + return linuxInfo; |
| 332 | +} |
0 commit comments