Skip to content

Commit c508a9a

Browse files
committed
Add additional context for Node
1 parent 6ea53ed commit c508a9a

File tree

5 files changed

+415
-3
lines changed

5 files changed

+415
-3
lines changed
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
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+
}

packages/node/src/integrations/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { OnUnhandledRejection } from './onunhandledrejection';
55
export { LinkedErrors } from './linkederrors';
66
export { Modules } from './modules';
77
export { ContextLines } from './contextlines';
8+
export { Context } from './context';

packages/node/src/sdk.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,15 @@ import * as domain from 'domain';
1818
import * as url from 'url';
1919

2020
import { NodeClient } from './client';
21-
import { Console, ContextLines, Http, LinkedErrors, OnUncaughtException, OnUnhandledRejection } from './integrations';
21+
import {
22+
Console,
23+
Context,
24+
ContextLines,
25+
Http,
26+
LinkedErrors,
27+
OnUncaughtException,
28+
OnUnhandledRejection,
29+
} from './integrations';
2230
import { getModule } from './module';
2331
import { makeNodeTransport } from './transports';
2432
import { NodeClientOptions, NodeOptions } from './types';
@@ -36,6 +44,7 @@ export const defaultIntegrations = [
3644
new OnUnhandledRejection(),
3745
// Misc
3846
new LinkedErrors(),
47+
new Context(),
3948
];
4049

4150
/**

0 commit comments

Comments
 (0)