@stimpact/sdk 0.1.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 +102 -0
- package/dist/client.d.ts +33 -0
- package/dist/client.js +487 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +1 -0
- package/package.json +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Stimpact SDK
|
|
2
|
+
|
|
3
|
+
The TypeScript SDK sends runtime errors and heartbeats to the Stimpact agent platform.
|
|
4
|
+
|
|
5
|
+
Server runtimes should use a private project `apiKey`.
|
|
6
|
+
|
|
7
|
+
Browser runtimes should use either:
|
|
8
|
+
|
|
9
|
+
- a short-lived token flow via `browserKey`
|
|
10
|
+
- a custom `browserTokenEndpoint`
|
|
11
|
+
- a custom `tokenProvider`
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install @stimpact/sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Server usage
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { StimpactClient } from "@stimpact/sdk";
|
|
23
|
+
|
|
24
|
+
const stimpact = new StimpactClient({
|
|
25
|
+
baseUrl: "https://your-stimpact-api.example.com",
|
|
26
|
+
projectId: "billing-prod",
|
|
27
|
+
apiKey: "stimp_live_...",
|
|
28
|
+
service: "billing-api",
|
|
29
|
+
environment: "production",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await doWork();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
await stimpact.captureError({
|
|
36
|
+
error,
|
|
37
|
+
request: {
|
|
38
|
+
method: "POST",
|
|
39
|
+
url: "/api/charge",
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Browser usage
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { StimpactClient } from "@stimpact/sdk";
|
|
49
|
+
|
|
50
|
+
const stimpact = new StimpactClient({
|
|
51
|
+
baseUrl: "https://your-stimpact-api.example.com",
|
|
52
|
+
projectId: "billing-prod",
|
|
53
|
+
browserKey: "stimp_browser_...",
|
|
54
|
+
service: "billing-web",
|
|
55
|
+
environment: "production",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
stimpact.startHeartbeat();
|
|
59
|
+
stimpact.registerBrowserAutoCapture();
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
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
|
+
|
|
64
|
+
## Browser autocapture
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const subscription = stimpact.registerBrowserAutoCapture();
|
|
68
|
+
|
|
69
|
+
// Later, if needed:
|
|
70
|
+
subscription.dispose();
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Wrapped async flows
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
await stimpact.wrapAsync(async () => {
|
|
77
|
+
await saveInvoice();
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Data minimization defaults
|
|
82
|
+
|
|
83
|
+
By default the SDK:
|
|
84
|
+
|
|
85
|
+
- sends the normalized error message and stacktrace
|
|
86
|
+
- does not forward request or response context unless you explicitly opt in
|
|
87
|
+
- redacts common sensitive headers such as `authorization`, `cookie`, `set-cookie`, and `x-api-key`
|
|
88
|
+
- omits request and response bodies unless `includeBodies` is enabled
|
|
89
|
+
|
|
90
|
+
To opt in to richer HTTP context:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const stimpact = new StimpactClient({
|
|
94
|
+
baseUrl: "https://your-stimpact-api.example.com",
|
|
95
|
+
projectId: "billing-prod",
|
|
96
|
+
apiKey: "stimp_live_...",
|
|
97
|
+
service: "billing-api",
|
|
98
|
+
captureRequestContext: true,
|
|
99
|
+
captureResponseContext: true,
|
|
100
|
+
includeBodies: false,
|
|
101
|
+
});
|
|
102
|
+
```
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BrowserAutoCaptureOptions, BrowserCaptureSubscription, CaptureErrorInput, HeartbeatInput, HeartbeatSubscription, StimpactClientOptions } from "./types.js";
|
|
2
|
+
export declare class StimpactRequestError extends Error {
|
|
3
|
+
status: number | null;
|
|
4
|
+
retryable: boolean;
|
|
5
|
+
responseBody: string | null;
|
|
6
|
+
constructor(message: string, options?: {
|
|
7
|
+
status?: number | null;
|
|
8
|
+
retryable?: boolean;
|
|
9
|
+
responseBody?: string | null;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export declare class StimpactClient {
|
|
13
|
+
private readonly options;
|
|
14
|
+
private cachedBrowserToken;
|
|
15
|
+
private cachedBrowserTokenExpiresAtMs;
|
|
16
|
+
private browserTokenPromise;
|
|
17
|
+
constructor(options: StimpactClientOptions);
|
|
18
|
+
captureError(input: CaptureErrorInput): Promise<void>;
|
|
19
|
+
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>;
|
|
24
|
+
registerBrowserAutoCapture(options?: BrowserAutoCaptureOptions): BrowserCaptureSubscription;
|
|
25
|
+
private sendTelemetry;
|
|
26
|
+
private sendHeartbeatPayload;
|
|
27
|
+
private sendTelemetryOnce;
|
|
28
|
+
private sendJson;
|
|
29
|
+
private buildAuthHeaders;
|
|
30
|
+
private getBrowserToken;
|
|
31
|
+
private fetchBrowserToken;
|
|
32
|
+
private performJsonRequest;
|
|
33
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
const DEFAULT_REDACTED_HEADERS = new Set([
|
|
2
|
+
"authorization",
|
|
3
|
+
"cookie",
|
|
4
|
+
"set-cookie",
|
|
5
|
+
"proxy-authorization",
|
|
6
|
+
"x-api-key",
|
|
7
|
+
"x-stimpact-project-key",
|
|
8
|
+
]);
|
|
9
|
+
export class StimpactRequestError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
retryable;
|
|
12
|
+
responseBody;
|
|
13
|
+
constructor(message, options = {}) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "StimpactRequestError";
|
|
16
|
+
this.status = options.status ?? null;
|
|
17
|
+
this.retryable = options.retryable ?? false;
|
|
18
|
+
this.responseBody = options.responseBody ?? null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class StimpactClient {
|
|
22
|
+
options;
|
|
23
|
+
cachedBrowserToken = null;
|
|
24
|
+
cachedBrowserTokenExpiresAtMs = 0;
|
|
25
|
+
browserTokenPromise = null;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
if (!options.apiKey && !options.browserKey && !options.browserTokenEndpoint && !options.tokenProvider) {
|
|
28
|
+
throw new Error("StimpactClient requires apiKey, browserKey, browserTokenEndpoint, or tokenProvider.");
|
|
29
|
+
}
|
|
30
|
+
this.options = {
|
|
31
|
+
...options,
|
|
32
|
+
baseUrl: options.baseUrl.replace(/\/$/, ""),
|
|
33
|
+
environment: options.environment ?? "production",
|
|
34
|
+
fetchImpl: options.fetchImpl ?? fetch,
|
|
35
|
+
headers: options.headers ?? {},
|
|
36
|
+
timeoutMs: options.timeoutMs ?? 5_000,
|
|
37
|
+
retryAttempts: options.retryAttempts ?? 2,
|
|
38
|
+
retryDelayMs: options.retryDelayMs ?? 250,
|
|
39
|
+
captureRequestContext: options.captureRequestContext ?? false,
|
|
40
|
+
captureResponseContext: options.captureResponseContext ?? false,
|
|
41
|
+
includeBodies: options.includeBodies ?? false,
|
|
42
|
+
redactedHeaders: options.redactedHeaders ?? [],
|
|
43
|
+
maxValueLength: options.maxValueLength ?? 2_048,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
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);
|
|
64
|
+
}
|
|
65
|
+
async sendHeartbeat(input = {}) {
|
|
66
|
+
const payload = {
|
|
67
|
+
project_id: this.options.projectId,
|
|
68
|
+
environment: input.environment ?? this.options.environment ?? "production",
|
|
69
|
+
service: input.service ?? this.options.service,
|
|
70
|
+
commit_sha: input.commitSha ?? null,
|
|
71
|
+
timestamp: normalizeTimestamp(input.timestamp),
|
|
72
|
+
};
|
|
73
|
+
await this.sendHeartbeatPayload(payload);
|
|
74
|
+
}
|
|
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);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
dispose: () => {
|
|
86
|
+
if (intervalId !== null && typeof clearInterval !== "undefined") {
|
|
87
|
+
clearInterval(intervalId);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
async wrapAsync(operation, context) {
|
|
93
|
+
try {
|
|
94
|
+
return await operation();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
try {
|
|
98
|
+
await this.captureError({
|
|
99
|
+
...context,
|
|
100
|
+
error,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (captureFailure) {
|
|
104
|
+
if (error instanceof Error) {
|
|
105
|
+
Object.defineProperty(error, "captureFailure", {
|
|
106
|
+
value: captureFailure,
|
|
107
|
+
configurable: true,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
registerBrowserAutoCapture(options = {}) {
|
|
115
|
+
if (typeof window === "undefined") {
|
|
116
|
+
return { dispose: () => undefined };
|
|
117
|
+
}
|
|
118
|
+
const captureWindowErrors = options.captureWindowErrors ?? true;
|
|
119
|
+
const captureUnhandledRejections = options.captureUnhandledRejections ?? true;
|
|
120
|
+
const errorListener = (event) => {
|
|
121
|
+
void this.captureError({
|
|
122
|
+
error: event.error ?? event.message,
|
|
123
|
+
service: this.options.service,
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
const rejectionListener = (event) => {
|
|
127
|
+
void this.captureError({
|
|
128
|
+
error: event.reason ?? "Unhandled promise rejection",
|
|
129
|
+
service: this.options.service,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
if (captureWindowErrors) {
|
|
133
|
+
window.addEventListener("error", errorListener);
|
|
134
|
+
}
|
|
135
|
+
if (captureUnhandledRejections) {
|
|
136
|
+
window.addEventListener("unhandledrejection", rejectionListener);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
dispose: () => {
|
|
140
|
+
if (captureWindowErrors) {
|
|
141
|
+
window.removeEventListener("error", errorListener);
|
|
142
|
+
}
|
|
143
|
+
if (captureUnhandledRejections) {
|
|
144
|
+
window.removeEventListener("unhandledrejection", rejectionListener);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async sendTelemetry(payload) {
|
|
150
|
+
let lastError;
|
|
151
|
+
for (let attempt = 0; attempt <= this.options.retryAttempts; attempt += 1) {
|
|
152
|
+
try {
|
|
153
|
+
await this.sendTelemetryOnce(payload);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
lastError = error;
|
|
158
|
+
if (!(error instanceof StimpactRequestError) || !error.retryable || attempt === this.options.retryAttempts) {
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
await delay(this.options.retryDelayMs * (attempt + 1));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
throw lastError instanceof Error
|
|
165
|
+
? lastError
|
|
166
|
+
: new StimpactRequestError("Telemetry delivery failed.");
|
|
167
|
+
}
|
|
168
|
+
async sendHeartbeatPayload(payload) {
|
|
169
|
+
let lastError;
|
|
170
|
+
for (let attempt = 0; attempt <= this.options.retryAttempts; attempt += 1) {
|
|
171
|
+
try {
|
|
172
|
+
await this.sendJson(`${this.options.baseUrl}/telemetry/heartbeat`, payload);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
lastError = error;
|
|
177
|
+
if (!(error instanceof StimpactRequestError) || !error.retryable || attempt === this.options.retryAttempts) {
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
await delay(this.options.retryDelayMs * (attempt + 1));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
throw lastError instanceof Error
|
|
184
|
+
? lastError
|
|
185
|
+
: new StimpactRequestError("Telemetry heartbeat delivery failed.");
|
|
186
|
+
}
|
|
187
|
+
async sendTelemetryOnce(payload) {
|
|
188
|
+
await this.sendJson(`${this.options.baseUrl}/telemetry/error`, payload);
|
|
189
|
+
}
|
|
190
|
+
async sendJson(url, payload) {
|
|
191
|
+
const fetchImpl = this.options.fetchImpl ?? fetch;
|
|
192
|
+
const controller = typeof AbortController === "undefined" ? null : new AbortController();
|
|
193
|
+
const timeout = controller
|
|
194
|
+
? setTimeout(() => controller.abort(), this.options.timeoutMs)
|
|
195
|
+
: null;
|
|
196
|
+
try {
|
|
197
|
+
const authHeaders = await this.buildAuthHeaders(payload);
|
|
198
|
+
const response = await fetchImpl(url, {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: {
|
|
201
|
+
"Content-Type": "application/json",
|
|
202
|
+
...authHeaders,
|
|
203
|
+
...this.options.headers,
|
|
204
|
+
},
|
|
205
|
+
body: JSON.stringify(payload),
|
|
206
|
+
signal: controller?.signal,
|
|
207
|
+
});
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
const responseBody = await safeReadResponseText(response);
|
|
210
|
+
throw new StimpactRequestError(`Telemetry delivery failed with status ${response.status}.`, {
|
|
211
|
+
status: response.status,
|
|
212
|
+
retryable: response.status >= 500 || response.status === 429,
|
|
213
|
+
responseBody,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (error instanceof StimpactRequestError) {
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
throw new StimpactRequestError("Telemetry delivery failed before the platform acknowledged it.", {
|
|
222
|
+
retryable: true,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
if (timeout !== null) {
|
|
227
|
+
clearTimeout(timeout);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async buildAuthHeaders(payload) {
|
|
232
|
+
if (this.options.apiKey) {
|
|
233
|
+
return {
|
|
234
|
+
"X-Stimpact-Project-Key": this.options.apiKey,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const token = await this.getBrowserToken(payload);
|
|
238
|
+
return {
|
|
239
|
+
Authorization: `Bearer ${token}`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
async getBrowserToken(payload) {
|
|
243
|
+
if (this.options.tokenProvider) {
|
|
244
|
+
return this.options.tokenProvider();
|
|
245
|
+
}
|
|
246
|
+
const refreshThresholdMs = 15_000;
|
|
247
|
+
if (this.cachedBrowserToken &&
|
|
248
|
+
Date.now() + refreshThresholdMs < this.cachedBrowserTokenExpiresAtMs) {
|
|
249
|
+
return this.cachedBrowserToken;
|
|
250
|
+
}
|
|
251
|
+
if (this.browserTokenPromise) {
|
|
252
|
+
return this.browserTokenPromise;
|
|
253
|
+
}
|
|
254
|
+
this.browserTokenPromise = this.fetchBrowserToken(payload).finally(() => {
|
|
255
|
+
this.browserTokenPromise = null;
|
|
256
|
+
});
|
|
257
|
+
return this.browserTokenPromise;
|
|
258
|
+
}
|
|
259
|
+
async fetchBrowserToken(payload) {
|
|
260
|
+
const endpoint = this.options.browserTokenEndpoint ??
|
|
261
|
+
`${this.options.baseUrl}/telemetry/browser-token`;
|
|
262
|
+
const body = {
|
|
263
|
+
project_id: this.options.projectId,
|
|
264
|
+
browser_key: this.options.browserKey,
|
|
265
|
+
service: payload.service,
|
|
266
|
+
environment: payload.environment,
|
|
267
|
+
};
|
|
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.");
|
|
273
|
+
}
|
|
274
|
+
const expiresAtMs = resolveTokenExpiryMs(parsed);
|
|
275
|
+
this.cachedBrowserToken = token;
|
|
276
|
+
this.cachedBrowserTokenExpiresAtMs = expiresAtMs;
|
|
277
|
+
return token;
|
|
278
|
+
}
|
|
279
|
+
async performJsonRequest(url, payload) {
|
|
280
|
+
const fetchImpl = this.options.fetchImpl ?? fetch;
|
|
281
|
+
const controller = typeof AbortController === "undefined" ? null : new AbortController();
|
|
282
|
+
const timeout = controller
|
|
283
|
+
? setTimeout(() => controller.abort(), this.options.timeoutMs)
|
|
284
|
+
: null;
|
|
285
|
+
try {
|
|
286
|
+
const response = await fetchImpl(url, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: {
|
|
289
|
+
"Content-Type": "application/json",
|
|
290
|
+
...this.options.headers,
|
|
291
|
+
},
|
|
292
|
+
body: JSON.stringify(payload),
|
|
293
|
+
signal: controller?.signal,
|
|
294
|
+
});
|
|
295
|
+
if (!response.ok) {
|
|
296
|
+
const responseBody = await safeReadResponseText(response);
|
|
297
|
+
throw new StimpactRequestError(`Request failed with status ${response.status}.`, {
|
|
298
|
+
status: response.status,
|
|
299
|
+
retryable: response.status >= 500 || response.status === 429,
|
|
300
|
+
responseBody,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return response;
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
if (error instanceof StimpactRequestError) {
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
throw new StimpactRequestError("Request failed before the platform acknowledged it.", {
|
|
310
|
+
retryable: true,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
if (timeout !== null) {
|
|
315
|
+
clearTimeout(timeout);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function normalizeTimestamp(value) {
|
|
321
|
+
if (!value) {
|
|
322
|
+
return new Date().toISOString();
|
|
323
|
+
}
|
|
324
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
325
|
+
}
|
|
326
|
+
function normalizeError(error, maxValueLength) {
|
|
327
|
+
if (error instanceof Error) {
|
|
328
|
+
return {
|
|
329
|
+
message: clampString(error.message || error.name || "Unknown error", maxValueLength),
|
|
330
|
+
stacktrace: clampString(error.stack || error.message || error.name, maxValueLength * 4),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (typeof error === "string") {
|
|
334
|
+
return {
|
|
335
|
+
message: clampString(error, maxValueLength),
|
|
336
|
+
stacktrace: clampString(error, maxValueLength * 4),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const sanitized = sanitizeUnknown(error, {
|
|
340
|
+
includeBodies: false,
|
|
341
|
+
maxValueLength,
|
|
342
|
+
redactedHeaders: [],
|
|
343
|
+
});
|
|
344
|
+
const serialized = safeSerialize(sanitized);
|
|
345
|
+
return {
|
|
346
|
+
message: typeof sanitized === "object" &&
|
|
347
|
+
sanitized !== null &&
|
|
348
|
+
"message" in sanitized &&
|
|
349
|
+
typeof sanitized.message === "string"
|
|
350
|
+
? clampString(sanitized.message, maxValueLength)
|
|
351
|
+
: "Unknown error",
|
|
352
|
+
stacktrace: clampString(serialized, maxValueLength * 4),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
async function safeReadResponseText(response) {
|
|
356
|
+
try {
|
|
357
|
+
const text = await response.text();
|
|
358
|
+
return text || null;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async function safeReadResponseJson(response) {
|
|
365
|
+
try {
|
|
366
|
+
return await response.json();
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function sanitizeRequestContext(request, options) {
|
|
373
|
+
if (!request) {
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
return omitUndefined({
|
|
377
|
+
method: request.method
|
|
378
|
+
? clampString(request.method, 32)
|
|
379
|
+
: undefined,
|
|
380
|
+
url: request.url
|
|
381
|
+
? clampString(request.url, options.maxValueLength ?? 2_048)
|
|
382
|
+
: undefined,
|
|
383
|
+
headers: sanitizeHeaders(request.headers, options.redactedHeaders, options.maxValueLength),
|
|
384
|
+
body: options.includeBodies
|
|
385
|
+
? sanitizeUnknown(request.body, options)
|
|
386
|
+
: undefined,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function sanitizeResponseContext(response, options) {
|
|
390
|
+
if (!response) {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
return omitUndefined({
|
|
394
|
+
status_code: response.status_code,
|
|
395
|
+
headers: sanitizeHeaders(response.headers, options.redactedHeaders, options.maxValueLength),
|
|
396
|
+
body: options.includeBodies
|
|
397
|
+
? sanitizeUnknown(response.body, options)
|
|
398
|
+
: undefined,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
function sanitizeHeaders(headers, redactedHeaders, maxValueLength = 2_048) {
|
|
402
|
+
if (!headers) {
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
const extraRedactions = new Set((redactedHeaders ?? []).map((value) => value.toLowerCase()));
|
|
406
|
+
const entries = Object.entries(headers)
|
|
407
|
+
.slice(0, 40)
|
|
408
|
+
.map(([key, value]) => {
|
|
409
|
+
const lowerKey = key.toLowerCase();
|
|
410
|
+
const shouldRedact = DEFAULT_REDACTED_HEADERS.has(lowerKey) || extraRedactions.has(lowerKey);
|
|
411
|
+
return [key, shouldRedact ? "[REDACTED]" : clampString(String(value), maxValueLength)];
|
|
412
|
+
});
|
|
413
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
414
|
+
}
|
|
415
|
+
function sanitizeUnknown(value, options, seen = new WeakSet(), depth = 0) {
|
|
416
|
+
const maxValueLength = options.maxValueLength ?? 2_048;
|
|
417
|
+
if (value == null || typeof value === "number" || typeof value === "boolean") {
|
|
418
|
+
return value;
|
|
419
|
+
}
|
|
420
|
+
if (typeof value === "string") {
|
|
421
|
+
return clampString(value, maxValueLength);
|
|
422
|
+
}
|
|
423
|
+
if (typeof value === "bigint") {
|
|
424
|
+
return clampString(value.toString(), maxValueLength);
|
|
425
|
+
}
|
|
426
|
+
if (typeof value === "function" || typeof value === "symbol") {
|
|
427
|
+
return clampString(String(value), maxValueLength);
|
|
428
|
+
}
|
|
429
|
+
if (depth >= 4) {
|
|
430
|
+
return "[TRUNCATED]";
|
|
431
|
+
}
|
|
432
|
+
if (Array.isArray(value)) {
|
|
433
|
+
return value
|
|
434
|
+
.slice(0, 20)
|
|
435
|
+
.map((item) => sanitizeUnknown(item, options, seen, depth + 1));
|
|
436
|
+
}
|
|
437
|
+
if (typeof value === "object") {
|
|
438
|
+
if (seen.has(value)) {
|
|
439
|
+
return "[Circular]";
|
|
440
|
+
}
|
|
441
|
+
seen.add(value);
|
|
442
|
+
const output = {};
|
|
443
|
+
for (const [key, entry] of Object.entries(value).slice(0, 25)) {
|
|
444
|
+
output[key] = sanitizeUnknown(entry, options, seen, depth + 1);
|
|
445
|
+
}
|
|
446
|
+
return output;
|
|
447
|
+
}
|
|
448
|
+
return clampString(String(value), maxValueLength);
|
|
449
|
+
}
|
|
450
|
+
function safeSerialize(value) {
|
|
451
|
+
try {
|
|
452
|
+
return JSON.stringify(value, null, 2);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return String(value);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function clampString(value, maxLength) {
|
|
459
|
+
if (value.length <= maxLength) {
|
|
460
|
+
return value;
|
|
461
|
+
}
|
|
462
|
+
return `${value.slice(0, maxLength)}...[truncated]`;
|
|
463
|
+
}
|
|
464
|
+
function omitUndefined(value) {
|
|
465
|
+
const entries = Object.entries(value).filter(([, entry]) => entry !== undefined);
|
|
466
|
+
if (entries.length === 0) {
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
return Object.fromEntries(entries);
|
|
470
|
+
}
|
|
471
|
+
function resolveTokenExpiryMs(response) {
|
|
472
|
+
if (typeof response?.expires_at === "string") {
|
|
473
|
+
const parsed = Date.parse(response.expires_at);
|
|
474
|
+
if (!Number.isNaN(parsed)) {
|
|
475
|
+
return parsed;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (typeof response?.expires_in_seconds === "number" && Number.isFinite(response.expires_in_seconds)) {
|
|
479
|
+
return Date.now() + Math.max(30, response.expires_in_seconds) * 1_000;
|
|
480
|
+
}
|
|
481
|
+
return Date.now() + 60_000;
|
|
482
|
+
}
|
|
483
|
+
function delay(ms) {
|
|
484
|
+
return new Promise((resolve) => {
|
|
485
|
+
setTimeout(resolve, ms);
|
|
486
|
+
});
|
|
487
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { StimpactClient, StimpactRequestError } from "./client.js";
|
|
2
|
+
export type { BrowserAutoCaptureOptions, BrowserCaptureSubscription, CaptureErrorInput, HeartbeatInput, HeartbeatSubscription, HttpRequestContext, HttpResponseContext, StimpactClientOptions, StimpactEnvironment, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { StimpactClient, StimpactRequestError } from "./client.js";
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type StimpactEnvironment = "production" | "staging" | "development" | "test";
|
|
2
|
+
export type StimpactTokenProvider = () => Promise<string>;
|
|
3
|
+
export type HttpRequestContext = {
|
|
4
|
+
method?: string;
|
|
5
|
+
url?: string;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
body?: unknown;
|
|
8
|
+
};
|
|
9
|
+
export type HttpResponseContext = {
|
|
10
|
+
status_code?: number;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
body?: unknown;
|
|
13
|
+
};
|
|
14
|
+
export type CaptureErrorInput = {
|
|
15
|
+
error: unknown;
|
|
16
|
+
request?: HttpRequestContext;
|
|
17
|
+
response?: HttpResponseContext;
|
|
18
|
+
commitSha?: string | null;
|
|
19
|
+
environment?: StimpactEnvironment;
|
|
20
|
+
service?: string;
|
|
21
|
+
timestamp?: string | Date;
|
|
22
|
+
};
|
|
23
|
+
export type HeartbeatInput = {
|
|
24
|
+
commitSha?: string | null;
|
|
25
|
+
environment?: StimpactEnvironment;
|
|
26
|
+
service?: string;
|
|
27
|
+
timestamp?: string | Date;
|
|
28
|
+
};
|
|
29
|
+
export type StimpactClientOptions = {
|
|
30
|
+
baseUrl: string;
|
|
31
|
+
projectId: string;
|
|
32
|
+
apiKey?: string;
|
|
33
|
+
browserKey?: string;
|
|
34
|
+
browserTokenEndpoint?: string;
|
|
35
|
+
tokenProvider?: StimpactTokenProvider;
|
|
36
|
+
service: string;
|
|
37
|
+
environment?: StimpactEnvironment;
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
headers?: Record<string, string>;
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
retryAttempts?: number;
|
|
42
|
+
retryDelayMs?: number;
|
|
43
|
+
captureRequestContext?: boolean;
|
|
44
|
+
captureResponseContext?: boolean;
|
|
45
|
+
includeBodies?: boolean;
|
|
46
|
+
redactedHeaders?: string[];
|
|
47
|
+
maxValueLength?: number;
|
|
48
|
+
};
|
|
49
|
+
export type BrowserAutoCaptureOptions = {
|
|
50
|
+
captureWindowErrors?: boolean;
|
|
51
|
+
captureUnhandledRejections?: boolean;
|
|
52
|
+
};
|
|
53
|
+
export type BrowserCaptureSubscription = {
|
|
54
|
+
dispose: () => void;
|
|
55
|
+
};
|
|
56
|
+
export type HeartbeatSubscription = {
|
|
57
|
+
dispose: () => void;
|
|
58
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stimpact/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -p tsconfig.json",
|
|
12
|
+
"test": "npm run build && node --test tests/**/*.test.mjs"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"typescript": "^5.9.3"
|
|
16
|
+
}
|
|
17
|
+
}
|