@flareapp/core 2.2.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.
- package/.oxlintrc.json +7 -0
- package/.release-it.json +13 -0
- package/CHANGELOG.md +16 -0
- package/README.md +22 -0
- package/package.json +52 -0
- package/src/Flare.ts +543 -0
- package/src/Scope.ts +96 -0
- package/src/api/Api.ts +35 -0
- package/src/api/index.ts +1 -0
- package/src/env/index.ts +14 -0
- package/src/index.ts +41 -0
- package/src/stacktrace/NullFileReader.ts +28 -0
- package/src/stacktrace/createStackTrace.ts +74 -0
- package/src/stacktrace/fileReader.ts +96 -0
- package/src/stacktrace/index.ts +4 -0
- package/src/types.ts +81 -0
- package/src/util/assert.ts +9 -0
- package/src/util/assertKey.ts +11 -0
- package/src/util/convertToError.ts +22 -0
- package/src/util/extractCode.ts +11 -0
- package/src/util/flatJsonStringify.ts +45 -0
- package/src/util/glowsToEvents.ts +16 -0
- package/src/util/index.ts +8 -0
- package/src/util/now.ts +3 -0
- package/src/util/redactUrl.ts +83 -0
- package/tests/api.test.ts +95 -0
- package/tests/configure.test.ts +16 -0
- package/tests/contextCollector.test.ts +37 -0
- package/tests/convertToError.test.ts +95 -0
- package/tests/createStackTrace.test.ts +54 -0
- package/tests/extractCode.test.ts +30 -0
- package/tests/fileReader.test.ts +51 -0
- package/tests/flatJsonStringify.test.ts +31 -0
- package/tests/flush.test.ts +47 -0
- package/tests/glows.test.ts +47 -0
- package/tests/glowsToEvents.test.ts +41 -0
- package/tests/helpers/FakeApi.ts +20 -0
- package/tests/helpers/index.ts +1 -0
- package/tests/hooks.test.ts +123 -0
- package/tests/light.test.ts +25 -0
- package/tests/nullFileReader.test.ts +11 -0
- package/tests/publicExports.test.ts +17 -0
- package/tests/redactUrl.test.ts +151 -0
- package/tests/report.test.ts +146 -0
- package/tests/sampleRate.test.ts +88 -0
- package/tests/scope.test.ts +64 -0
- package/tests/setEntryPoint.test.ts +79 -0
- package/tests/setFramework.test.ts +48 -0
- package/tests/setSdkInfo.test.ts +62 -0
- package/tsconfig.json +4 -0
- package/vitest.config.ts +17 -0
package/.oxlintrc.json
ADDED
package/.release-it.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"git": {
|
|
3
|
+
"tagName": "@flareapp/core@${version}",
|
|
4
|
+
"tagAnnotation": "Release @flareapp/core@${version}",
|
|
5
|
+
"commitMessage": "chore: release @flareapp/core@${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
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# @flareapp/core changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-05-28
|
|
4
|
+
|
|
5
|
+
Initial release. Extracted from `@flareapp/js`. Public API is unstable until `1.0.0`.
|
|
6
|
+
|
|
7
|
+
- `Flare` class accepts three constructor injection points: `ScopeProvider`,
|
|
8
|
+
`ContextCollector`, `FileReader`.
|
|
9
|
+
- `Scope` class owns per-call mutable state: `glows`, `pendingAttributes`,
|
|
10
|
+
`entryPoint`. In the browser, a single `GlobalScopeProvider` is used. In Node,
|
|
11
|
+
`@flareapp/node` provides an `AsyncLocalStorageScopeProvider` for per-request
|
|
12
|
+
isolation.
|
|
13
|
+
- `flare.flush(timeoutMs)` drains in-flight reports across the full pipeline
|
|
14
|
+
(not just the final `api.report()` step).
|
|
15
|
+
- `redactUrlQuery` renamed from `redactFullPath`. The old name is re-exported
|
|
16
|
+
as a `@deprecated` alias from `@flareapp/js`.
|
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# @flareapp/core
|
|
2
|
+
|
|
3
|
+
Environment-agnostic core for the Flare JavaScript SDK. Most users want
|
|
4
|
+
[`@flareapp/js`](../js) (browser) or [`@flareapp/node`](../node) (Node.js
|
|
5
|
+
servers). This package is the shared base that both consume.
|
|
6
|
+
|
|
7
|
+
Public and stable, versioned in lockstep with `@flareapp/js` (2.x). Intended
|
|
8
|
+
for third-party Flare integrators who need to build against the same primitives
|
|
9
|
+
the official SDKs use.
|
|
10
|
+
|
|
11
|
+
## Surface
|
|
12
|
+
|
|
13
|
+
- `Flare` — the core class. Takes three optional injection points: `ScopeProvider`,
|
|
14
|
+
`ContextCollector`, `FileReader`.
|
|
15
|
+
- `Scope`, `GlobalScopeProvider`, `ScopeProvider` — per-call mutable state.
|
|
16
|
+
- `FileReader`, `NullFileReader` — source-snippet reading abstraction.
|
|
17
|
+
- `Api` — the HTTP client used to send reports.
|
|
18
|
+
- Types: `Config`, `Report`, `Attributes`, `Glow`, `StackFrame`, etc.
|
|
19
|
+
- Util: `redactUrlQuery`, `resolveDenylist`, `convertToError`, `DEFAULT_URL_DENYLIST`.
|
|
20
|
+
|
|
21
|
+
See `docs/superpowers/specs/2026-05-28-nodejs-sdk-design.md` in the monorepo
|
|
22
|
+
for the architectural contract.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flareapp/core",
|
|
3
|
+
"version": "2.2.0",
|
|
4
|
+
"description": "Environment-agnostic core for the Flare JS SDK",
|
|
5
|
+
"homepage": "https://flareapp.io",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/spatie/flare-client-js/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/spatie/flare-client-js.git"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": {
|
|
15
|
+
"name": "Spatie",
|
|
16
|
+
"email": "info@spatie.be"
|
|
17
|
+
},
|
|
18
|
+
"main": "./dist/index.cjs",
|
|
19
|
+
"module": "./dist/index.mjs",
|
|
20
|
+
"types": "./dist/index.d.cts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"import": {
|
|
24
|
+
"types": "./dist/index.d.mts",
|
|
25
|
+
"default": "./dist/index.mjs"
|
|
26
|
+
},
|
|
27
|
+
"require": {
|
|
28
|
+
"types": "./dist/index.d.cts",
|
|
29
|
+
"default": "./dist/index.cjs"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"prepublishOnly": "npm run build",
|
|
35
|
+
"build": "tsdown src/index.ts --format cjs,esm --dts --env.FLARE_JS_CLIENT_VERSION=$(node -p \"require('./package.json').version\") --clean",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"typescript": "tsc --noEmit",
|
|
38
|
+
"release": "release-it"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"error-stack-parser": "^2.0.2"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"jsdom": "^26.1.0",
|
|
45
|
+
"tsdown": "^0.20.3",
|
|
46
|
+
"typescript": "^5.7.0",
|
|
47
|
+
"vitest": "^4.0.18"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/Flare.ts
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { Api } from './api';
|
|
2
|
+
import { CLIENT_VERSION, KEY, SOURCEMAP_VERSION } from './env';
|
|
3
|
+
import { GlobalScopeProvider, type ScopeProvider } from './Scope';
|
|
4
|
+
import { createStackTrace } from './stacktrace';
|
|
5
|
+
import type { FileReader } from './stacktrace/fileReader';
|
|
6
|
+
import { NullFileReader } from './stacktrace/NullFileReader';
|
|
7
|
+
import {
|
|
8
|
+
AttributeValue,
|
|
9
|
+
Attributes,
|
|
10
|
+
Config,
|
|
11
|
+
EntryPointHandler,
|
|
12
|
+
Framework,
|
|
13
|
+
Glow,
|
|
14
|
+
MessageLevel,
|
|
15
|
+
Report,
|
|
16
|
+
SdkInfo,
|
|
17
|
+
} from './types';
|
|
18
|
+
import { DEFAULT_URL_DENYLIST, assert, assertKey, extractCode, glowsToEvents, now, resolveDenylist } from './util';
|
|
19
|
+
|
|
20
|
+
export type ContextCollector = (config: Readonly<Config>) => Attributes;
|
|
21
|
+
|
|
22
|
+
const DEFAULT_SDK_NAME = '@flareapp/core';
|
|
23
|
+
|
|
24
|
+
export class Flare {
|
|
25
|
+
private inflight = new Set<Promise<void>>();
|
|
26
|
+
|
|
27
|
+
private _config: Config = {
|
|
28
|
+
key: null,
|
|
29
|
+
version: '',
|
|
30
|
+
sourcemapVersionId: SOURCEMAP_VERSION,
|
|
31
|
+
stage: '',
|
|
32
|
+
maxGlowsPerReport: 30,
|
|
33
|
+
ingestUrl: 'https://ingress.flareapp.io/v1/errors',
|
|
34
|
+
reportBrowserExtensionErrors: false,
|
|
35
|
+
debug: false,
|
|
36
|
+
urlDenylist: DEFAULT_URL_DENYLIST,
|
|
37
|
+
replaceDefaultUrlDenylist: false,
|
|
38
|
+
sampleRate: 1,
|
|
39
|
+
beforeEvaluate: (error) => error,
|
|
40
|
+
beforeSubmit: (report) => report,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
private sdkInfo: SdkInfo = { name: DEFAULT_SDK_NAME, version: CLIENT_VERSION };
|
|
44
|
+
private framework: Framework | null = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param api sends the report over HTTP.
|
|
48
|
+
* @param contextCollector returns per-report attributes (browser DOM info, Node
|
|
49
|
+
* process info, etc). Default is a no-op.
|
|
50
|
+
* @param fileReader reads source files for stack-trace snippets. Default
|
|
51
|
+
* returns null (no snippets); `@flareapp/js` injects a
|
|
52
|
+
* fetch-based reader, `@flareapp/node` injects a disk reader.
|
|
53
|
+
* @param scopeProvider returns the current `Scope` (per-call mutable state:
|
|
54
|
+
* glows, pendingAttributes, entryPoint). Browser uses a
|
|
55
|
+
* single global scope; Node uses an AsyncLocalStorage-
|
|
56
|
+
* backed provider so each request gets its own.
|
|
57
|
+
*/
|
|
58
|
+
constructor(
|
|
59
|
+
public api: Api = new Api(),
|
|
60
|
+
private contextCollector: ContextCollector = () => ({}),
|
|
61
|
+
private fileReader: FileReader = new NullFileReader(),
|
|
62
|
+
private scopeProvider: ScopeProvider = new GlobalScopeProvider(),
|
|
63
|
+
) {}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Register an in-flight report so `flush()` can wait for it. Called by
|
|
67
|
+
* every public report entry point (`report`, `reportSilently`,
|
|
68
|
+
* `reportMessage`, `reportUnhandledRejection`, `test`); each wraps its
|
|
69
|
+
* full async pipeline (beforeEvaluate -> stack trace + source snippets ->
|
|
70
|
+
* beforeSubmit -> `api.report()`) so the entire roundtrip is what's
|
|
71
|
+
* tracked, not just the HTTP send at the end.
|
|
72
|
+
*
|
|
73
|
+
* Two problems this method solves at once.
|
|
74
|
+
*
|
|
75
|
+
* Problem 1: hold a reference to the work without leaking rejections.
|
|
76
|
+
*
|
|
77
|
+
* `p` is the real report pipeline; it can reject (network failure,
|
|
78
|
+
* `beforeSubmit` throws, etc). If we stored `p` directly in `inflight`
|
|
79
|
+
* and no caller attached a `.catch` (the global error listeners use
|
|
80
|
+
* `reportSilently` which DOES catch, but the path is still subtle), an
|
|
81
|
+
* eventual rejection would surface as an unhandled-rejection warning
|
|
82
|
+
* on Node and a console error in the browser. Bad citizen.
|
|
83
|
+
*
|
|
84
|
+
* So we build a SHADOW promise that mirrors `p`'s timing but cannot
|
|
85
|
+
* reject:
|
|
86
|
+
*
|
|
87
|
+
* p.then(
|
|
88
|
+
* () => undefined, // on fulfilment, value is undefined
|
|
89
|
+
* () => undefined, // on rejection, ALSO resolve with undefined
|
|
90
|
+
* )
|
|
91
|
+
*
|
|
92
|
+
* Providing the second argument means we have "handled" any rejection
|
|
93
|
+
* from `p`. The shadow always resolves with `undefined`, and `p`'s
|
|
94
|
+
* rejection is consumed at the boundary. From the runtime's point of
|
|
95
|
+
* view, the shadow is well-behaved.
|
|
96
|
+
*
|
|
97
|
+
* Problem 2: self-cleaning entry.
|
|
98
|
+
*
|
|
99
|
+
* `tracked.finally(() => this.inflight.delete(tracked))`. `finally`
|
|
100
|
+
* fires whether the shadow resolves or rejects, but the shadow can no
|
|
101
|
+
* longer reject (problem 1 normalized it), so this is effectively
|
|
102
|
+
* "when the underlying report has settled, remove me from the Set."
|
|
103
|
+
* No GC magic, no external cleanup, no race window.
|
|
104
|
+
*
|
|
105
|
+
* Note that `.finally` itself returns a new promise that we drop on
|
|
106
|
+
* the floor. If the cleanup callback ever throws, that would surface
|
|
107
|
+
* as an unhandled rejection on the dropped promise; `delete` does not
|
|
108
|
+
* throw so we are safe today, but anything more elaborate added here
|
|
109
|
+
* should be wrapped in try/catch.
|
|
110
|
+
*
|
|
111
|
+
* The return value is the ORIGINAL `p`. The caller awaits real success
|
|
112
|
+
* or failure; the tracking is completely invisible to them. This is why
|
|
113
|
+
* `await flare.report(err)` inside a fatal handler observes network
|
|
114
|
+
* errors the same as before tracking was added.
|
|
115
|
+
*/
|
|
116
|
+
private track<T>(p: Promise<T>): Promise<T> {
|
|
117
|
+
const tracked = p.then(
|
|
118
|
+
() => undefined,
|
|
119
|
+
() => undefined,
|
|
120
|
+
) as Promise<void>;
|
|
121
|
+
this.inflight.add(tracked);
|
|
122
|
+
tracked.finally(() => this.inflight.delete(tracked));
|
|
123
|
+
return p;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Wait until every in-flight report settles, or until `timeoutMs`
|
|
128
|
+
* elapses, whichever comes first. Always resolves; never rejects.
|
|
129
|
+
*
|
|
130
|
+
* The main consumer is `@flareapp/node`'s fatal handler:
|
|
131
|
+
*
|
|
132
|
+
* process.on('uncaughtException', async (err) => {
|
|
133
|
+
* process.exitCode = 1;
|
|
134
|
+
* try { await flare.report(err); } catch {}
|
|
135
|
+
* await flare.flush(shutdownTimeoutMs);
|
|
136
|
+
* process.exit(1);
|
|
137
|
+
* });
|
|
138
|
+
*
|
|
139
|
+
* The fatal `report` is awaited explicitly; `flush` then drains any
|
|
140
|
+
* OTHER reports that were already in flight (a request handler that
|
|
141
|
+
* fired `flare.report(...)` concurrently with the crash). The timeout
|
|
142
|
+
* caps the wait so a hung HTTP request cannot indefinitely block
|
|
143
|
+
* shutdown.
|
|
144
|
+
*
|
|
145
|
+
* Walking the implementation:
|
|
146
|
+
*
|
|
147
|
+
* const pending = [...this.inflight];
|
|
148
|
+
*
|
|
149
|
+
* Spread takes a SNAPSHOT of the Set at this instant. Reports that
|
|
150
|
+
* start AFTER this line are not included in `pending`, so they are
|
|
151
|
+
* not awaited by THIS flush call. This is intentional: it bounds
|
|
152
|
+
* the wait. Without the snapshot, a handler that kept emitting
|
|
153
|
+
* reports during shutdown could keep flush alive forever and block
|
|
154
|
+
* the process from exiting.
|
|
155
|
+
*
|
|
156
|
+
* if (pending.length === 0) return Promise.resolve();
|
|
157
|
+
*
|
|
158
|
+
* Fast path. No timer scheduled, no promise constructor needed.
|
|
159
|
+
* Resolves on the microtask queue. Cheap.
|
|
160
|
+
*
|
|
161
|
+
* return new Promise<void>((resolve) => {
|
|
162
|
+
* const timer = setTimeout(resolve, timeoutMs);
|
|
163
|
+
* Promise.allSettled(pending).then(() => {
|
|
164
|
+
* clearTimeout(timer);
|
|
165
|
+
* resolve();
|
|
166
|
+
* });
|
|
167
|
+
* });
|
|
168
|
+
*
|
|
169
|
+
* The race between two outcomes, both calling the same `resolve`:
|
|
170
|
+
*
|
|
171
|
+
* 1. `setTimeout(resolve, timeoutMs)` schedules a "give up" call.
|
|
172
|
+
* After `timeoutMs` it fires, calling `resolve()` from the
|
|
173
|
+
* timer-queue side. The outer promise resolves immediately,
|
|
174
|
+
* even if reports are still pending. Those reports are abandoned
|
|
175
|
+
* (they continue running but the process is about to die).
|
|
176
|
+
*
|
|
177
|
+
* 2. `Promise.allSettled(pending)` returns a promise that resolves
|
|
178
|
+
* when every promise in `pending` has either fulfilled or
|
|
179
|
+
* rejected. It NEVER rejects on its own. We use `allSettled`
|
|
180
|
+
* rather than `Promise.all` because `all` short-circuits on the
|
|
181
|
+
* first rejection -- we want to wait for everyone regardless of
|
|
182
|
+
* whether their HTTP calls succeed or fail. (Our shadows cannot
|
|
183
|
+
* reject anyway because `track` normalized them, but using
|
|
184
|
+
* `allSettled` documents the intent and survives future changes
|
|
185
|
+
* to shadow construction.) When it resolves, we call
|
|
186
|
+
* `clearTimeout(timer)` to cancel the pending timer (so it does
|
|
187
|
+
* not fire later and call `resolve` a second time -- a no-op,
|
|
188
|
+
* but wasted work) and then `resolve()` ourselves.
|
|
189
|
+
*
|
|
190
|
+
* Resolve can only meaningfully fire once. Subsequent calls to the
|
|
191
|
+
* same `resolve` are silently ignored by the Promise spec, so the
|
|
192
|
+
* race is safe even if for some reason both branches fired together.
|
|
193
|
+
*
|
|
194
|
+
* Things flush() deliberately does NOT do:
|
|
195
|
+
*
|
|
196
|
+
* - It does not reject. Even if every report failed, allSettled
|
|
197
|
+
* resolves. Callers do not need a `.catch`.
|
|
198
|
+
* - It does not retry. One pipeline attempt per report, then move on.
|
|
199
|
+
* - It does not stop new reports from starting. The Flare instance
|
|
200
|
+
* is still usable after flush resolves. flush is "wait for what is
|
|
201
|
+
* in flight," not "freeze the SDK."
|
|
202
|
+
* - It does not drain reports started after the snapshot. Call flush
|
|
203
|
+
* again if you need to wait for those too.
|
|
204
|
+
*/
|
|
205
|
+
flush(timeoutMs = 2000): Promise<void> {
|
|
206
|
+
const pending = [...this.inflight];
|
|
207
|
+
if (pending.length === 0) return Promise.resolve();
|
|
208
|
+
return new Promise<void>((resolve) => {
|
|
209
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
210
|
+
Promise.allSettled(pending).then(() => {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
resolve();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
get config(): Readonly<Config> {
|
|
218
|
+
return this._config;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
get glows(): readonly Glow[] {
|
|
222
|
+
return this.scopeProvider.active().glows;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
light(key: string = KEY, debug?: boolean): this {
|
|
226
|
+
this._config.key = key;
|
|
227
|
+
if (debug !== undefined) {
|
|
228
|
+
this._config.debug = debug;
|
|
229
|
+
}
|
|
230
|
+
return this;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
configure(config: Partial<Config>): this {
|
|
234
|
+
this._config = { ...this._config, ...config };
|
|
235
|
+
|
|
236
|
+
if (config.sampleRate !== undefined) {
|
|
237
|
+
this._config.sampleRate = Math.max(0, Math.min(1, config.sampleRate));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this._config.urlDenylist = resolveDenylist(
|
|
241
|
+
config.urlDenylist,
|
|
242
|
+
config.replaceDefaultUrlDenylist ?? this._config.replaceDefaultUrlDenylist,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
return this;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
test(): Promise<void> {
|
|
249
|
+
return this.track(this.testInternal());
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async testInternal(): Promise<void> {
|
|
253
|
+
const report = await this.createReportFromError(new Error('The Flare client is set up correctly!'));
|
|
254
|
+
if (!report) return;
|
|
255
|
+
return this.sendReport(report);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
glow(
|
|
259
|
+
name: string,
|
|
260
|
+
level: MessageLevel = 'info',
|
|
261
|
+
data: Record<string, unknown> | Record<string, unknown>[] = [],
|
|
262
|
+
): this {
|
|
263
|
+
const time = now();
|
|
264
|
+
this.scopeProvider.active().addGlow(
|
|
265
|
+
{
|
|
266
|
+
name,
|
|
267
|
+
messageLevel: level,
|
|
268
|
+
metaData: data,
|
|
269
|
+
time,
|
|
270
|
+
microtime: time,
|
|
271
|
+
},
|
|
272
|
+
this._config.maxGlowsPerReport,
|
|
273
|
+
);
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
clearGlows(): this {
|
|
278
|
+
this.scopeProvider.active().clearGlows();
|
|
279
|
+
return this;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
addContext(name: string, value: AttributeValue): this {
|
|
283
|
+
const scope = this.scopeProvider.active();
|
|
284
|
+
const existing =
|
|
285
|
+
(scope.pendingAttributes['context.custom'] as Record<string, AttributeValue> | undefined) ?? {};
|
|
286
|
+
scope.setAttribute('context.custom', { ...existing, [name]: value });
|
|
287
|
+
return this;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
addContextGroup(groupName: string, value: Record<string, AttributeValue>): this {
|
|
291
|
+
this.scopeProvider.active().setAttribute(`context.${groupName}`, value);
|
|
292
|
+
return this;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
setEntryPoint(handler: EntryPointHandler): this {
|
|
296
|
+
this.scopeProvider.active().entryPoint = handler;
|
|
297
|
+
return this;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
setSdkInfo(info: SdkInfo): this {
|
|
301
|
+
this.sdkInfo = info;
|
|
302
|
+
return this;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
setFramework(framework: Framework): this {
|
|
306
|
+
this.framework = framework;
|
|
307
|
+
return this;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
report(error: Error, attributes: Attributes = {}): Promise<void> {
|
|
311
|
+
return this.track(this.reportInternal(error, attributes));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private async reportInternal(error: Error, attributes: Attributes = {}): Promise<void> {
|
|
315
|
+
if (this._config.sampleRate < 1 && Math.random() >= this._config.sampleRate) return;
|
|
316
|
+
|
|
317
|
+
const seenAtUnixNano = Date.now() * 1_000_000;
|
|
318
|
+
|
|
319
|
+
// Coerce non-Error values (strings, rejected promises, etc) so we always have a real Error
|
|
320
|
+
// to walk a stack from. Typed as Error for ergonomics, but consumers may pass anything.
|
|
321
|
+
const coerced = error instanceof Error ? error : new Error(typeof error === 'string' ? error : String(error));
|
|
322
|
+
|
|
323
|
+
const errorToReport = await this._config.beforeEvaluate(coerced);
|
|
324
|
+
if (!errorToReport) return;
|
|
325
|
+
|
|
326
|
+
const report = await this.createReportFromError(errorToReport, attributes, seenAtUnixNano);
|
|
327
|
+
if (!report) return;
|
|
328
|
+
|
|
329
|
+
return this.sendReport(report);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
reportSilently(error: Error, attributes: Attributes = {}): void {
|
|
333
|
+
void this.track(this.reportInternal(error, attributes).catch(() => {}));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
reportUnhandledRejection(message: string, attributes: Attributes = {}): Promise<void> {
|
|
337
|
+
return this.track(this.reportUnhandledRejectionInternal(message, attributes));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private async reportUnhandledRejectionInternal(message: string, attributes: Attributes = {}): Promise<void> {
|
|
341
|
+
if (this._config.sampleRate < 1 && Math.random() >= this._config.sampleRate) return;
|
|
342
|
+
|
|
343
|
+
const seenAtUnixNano = Date.now() * 1_000_000;
|
|
344
|
+
|
|
345
|
+
const report = this.buildReport({
|
|
346
|
+
exceptionClass: 'UnhandledRejection',
|
|
347
|
+
message,
|
|
348
|
+
stacktrace: [],
|
|
349
|
+
isLog: false,
|
|
350
|
+
level: undefined,
|
|
351
|
+
extraAttributes: attributes,
|
|
352
|
+
code: undefined,
|
|
353
|
+
seenAtUnixNano,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return this.sendReport(report);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
reportMessage(message: string, level?: MessageLevel, attributes: Attributes = {}): Promise<void> {
|
|
360
|
+
return this.track(this.reportMessageInternal(message, level, attributes));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private async reportMessageInternal(
|
|
364
|
+
message: string,
|
|
365
|
+
level?: MessageLevel,
|
|
366
|
+
attributes: Attributes = {},
|
|
367
|
+
): Promise<void> {
|
|
368
|
+
if (this._config.sampleRate < 1 && Math.random() >= this._config.sampleRate) return;
|
|
369
|
+
|
|
370
|
+
const seenAtUnixNano = Date.now() * 1_000_000;
|
|
371
|
+
const stackTrace = await createStackTrace(new Error(), this._config.debug, this.fileReader);
|
|
372
|
+
// Drop the top frame so reportMessage itself doesn't appear as the call site.
|
|
373
|
+
stackTrace.shift();
|
|
374
|
+
|
|
375
|
+
const report = this.buildReport({
|
|
376
|
+
exceptionClass: 'Log',
|
|
377
|
+
message,
|
|
378
|
+
stacktrace: stackTrace,
|
|
379
|
+
isLog: true,
|
|
380
|
+
level,
|
|
381
|
+
extraAttributes: attributes,
|
|
382
|
+
code: undefined,
|
|
383
|
+
seenAtUnixNano,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
return this.sendReport(report);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async createReportFromError(
|
|
390
|
+
error: Error,
|
|
391
|
+
attributes: Attributes = {},
|
|
392
|
+
seenAtUnixNano: number = Date.now() * 1_000_000,
|
|
393
|
+
): Promise<Report | false> {
|
|
394
|
+
if (!assert(error, 'No error provided.', this._config.debug)) {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const stacktrace = await createStackTrace(error, this._config.debug, this.fileReader);
|
|
399
|
+
|
|
400
|
+
assert(stacktrace.length, "Couldn't generate stacktrace of this error: " + error, this._config.debug);
|
|
401
|
+
|
|
402
|
+
const exceptionClass = error.constructor && error.constructor.name ? error.constructor.name : 'undefined';
|
|
403
|
+
|
|
404
|
+
return this.buildReport({
|
|
405
|
+
exceptionClass,
|
|
406
|
+
message: error.message,
|
|
407
|
+
stacktrace,
|
|
408
|
+
isLog: false,
|
|
409
|
+
level: undefined,
|
|
410
|
+
extraAttributes: attributes,
|
|
411
|
+
code: extractCode(error),
|
|
412
|
+
seenAtUnixNano,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private buildReport(input: {
|
|
417
|
+
exceptionClass: string;
|
|
418
|
+
message: string;
|
|
419
|
+
stacktrace: Report['stacktrace'];
|
|
420
|
+
isLog: boolean;
|
|
421
|
+
level: MessageLevel | undefined;
|
|
422
|
+
extraAttributes: Attributes;
|
|
423
|
+
code: string | undefined;
|
|
424
|
+
seenAtUnixNano: number;
|
|
425
|
+
}): Report {
|
|
426
|
+
const activeScope = this.scopeProvider.active();
|
|
427
|
+
|
|
428
|
+
const baseAttributes: Attributes = {
|
|
429
|
+
'telemetry.sdk.language': 'javascript',
|
|
430
|
+
'telemetry.sdk.name': this.sdkInfo.name,
|
|
431
|
+
'telemetry.sdk.version': this.sdkInfo.version,
|
|
432
|
+
'flare.language.name': 'javascript',
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
if (this._config.stage) {
|
|
436
|
+
baseAttributes['service.stage'] = this._config.stage;
|
|
437
|
+
}
|
|
438
|
+
if (this._config.version) {
|
|
439
|
+
baseAttributes['service.version'] = this._config.version;
|
|
440
|
+
}
|
|
441
|
+
if (this.framework?.name) {
|
|
442
|
+
baseAttributes['flare.framework.name'] = this.framework.name;
|
|
443
|
+
}
|
|
444
|
+
if (this.framework?.version) {
|
|
445
|
+
baseAttributes['flare.framework.version'] = this.framework.version;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Scope entryPoint overrides are applied after the context collector so that
|
|
449
|
+
// explicitly set values (via setEntryPoint) win over collector-provided defaults.
|
|
450
|
+
const entryPoint = activeScope.entryPoint;
|
|
451
|
+
const entryPointOverrides: Attributes = {};
|
|
452
|
+
if (entryPoint?.identifier !== undefined) {
|
|
453
|
+
entryPointOverrides['flare.entry_point.handler.identifier'] = entryPoint.identifier;
|
|
454
|
+
}
|
|
455
|
+
if (entryPoint?.type !== undefined) {
|
|
456
|
+
entryPointOverrides['flare.entry_point.handler.type'] = entryPoint.type;
|
|
457
|
+
}
|
|
458
|
+
if (entryPoint?.name !== undefined) {
|
|
459
|
+
entryPointOverrides['flare.entry_point.handler.name'] = entryPoint.name;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const attributes: Attributes = {
|
|
463
|
+
...baseAttributes,
|
|
464
|
+
...this.contextCollector(this._config),
|
|
465
|
+
...entryPointOverrides,
|
|
466
|
+
...activeScope.pendingAttributes,
|
|
467
|
+
...input.extraAttributes,
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// Merge `context.custom` from extraAttributes into pendingAttributes' value
|
|
471
|
+
// instead of overwriting, so framework adapters can attach custom context
|
|
472
|
+
// without clobbering user-set context from addContext().
|
|
473
|
+
const pendingCustom = activeScope.pendingAttributes['context.custom'];
|
|
474
|
+
const extraCustom = input.extraAttributes['context.custom'];
|
|
475
|
+
if (
|
|
476
|
+
pendingCustom &&
|
|
477
|
+
extraCustom &&
|
|
478
|
+
typeof pendingCustom === 'object' &&
|
|
479
|
+
typeof extraCustom === 'object' &&
|
|
480
|
+
!Array.isArray(pendingCustom) &&
|
|
481
|
+
!Array.isArray(extraCustom)
|
|
482
|
+
) {
|
|
483
|
+
attributes['context.custom'] = {
|
|
484
|
+
...(pendingCustom as Record<string, AttributeValue>),
|
|
485
|
+
...(extraCustom as Record<string, AttributeValue>),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Emit context.custom.framework from instance state so it is present even
|
|
490
|
+
// inside a fresh request scope (where pendingAttributes starts empty).
|
|
491
|
+
// This mirrors the pre-refactor behavior: setFramework always set framework.
|
|
492
|
+
if (this.framework?.name) {
|
|
493
|
+
const existing = (attributes['context.custom'] as Record<string, AttributeValue> | undefined) ?? {};
|
|
494
|
+
attributes['context.custom'] = { ...existing, framework: this.framework.name.toLowerCase() };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// seenAtUnixNano: real nanoseconds. Date.now() * 1_000_000 exceeds Number.MAX_SAFE_INTEGER
|
|
498
|
+
// by ~3 bits (~256 ns of drift), but browser clocks are millisecond-precision so the lost
|
|
499
|
+
// bits are below source resolution. PHP's json_decode reads the resulting 19-digit literal
|
|
500
|
+
// as a 64-bit int (PHP_INT_MAX ~ 9.22e18 vs our value ~ 1.78e18).
|
|
501
|
+
const report: Report = {
|
|
502
|
+
exceptionClass: input.exceptionClass,
|
|
503
|
+
message: input.message,
|
|
504
|
+
seenAtUnixNano: input.seenAtUnixNano,
|
|
505
|
+
stacktrace: input.stacktrace,
|
|
506
|
+
events: glowsToEvents(activeScope.glows),
|
|
507
|
+
attributes,
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
if (input.isLog) {
|
|
511
|
+
report.isLog = true;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (input.level !== undefined) {
|
|
515
|
+
report.level = input.level;
|
|
516
|
+
}
|
|
517
|
+
if (this._config.sourcemapVersionId) {
|
|
518
|
+
report.sourcemapVersionId = this._config.sourcemapVersionId;
|
|
519
|
+
}
|
|
520
|
+
if (input.code !== undefined) {
|
|
521
|
+
report.code = input.code;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return report;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async sendReport(report: Report): Promise<void> {
|
|
528
|
+
if (!assertKey(this._config.key, this._config.debug)) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const reportToSubmit = await this._config.beforeSubmit(report);
|
|
533
|
+
if (!reportToSubmit) return;
|
|
534
|
+
|
|
535
|
+
return this.api.report(
|
|
536
|
+
reportToSubmit,
|
|
537
|
+
this._config.ingestUrl,
|
|
538
|
+
this._config.key,
|
|
539
|
+
this._config.reportBrowserExtensionErrors,
|
|
540
|
+
this._config.debug,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|