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