@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flareapp/node",
3
- "version": "0.1.0",
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.0"
47
+ "@flareapp/core": "2.2.1"
45
48
  },
46
49
  "devDependencies": {
47
50
  "tsdown": "^0.20.3",
package/.oxlintrc.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "$schema": "../../node_modules/oxlint/configuration_schema.json",
3
- "extends": ["../../.oxlintrc.json"],
4
- "env": {
5
- "node": true
6
- }
7
- }
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
- }
@@ -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
- }