@stimpact/sdk 0.1.0 → 0.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/README.md +112 -2
- package/dist/client.d.ts +16 -5
- package/dist/client.js +342 -49
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +27 -0
- package/package.json +25 -2
package/README.md
CHANGED
|
@@ -10,12 +10,29 @@ Browser runtimes should use either:
|
|
|
10
10
|
- a custom `browserTokenEndpoint`
|
|
11
11
|
- a custom `tokenProvider`
|
|
12
12
|
|
|
13
|
+
## Capture contract
|
|
14
|
+
|
|
15
|
+
Stimpact treats error capture as two complementary layers:
|
|
16
|
+
|
|
17
|
+
- auto-capture for uncaught failures that escape to the runtime
|
|
18
|
+
- handled-error capture for failures your app catches intentionally
|
|
19
|
+
|
|
20
|
+
In practice that means:
|
|
21
|
+
|
|
22
|
+
- browser runtimes should enable `registerBrowserAutoCapture()` for `window` errors and unhandled promise rejections
|
|
23
|
+
- Node and other server runtimes should enable `registerProcessAutoCapture()` for uncaught exceptions and unhandled rejections
|
|
24
|
+
- handled errors inside `catch` blocks, request wrappers, mutation handlers, and framework callbacks should use `captureHandledError()`, `wrap()`, or `wrapAsync()`
|
|
25
|
+
- the SDK suppresses duplicate reporting for the same error object when a handled capture later reaches an auto-capture hook
|
|
26
|
+
- SDK transport failures are never recursively auto-captured as application errors
|
|
27
|
+
|
|
13
28
|
## Install
|
|
14
29
|
|
|
15
30
|
```sh
|
|
16
31
|
npm install @stimpact/sdk
|
|
17
32
|
```
|
|
18
33
|
|
|
34
|
+
For package release validation and the prepublish checklist, see `PUBLISH_VALIDATION.md`.
|
|
35
|
+
|
|
19
36
|
## Server usage
|
|
20
37
|
|
|
21
38
|
```ts
|
|
@@ -29,10 +46,12 @@ const stimpact = new StimpactClient({
|
|
|
29
46
|
environment: "production",
|
|
30
47
|
});
|
|
31
48
|
|
|
49
|
+
stimpact.registerProcessAutoCapture();
|
|
50
|
+
|
|
32
51
|
try {
|
|
33
52
|
await doWork();
|
|
34
53
|
} catch (error) {
|
|
35
|
-
await stimpact.
|
|
54
|
+
await stimpact.captureHandledError({
|
|
36
55
|
error,
|
|
37
56
|
request: {
|
|
38
57
|
method: "POST",
|
|
@@ -53,14 +72,51 @@ const stimpact = new StimpactClient({
|
|
|
53
72
|
browserKey: "stimp_browser_...",
|
|
54
73
|
service: "billing-web",
|
|
55
74
|
environment: "production",
|
|
75
|
+
browserTokenFailureCooldownMs: 60_000,
|
|
56
76
|
});
|
|
57
77
|
|
|
58
|
-
stimpact.startHeartbeat(
|
|
78
|
+
stimpact.startHeartbeat({
|
|
79
|
+
intervalMs: 300_000,
|
|
80
|
+
jitterRatio: 0.1,
|
|
81
|
+
skipWhenOffline: true,
|
|
82
|
+
});
|
|
59
83
|
stimpact.registerBrowserAutoCapture();
|
|
60
84
|
```
|
|
61
85
|
|
|
86
|
+
When you use `browserKey`, configure at least one deployed app origin for that specific key during onboarding. Stimpact keeps CORS broadly compatible across active browser-key origins so browser preflights can succeed, but the actual security boundary is the browser key's `allowed_origins` check during token exchange plus the ingest token's origin binding.
|
|
87
|
+
|
|
88
|
+
If you later revoke the key or remove an origin from its allowlist, new token exchanges from that origin fail and ingest re-checks the live browser-key record for defense in depth.
|
|
89
|
+
|
|
90
|
+
The SDK also applies a cooldown after non-retryable browser token failures such as invalid, revoked, or origin-blocked browser keys so it does not hammer `/telemetry/browser-token` on every subsequent error event.
|
|
91
|
+
|
|
62
92
|
For the strongest separation, use `browserTokenEndpoint` or `tokenProvider` so your app backend can mint short-lived ingest tokens without exposing any long-lived server credential to the browser.
|
|
63
93
|
|
|
94
|
+
## Heartbeats and live status
|
|
95
|
+
|
|
96
|
+
Use heartbeats as a lightweight "recently alive" signal, not a per-request health check.
|
|
97
|
+
|
|
98
|
+
- `sendHeartbeat()` sends one manual heartbeat immediately.
|
|
99
|
+
- `ping()` is an alias for `sendHeartbeat()` when you want a more explicit manual check-in call from UI code later.
|
|
100
|
+
- `startHeartbeat()` schedules periodic heartbeats with jitter and optional browser-aware pause behavior.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const heartbeat = stimpact.startHeartbeat({
|
|
104
|
+
intervalMs: 5 * 60_000,
|
|
105
|
+
jitterRatio: 0.1,
|
|
106
|
+
skipWhenOffline: true,
|
|
107
|
+
pauseWhenHidden: false,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await stimpact.ping();
|
|
111
|
+
|
|
112
|
+
// Manual dashboard-triggered check-in later:
|
|
113
|
+
await heartbeat.triggerNow({ commitSha: window.__APP_COMMIT_SHA__ });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For frontend apps, heartbeat freshness means "a real browser session for this app has checked in recently." It does not guarantee the site is globally up for every visitor when no browser session is active.
|
|
117
|
+
|
|
118
|
+
For server runtimes, heartbeats are a stronger liveness signal because they can run inside the long-lived process itself.
|
|
119
|
+
|
|
64
120
|
## Browser autocapture
|
|
65
121
|
|
|
66
122
|
```ts
|
|
@@ -70,6 +126,37 @@ const subscription = stimpact.registerBrowserAutoCapture();
|
|
|
70
126
|
subscription.dispose();
|
|
71
127
|
```
|
|
72
128
|
|
|
129
|
+
The SDK ignores its own transport errors during browser auto-capture so a failed telemetry request does not recursively report itself as another browser error.
|
|
130
|
+
|
|
131
|
+
## Handled errors and framework integrations
|
|
132
|
+
|
|
133
|
+
Use handled-error capture anywhere your app intentionally catches and renders the failure instead of letting it escape globally.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
try {
|
|
137
|
+
await saveInvoice();
|
|
138
|
+
} catch (error) {
|
|
139
|
+
await stimpact.captureHandledError({
|
|
140
|
+
error,
|
|
141
|
+
request: {
|
|
142
|
+
method: "POST",
|
|
143
|
+
url: "/api/invoices",
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
For synchronous code:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
stimpact.wrap(() => {
|
|
154
|
+
performDangerousSynchronousWork();
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
For async flows:
|
|
159
|
+
|
|
73
160
|
## Wrapped async flows
|
|
74
161
|
|
|
75
162
|
```ts
|
|
@@ -78,6 +165,29 @@ await stimpact.wrapAsync(async () => {
|
|
|
78
165
|
});
|
|
79
166
|
```
|
|
80
167
|
|
|
168
|
+
For framework-driven apps, instrument the shared boundary first instead of every component:
|
|
169
|
+
|
|
170
|
+
- request wrappers such as `fetch` helpers or API clients
|
|
171
|
+
- React Query / TanStack Query mutation and query defaults
|
|
172
|
+
- framework error boundaries, loaders, and action handlers
|
|
173
|
+
|
|
174
|
+
For example, a React Query mutation can capture the handled failure once in the shared `onError` path or request wrapper, then let the UI keep rendering the toast or retry state:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
onError: async (error) => {
|
|
178
|
+
await stimpact.captureHandledError({
|
|
179
|
+
error,
|
|
180
|
+
request: {
|
|
181
|
+
method: "POST",
|
|
182
|
+
url: "/requests",
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
toast({ title: "Request failed" });
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Avoid capturing the same failure in both a shared request wrapper and a component-level `onError` unless they are intentionally distinct telemetry events.
|
|
190
|
+
|
|
81
191
|
## Data minimization defaults
|
|
82
192
|
|
|
83
193
|
By default the SDK:
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BrowserAutoCaptureOptions, BrowserCaptureSubscription, CaptureErrorInput, HeartbeatInput, HeartbeatSubscription, StimpactClientOptions } from "./types.js";
|
|
1
|
+
import type { BrowserAutoCaptureOptions, BrowserCaptureSubscription, CaptureErrorInput, ErrorCaptureContext, HeartbeatInput, HeartbeatScheduleOptions, HeartbeatSubscription, ProcessAutoCaptureOptions, ProcessCaptureSubscription, StimpactClientOptions } from "./types.js";
|
|
2
2
|
export declare class StimpactRequestError extends Error {
|
|
3
3
|
status: number | null;
|
|
4
4
|
retryable: boolean;
|
|
@@ -14,14 +14,24 @@ export declare class StimpactClient {
|
|
|
14
14
|
private cachedBrowserToken;
|
|
15
15
|
private cachedBrowserTokenExpiresAtMs;
|
|
16
16
|
private browserTokenPromise;
|
|
17
|
+
private browserTokenFailure;
|
|
18
|
+
private browserTokenFailureBlockedUntilMs;
|
|
19
|
+
private readonly inFlightErrors;
|
|
20
|
+
private readonly reportedErrors;
|
|
17
21
|
constructor(options: StimpactClientOptions);
|
|
18
22
|
captureError(input: CaptureErrorInput): Promise<void>;
|
|
23
|
+
captureHandledError(input: CaptureErrorInput): Promise<void>;
|
|
19
24
|
sendHeartbeat(input?: HeartbeatInput): Promise<void>;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
ping(input?: HeartbeatInput): Promise<void>;
|
|
26
|
+
wrap<T>(operation: () => T, context?: ErrorCaptureContext): T;
|
|
27
|
+
wrapAsync<T>(operation: () => Promise<T>, context?: ErrorCaptureContext): Promise<T>;
|
|
28
|
+
registerProcessAutoCapture(options?: ProcessAutoCaptureOptions): ProcessCaptureSubscription;
|
|
29
|
+
private captureWithState;
|
|
30
|
+
startHeartbeat(options?: HeartbeatScheduleOptions): HeartbeatSubscription;
|
|
24
31
|
registerBrowserAutoCapture(options?: BrowserAutoCaptureOptions): BrowserCaptureSubscription;
|
|
32
|
+
private shouldIgnoreAutoCapturedError;
|
|
33
|
+
private captureSyncFailure;
|
|
34
|
+
private attachCaptureFailure;
|
|
25
35
|
private sendTelemetry;
|
|
26
36
|
private sendHeartbeatPayload;
|
|
27
37
|
private sendTelemetryOnce;
|
|
@@ -30,4 +40,5 @@ export declare class StimpactClient {
|
|
|
30
40
|
private getBrowserToken;
|
|
31
41
|
private fetchBrowserToken;
|
|
32
42
|
private performJsonRequest;
|
|
43
|
+
private rememberBrowserTokenFailure;
|
|
33
44
|
}
|
package/dist/client.js
CHANGED
|
@@ -6,6 +6,8 @@ const DEFAULT_REDACTED_HEADERS = new Set([
|
|
|
6
6
|
"x-api-key",
|
|
7
7
|
"x-stimpact-project-key",
|
|
8
8
|
]);
|
|
9
|
+
const CAPTURE_IN_FLIGHT = Symbol("stimpact.captureInFlight");
|
|
10
|
+
const CAPTURE_REPORTED = Symbol("stimpact.captureReported");
|
|
9
11
|
export class StimpactRequestError extends Error {
|
|
10
12
|
status;
|
|
11
13
|
retryable;
|
|
@@ -23,6 +25,10 @@ export class StimpactClient {
|
|
|
23
25
|
cachedBrowserToken = null;
|
|
24
26
|
cachedBrowserTokenExpiresAtMs = 0;
|
|
25
27
|
browserTokenPromise = null;
|
|
28
|
+
browserTokenFailure = null;
|
|
29
|
+
browserTokenFailureBlockedUntilMs = 0;
|
|
30
|
+
inFlightErrors = new WeakSet();
|
|
31
|
+
reportedErrors = new WeakSet();
|
|
26
32
|
constructor(options) {
|
|
27
33
|
if (!options.apiKey && !options.browserKey && !options.browserTokenEndpoint && !options.tokenProvider) {
|
|
28
34
|
throw new Error("StimpactClient requires apiKey, browserKey, browserTokenEndpoint, or tokenProvider.");
|
|
@@ -36,6 +42,7 @@ export class StimpactClient {
|
|
|
36
42
|
timeoutMs: options.timeoutMs ?? 5_000,
|
|
37
43
|
retryAttempts: options.retryAttempts ?? 2,
|
|
38
44
|
retryDelayMs: options.retryDelayMs ?? 250,
|
|
45
|
+
browserTokenFailureCooldownMs: options.browserTokenFailureCooldownMs ?? 60_000,
|
|
39
46
|
captureRequestContext: options.captureRequestContext ?? false,
|
|
40
47
|
captureResponseContext: options.captureResponseContext ?? false,
|
|
41
48
|
includeBodies: options.includeBodies ?? false,
|
|
@@ -44,23 +51,10 @@ export class StimpactClient {
|
|
|
44
51
|
};
|
|
45
52
|
}
|
|
46
53
|
async captureError(input) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
service: input.service ?? this.options.service,
|
|
52
|
-
error_message: normalized.message,
|
|
53
|
-
stacktrace: normalized.stacktrace,
|
|
54
|
-
request: this.options.captureRequestContext
|
|
55
|
-
? sanitizeRequestContext(input.request, this.options)
|
|
56
|
-
: undefined,
|
|
57
|
-
response: this.options.captureResponseContext
|
|
58
|
-
? sanitizeResponseContext(input.response, this.options)
|
|
59
|
-
: undefined,
|
|
60
|
-
commit_sha: input.commitSha ?? null,
|
|
61
|
-
timestamp: normalizeTimestamp(input.timestamp),
|
|
62
|
-
};
|
|
63
|
-
await this.sendTelemetry(payload);
|
|
54
|
+
await this.captureWithState(input);
|
|
55
|
+
}
|
|
56
|
+
async captureHandledError(input) {
|
|
57
|
+
await this.captureWithState(input);
|
|
64
58
|
}
|
|
65
59
|
async sendHeartbeat(input = {}) {
|
|
66
60
|
const payload = {
|
|
@@ -72,22 +66,17 @@ export class StimpactClient {
|
|
|
72
66
|
};
|
|
73
67
|
await this.sendHeartbeatPayload(payload);
|
|
74
68
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
69
|
+
async ping(input = {}) {
|
|
70
|
+
await this.sendHeartbeat(input);
|
|
71
|
+
}
|
|
72
|
+
wrap(operation, context) {
|
|
73
|
+
try {
|
|
74
|
+
return operation();
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
this.captureSyncFailure(error, context);
|
|
78
|
+
throw error;
|
|
83
79
|
}
|
|
84
|
-
return {
|
|
85
|
-
dispose: () => {
|
|
86
|
-
if (intervalId !== null && typeof clearInterval !== "undefined") {
|
|
87
|
-
clearInterval(intervalId);
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
};
|
|
91
80
|
}
|
|
92
81
|
async wrapAsync(operation, context) {
|
|
93
82
|
try {
|
|
@@ -95,21 +84,214 @@ export class StimpactClient {
|
|
|
95
84
|
}
|
|
96
85
|
catch (error) {
|
|
97
86
|
try {
|
|
98
|
-
await this.
|
|
87
|
+
await this.captureHandledError({
|
|
99
88
|
...context,
|
|
100
89
|
error,
|
|
101
90
|
});
|
|
102
91
|
}
|
|
103
92
|
catch (captureFailure) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
93
|
+
this.attachCaptureFailure(error, captureFailure);
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
registerProcessAutoCapture(options = {}) {
|
|
99
|
+
const processTarget = options.processTarget ?? resolveProcessTarget();
|
|
100
|
+
if (!processTarget) {
|
|
101
|
+
return { dispose: () => undefined };
|
|
102
|
+
}
|
|
103
|
+
const captureUncaughtExceptions = options.captureUncaughtExceptions ?? true;
|
|
104
|
+
const captureUnhandledRejections = options.captureUnhandledRejections ?? true;
|
|
105
|
+
const uncaughtExceptionListener = (error) => {
|
|
106
|
+
if (this.shouldIgnoreAutoCapturedError(error)) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
void this.captureError({
|
|
110
|
+
error: toError(error),
|
|
111
|
+
service: this.options.service,
|
|
112
|
+
}).catch(() => undefined);
|
|
113
|
+
};
|
|
114
|
+
const unhandledRejectionListener = (reason) => {
|
|
115
|
+
if (this.shouldIgnoreAutoCapturedError(reason)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
void this.captureError({
|
|
119
|
+
error: toError(reason),
|
|
120
|
+
service: this.options.service,
|
|
121
|
+
}).catch(() => undefined);
|
|
122
|
+
};
|
|
123
|
+
if (captureUncaughtExceptions) {
|
|
124
|
+
addProcessListener(processTarget, "uncaughtException", uncaughtExceptionListener);
|
|
125
|
+
}
|
|
126
|
+
if (captureUnhandledRejections) {
|
|
127
|
+
addProcessListener(processTarget, "unhandledRejection", unhandledRejectionListener);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
dispose: () => {
|
|
131
|
+
if (captureUncaughtExceptions) {
|
|
132
|
+
removeProcessListener(processTarget, "uncaughtException", uncaughtExceptionListener);
|
|
109
133
|
}
|
|
134
|
+
if (captureUnhandledRejections) {
|
|
135
|
+
removeProcessListener(processTarget, "unhandledRejection", unhandledRejectionListener);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
async captureWithState(input) {
|
|
141
|
+
const trackedError = asTrackableObject(input.error);
|
|
142
|
+
if (trackedError && (this.inFlightErrors.has(trackedError) || this.reportedErrors.has(trackedError))) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (trackedError) {
|
|
146
|
+
this.inFlightErrors.add(trackedError);
|
|
147
|
+
markCaptureState(trackedError, CAPTURE_IN_FLIGHT);
|
|
148
|
+
}
|
|
149
|
+
const normalized = normalizeError(input.error, this.options.maxValueLength);
|
|
150
|
+
const payload = {
|
|
151
|
+
project_id: this.options.projectId,
|
|
152
|
+
environment: input.environment ?? this.options.environment ?? "production",
|
|
153
|
+
service: input.service ?? this.options.service,
|
|
154
|
+
error_message: normalized.message,
|
|
155
|
+
stacktrace: normalized.stacktrace,
|
|
156
|
+
request: this.options.captureRequestContext
|
|
157
|
+
? sanitizeRequestContext(input.request, this.options)
|
|
158
|
+
: undefined,
|
|
159
|
+
response: this.options.captureResponseContext
|
|
160
|
+
? sanitizeResponseContext(input.response, this.options)
|
|
161
|
+
: undefined,
|
|
162
|
+
commit_sha: input.commitSha ?? null,
|
|
163
|
+
timestamp: normalizeTimestamp(input.timestamp),
|
|
164
|
+
};
|
|
165
|
+
try {
|
|
166
|
+
await this.sendTelemetry(payload);
|
|
167
|
+
if (trackedError) {
|
|
168
|
+
this.reportedErrors.add(trackedError);
|
|
169
|
+
markCaptureState(trackedError, CAPTURE_REPORTED);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (trackedError) {
|
|
174
|
+
clearCaptureState(trackedError, CAPTURE_REPORTED);
|
|
110
175
|
}
|
|
111
176
|
throw error;
|
|
112
177
|
}
|
|
178
|
+
finally {
|
|
179
|
+
if (trackedError) {
|
|
180
|
+
this.inFlightErrors.delete(trackedError);
|
|
181
|
+
clearCaptureState(trackedError, CAPTURE_IN_FLIGHT);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
startHeartbeat(options = {}) {
|
|
186
|
+
const intervalMs = options.intervalMs ?? 300_000;
|
|
187
|
+
const jitterRatio = clampNumber(options.jitterRatio ?? 0.1, 0, 0.5);
|
|
188
|
+
const immediate = options.immediate ?? true;
|
|
189
|
+
const pauseWhenHidden = options.pauseWhenHidden ?? false;
|
|
190
|
+
const skipWhenOffline = options.skipWhenOffline ?? true;
|
|
191
|
+
let timeoutId = null;
|
|
192
|
+
let disposed = false;
|
|
193
|
+
let paused = false;
|
|
194
|
+
const clearScheduledHeartbeat = () => {
|
|
195
|
+
if (timeoutId !== null && typeof clearTimeout !== "undefined") {
|
|
196
|
+
clearTimeout(timeoutId);
|
|
197
|
+
}
|
|
198
|
+
timeoutId = null;
|
|
199
|
+
};
|
|
200
|
+
const isDocumentHidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
201
|
+
const isNavigatorOffline = () => typeof navigator !== "undefined" &&
|
|
202
|
+
"onLine" in navigator &&
|
|
203
|
+
navigator.onLine === false;
|
|
204
|
+
const shouldSkipHeartbeat = () => (pauseWhenHidden && isDocumentHidden()) || (skipWhenOffline && isNavigatorOffline());
|
|
205
|
+
const scheduleNextHeartbeat = () => {
|
|
206
|
+
if (disposed || paused || typeof setTimeout === "undefined") {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
clearScheduledHeartbeat();
|
|
210
|
+
const jitterWindowMs = Math.round(intervalMs * jitterRatio);
|
|
211
|
+
const delayMs = jitterWindowMs > 0 ? intervalMs - jitterWindowMs + Math.round(Math.random() * jitterWindowMs * 2) : intervalMs;
|
|
212
|
+
timeoutId = setTimeout(() => {
|
|
213
|
+
void triggerHeartbeat().catch(() => undefined);
|
|
214
|
+
}, delayMs);
|
|
215
|
+
};
|
|
216
|
+
const triggerHeartbeat = async (input = options) => {
|
|
217
|
+
if (disposed) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (shouldSkipHeartbeat()) {
|
|
221
|
+
scheduleNextHeartbeat();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
await this.sendHeartbeat(input);
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
scheduleNextHeartbeat();
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const resume = () => {
|
|
232
|
+
if (disposed) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
paused = false;
|
|
236
|
+
scheduleNextHeartbeat();
|
|
237
|
+
};
|
|
238
|
+
const pause = () => {
|
|
239
|
+
paused = true;
|
|
240
|
+
clearScheduledHeartbeat();
|
|
241
|
+
};
|
|
242
|
+
const handleVisibilityChange = () => {
|
|
243
|
+
if (!pauseWhenHidden || disposed) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (isDocumentHidden()) {
|
|
247
|
+
pause();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
resume();
|
|
251
|
+
};
|
|
252
|
+
const handleOnline = () => {
|
|
253
|
+
if (!skipWhenOffline || disposed) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
resume();
|
|
257
|
+
void triggerHeartbeat().catch(() => undefined);
|
|
258
|
+
};
|
|
259
|
+
const handleOffline = () => {
|
|
260
|
+
if (!skipWhenOffline || disposed) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
pause();
|
|
264
|
+
};
|
|
265
|
+
if (pauseWhenHidden && typeof document !== "undefined") {
|
|
266
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
267
|
+
}
|
|
268
|
+
if (skipWhenOffline && typeof window !== "undefined") {
|
|
269
|
+
window.addEventListener("online", handleOnline);
|
|
270
|
+
window.addEventListener("offline", handleOffline);
|
|
271
|
+
}
|
|
272
|
+
if (immediate) {
|
|
273
|
+
void triggerHeartbeat().catch(() => undefined);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
scheduleNextHeartbeat();
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
dispose: () => {
|
|
280
|
+
disposed = true;
|
|
281
|
+
clearScheduledHeartbeat();
|
|
282
|
+
if (pauseWhenHidden && typeof document !== "undefined") {
|
|
283
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
284
|
+
}
|
|
285
|
+
if (skipWhenOffline && typeof window !== "undefined") {
|
|
286
|
+
window.removeEventListener("online", handleOnline);
|
|
287
|
+
window.removeEventListener("offline", handleOffline);
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
pause,
|
|
291
|
+
resume,
|
|
292
|
+
triggerNow: (input = options) => triggerHeartbeat(input),
|
|
293
|
+
isRunning: () => !disposed && !paused,
|
|
294
|
+
};
|
|
113
295
|
}
|
|
114
296
|
registerBrowserAutoCapture(options = {}) {
|
|
115
297
|
if (typeof window === "undefined") {
|
|
@@ -118,16 +300,22 @@ export class StimpactClient {
|
|
|
118
300
|
const captureWindowErrors = options.captureWindowErrors ?? true;
|
|
119
301
|
const captureUnhandledRejections = options.captureUnhandledRejections ?? true;
|
|
120
302
|
const errorListener = (event) => {
|
|
303
|
+
if (this.shouldIgnoreAutoCapturedError(event.error)) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
121
306
|
void this.captureError({
|
|
122
307
|
error: event.error ?? event.message,
|
|
123
308
|
service: this.options.service,
|
|
124
|
-
});
|
|
309
|
+
}).catch(() => undefined);
|
|
125
310
|
};
|
|
126
311
|
const rejectionListener = (event) => {
|
|
312
|
+
if (this.shouldIgnoreAutoCapturedError(event.reason)) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
127
315
|
void this.captureError({
|
|
128
316
|
error: event.reason ?? "Unhandled promise rejection",
|
|
129
317
|
service: this.options.service,
|
|
130
|
-
});
|
|
318
|
+
}).catch(() => undefined);
|
|
131
319
|
};
|
|
132
320
|
if (captureWindowErrors) {
|
|
133
321
|
window.addEventListener("error", errorListener);
|
|
@@ -146,6 +334,35 @@ export class StimpactClient {
|
|
|
146
334
|
},
|
|
147
335
|
};
|
|
148
336
|
}
|
|
337
|
+
shouldIgnoreAutoCapturedError(error) {
|
|
338
|
+
if (error instanceof StimpactRequestError) {
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
const trackedError = asTrackableObject(error);
|
|
342
|
+
if (!trackedError) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
return (this.inFlightErrors.has(trackedError) ||
|
|
346
|
+
this.reportedErrors.has(trackedError) ||
|
|
347
|
+
hasCaptureState(trackedError, CAPTURE_IN_FLIGHT) ||
|
|
348
|
+
hasCaptureState(trackedError, CAPTURE_REPORTED));
|
|
349
|
+
}
|
|
350
|
+
captureSyncFailure(error, context) {
|
|
351
|
+
void this.captureHandledError({
|
|
352
|
+
...context,
|
|
353
|
+
error,
|
|
354
|
+
}).catch((captureFailure) => {
|
|
355
|
+
this.attachCaptureFailure(error, captureFailure);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
attachCaptureFailure(error, captureFailure) {
|
|
359
|
+
if (error instanceof Error) {
|
|
360
|
+
Object.defineProperty(error, "captureFailure", {
|
|
361
|
+
value: captureFailure,
|
|
362
|
+
configurable: true,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
149
366
|
async sendTelemetry(payload) {
|
|
150
367
|
let lastError;
|
|
151
368
|
for (let attempt = 0; attempt <= this.options.retryAttempts; attempt += 1) {
|
|
@@ -248,6 +465,10 @@ export class StimpactClient {
|
|
|
248
465
|
Date.now() + refreshThresholdMs < this.cachedBrowserTokenExpiresAtMs) {
|
|
249
466
|
return this.cachedBrowserToken;
|
|
250
467
|
}
|
|
468
|
+
if (this.browserTokenFailure &&
|
|
469
|
+
Date.now() < this.browserTokenFailureBlockedUntilMs) {
|
|
470
|
+
throw this.browserTokenFailure;
|
|
471
|
+
}
|
|
251
472
|
if (this.browserTokenPromise) {
|
|
252
473
|
return this.browserTokenPromise;
|
|
253
474
|
}
|
|
@@ -265,16 +486,26 @@ export class StimpactClient {
|
|
|
265
486
|
service: payload.service,
|
|
266
487
|
environment: payload.environment,
|
|
267
488
|
};
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
489
|
+
try {
|
|
490
|
+
const response = await this.performJsonRequest(endpoint, body);
|
|
491
|
+
const parsed = (await safeReadResponseJson(response));
|
|
492
|
+
const token = typeof parsed?.token === "string" ? parsed.token : null;
|
|
493
|
+
if (!token) {
|
|
494
|
+
throw new StimpactRequestError("Browser token request succeeded without a token payload.");
|
|
495
|
+
}
|
|
496
|
+
const expiresAtMs = resolveTokenExpiryMs(parsed);
|
|
497
|
+
this.cachedBrowserToken = token;
|
|
498
|
+
this.cachedBrowserTokenExpiresAtMs = expiresAtMs;
|
|
499
|
+
this.browserTokenFailure = null;
|
|
500
|
+
this.browserTokenFailureBlockedUntilMs = 0;
|
|
501
|
+
return token;
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
if (error instanceof StimpactRequestError) {
|
|
505
|
+
this.rememberBrowserTokenFailure(error);
|
|
506
|
+
}
|
|
507
|
+
throw error;
|
|
273
508
|
}
|
|
274
|
-
const expiresAtMs = resolveTokenExpiryMs(parsed);
|
|
275
|
-
this.cachedBrowserToken = token;
|
|
276
|
-
this.cachedBrowserTokenExpiresAtMs = expiresAtMs;
|
|
277
|
-
return token;
|
|
278
509
|
}
|
|
279
510
|
async performJsonRequest(url, payload) {
|
|
280
511
|
const fetchImpl = this.options.fetchImpl ?? fetch;
|
|
@@ -316,6 +547,16 @@ export class StimpactClient {
|
|
|
316
547
|
}
|
|
317
548
|
}
|
|
318
549
|
}
|
|
550
|
+
rememberBrowserTokenFailure(error) {
|
|
551
|
+
if (error.retryable) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
this.cachedBrowserToken = null;
|
|
555
|
+
this.cachedBrowserTokenExpiresAtMs = 0;
|
|
556
|
+
this.browserTokenFailure = error;
|
|
557
|
+
this.browserTokenFailureBlockedUntilMs =
|
|
558
|
+
Date.now() + this.options.browserTokenFailureCooldownMs;
|
|
559
|
+
}
|
|
319
560
|
}
|
|
320
561
|
function normalizeTimestamp(value) {
|
|
321
562
|
if (!value) {
|
|
@@ -323,6 +564,52 @@ function normalizeTimestamp(value) {
|
|
|
323
564
|
}
|
|
324
565
|
return value instanceof Date ? value.toISOString() : value;
|
|
325
566
|
}
|
|
567
|
+
function asTrackableObject(value) {
|
|
568
|
+
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
|
569
|
+
return value;
|
|
570
|
+
}
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
function hasCaptureState(target, symbol) {
|
|
574
|
+
return Boolean(target[symbol]);
|
|
575
|
+
}
|
|
576
|
+
function markCaptureState(target, symbol) {
|
|
577
|
+
Object.defineProperty(target, symbol, {
|
|
578
|
+
value: true,
|
|
579
|
+
configurable: true,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
function clearCaptureState(target, symbol) {
|
|
583
|
+
delete target[symbol];
|
|
584
|
+
}
|
|
585
|
+
function toError(error) {
|
|
586
|
+
if (error instanceof Error) {
|
|
587
|
+
return error;
|
|
588
|
+
}
|
|
589
|
+
return new Error(typeof error === "string" ? error : safeSerialize(error));
|
|
590
|
+
}
|
|
591
|
+
function resolveProcessTarget() {
|
|
592
|
+
const candidate = globalThis.process;
|
|
593
|
+
return candidate ?? null;
|
|
594
|
+
}
|
|
595
|
+
function addProcessListener(target, event, listener) {
|
|
596
|
+
if (typeof target.on === "function") {
|
|
597
|
+
target.on(event, listener);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (typeof target.addListener === "function") {
|
|
601
|
+
target.addListener(event, listener);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
function removeProcessListener(target, event, listener) {
|
|
605
|
+
if (typeof target.off === "function") {
|
|
606
|
+
target.off(event, listener);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (typeof target.removeListener === "function") {
|
|
610
|
+
target.removeListener(event, listener);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
326
613
|
function normalizeError(error, maxValueLength) {
|
|
327
614
|
if (error instanceof Error) {
|
|
328
615
|
return {
|
|
@@ -485,3 +772,9 @@ function delay(ms) {
|
|
|
485
772
|
setTimeout(resolve, ms);
|
|
486
773
|
});
|
|
487
774
|
}
|
|
775
|
+
function clampNumber(value, min, max) {
|
|
776
|
+
if (!Number.isFinite(value)) {
|
|
777
|
+
return min;
|
|
778
|
+
}
|
|
779
|
+
return Math.min(max, Math.max(min, value));
|
|
780
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { StimpactClient, StimpactRequestError } from "./client.js";
|
|
2
|
-
export type { BrowserAutoCaptureOptions, BrowserCaptureSubscription, CaptureErrorInput, HeartbeatInput, HeartbeatSubscription, HttpRequestContext, HttpResponseContext, StimpactClientOptions, StimpactEnvironment, } from "./types.js";
|
|
2
|
+
export type { BrowserAutoCaptureOptions, BrowserCaptureSubscription, CaptureErrorInput, ErrorCaptureContext, HeartbeatInput, HeartbeatScheduleOptions, HeartbeatSubscription, HttpRequestContext, ProcessAutoCaptureOptions, ProcessCaptureSubscription, ProcessListenerTarget, HttpResponseContext, StimpactClientOptions, StimpactEnvironment, } from "./types.js";
|
package/dist/types.d.ts
CHANGED
|
@@ -20,12 +20,20 @@ export type CaptureErrorInput = {
|
|
|
20
20
|
service?: string;
|
|
21
21
|
timestamp?: string | Date;
|
|
22
22
|
};
|
|
23
|
+
export type ErrorCaptureContext = Omit<CaptureErrorInput, "error">;
|
|
23
24
|
export type HeartbeatInput = {
|
|
24
25
|
commitSha?: string | null;
|
|
25
26
|
environment?: StimpactEnvironment;
|
|
26
27
|
service?: string;
|
|
27
28
|
timestamp?: string | Date;
|
|
28
29
|
};
|
|
30
|
+
export type HeartbeatScheduleOptions = HeartbeatInput & {
|
|
31
|
+
intervalMs?: number;
|
|
32
|
+
immediate?: boolean;
|
|
33
|
+
jitterRatio?: number;
|
|
34
|
+
pauseWhenHidden?: boolean;
|
|
35
|
+
skipWhenOffline?: boolean;
|
|
36
|
+
};
|
|
29
37
|
export type StimpactClientOptions = {
|
|
30
38
|
baseUrl: string;
|
|
31
39
|
projectId: string;
|
|
@@ -40,6 +48,7 @@ export type StimpactClientOptions = {
|
|
|
40
48
|
timeoutMs?: number;
|
|
41
49
|
retryAttempts?: number;
|
|
42
50
|
retryDelayMs?: number;
|
|
51
|
+
browserTokenFailureCooldownMs?: number;
|
|
43
52
|
captureRequestContext?: boolean;
|
|
44
53
|
captureResponseContext?: boolean;
|
|
45
54
|
includeBodies?: boolean;
|
|
@@ -53,6 +62,24 @@ export type BrowserAutoCaptureOptions = {
|
|
|
53
62
|
export type BrowserCaptureSubscription = {
|
|
54
63
|
dispose: () => void;
|
|
55
64
|
};
|
|
65
|
+
export type ProcessListenerTarget = {
|
|
66
|
+
on?: (event: string, listener: (...args: unknown[]) => void) => void;
|
|
67
|
+
off?: (event: string, listener: (...args: unknown[]) => void) => void;
|
|
68
|
+
addListener?: (event: string, listener: (...args: unknown[]) => void) => void;
|
|
69
|
+
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
|
|
70
|
+
};
|
|
71
|
+
export type ProcessAutoCaptureOptions = {
|
|
72
|
+
captureUncaughtExceptions?: boolean;
|
|
73
|
+
captureUnhandledRejections?: boolean;
|
|
74
|
+
processTarget?: ProcessListenerTarget | null;
|
|
75
|
+
};
|
|
76
|
+
export type ProcessCaptureSubscription = {
|
|
77
|
+
dispose: () => void;
|
|
78
|
+
};
|
|
56
79
|
export type HeartbeatSubscription = {
|
|
57
80
|
dispose: () => void;
|
|
81
|
+
pause: () => void;
|
|
82
|
+
resume: () => void;
|
|
83
|
+
triggerNow: (input?: HeartbeatInput) => Promise<void>;
|
|
84
|
+
isRunning: () => boolean;
|
|
58
85
|
};
|
package/package.json
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stimpact/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
7
13
|
"files": [
|
|
8
14
|
"dist"
|
|
9
15
|
],
|
|
10
16
|
"scripts": {
|
|
11
17
|
"build": "tsc -p tsconfig.json",
|
|
12
|
-
"test": "npm run build && node --test tests
|
|
18
|
+
"test:unit": "npm run build && node --test tests/client.test.mjs",
|
|
19
|
+
"test:consumer": "npm run build && node --test tests/package-consumer.test.mjs",
|
|
20
|
+
"test": "npm run test:unit && npm run test:consumer",
|
|
21
|
+
"pack:dry-run": "npm pack --dry-run",
|
|
22
|
+
"prepack": "npm run build",
|
|
23
|
+
"prepublishOnly": "npm run test"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/stimpactai/stimpact-ai.git",
|
|
31
|
+
"directory": "sdk"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/stimpactai/stimpact-ai/tree/main/sdk",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
13
36
|
},
|
|
14
37
|
"devDependencies": {
|
|
15
38
|
"typescript": "^5.9.3"
|