@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 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.captureError({
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
- startHeartbeat(options?: HeartbeatInput & {
21
- intervalMs?: number;
22
- }): HeartbeatSubscription;
23
- wrapAsync<T>(operation: () => Promise<T>, context?: Omit<CaptureErrorInput, "error">): Promise<T>;
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
- const normalized = normalizeError(input.error, this.options.maxValueLength);
48
- const payload = {
49
- project_id: this.options.projectId,
50
- environment: input.environment ?? this.options.environment ?? "production",
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
- startHeartbeat(options = {}) {
76
- const intervalMs = options.intervalMs ?? 300_000;
77
- let intervalId = null;
78
- void this.sendHeartbeat(options);
79
- if (typeof setInterval !== "undefined") {
80
- intervalId = setInterval(() => {
81
- void this.sendHeartbeat(options);
82
- }, intervalMs);
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.captureError({
87
+ await this.captureHandledError({
99
88
  ...context,
100
89
  error,
101
90
  });
102
91
  }
103
92
  catch (captureFailure) {
104
- if (error instanceof Error) {
105
- Object.defineProperty(error, "captureFailure", {
106
- value: captureFailure,
107
- configurable: true,
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
- const response = await this.performJsonRequest(endpoint, body);
269
- const parsed = (await safeReadResponseJson(response));
270
- const token = typeof parsed?.token === "string" ? parsed.token : null;
271
- if (!token) {
272
- throw new StimpactRequestError("Browser token request succeeded without a token payload.");
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.1.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/**/*.test.mjs"
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"