@flareapp/node 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ import { Readable } from 'node:stream';
2
+
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { captureBody, DEFAULT_BODY_CONTENT_TYPES, DEFAULT_BODY_KEY_DENYLIST } from '../src/context/body';
6
+
7
+ const opts = {
8
+ bodyAllowedContentTypes: DEFAULT_BODY_CONTENT_TYPES,
9
+ bodyKeyDenylist: DEFAULT_BODY_KEY_DENYLIST,
10
+ bodyMaxBytes: 16_384,
11
+ };
12
+
13
+ describe('captureBody', () => {
14
+ it('returns null for empty body', () => {
15
+ expect(captureBody(undefined, 'application/json', opts)).toBeNull();
16
+ });
17
+
18
+ it('parses JSON body and redacts password key', () => {
19
+ const out = captureBody('{"user":"x","password":"secret"}', 'application/json', opts);
20
+ const parsed = JSON.parse(out!);
21
+ expect(parsed).toEqual({ user: 'x', password: '[redacted]' });
22
+ });
23
+
24
+ it('accepts content-type with parameters', () => {
25
+ const out = captureBody('{"a":1}', 'application/json; charset=utf-8', opts);
26
+ expect(out).toBe('{"a":1}');
27
+ });
28
+
29
+ it('normalizes the media type so a strict custom regex matches parameterized headers', () => {
30
+ const strict = { ...opts, bodyAllowedContentTypes: /^application\/json$/ };
31
+ // Anchored regex would reject the raw header `application/json; charset=utf-8`;
32
+ // matchesContentType strips params and lowercases first, so it matches.
33
+ expect(captureBody('{"a":1}', 'application/json; charset=utf-8', strict)).toBe('{"a":1}');
34
+ expect(captureBody('{"a":1}', 'APPLICATION/JSON', strict)).toBe('{"a":1}');
35
+ });
36
+
37
+ it('rejects content-types not in allowlist', () => {
38
+ expect(captureBody('hello', 'text/plain', opts)).toBeNull();
39
+ });
40
+
41
+ it('decodes Buffer input as UTF-8', () => {
42
+ const out = captureBody(Buffer.from('{"k":1}'), 'application/json', opts);
43
+ expect(out).toBe('{"k":1}');
44
+ });
45
+
46
+ it('skips content-type check when body is already an object', () => {
47
+ const out = captureBody({ a: 1, token: 'x' }, undefined, opts);
48
+ expect(JSON.parse(out!)).toEqual({ a: 1, token: '[redacted]' });
49
+ });
50
+
51
+ it('handles URLSearchParams', () => {
52
+ const out = captureBody(new URLSearchParams({ a: '1', secret: 'x' }), undefined, opts);
53
+ expect(JSON.parse(out!)).toEqual({ a: '1', secret: '[redacted]' });
54
+ });
55
+
56
+ it('truncates over bodyMaxBytes', () => {
57
+ const big = { v: 'x'.repeat(20_000) };
58
+ const out = captureBody(big, undefined, { ...opts, bodyMaxBytes: 100 });
59
+ expect(Buffer.byteLength(out!, 'utf8')).toBeLessThanOrEqual(100);
60
+ expect(out!.endsWith('…[truncated]')).toBe(true);
61
+ });
62
+
63
+ it('truncates by UTF-8 byte length, not character length, for multi-byte payloads', () => {
64
+ // Three-byte char (CJK) repeated. 200 chars = 600 UTF-8 bytes.
65
+ const big = { v: '漢'.repeat(200) };
66
+ const out = captureBody(big, undefined, { ...opts, bodyMaxBytes: 100 });
67
+ expect(out).not.toBeNull();
68
+ expect(Buffer.byteLength(out!, 'utf8')).toBeLessThanOrEqual(100);
69
+ expect(out!.endsWith('…[truncated]')).toBe(true);
70
+ });
71
+
72
+ it('never leaves a partial multi-byte sequence at the cut', () => {
73
+ const big = { v: '漢'.repeat(200) };
74
+ const out = captureBody(big, undefined, { ...opts, bodyMaxBytes: 50 });
75
+ // Decoded buffer must round-trip cleanly (no Unicode replacement char).
76
+ expect(out!.includes('�')).toBe(false);
77
+ });
78
+
79
+ it('emits only suffix when budget is too small', () => {
80
+ const big = { v: 'x'.repeat(100) };
81
+ const out = captureBody(big, undefined, { ...opts, bodyMaxBytes: 5 });
82
+ // 5 bytes can't fit 14-byte suffix + any payload. Result should be the
83
+ // suffix truncated to 5 bytes, staying within the byte budget.
84
+ expect(Buffer.byteLength(out!, 'utf8')).toBeLessThanOrEqual(5);
85
+ });
86
+
87
+ it('ASCII-only path still truncates at byte budget including suffix', () => {
88
+ const big = { v: 'x'.repeat(200) };
89
+ const out = captureBody(big, undefined, { ...opts, bodyMaxBytes: 50 });
90
+ expect(Buffer.byteLength(out!, 'utf8')).toBeLessThanOrEqual(50);
91
+ expect(out!.endsWith('…[truncated]')).toBe(true);
92
+ });
93
+
94
+ it('handles circular references', () => {
95
+ const obj: any = { a: 1 };
96
+ obj.self = obj;
97
+ const out = captureBody(obj, undefined, opts);
98
+ expect(out).toContain('"[Circular]"');
99
+ });
100
+
101
+ it('skips Node streams', () => {
102
+ const stream = Readable.from(['x']);
103
+ expect(captureBody(stream, undefined, opts)).toBeNull();
104
+ });
105
+
106
+ it('skips ArrayBuffer and typed arrays', () => {
107
+ expect(captureBody(new ArrayBuffer(8), undefined, opts)).toBeNull();
108
+ expect(captureBody(new Uint8Array([1, 2, 3]), undefined, opts)).toBeNull();
109
+ });
110
+
111
+ it('skips FormData', () => {
112
+ const fd = new FormData();
113
+ fd.append('a', '1');
114
+ expect(captureBody(fd, undefined, opts)).toBeNull();
115
+ });
116
+
117
+ it('skips class instances with non-Object prototypes', () => {
118
+ class User {
119
+ constructor(public id: string) {}
120
+ }
121
+ expect(captureBody(new User('u1'), undefined, opts)).toBeNull();
122
+ });
123
+
124
+ it('still accepts plain objects and arrays', () => {
125
+ expect(captureBody({ a: 1 }, undefined, opts)).toBe('{"a":1}');
126
+ expect(captureBody([1, 2, 3], undefined, opts)).toBe('[1,2,3]');
127
+ expect(captureBody(Object.create(null), undefined, opts)).toBe('{}');
128
+ });
129
+ });
@@ -0,0 +1,36 @@
1
+ import { writeFileSync, mkdtempSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ import { DiskFileReader } from '../src/stacktrace/DiskFileReader';
9
+
10
+ describe('DiskFileReader', () => {
11
+ it('reads a real file from disk by absolute path', async () => {
12
+ const dir = mkdtempSync(join(tmpdir(), 'flare-test-'));
13
+ const file = join(dir, 'sample.js');
14
+ writeFileSync(file, 'hello\nworld');
15
+ const reader = new DiskFileReader();
16
+ expect(await reader.read(file)).toBe('hello\nworld');
17
+ });
18
+
19
+ it('reads a file:// URL', async () => {
20
+ const dir = mkdtempSync(join(tmpdir(), 'flare-test-'));
21
+ const file = join(dir, 'sample.js');
22
+ writeFileSync(file, 'x');
23
+ const reader = new DiskFileReader();
24
+ expect(await reader.read(pathToFileURL(file).href)).toBe('x');
25
+ });
26
+
27
+ it('returns null for http urls', async () => {
28
+ const reader = new DiskFileReader();
29
+ expect(await reader.read('https://example.com/x.js')).toBeNull();
30
+ });
31
+
32
+ it('returns null for missing files', async () => {
33
+ const reader = new DiskFileReader();
34
+ expect(await reader.read('/definitely/not/a/file.js')).toBeNull();
35
+ });
36
+ });
@@ -0,0 +1,140 @@
1
+ import { Flare, Api } from '@flareapp/core';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { buildFatalCallbacks } from '../src/process/fatal';
5
+
6
+ function fakeFlare(): { flare: Flare; sent: any[] } {
7
+ const sent: any[] = [];
8
+ const api = new Api();
9
+ api.report = (report: any) => {
10
+ sent.push(report);
11
+ return Promise.resolve();
12
+ };
13
+ const flare = new Flare(api);
14
+ flare.light('k');
15
+ return { flare, sent };
16
+ }
17
+
18
+ afterEach(() => {
19
+ // Reset exitCode so one test doesn't bleed into the next
20
+ process.exitCode = undefined;
21
+ });
22
+
23
+ describe('fatal callbacks', () => {
24
+ it('uncaught: awaits full report pipeline before resolving', async () => {
25
+ const { flare, sent } = fakeFlare();
26
+ const exit = vi.fn();
27
+ const { onUncaught } = buildFatalCallbacks(
28
+ flare,
29
+ () => ({
30
+ uncaughtExceptionMode: 'report-and-exit',
31
+ unhandledRejectionMode: 'off',
32
+ shutdownTimeoutMs: 1000,
33
+ }),
34
+ exit,
35
+ );
36
+ await onUncaught(new Error('boom'), 'uncaughtException');
37
+ expect(sent.length).toBe(1);
38
+ expect(sent[0].attributes['process.uncaught_exception.origin']).toBe('uncaughtException');
39
+ expect(exit).toHaveBeenCalledWith(1);
40
+ });
41
+
42
+ it('uncaught: does NOT exit when mode is report', async () => {
43
+ const { flare, sent } = fakeFlare();
44
+ const exit = vi.fn();
45
+ const { onUncaught } = buildFatalCallbacks(
46
+ flare,
47
+ () => ({
48
+ uncaughtExceptionMode: 'report',
49
+ unhandledRejectionMode: 'off',
50
+ shutdownTimeoutMs: 1000,
51
+ }),
52
+ exit,
53
+ );
54
+ await onUncaught(new Error('boom'), 'uncaughtException');
55
+ expect(sent.length).toBe(1);
56
+ expect(exit).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it('unhandled rejection: coerces non-Error reason', async () => {
60
+ const { flare, sent } = fakeFlare();
61
+ const exit = vi.fn();
62
+ const { onRejection } = buildFatalCallbacks(
63
+ flare,
64
+ () => ({
65
+ uncaughtExceptionMode: 'off',
66
+ unhandledRejectionMode: 'report',
67
+ shutdownTimeoutMs: 1000,
68
+ }),
69
+ exit,
70
+ );
71
+ await onRejection('a string reason');
72
+ expect(sent.length).toBe(1);
73
+ expect(sent[0].message).toBe('a string reason');
74
+ expect(exit).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('uncaught: sets process.exitCode=1 synchronously before awaiting report', async () => {
78
+ const { flare } = fakeFlare();
79
+ let exitCodeDuringReport: number | string | undefined;
80
+ const api = new Api();
81
+ api.report = () => {
82
+ exitCodeDuringReport = process.exitCode;
83
+ return Promise.resolve();
84
+ };
85
+ flare.api = api;
86
+
87
+ const exit = vi.fn();
88
+ const { onUncaught } = buildFatalCallbacks(
89
+ flare,
90
+ () => ({
91
+ uncaughtExceptionMode: 'report-and-exit',
92
+ unhandledRejectionMode: 'off',
93
+ shutdownTimeoutMs: 1000,
94
+ }),
95
+ exit,
96
+ );
97
+ await onUncaught(new Error('boom'), 'uncaughtException');
98
+ expect(exitCodeDuringReport).toBe(1);
99
+ });
100
+
101
+ it('rejection: sets process.exitCode=1 synchronously before awaiting report', async () => {
102
+ const { flare } = fakeFlare();
103
+ let exitCodeDuringReport: number | string | undefined;
104
+ const api = new Api();
105
+ api.report = () => {
106
+ exitCodeDuringReport = process.exitCode;
107
+ return Promise.resolve();
108
+ };
109
+ flare.api = api;
110
+
111
+ const exit = vi.fn();
112
+ const { onRejection } = buildFatalCallbacks(
113
+ flare,
114
+ () => ({
115
+ uncaughtExceptionMode: 'off',
116
+ unhandledRejectionMode: 'report-and-exit',
117
+ shutdownTimeoutMs: 1000,
118
+ }),
119
+ exit,
120
+ );
121
+ await onRejection(new Error('rejected'));
122
+ expect(exitCodeDuringReport).toBe(1);
123
+ });
124
+
125
+ it('uncaught: does NOT set process.exitCode when mode is report', async () => {
126
+ const { flare } = fakeFlare();
127
+ const exit = vi.fn();
128
+ const { onUncaught } = buildFatalCallbacks(
129
+ flare,
130
+ () => ({
131
+ uncaughtExceptionMode: 'report',
132
+ unhandledRejectionMode: 'off',
133
+ shutdownTimeoutMs: 1000,
134
+ }),
135
+ exit,
136
+ );
137
+ await onUncaught(new Error('boom'), 'uncaughtException');
138
+ expect(process.exitCode).toBeUndefined();
139
+ });
140
+ });
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { flare } from '../src';
4
+
5
+ describe('Node flare.flush', () => {
6
+ it('resolves quickly when there is nothing in flight', async () => {
7
+ const start = Date.now();
8
+ await flare.flush(1000);
9
+ expect(Date.now() - start).toBeLessThan(50);
10
+ });
11
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { DEFAULT_HEADER_DENYLIST, findHeader, projectHeaders } from '../src/context/headers';
4
+
5
+ describe('projectHeaders', () => {
6
+ it('emits each header as http.request.header.<lowercase-name>', () => {
7
+ const attrs = projectHeaders(
8
+ { 'Content-Type': 'application/json', 'X-Foo': 'bar' },
9
+ {
10
+ headerDenylist: DEFAULT_HEADER_DENYLIST,
11
+ headerAllowlist: null,
12
+ },
13
+ );
14
+ expect(attrs['http.request.header.content-type']).toBe('application/json');
15
+ expect(attrs['http.request.header.x-foo']).toBe('bar');
16
+ });
17
+
18
+ it('redacts default-denylisted headers', () => {
19
+ const attrs = projectHeaders(
20
+ { Authorization: 'Bearer xyz', Cookie: 'sid=1' },
21
+ {
22
+ headerDenylist: DEFAULT_HEADER_DENYLIST,
23
+ headerAllowlist: null,
24
+ },
25
+ );
26
+ expect(attrs['http.request.header.authorization']).toBe('[redacted]');
27
+ expect(attrs['http.request.header.cookie']).toBe('[redacted]');
28
+ });
29
+
30
+ it('allowlist filters out non-allowed headers entirely', () => {
31
+ const attrs = projectHeaders(
32
+ { 'X-Foo': 'bar', 'X-Baz': 'qux' },
33
+ {
34
+ headerDenylist: DEFAULT_HEADER_DENYLIST,
35
+ headerAllowlist: /^x-foo$/i,
36
+ },
37
+ );
38
+ expect(attrs['http.request.header.x-foo']).toBe('bar');
39
+ expect(attrs['http.request.header.x-baz']).toBeUndefined();
40
+ });
41
+
42
+ it('coalesces array values', () => {
43
+ const attrs = projectHeaders(
44
+ { 'X-Foo': ['a', 'b'] as any },
45
+ {
46
+ headerDenylist: DEFAULT_HEADER_DENYLIST,
47
+ headerAllowlist: null,
48
+ },
49
+ );
50
+ expect(attrs['http.request.header.x-foo']).toBe('a, b');
51
+ });
52
+ });
53
+
54
+ describe('findHeader', () => {
55
+ it('returns undefined when headers is undefined', () => {
56
+ expect(findHeader(undefined, 'content-type')).toBeUndefined();
57
+ });
58
+
59
+ it('finds header with exact lowercase key', () => {
60
+ expect(findHeader({ 'content-type': 'application/json' }, 'content-type')).toBe('application/json');
61
+ });
62
+
63
+ it('finds header with uppercase key', () => {
64
+ expect(findHeader({ 'CONTENT-TYPE': 'application/json' }, 'content-type')).toBe('application/json');
65
+ });
66
+
67
+ it('finds header with mixed case key', () => {
68
+ expect(findHeader({ 'Content-type': 'application/json; charset=utf-8' }, 'content-type')).toBe(
69
+ 'application/json; charset=utf-8',
70
+ );
71
+ });
72
+
73
+ it('coalesces array values to first element', () => {
74
+ expect(findHeader({ 'content-type': ['application/json', 'text/plain'] as any }, 'content-type')).toBe(
75
+ 'application/json',
76
+ );
77
+ });
78
+
79
+ it('returns undefined when header is not present', () => {
80
+ expect(findHeader({ 'x-foo': 'bar' }, 'content-type')).toBeUndefined();
81
+ });
82
+
83
+ it('returns undefined when header value is undefined', () => {
84
+ expect(findHeader({ 'content-type': undefined }, 'content-type')).toBeUndefined();
85
+ });
86
+ });
@@ -0,0 +1,63 @@
1
+ import { createServer, type Server } from 'node:http';
2
+
3
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
4
+
5
+ import { startFakeFlareServer } from '../../../e2e/fake-flare-server';
6
+ import type { FakeFlareServer } from '../../../e2e/fake-flare-server';
7
+
8
+ let fakeFlareServer: FakeFlareServer;
9
+
10
+ beforeAll(async () => {
11
+ fakeFlareServer = await startFakeFlareServer();
12
+ });
13
+
14
+ afterAll(async () => {
15
+ await fakeFlareServer?.stop();
16
+ });
17
+
18
+ describe('Node SDK integration', () => {
19
+ it('reports an error through a real HTTP server', async () => {
20
+ const { flare } = await import('../src');
21
+ flare.removeProcessListeners();
22
+ flare.configureNode({ uncaughtExceptionMode: 'off', unhandledRejectionMode: 'off' });
23
+ flare.configure({ ingestUrl: `${fakeFlareServer.url}/api/reports` });
24
+ flare.light('test-key');
25
+
26
+ fakeFlareServer.reset();
27
+
28
+ let captured = false;
29
+ const app: Server = createServer((req, res) => {
30
+ flare.runWithContext({ method: req.method!, path: req.url! }, async () => {
31
+ if (req.url === '/boom') {
32
+ try {
33
+ throw new Error('integration-boom');
34
+ } catch (e) {
35
+ await flare.report(e as Error);
36
+ captured = true;
37
+ }
38
+ }
39
+ res.end('ok');
40
+ });
41
+ });
42
+
43
+ await new Promise<void>((r) => app.listen(0, r));
44
+ const port = (app.address() as { port: number }).port;
45
+
46
+ await fetch(`http://localhost:${port}/boom`);
47
+ await flare.flush(1500);
48
+
49
+ expect(captured).toBe(true);
50
+
51
+ const reports = fakeFlareServer.reports();
52
+ expect(reports.length).toBe(1);
53
+
54
+ const body = reports[0].bodyJson as Record<string, unknown>;
55
+ expect(body.message).toBe('integration-boom');
56
+
57
+ const attributes = body.attributes as Record<string, unknown>;
58
+ expect(attributes['http.request.method']).toBe('GET');
59
+ expect(attributes['url.path']).toBe('/boom');
60
+
61
+ await new Promise<void>((r) => app.close(() => r()));
62
+ });
63
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { flare } from '../src';
4
+ import { NodeFlare } from '../src/Flare';
5
+
6
+ describe('Node singleton lifecycle', () => {
7
+ it('configureNode before light: no listeners until light', () => {
8
+ flare.removeProcessListeners();
9
+ const before = process.listeners('uncaughtException').length;
10
+ flare.configureNode({ uncaughtExceptionMode: 'report' });
11
+ expect(process.listeners('uncaughtException').length).toBe(before); // not lit yet
12
+ flare.light('k');
13
+ expect(process.listeners('uncaughtException').length).toBe(before + 1);
14
+ flare.removeProcessListeners();
15
+ });
16
+
17
+ it('configureNode after light: dynamically attaches and detaches', () => {
18
+ flare.removeProcessListeners();
19
+ flare.configureNode({ uncaughtExceptionMode: 'off' });
20
+ flare.light('k');
21
+ const baseline = process.listeners('uncaughtException').length;
22
+ flare.configureNode({ uncaughtExceptionMode: 'report' });
23
+ expect(process.listeners('uncaughtException').length).toBe(baseline + 1);
24
+ flare.configureNode({ uncaughtExceptionMode: 'off' });
25
+ expect(process.listeners('uncaughtException').length).toBe(baseline);
26
+ flare.removeProcessListeners();
27
+ });
28
+
29
+ it('runWithContext isolates request scope', () => {
30
+ const seen: Array<string | undefined> = [];
31
+ flare.runWithContext({ path: '/a' }, () => {
32
+ seen.push(flare.getContext()?.request.path);
33
+ });
34
+ flare.runWithContext({ path: '/b' }, () => {
35
+ seen.push(flare.getContext()?.request.path);
36
+ });
37
+ expect(flare.getContext()).toBeNull();
38
+ expect(seen).toEqual(['/a', '/b']);
39
+ });
40
+
41
+ it('removeProcessListeners then light reattaches handlers', () => {
42
+ const instance = new NodeFlare();
43
+ instance.removeProcessListeners();
44
+ instance.configureNode({ uncaughtExceptionMode: 'report' });
45
+ instance.light('k');
46
+ const after_first_light = process.listeners('uncaughtException').length;
47
+ expect(after_first_light).toBeGreaterThanOrEqual(1);
48
+ const baseline = after_first_light - 1;
49
+ instance.removeProcessListeners();
50
+ expect(process.listeners('uncaughtException').length).toBe(baseline);
51
+ instance.light('k');
52
+ expect(process.listeners('uncaughtException').length).toBe(baseline + 1);
53
+ instance.removeProcessListeners();
54
+ });
55
+
56
+ it('supports subclass fluent chaining', () => {
57
+ const instance = new NodeFlare();
58
+ // Type-only check: ensure subclass methods remain chainable
59
+ instance.configure({ stage: 'prod' }).configureNode({ uncaughtExceptionMode: 'off' });
60
+ instance.removeProcessListeners();
61
+ });
62
+
63
+ it('configureNode({ headerAllowlist: null }) clears a previously set allowlist', () => {
64
+ const instance = new NodeFlare();
65
+ instance.configureNode({ headerAllowlist: /^x-foo$/i });
66
+ expect((instance as any).nodeOptions.headerAllowlist).not.toBeNull();
67
+ instance.configureNode({ headerAllowlist: null });
68
+ expect((instance as any).nodeOptions.headerAllowlist).toBeNull();
69
+ instance.removeProcessListeners();
70
+ });
71
+ });
@@ -0,0 +1,106 @@
1
+ import { DEFAULT_URL_DENYLIST } from '@flareapp/core';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { DEFAULT_BODY_CONTENT_TYPES, DEFAULT_BODY_KEY_DENYLIST } from '../src/context/body';
5
+ import { makeNodeContextCollector } from '../src/context/collectNode';
6
+ import { DEFAULT_HEADER_DENYLIST } from '../src/context/headers';
7
+ import { AsyncLocalStorageScopeProvider } from '../src/scope/AsyncLocalStorageScopeProvider';
8
+
9
+ const baseOpts = {
10
+ headerDenylist: DEFAULT_HEADER_DENYLIST,
11
+ headerAllowlist: null,
12
+ captureRequestBody: false,
13
+ bodyAllowedContentTypes: DEFAULT_BODY_CONTENT_TYPES,
14
+ bodyKeyDenylist: DEFAULT_BODY_KEY_DENYLIST,
15
+ bodyMaxBytes: 16_384,
16
+ };
17
+
18
+ describe('Node ContextCollector', () => {
19
+ it('emits process attributes when called outside a scope', () => {
20
+ const provider = new AsyncLocalStorageScopeProvider();
21
+ const collect = makeNodeContextCollector(provider, () => baseOpts);
22
+ const attrs = collect({ urlDenylist: DEFAULT_URL_DENYLIST } as any);
23
+ expect(attrs['process.runtime.name']).toBe('nodejs');
24
+ expect(attrs['flare.entry_point.type']).toBe('server');
25
+ });
26
+
27
+ it('projects request.path into url.path + url.query', () => {
28
+ const provider = new AsyncLocalStorageScopeProvider();
29
+ const collect = makeNodeContextCollector(provider, () => baseOpts);
30
+ provider.runWithContext({ method: 'POST', path: '/foo?bar=1&token=x' }, () => {
31
+ const attrs = collect({ urlDenylist: DEFAULT_URL_DENYLIST } as any);
32
+ expect(attrs['http.request.method']).toBe('POST');
33
+ expect(attrs['url.path']).toBe('/foo');
34
+ expect(attrs['url.query']).toBe('bar=1&token=[redacted]');
35
+ });
36
+ });
37
+
38
+ it('projects request.url through redactUrlQuery into url.full', () => {
39
+ const provider = new AsyncLocalStorageScopeProvider();
40
+ const collect = makeNodeContextCollector(provider, () => baseOpts);
41
+ provider.runWithContext({ url: 'https://x.test/a?password=hunter2' }, () => {
42
+ const attrs = collect({ urlDenylist: DEFAULT_URL_DENYLIST } as any);
43
+ expect(attrs['url.full']).toBe('https://x.test/a?password=[redacted]');
44
+ });
45
+ });
46
+
47
+ it('projects user fields with OTel keys', () => {
48
+ const provider = new AsyncLocalStorageScopeProvider();
49
+ const collect = makeNodeContextCollector(provider, () => baseOpts);
50
+ provider.runWithContext({}, () => {
51
+ provider.setUser({ id: 'u1', email: 'a@b.c', ipAddress: '1.2.3.4' });
52
+ const attrs = collect({ urlDenylist: DEFAULT_URL_DENYLIST } as any);
53
+ expect(attrs['enduser.id']).toBe('u1');
54
+ expect(attrs['enduser.email']).toBe('a@b.c');
55
+ expect(attrs['client.address']).toBe('1.2.3.4');
56
+ });
57
+ });
58
+
59
+ it('respects captureRequestBody=false', () => {
60
+ const provider = new AsyncLocalStorageScopeProvider();
61
+ const collect = makeNodeContextCollector(provider, () => baseOpts);
62
+ provider.runWithContext({ body: { a: 1 }, headers: { 'content-type': 'application/json' } }, () => {
63
+ const attrs = collect({ urlDenylist: DEFAULT_URL_DENYLIST } as any);
64
+ expect(attrs['http.request.body']).toBeUndefined();
65
+ });
66
+ });
67
+
68
+ it('captures body when enabled', () => {
69
+ const provider = new AsyncLocalStorageScopeProvider();
70
+ const collect = makeNodeContextCollector(provider, () => ({ ...baseOpts, captureRequestBody: true }));
71
+ provider.runWithContext({ body: { a: 1 }, headers: { 'content-type': 'application/json' } }, () => {
72
+ const attrs = collect({ urlDenylist: DEFAULT_URL_DENYLIST } as any);
73
+ expect(attrs['http.request.body']).toBe('{"a":1}');
74
+ });
75
+ });
76
+
77
+ it('captures body with CONTENT-TYPE header casing', () => {
78
+ const provider = new AsyncLocalStorageScopeProvider();
79
+ const collect = makeNodeContextCollector(provider, () => ({ ...baseOpts, captureRequestBody: true }));
80
+ provider.runWithContext({ body: { a: 1 }, headers: { 'CONTENT-TYPE': 'application/json' } }, () => {
81
+ const attrs = collect({ urlDenylist: DEFAULT_URL_DENYLIST } as any);
82
+ expect(attrs['http.request.body']).toBe('{"a":1}');
83
+ });
84
+ });
85
+
86
+ it('captures body with Content-type header casing', () => {
87
+ const provider = new AsyncLocalStorageScopeProvider();
88
+ const collect = makeNodeContextCollector(provider, () => ({ ...baseOpts, captureRequestBody: true }));
89
+ provider.runWithContext(
90
+ { body: '{"a":1}', headers: { 'Content-type': 'application/json; charset=utf-8' } },
91
+ () => {
92
+ const attrs = collect({ urlDenylist: DEFAULT_URL_DENYLIST } as any);
93
+ expect(attrs['http.request.body']).toBe('{"a":1}');
94
+ },
95
+ );
96
+ });
97
+
98
+ it('captures body when content-type is an array value (uses first element)', () => {
99
+ const provider = new AsyncLocalStorageScopeProvider();
100
+ const collect = makeNodeContextCollector(provider, () => ({ ...baseOpts, captureRequestBody: true }));
101
+ provider.runWithContext({ body: { a: 1 }, headers: { 'content-type': ['application/json'] as any } }, () => {
102
+ const attrs = collect({ urlDenylist: DEFAULT_URL_DENYLIST } as any);
103
+ expect(attrs['http.request.body']).toBe('{"a":1}');
104
+ });
105
+ });
106
+ });
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { NodeScope } from '../src';
4
+
5
+ describe('public exports from @flareapp/node', () => {
6
+ it('exports NodeScope as a class', () => {
7
+ expect(typeof NodeScope).toBe('function');
8
+ const s = new NodeScope();
9
+ expect(s).toBeDefined();
10
+ });
11
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { NodeScope } from '../src/scope/NodeScope';
4
+
5
+ describe('NodeScope', () => {
6
+ it('starts with empty request and null user', () => {
7
+ const scope = new NodeScope();
8
+ expect(scope.request).toEqual({});
9
+ expect(scope.user).toBeNull();
10
+ });
11
+
12
+ it('inherits core Scope behavior (glows, attributes, entryPoint)', () => {
13
+ const scope = new NodeScope();
14
+ scope.setAttribute('k', 'v');
15
+ scope.addGlow({ name: 'g', messageLevel: 'info', metaData: {}, time: 0, microtime: 0 }, 10);
16
+ expect(scope.pendingAttributes).toEqual({ k: 'v' });
17
+ expect(scope.glows.length).toBe(1);
18
+ });
19
+ });