@flareapp/node 0.1.0 → 0.1.1
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.
- package/dist/index.cjs +834 -0
- package/dist/index.d.cts +138 -0
- package/dist/index.d.mts +138 -0
- package/dist/index.mjs +755 -0
- package/package.json +5 -2
- package/.oxlintrc.json +0 -7
- package/.release-it.json +0 -13
- package/CHANGELOG.md +0 -22
- package/src/Flare.ts +0 -224
- package/src/context/body.ts +0 -185
- package/src/context/collectNode.ts +0 -116
- package/src/context/headers.ts +0 -90
- package/src/context/process.ts +0 -25
- package/src/index.ts +0 -27
- package/src/process/fatal.ts +0 -54
- package/src/process/handlers.ts +0 -109
- package/src/scope/AsyncLocalStorageScopeProvider.ts +0 -86
- package/src/scope/NodeScope.ts +0 -8
- package/src/stacktrace/DiskFileReader.ts +0 -57
- package/src/types.ts +0 -37
- package/tests/asyncScopeProvider.test.ts +0 -43
- package/tests/body.test.ts +0 -129
- package/tests/diskFileReader.test.ts +0 -36
- package/tests/fatalHandlers.test.ts +0 -140
- package/tests/flush.test.ts +0 -11
- package/tests/headers.test.ts +0 -86
- package/tests/integration.test.ts +0 -63
- package/tests/lifecycle.test.ts +0 -71
- package/tests/nodeContextCollector.test.ts +0 -106
- package/tests/nodeExports.test.ts +0 -11
- package/tests/nodeScope.test.ts +0 -19
- package/tests/processAttributes.test.ts +0 -15
- package/tests/processHandlers.test.ts +0 -47
- package/tests/regexFlagSanitization.test.ts +0 -88
- package/tests/scopeIsolation.test.ts +0 -47
- package/tests/setFrameworkScope.test.ts +0 -86
- package/tsconfig.json +0 -9
- package/vitest.config.ts +0 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flareapp/node",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Node.js SDK for flareapp.io",
|
|
5
5
|
"homepage": "https://flareapp.io",
|
|
6
6
|
"bugs": {
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
"name": "Spatie",
|
|
16
16
|
"email": "info@spatie.be"
|
|
17
17
|
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
18
21
|
"main": "./dist/index.cjs",
|
|
19
22
|
"module": "./dist/index.mjs",
|
|
20
23
|
"types": "./dist/index.d.cts",
|
|
@@ -41,7 +44,7 @@
|
|
|
41
44
|
"release": "release-it"
|
|
42
45
|
},
|
|
43
46
|
"dependencies": {
|
|
44
|
-
"@flareapp/core": "2.2.
|
|
47
|
+
"@flareapp/core": "2.2.1"
|
|
45
48
|
},
|
|
46
49
|
"devDependencies": {
|
|
47
50
|
"tsdown": "^0.20.3",
|
package/.oxlintrc.json
DELETED
package/.release-it.json
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"git": {
|
|
3
|
-
"tagName": "@flareapp/node@${version}",
|
|
4
|
-
"tagAnnotation": "Release @flareapp/node@${version}",
|
|
5
|
-
"commitMessage": "chore: release @flareapp/node@${version}",
|
|
6
|
-
"requireBranch": "main",
|
|
7
|
-
"requireCleanWorkingDir": true,
|
|
8
|
-
"push": true
|
|
9
|
-
},
|
|
10
|
-
"npm": { "publish": true },
|
|
11
|
-
"github": { "release": false },
|
|
12
|
-
"hooks": { "before:release": "node ../../scripts/check-deps-published.mjs && npm test --if-present" }
|
|
13
|
-
}
|
package/CHANGELOG.md
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
# @flareapp/node changelog
|
|
2
|
-
|
|
3
|
-
## 0.1.0 — 2026-05-28
|
|
4
|
-
|
|
5
|
-
Initial release. Standalone Node.js SDK.
|
|
6
|
-
|
|
7
|
-
- `flare.runWithContext({ method, path, url, headers, body }, fn)` opens an
|
|
8
|
-
AsyncLocalStorage-backed scope for a single request. Glows, custom context,
|
|
9
|
-
user identity, and entry point are all isolated.
|
|
10
|
-
- `flare.setUser({ id, email, username, ipAddress })` attaches identity to the
|
|
11
|
-
active scope. Mirrors Sentry's split between user identity and request context.
|
|
12
|
-
- Process listeners attached on `flare.light(key)` based on
|
|
13
|
-
`uncaughtExceptionMode` and `unhandledRejectionMode` (each `off`, `report`,
|
|
14
|
-
or `report-and-exit`; default `report-and-exit`). Listener state reconciles
|
|
15
|
-
dynamically when `configureNode(...)` is called later.
|
|
16
|
-
- Default header denylist redacts `authorization`, `cookie`, `set-cookie`,
|
|
17
|
-
`x-api-key`, CSRF tokens, and forwarding headers. Custom allowlist and
|
|
18
|
-
denylist available via `configureNode`.
|
|
19
|
-
- Request body capture is off by default; opt in via
|
|
20
|
-
`configureNode({ captureRequestBody: true })`. JSON and form-urlencoded
|
|
21
|
-
content types accepted; 16 KB cap; circular references and PII keys redacted.
|
|
22
|
-
- Requires Node >=22.
|
package/src/Flare.ts
DELETED
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
import { Api, Flare as CoreFlare } from '@flareapp/core';
|
|
2
|
-
|
|
3
|
-
import { DEFAULT_BODY_CONTENT_TYPES, DEFAULT_BODY_KEY_DENYLIST } from './context/body';
|
|
4
|
-
import { makeNodeContextCollector } from './context/collectNode';
|
|
5
|
-
import { DEFAULT_HEADER_DENYLIST, resolveHeaderDenylist } from './context/headers';
|
|
6
|
-
import { buildFatalCallbacks } from './process/fatal';
|
|
7
|
-
import { ProcessHandlerManager } from './process/handlers';
|
|
8
|
-
import { AsyncLocalStorageScopeProvider } from './scope/AsyncLocalStorageScopeProvider';
|
|
9
|
-
import type { NodeScope } from './scope/NodeScope';
|
|
10
|
-
import { DiskFileReader } from './stacktrace/DiskFileReader';
|
|
11
|
-
import type { NodeOptions, RequestContext, ResolvedNodeOptions, User } from './types';
|
|
12
|
-
|
|
13
|
-
const NODE_SDK_NAME = '@flareapp/node';
|
|
14
|
-
const NODE_SDK_VERSION =
|
|
15
|
-
typeof process !== 'undefined' && process.env?.FLARE_JS_CLIENT_VERSION !== undefined
|
|
16
|
-
? process.env.FLARE_JS_CLIENT_VERSION
|
|
17
|
-
: '?';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Strip the `g` and `y` flags from a user-supplied regex.
|
|
21
|
-
*
|
|
22
|
-
* `RegExp.prototype.test()` and `.exec()` keep `lastIndex` state when either of
|
|
23
|
-
* these flags is set, which means reusing the same regex across many keys (as
|
|
24
|
-
* the header denylist and body redaction do) silently skips matches after the
|
|
25
|
-
* first hit. Reconstructing the regex without those flags gives stateless
|
|
26
|
-
* matching while preserving everything else (`i`, `m`, `s`, `u`, source).
|
|
27
|
-
*/
|
|
28
|
-
function sanitizeRegex(re: RegExp): RegExp {
|
|
29
|
-
const safeFlags = re.flags.replace(/[gy]/g, '');
|
|
30
|
-
return new RegExp(re.source, safeFlags);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const DEFAULT_NODE_OPTIONS: ResolvedNodeOptions = {
|
|
34
|
-
uncaughtExceptionMode: 'report-and-exit',
|
|
35
|
-
unhandledRejectionMode: 'report-and-exit',
|
|
36
|
-
shutdownTimeoutMs: 2000,
|
|
37
|
-
headerDenylist: DEFAULT_HEADER_DENYLIST,
|
|
38
|
-
headerAllowlist: null,
|
|
39
|
-
replaceDefaultHeaderDenylist: false,
|
|
40
|
-
captureRequestBody: false,
|
|
41
|
-
bodyMaxBytes: 16_384,
|
|
42
|
-
bodyAllowedContentTypes: DEFAULT_BODY_CONTENT_TYPES,
|
|
43
|
-
bodyKeyDenylist: DEFAULT_BODY_KEY_DENYLIST,
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Node.js-specific `Flare` singleton, exposed from `@flareapp/node` as `flare`.
|
|
48
|
-
*
|
|
49
|
-
* Subclasses core's `Flare` and wires the Node-only seams in its constructor:
|
|
50
|
-
*
|
|
51
|
-
* - `AsyncLocalStorageScopeProvider` so each `runWithContext(...)` callback
|
|
52
|
-
* gets its own `NodeScope` (glows, attributes, user, entry-point, request),
|
|
53
|
-
* isolated from concurrent requests.
|
|
54
|
-
* - `makeNodeContextCollector(...)` to project the current `NodeScope` and
|
|
55
|
-
* process info into report attributes (http.request.*, url.path, etc).
|
|
56
|
-
* - `DiskFileReader` to read source files for stack-trace snippets via
|
|
57
|
-
* `node:fs/promises` instead of the browser's `fetch`.
|
|
58
|
-
* - `ProcessHandlerManager` to attach/detach `uncaughtException` and
|
|
59
|
-
* `unhandledRejection` listeners based on the current `NodeOptions`.
|
|
60
|
-
*
|
|
61
|
-
* Also adds Node-only API surface on top of core: `configureNode(...)`,
|
|
62
|
-
* `runWithContext(...)`, `mergeContext(...)`, `setUser(...)`, `getContext()`,
|
|
63
|
-
* `removeProcessListeners()`. Inherited core methods (`light`, `configure`,
|
|
64
|
-
* `addContext`, `glow`, etc.) return `this`, so chaining keeps the
|
|
65
|
-
* `NodeFlare` type and `configureNode(...)` stays callable mid-chain.
|
|
66
|
-
*/
|
|
67
|
-
export class NodeFlare extends CoreFlare {
|
|
68
|
-
private nodeOptions: ResolvedNodeOptions = { ...DEFAULT_NODE_OPTIONS };
|
|
69
|
-
private isLit = false;
|
|
70
|
-
private nodeScopeProvider: AsyncLocalStorageScopeProvider;
|
|
71
|
-
private handlerManager: ProcessHandlerManager;
|
|
72
|
-
|
|
73
|
-
constructor() {
|
|
74
|
-
const scopeProvider = new AsyncLocalStorageScopeProvider();
|
|
75
|
-
// The collector closes over `() => this.nodeOptions` (a getter, not a
|
|
76
|
-
// value) so subsequent `configureNode(...)` calls take effect on
|
|
77
|
-
// future reports without reinjecting the collector.
|
|
78
|
-
const collector = makeNodeContextCollector(scopeProvider, () => this.nodeOptions);
|
|
79
|
-
super(new Api(), collector, new DiskFileReader(), scopeProvider);
|
|
80
|
-
this.nodeScopeProvider = scopeProvider;
|
|
81
|
-
this.setSdkInfo({ name: NODE_SDK_NAME, version: NODE_SDK_VERSION });
|
|
82
|
-
|
|
83
|
-
const cbs = buildFatalCallbacks(this, () => this.nodeOptions);
|
|
84
|
-
this.handlerManager = new ProcessHandlerManager(cbs);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Set the API key (and optional debug flag), then reconcile process
|
|
89
|
-
* listeners with the current `nodeOptions`. Reconcile runs on EVERY call,
|
|
90
|
-
* not just the first, so `light()` is the right escape hatch to re-attach
|
|
91
|
-
* after `removeProcessListeners()`.
|
|
92
|
-
*/
|
|
93
|
-
light(key?: string, debug?: boolean) {
|
|
94
|
-
super.light(key, debug);
|
|
95
|
-
this.isLit = true;
|
|
96
|
-
this.handlerManager.reconcile(this.nodeOptions);
|
|
97
|
-
return this;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Merge Node-only options (fatal-handler modes, header/body redaction
|
|
102
|
-
* config, shutdown timeout) into the active configuration. Safe to call
|
|
103
|
-
* before or after `light()`:
|
|
104
|
-
*
|
|
105
|
-
* - Before `light()`: options are stored; listeners are attached when
|
|
106
|
-
* `light()` runs.
|
|
107
|
-
* - After `light()`: options are stored AND listeners are reconciled
|
|
108
|
-
* immediately, so flipping a mode to `'off'` detaches the handler and
|
|
109
|
-
* flipping it back to `'report'`/`'report-and-exit'` re-attaches.
|
|
110
|
-
*
|
|
111
|
-
* Regex options (`headerAllowlist`, `bodyAllowedContentTypes`,
|
|
112
|
-
* `bodyKeyDenylist`) are passed through `sanitizeRegex` to strip stateful
|
|
113
|
-
* `g`/`y` flags; without that, `RegExp.prototype.test` would skip matches
|
|
114
|
-
* across keys.
|
|
115
|
-
*/
|
|
116
|
-
configureNode(partial: Partial<NodeOptions>): NodeFlare {
|
|
117
|
-
if (partial.headerDenylist !== undefined || partial.replaceDefaultHeaderDenylist !== undefined) {
|
|
118
|
-
this.nodeOptions.headerDenylist = resolveHeaderDenylist(
|
|
119
|
-
partial.headerDenylist ?? undefined,
|
|
120
|
-
partial.replaceDefaultHeaderDenylist ?? this.nodeOptions.replaceDefaultHeaderDenylist,
|
|
121
|
-
);
|
|
122
|
-
this.nodeOptions.replaceDefaultHeaderDenylist =
|
|
123
|
-
partial.replaceDefaultHeaderDenylist ?? this.nodeOptions.replaceDefaultHeaderDenylist;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (partial.headerAllowlist !== undefined) {
|
|
127
|
-
this.nodeOptions.headerAllowlist =
|
|
128
|
-
partial.headerAllowlist === null ? null : sanitizeRegex(partial.headerAllowlist);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (partial.uncaughtExceptionMode !== undefined) {
|
|
132
|
-
this.nodeOptions.uncaughtExceptionMode = partial.uncaughtExceptionMode;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (partial.unhandledRejectionMode !== undefined) {
|
|
136
|
-
this.nodeOptions.unhandledRejectionMode = partial.unhandledRejectionMode;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (partial.shutdownTimeoutMs !== undefined) {
|
|
140
|
-
this.nodeOptions.shutdownTimeoutMs = partial.shutdownTimeoutMs;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (partial.captureRequestBody !== undefined) {
|
|
144
|
-
this.nodeOptions.captureRequestBody = partial.captureRequestBody;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (partial.bodyMaxBytes !== undefined) {
|
|
148
|
-
this.nodeOptions.bodyMaxBytes = partial.bodyMaxBytes;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (partial.bodyAllowedContentTypes !== undefined) {
|
|
152
|
-
this.nodeOptions.bodyAllowedContentTypes = sanitizeRegex(partial.bodyAllowedContentTypes);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (partial.bodyKeyDenylist !== undefined) {
|
|
156
|
-
this.nodeOptions.bodyKeyDenylist = sanitizeRegex(partial.bodyKeyDenylist);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (this.isLit) {
|
|
160
|
-
this.handlerManager.reconcile(this.nodeOptions);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return this;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Run `fn` inside a fresh `NodeScope` carrying the supplied request
|
|
168
|
-
* metadata. Inside `fn` (and any async work it awaits), `flare.glow(...)`,
|
|
169
|
-
* `flare.addContext(...)`, `flare.setUser(...)`, and `flare.report(...)`
|
|
170
|
-
* see a scope that is isolated from other concurrent requests.
|
|
171
|
-
*
|
|
172
|
-
* Mirrors a typical web-framework middleware: call once per request,
|
|
173
|
-
* wrapping the request handler, and the SDK will attribute any error
|
|
174
|
-
* reported inside the chain to the right request.
|
|
175
|
-
*/
|
|
176
|
-
runWithContext<T>(request: RequestContext, fn: () => T): T {
|
|
177
|
-
return this.nodeScopeProvider.runWithContext(request, fn);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Patch the request metadata on the active scope after `runWithContext(...)`
|
|
182
|
-
* has already started. Useful when fields become known partway through a
|
|
183
|
-
* request (e.g., the resolved absolute URL after proxy headers are parsed).
|
|
184
|
-
*
|
|
185
|
-
* Outside any `runWithContext(...)` callback, this writes to the fallback
|
|
186
|
-
* scope; the patch is visible to subsequent reports issued from outside a
|
|
187
|
-
* request scope but is NOT inherited by future `runWithContext(...)` calls.
|
|
188
|
-
*/
|
|
189
|
-
mergeContext(partial: Partial<RequestContext>): void {
|
|
190
|
-
this.nodeScopeProvider.mergeContext(partial);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Attach an authenticated user to the active scope. Inside a request scope
|
|
195
|
-
* this is per-request; outside it lands on the fallback scope. The fields
|
|
196
|
-
* are projected to OTel-style keys (`enduser.id`, `enduser.email`,
|
|
197
|
-
* `enduser.username`, `client.address`) by the Node context collector.
|
|
198
|
-
*/
|
|
199
|
-
setUser(user: User | null): void {
|
|
200
|
-
this.nodeScopeProvider.setUser(user);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Returns the request scope when called inside `runWithContext(...)`, or
|
|
205
|
-
* `null` outside. Intentionally returns `null` (not the fallback scope)
|
|
206
|
-
* when no request is active, so callers can distinguish "we are inside a
|
|
207
|
-
* request" from "we are not". Primarily useful for debugging.
|
|
208
|
-
*/
|
|
209
|
-
getContext(): NodeScope | null {
|
|
210
|
-
return this.nodeScopeProvider.getContext();
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Detach the `uncaughtException` and `unhandledRejection` listeners
|
|
215
|
-
* without changing `nodeOptions`. Intended for tests and for graceful
|
|
216
|
-
* shutdown paths where you want to take ownership of process exit
|
|
217
|
-
* yourself.
|
|
218
|
-
*
|
|
219
|
-
* Calling `light()` afterwards re-attaches based on the current options.
|
|
220
|
-
*/
|
|
221
|
-
removeProcessListeners(): void {
|
|
222
|
-
this.handlerManager.detach();
|
|
223
|
-
}
|
|
224
|
-
}
|
package/src/context/body.ts
DELETED
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
import { DEFAULT_URL_DENYLIST } from '@flareapp/core';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Content types accepted by default for body capture. JSON and
|
|
5
|
-
* URL-encoded forms cover the vast majority of API payloads while keeping
|
|
6
|
-
* the parser surface tiny. `\b` after the second alternative prevents
|
|
7
|
-
* accidental matches like `application/x-www-form-urlencoded-foo` (artificial
|
|
8
|
-
* but cheap to defend against). The leading `^` plus `\b` lets us match
|
|
9
|
-
* either bare types or types with `; charset=utf-8` style suffixes.
|
|
10
|
-
*/
|
|
11
|
-
export const DEFAULT_BODY_CONTENT_TYPES = /^application\/(json|x-www-form-urlencoded)\b/i;
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Keys whose values get replaced with `[redacted]` during body redaction.
|
|
15
|
-
* Reuses core's URL denylist so credentials, tokens, etc are caught with the
|
|
16
|
-
* same regex everywhere (less surface for users to keep in sync).
|
|
17
|
-
*/
|
|
18
|
-
export const DEFAULT_BODY_KEY_DENYLIST = DEFAULT_URL_DENYLIST;
|
|
19
|
-
|
|
20
|
-
type BodyOptions = {
|
|
21
|
-
bodyAllowedContentTypes: RegExp;
|
|
22
|
-
bodyKeyDenylist: RegExp;
|
|
23
|
-
bodyMaxBytes: number;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Normalize, redact, serialize, and size-cap a request body for inclusion in
|
|
28
|
-
* a Flare report.
|
|
29
|
-
*
|
|
30
|
-
* Accepts four runtime shapes (whatever the user hands us via
|
|
31
|
-
* `runWithContext({ body, ... })`):
|
|
32
|
-
*
|
|
33
|
-
* - `string` — assumed to match the declared `contentType`. Must be JSON or
|
|
34
|
-
* form-encoded text per `bodyAllowedContentTypes`; otherwise dropped.
|
|
35
|
-
* - `Buffer` — decoded as UTF-8 then treated like a string.
|
|
36
|
-
* - `URLSearchParams` — flattened to a plain `Record<string, string>`. No
|
|
37
|
-
* content-type gate (the type is unambiguous from the shape).
|
|
38
|
-
* - Other `object` (POJO, array) — used as-is, no content-type gate. This is
|
|
39
|
-
* the common middleware path (Express's `req.body`, Fastify's, etc).
|
|
40
|
-
*
|
|
41
|
-
* Anything else (`number`, `boolean`, class instance, stream) returns `null`
|
|
42
|
-
* and the body is not reported.
|
|
43
|
-
*
|
|
44
|
-
* After parsing:
|
|
45
|
-
*
|
|
46
|
-
* 1. **Redact.** Walk the value, replacing any property whose key matches
|
|
47
|
-
* `bodyKeyDenylist` with `'[redacted]'`. Handles arrays, nested objects,
|
|
48
|
-
* and circular references (`WeakSet`-tracked, emits `'[Circular]'` on
|
|
49
|
-
* repeat sight).
|
|
50
|
-
* 2. **Stringify.** `JSON.stringify`; if it throws (BigInt, Symbol, etc),
|
|
51
|
-
* drop the body entirely.
|
|
52
|
-
* 3. **Truncate.** Cap at `bodyMaxBytes` UTF-8 bytes (the option's named
|
|
53
|
-
* semantic) INCLUDING the suffix. Truncation respects codepoint boundaries
|
|
54
|
-
* so the result decodes cleanly with no replacement characters.
|
|
55
|
-
*
|
|
56
|
-
* Returns the final JSON string, or `null` when the body should not be
|
|
57
|
-
* reported (unknown shape, content-type miss, serialization failure).
|
|
58
|
-
*/
|
|
59
|
-
export function captureBody(body: unknown, contentType: string | undefined, opts: BodyOptions): string | null {
|
|
60
|
-
if (body === undefined || body === null) return null;
|
|
61
|
-
|
|
62
|
-
let parsed: unknown;
|
|
63
|
-
if (typeof body === 'string') {
|
|
64
|
-
if (!matchesContentType(contentType, opts.bodyAllowedContentTypes)) return null;
|
|
65
|
-
parsed = parseString(body, contentType);
|
|
66
|
-
if (parsed === undefined) return null;
|
|
67
|
-
} else if (Buffer.isBuffer(body)) {
|
|
68
|
-
if (!matchesContentType(contentType, opts.bodyAllowedContentTypes)) return null;
|
|
69
|
-
parsed = parseString(body.toString('utf8'), contentType);
|
|
70
|
-
if (parsed === undefined) return null;
|
|
71
|
-
} else if (body instanceof URLSearchParams) {
|
|
72
|
-
parsed = Object.fromEntries(body.entries());
|
|
73
|
-
} else if (Array.isArray(body) || isPlainObject(body)) {
|
|
74
|
-
parsed = body;
|
|
75
|
-
} else {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const redacted = redact(parsed, opts.bodyKeyDenylist);
|
|
80
|
-
let serialized: string;
|
|
81
|
-
try {
|
|
82
|
-
serialized = JSON.stringify(redacted);
|
|
83
|
-
} catch {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
return truncateToByteLimit(serialized, opts.bodyMaxBytes);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const TRUNCATION_SUFFIX = '…[truncated]';
|
|
90
|
-
const TRUNCATION_SUFFIX_BYTES = Buffer.byteLength(TRUNCATION_SUFFIX, 'utf8');
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Truncate a serialized string so the resulting UTF-8 byte length never
|
|
94
|
-
* exceeds `maxBytes`, including the appended truncation suffix.
|
|
95
|
-
*
|
|
96
|
-
* Walks backwards from the budget index while the byte at that position is a
|
|
97
|
-
* UTF-8 continuation byte (`10xxxxxx`), stopping at the first byte that
|
|
98
|
-
* starts a new codepoint. Slicing at that index leaves a buffer that decodes
|
|
99
|
-
* cleanly with no replacement characters.
|
|
100
|
-
*/
|
|
101
|
-
function truncateToByteLimit(serialized: string, maxBytes: number): string {
|
|
102
|
-
const buf = Buffer.from(serialized, 'utf8');
|
|
103
|
-
if (buf.length <= maxBytes) return serialized;
|
|
104
|
-
if (maxBytes <= TRUNCATION_SUFFIX_BYTES) {
|
|
105
|
-
// Budget too small to fit the suffix plus any payload. Emit the suffix
|
|
106
|
-
// truncated to the byte budget, respecting codepoint boundaries.
|
|
107
|
-
const suffixBuf = Buffer.from(TRUNCATION_SUFFIX, 'utf8');
|
|
108
|
-
let cut = maxBytes;
|
|
109
|
-
while (cut > 0 && (suffixBuf[cut] & 0xc0) === 0x80) cut--;
|
|
110
|
-
return suffixBuf.subarray(0, cut).toString('utf8');
|
|
111
|
-
}
|
|
112
|
-
let cut = maxBytes - TRUNCATION_SUFFIX_BYTES;
|
|
113
|
-
while (cut > 0 && (buf[cut] & 0xc0) === 0x80) cut--;
|
|
114
|
-
return buf.subarray(0, cut).toString('utf8') + TRUNCATION_SUFFIX;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Check whether a `content-type` header is on the allowlist. Normalizes to the
|
|
119
|
-
* bare media type first: strips any parameters (`; charset=utf-8`), trims, and
|
|
120
|
-
* lowercases, so the regex is tested against `application/json` rather than the
|
|
121
|
-
* full header. This lets a strict custom regex like `/^application\/json$/`
|
|
122
|
-
* still match `application/json; charset=utf-8`. Empty/missing is a hard miss.
|
|
123
|
-
*/
|
|
124
|
-
function matchesContentType(ct: string | undefined, allowed: RegExp): boolean {
|
|
125
|
-
if (!ct) return false;
|
|
126
|
-
const mediaType = ct.split(';')[0].trim().toLowerCase();
|
|
127
|
-
if (!mediaType) return false;
|
|
128
|
-
return allowed.test(mediaType);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Parse a serialized body string into a JS value, branching on the declared
|
|
133
|
-
* content type.
|
|
134
|
-
*
|
|
135
|
-
* - URL-encoded forms become a flat object so the same `redact` walker works.
|
|
136
|
-
* - Otherwise treat as JSON. Returns `undefined` (NOT `null`, which is a
|
|
137
|
-
* legitimate JSON value) when parsing fails, so the caller can distinguish
|
|
138
|
-
* "couldn't parse" from "parsed to literal null".
|
|
139
|
-
*/
|
|
140
|
-
function parseString(text: string, contentType?: string): unknown {
|
|
141
|
-
if (contentType && /x-www-form-urlencoded/i.test(contentType)) {
|
|
142
|
-
return Object.fromEntries(new URLSearchParams(text).entries());
|
|
143
|
-
}
|
|
144
|
-
try {
|
|
145
|
-
return JSON.parse(text);
|
|
146
|
-
} catch {
|
|
147
|
-
return undefined;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* True only for `Object.create(null)` or `{}`-shaped values. Excludes class
|
|
153
|
-
* instances (their prototype chain points somewhere other than Object.prototype
|
|
154
|
-
* or null), streams, FormData, ArrayBuffer views, Buffer, URLSearchParams,
|
|
155
|
-
* and other built-ins that happen to be `typeof === 'object'`.
|
|
156
|
-
*/
|
|
157
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
158
|
-
if (value === null || typeof value !== 'object') return false;
|
|
159
|
-
const proto = Object.getPrototypeOf(value);
|
|
160
|
-
return proto === null || proto === Object.prototype;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Recursively walk `value`, replacing values for denylisted keys with
|
|
165
|
-
* `'[redacted]'` and substituting `'[Circular]'` for any object visited more
|
|
166
|
-
* than once.
|
|
167
|
-
*
|
|
168
|
-
* `seen` is a `WeakSet` of already-visited objects. Carried as a parameter
|
|
169
|
-
* (rather than a closure variable) so the same recursive call can pass it
|
|
170
|
-
* down without per-call allocation.
|
|
171
|
-
*
|
|
172
|
-
* Primitives and `null` pass through unchanged. Arrays preserve order;
|
|
173
|
-
* objects preserve keys.
|
|
174
|
-
*/
|
|
175
|
-
function redact(value: unknown, denylist: RegExp, seen: WeakSet<object> = new WeakSet()): unknown {
|
|
176
|
-
if (value === null || typeof value !== 'object') return value;
|
|
177
|
-
if (seen.has(value as object)) return '[Circular]';
|
|
178
|
-
seen.add(value as object);
|
|
179
|
-
if (Array.isArray(value)) return value.map((v) => redact(v, denylist, seen));
|
|
180
|
-
const out: Record<string, unknown> = {};
|
|
181
|
-
for (const [k, v] of Object.entries(value)) {
|
|
182
|
-
out[k] = denylist.test(k) ? '[redacted]' : redact(v, denylist, seen);
|
|
183
|
-
}
|
|
184
|
-
return out;
|
|
185
|
-
}
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import type { Attributes, Config, ContextCollector } from '@flareapp/core';
|
|
2
|
-
import { redactUrlQuery } from '@flareapp/core';
|
|
3
|
-
|
|
4
|
-
import type { AsyncLocalStorageScopeProvider } from '../scope/AsyncLocalStorageScopeProvider';
|
|
5
|
-
import type { ResolvedNodeOptions } from '../types';
|
|
6
|
-
import { captureBody } from './body';
|
|
7
|
-
import { findHeader, projectHeaders } from './headers';
|
|
8
|
-
import { collectProcessAttributes } from './process';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Build the Node-side `ContextCollector` that core's `Flare` calls on every
|
|
12
|
-
* report. The returned function projects two sources into OTel-style report
|
|
13
|
-
* attributes:
|
|
14
|
-
*
|
|
15
|
-
* 1. **Process info** — runtime version, pid, hostname, etc. Always present.
|
|
16
|
-
* 2. **Active request scope** — method, path/url (with query-string keys
|
|
17
|
-
* redacted), headers (with the denylist applied), optional body, and
|
|
18
|
-
* authenticated user. Present when `runWithContext(...)` is active;
|
|
19
|
-
* falls back to the shared scope otherwise (no request attrs emitted then).
|
|
20
|
-
*
|
|
21
|
-
* Both `provider` and `getOptions` are passed in (not captured by reference to
|
|
22
|
-
* concrete instances) so the closure stays decoupled from `NodeFlare`'s
|
|
23
|
-
* internals. `getOptions` is a getter (not a value) so that `configureNode(...)`
|
|
24
|
-
* changes are visible on subsequent reports without rebuilding the collector.
|
|
25
|
-
*
|
|
26
|
-
* The function returned matches `ContextCollector = (config) => Attributes`,
|
|
27
|
-
* which is core's interface for `Flare`'s third constructor parameter.
|
|
28
|
-
*/
|
|
29
|
-
export function makeNodeContextCollector(
|
|
30
|
-
provider: AsyncLocalStorageScopeProvider,
|
|
31
|
-
getOptions: () => Pick<
|
|
32
|
-
ResolvedNodeOptions,
|
|
33
|
-
| 'headerDenylist'
|
|
34
|
-
| 'headerAllowlist'
|
|
35
|
-
| 'captureRequestBody'
|
|
36
|
-
| 'bodyAllowedContentTypes'
|
|
37
|
-
| 'bodyKeyDenylist'
|
|
38
|
-
| 'bodyMaxBytes'
|
|
39
|
-
>,
|
|
40
|
-
): ContextCollector {
|
|
41
|
-
return (config: Readonly<Config>): Attributes => {
|
|
42
|
-
// Always-on baseline: server entry point + Node runtime info.
|
|
43
|
-
const attrs: Attributes = {
|
|
44
|
-
'flare.entry_point.type': 'server',
|
|
45
|
-
...collectProcessAttributes(),
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// Pull the active scope (either the per-request NodeScope inside a
|
|
49
|
-
// runWithContext callback, or the shared fallback outside one). Either
|
|
50
|
-
// way `request` is a real RequestContext object; unset fields are just
|
|
51
|
-
// `undefined`.
|
|
52
|
-
const scope = provider.active();
|
|
53
|
-
const { request } = scope;
|
|
54
|
-
|
|
55
|
-
if (request.method) attrs['http.request.method'] = request.method;
|
|
56
|
-
|
|
57
|
-
// `request.path` is a server-relative path with an optional query
|
|
58
|
-
// string (the shape of `req.url` from `node:http`). Project to:
|
|
59
|
-
// - `url.path`: everything before `?`
|
|
60
|
-
// - `url.query`: everything after `?`, with denylisted keys redacted
|
|
61
|
-
//
|
|
62
|
-
// We piggy-back on core's `redactUrlQuery` (which expects a full path
|
|
63
|
-
// or URL with `?`) by passing the whole `request.path` and then
|
|
64
|
-
// slicing off the prefix back out of the result. Avoids reimplementing
|
|
65
|
-
// the redact-query logic in two places.
|
|
66
|
-
if (request.path) {
|
|
67
|
-
const queryStart = request.path.indexOf('?');
|
|
68
|
-
if (queryStart === -1) {
|
|
69
|
-
attrs['url.path'] = request.path;
|
|
70
|
-
} else {
|
|
71
|
-
attrs['url.path'] = request.path.slice(0, queryStart);
|
|
72
|
-
const redactedQuery = redactUrlQuery(request.path, config.urlDenylist);
|
|
73
|
-
const redactedQueryStart = redactedQuery.indexOf('?');
|
|
74
|
-
attrs['url.query'] = redactedQuery.slice(redactedQueryStart + 1);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// `request.url` is the absolute URL when the caller has it (after
|
|
79
|
-
// proxy/host resolution). Goes to `url.full` with its query string
|
|
80
|
-
// redacted. Independent of `request.path` — callers can set either,
|
|
81
|
-
// both, or neither.
|
|
82
|
-
if (request.url) {
|
|
83
|
-
attrs['url.full'] = redactUrlQuery(request.url, config.urlDenylist);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Fetch the live node options once per call. `headerDenylist`,
|
|
87
|
-
// `headerAllowlist`, body settings — all already-sanitized by
|
|
88
|
-
// `configureNode`, so we can use them directly.
|
|
89
|
-
const opts = getOptions();
|
|
90
|
-
Object.assign(attrs, projectHeaders(request.headers, opts));
|
|
91
|
-
|
|
92
|
-
// Body capture is off by default. When on, look up `content-type`
|
|
93
|
-
// case-insensitively (HTTP header names are case-insensitive but
|
|
94
|
-
// `request.headers` is just a Record so callers may use either case).
|
|
95
|
-
// `captureBody` returns null when the content type isn't allowed,
|
|
96
|
-
// the body is missing, or serialization fails; we only emit the
|
|
97
|
-
// attribute when there's something to emit.
|
|
98
|
-
if (opts.captureRequestBody) {
|
|
99
|
-
const contentType = findHeader(request.headers, 'content-type');
|
|
100
|
-
const body = captureBody(request.body, contentType, opts);
|
|
101
|
-
if (body !== null) attrs['http.request.body'] = body;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// User identity uses OTel's `enduser.*` and `client.address` keys.
|
|
105
|
-
// `id` is coerced to string because OTel attribute values are strings
|
|
106
|
-
// for these keys while callers commonly hand us a numeric id.
|
|
107
|
-
if (scope.user) {
|
|
108
|
-
if (scope.user.id !== undefined) attrs['enduser.id'] = String(scope.user.id);
|
|
109
|
-
if (scope.user.email !== undefined) attrs['enduser.email'] = scope.user.email;
|
|
110
|
-
if (scope.user.username !== undefined) attrs['enduser.username'] = scope.user.username;
|
|
111
|
-
if (scope.user.ipAddress !== undefined) attrs['client.address'] = scope.user.ipAddress;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return attrs;
|
|
115
|
-
};
|
|
116
|
-
}
|