@chaja/sdk-core 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,9 @@
1
- # Temporary Holding Version
1
+ # @chaja/sdk-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The runtime-independent client behind [`@chaja/sdk-browser`](https://www.npmjs.com/package/@chaja/sdk-browser) and
4
+ [`@chaja/sdk-node`](https://www.npmjs.com/package/@chaja/sdk-node): stack parsing, cause chains, scrubbing, sampling,
5
+ deduplication and a batching transport that honours `Retry-After`.
6
+
7
+ Install one of those instead unless you are adding support for another JavaScript runtime with `createClient(options, platform)`.
8
+ See [docs/sdk.md](https://github.com/emiliodominguez/chaja/blob/main/docs/sdk.md) and
9
+ [docs/protocol.md](https://github.com/emiliodominguez/chaja/blob/main/docs/protocol.md).
@@ -0,0 +1,382 @@
1
+ //#region ../protocol/src/wire.d.ts
2
+ /**
3
+ * Plain wire types for SDKs. They carry no runtime code and no zod types, so published SDK declarations stay self-contained.
4
+ * `wire.check.ts` proves they match the server's schemas in both directions.
5
+ */
6
+ type Level = "fatal" | "error" | "warning" | "info" | "debug";
7
+ type AttributeScalar = string | number | boolean;
8
+ type AttributeValue = AttributeScalar | AttributeScalar[];
9
+ type Attributes = Record<string, AttributeValue>;
10
+ interface Resource {
11
+ "service.name": string;
12
+ "service.version"?: string;
13
+ "deployment.environment"?: string;
14
+ attributes?: Attributes;
15
+ }
16
+ interface Frame {
17
+ function?: string;
18
+ module?: string;
19
+ filename?: string;
20
+ lineno?: number;
21
+ colno?: number;
22
+ inApp?: boolean;
23
+ contextLine?: string;
24
+ preContext?: string[];
25
+ postContext?: string[];
26
+ }
27
+ interface Mechanism {
28
+ type: string;
29
+ handled: boolean;
30
+ }
31
+ interface Exception {
32
+ type: string;
33
+ value: string;
34
+ frames: Frame[];
35
+ mechanism?: Mechanism;
36
+ }
37
+ interface Breadcrumb {
38
+ timestamp: number;
39
+ category: string;
40
+ level?: Level;
41
+ message?: string;
42
+ data?: Attributes;
43
+ }
44
+ interface User {
45
+ id?: string;
46
+ email?: string;
47
+ username?: string;
48
+ ipAddress?: string;
49
+ }
50
+ interface TraceContext {
51
+ traceId: string;
52
+ spanId: string;
53
+ }
54
+ interface RequestInfo {
55
+ url?: string;
56
+ method?: string;
57
+ headers?: Record<string, string>;
58
+ }
59
+ interface SdkInfo {
60
+ name: string;
61
+ version: string;
62
+ }
63
+ interface ErrorItemInput {
64
+ type: "error";
65
+ eventId: string;
66
+ timestamp: number;
67
+ level?: Level;
68
+ platform: string;
69
+ exceptions?: Exception[];
70
+ message?: string;
71
+ fingerprint?: string[];
72
+ release?: string;
73
+ environment?: string;
74
+ tags?: Record<string, string>;
75
+ attributes?: Attributes;
76
+ user?: User;
77
+ breadcrumbs?: Breadcrumb[];
78
+ trace?: TraceContext;
79
+ request?: RequestInfo;
80
+ }
81
+ interface EventItemInput {
82
+ type: "event";
83
+ eventId: string;
84
+ timestamp: number;
85
+ name: string;
86
+ distinctId: string;
87
+ anonymousId?: string;
88
+ sessionId?: string;
89
+ properties?: Attributes;
90
+ set?: Attributes;
91
+ setOnce?: Attributes;
92
+ }
93
+ type ItemInput = ErrorItemInput | EventItemInput;
94
+ interface EnvelopeInput {
95
+ version: 1;
96
+ sentAt: number;
97
+ sdk: SdkInfo;
98
+ resource: Resource;
99
+ items: ItemInput[];
100
+ }
101
+ //#endregion
102
+ //#region src/transport.d.ts
103
+ /**
104
+ * What a platform transport reports back: the HTTP status (0 for network failures) and any `Retry-After` in seconds.
105
+ */
106
+ interface TransportResult {
107
+ status: number;
108
+ retryAfterSeconds?: number;
109
+ }
110
+ interface Transport {
111
+ send: (body: string) => Promise<TransportResult>;
112
+ /**
113
+ * Best-effort send while the page or process is going away. Return false when unsupported.
114
+ */
115
+ sendFinal?: (body: string) => boolean;
116
+ }
117
+ interface QueueOptions {
118
+ transport: Transport;
119
+ envelope: (items: ItemInput[]) => EnvelopeInput;
120
+ maxQueue: number;
121
+ maxBatch: number;
122
+ flushDelayMs: number;
123
+ maxAttempts: number;
124
+ now: () => number;
125
+ schedule: (callback: () => void, delayMs: number) => unknown;
126
+ cancel: (handle: unknown) => void;
127
+ /**
128
+ * Called with envelopes that exhausted their retries on network failures (Node persists them to disk).
129
+ */
130
+ onUndeliverable?: (body: string) => void;
131
+ debug?: (event: string, fields: Record<string, unknown>) => void;
132
+ }
133
+ interface Queue {
134
+ /**
135
+ * Add an item; the oldest item is dropped when the queue is full.
136
+ */
137
+ push(item: ItemInput): void;
138
+ /**
139
+ * Send everything that can be sent now.
140
+ */
141
+ flush(): Promise<void>;
142
+ /**
143
+ * Wait for pending items to be sent, up to a timeout. Resolves to whether the queue drained.
144
+ */
145
+ drain(timeoutMs: number): Promise<boolean>;
146
+ /**
147
+ * Hand queued items to the transport's final send (page unload).
148
+ */
149
+ sendFinal(): boolean;
150
+ size(): number;
151
+ }
152
+ /**
153
+ * Batches items into envelopes, sends them and honours server back-pressure: 429/503 pause sending until `Retry-After`, network failures
154
+ * retry with exponential backoff, and permanent rejections (4xx) drop the batch.
155
+ *
156
+ * @param options - Transport, limits and clock.
157
+ * @returns Queue controls.
158
+ */
159
+ export declare function createQueue(options: QueueOptions): Queue;
160
+ //#endregion
161
+ //#region src/client.d.ts
162
+ interface ClientOptions {
163
+ /**
164
+ * Chajá server base URL, such as `https://chaja.example.com`.
165
+ */
166
+ endpoint: string;
167
+ /**
168
+ * Project ingest key.
169
+ */
170
+ key: string;
171
+ /**
172
+ * Logical service name (`service.name`).
173
+ */
174
+ service: string;
175
+ release?: string;
176
+ environment?: string;
177
+ /**
178
+ * Share of events to send, from 0 to 1. Default 1.
179
+ */
180
+ sampleRate?: number;
181
+ maxBreadcrumbs?: number;
182
+ /**
183
+ * Inspect or change an event before it is queued; return null to drop it.
184
+ */
185
+ beforeSend?: (item: ErrorItemInput) => ErrorItemInput | null;
186
+ /**
187
+ * Keys whose values are redacted in tags, attributes, headers, breadcrumb data and URL queries.
188
+ */
189
+ sensitiveKeys?: RegExp;
190
+ /**
191
+ * Identical errors within this window are sent once. Default 1000 ms.
192
+ */
193
+ dedupeWindowMs?: number;
194
+ enabled?: boolean;
195
+ resourceAttributes?: Attributes;
196
+ /**
197
+ * Send product analytics events (`capture`, `identify`, automatic page views). Default true.
198
+ */
199
+ analytics?: boolean;
200
+ }
201
+ /**
202
+ * Where a platform keeps who the current person is between page loads.
203
+ */
204
+ interface IdentityState {
205
+ distinctId: string;
206
+ /**
207
+ * True once `identify` named the person; until then `distinctId` is an anonymous id.
208
+ */
209
+ identified: boolean;
210
+ }
211
+ interface CaptureOptions {
212
+ /**
213
+ * Send as this person instead of the current one (servers capturing for many users).
214
+ */
215
+ distinctId?: string;
216
+ /**
217
+ * Person properties to set.
218
+ */
219
+ set?: Attributes;
220
+ /**
221
+ * Person properties to set only when the person does not have them yet.
222
+ */
223
+ setOnce?: Attributes;
224
+ }
225
+ interface Platform {
226
+ sdk: SdkInfo;
227
+ platform: string;
228
+ transport: Transport;
229
+ eventId: () => string;
230
+ now: () => number;
231
+ random: () => number;
232
+ schedule: (callback: () => void, delayMs: number) => unknown;
233
+ cancel: (handle: unknown) => void;
234
+ /**
235
+ * Add platform context (request URL, user agent, source lines) to an item.
236
+ */
237
+ enrich?: (item: ErrorItemInput) => ErrorItemInput;
238
+ onUndeliverable?: (body: string) => void;
239
+ debug?: (event: string, fields: Record<string, unknown>) => void;
240
+ /**
241
+ * Load and save the current person (browsers persist it; servers keep it in memory).
242
+ */
243
+ identity?: {
244
+ load(): IdentityState | undefined;
245
+ save(state: IdentityState | undefined): void;
246
+ };
247
+ /**
248
+ * The current session id, renewed by the platform after inactivity.
249
+ */
250
+ session?: {
251
+ current(): string | undefined;
252
+ reset(): void;
253
+ };
254
+ /**
255
+ * Properties added to every analytics event, such as the page URL.
256
+ */
257
+ eventProperties?: () => Attributes;
258
+ }
259
+ interface CaptureContext {
260
+ level?: Level;
261
+ tags?: Record<string, string>;
262
+ attributes?: Attributes;
263
+ user?: User;
264
+ fingerprint?: string[];
265
+ mechanism?: Mechanism;
266
+ request?: RequestInfo;
267
+ trace?: TraceContext;
268
+ }
269
+ interface BreadcrumbInput {
270
+ category: string;
271
+ message?: string;
272
+ level?: Level;
273
+ data?: Attributes;
274
+ timestamp?: number;
275
+ }
276
+ interface Client {
277
+ readonly options: Readonly<ClientOptions>;
278
+ captureException(error: unknown, context?: CaptureContext): string | undefined;
279
+ captureMessage(message: string, level?: Level, context?: CaptureContext): string | undefined;
280
+ addBreadcrumb(breadcrumb: BreadcrumbInput): void;
281
+ setUser(user: User | null): void;
282
+ setTag(key: string, value: string): void;
283
+ setTags(tags: Record<string, string>): void;
284
+ setAttributes(attributes: Attributes): void;
285
+ /**
286
+ * Supply the active trace (for example from OpenTelemetry) so errors link to traces.
287
+ */
288
+ setTraceProvider(provider: (() => TraceContext | undefined) | undefined): void;
289
+ /**
290
+ * Record a product analytics event, such as `signed_up`, for the current person (or `options.distinctId`).
291
+ */
292
+ capture(name: string, properties?: Attributes, options?: CaptureOptions): string | undefined;
293
+ /**
294
+ * Name the current person with your app's user id. Events captured before, under an anonymous id, become theirs. Errors
295
+ * from now on carry the id as `user.id`, so a person's errors show next to their events.
296
+ */
297
+ identify(distinctId: string, set?: Attributes, setOnce?: Attributes): void;
298
+ /**
299
+ * Forget the current person and session (sign out): later events start a new anonymous person.
300
+ */
301
+ reset(): void;
302
+ /**
303
+ * The current person's id: the identified id, or the anonymous one.
304
+ */
305
+ getDistinctId(): string;
306
+ /**
307
+ * The current session id (renewed by the platform after inactivity), if the platform keeps sessions.
308
+ */
309
+ getSessionId(): string | undefined;
310
+ /**
311
+ * Send queued events, waiting at most `timeoutMs`. Resolves to whether everything was sent.
312
+ */
313
+ flush(timeoutMs?: number): Promise<boolean>;
314
+ /**
315
+ * Hand queued events to the transport's last-chance send (page hide).
316
+ */
317
+ sendFinal(): boolean;
318
+ }
319
+ /**
320
+ * Create an SDK client. Platform packages supply the transport, ids, clocks and enrichment.
321
+ *
322
+ * @param options - User options.
323
+ * @param platform - Platform hooks.
324
+ * @returns The client.
325
+ */
326
+ export declare function createClient(options: ClientOptions, platform: Platform): Client;
327
+ //#endregion
328
+ //#region src/scrub.d.ts
329
+ export declare const DEFAULT_SENSITIVE_KEYS: RegExp;
330
+ export declare const REDACTED = "[redacted]";
331
+ /**
332
+ * Replace values whose key looks sensitive. Tags, attributes, headers and breadcrumb data are all flat records.
333
+ *
334
+ * @param record - Flat record.
335
+ * @param pattern - Keys to redact.
336
+ * @returns A scrubbed copy.
337
+ */
338
+ export declare function scrubRecord<V>(record: Record<string, V>, pattern?: RegExp): Record<string, V | string>;
339
+ /**
340
+ * Redact query values in a URL whose parameter names look sensitive.
341
+ *
342
+ * @param url - Absolute or relative URL.
343
+ * @param pattern - Parameter names to redact.
344
+ * @returns URL with sensitive values redacted.
345
+ */
346
+ export declare function scrubUrl(url: string, pattern?: RegExp): string;
347
+ //#endregion
348
+ //#region src/stack.d.ts
349
+ /**
350
+ * Parse a JavaScript `error.stack` string from V8 (Chrome, Edge, Node, Deno, Bun), SpiderMonkey (Firefox) or JavaScriptCore (Safari).
351
+ *
352
+ * @param stack - Stack string.
353
+ * @returns Frames, oldest call first.
354
+ */
355
+ export declare function parseStack(stack: string | undefined): Frame[];
356
+ /**
357
+ * Convert a thrown value and its `cause` chain into protocol exceptions, outermost first.
358
+ *
359
+ * @param error - Anything that was thrown or rejected.
360
+ * @param mechanism - How it was captured.
361
+ * @returns Exceptions.
362
+ */
363
+ export declare function exceptionsFrom(error: unknown, mechanism: Mechanism): Exception[];
364
+ //#endregion
365
+ //#region src/index.d.ts
366
+ export declare const SDK_VERSION = "0.1.0";
367
+ /**
368
+ * Parse a `Retry-After` header given in seconds or as an HTTP date.
369
+ *
370
+ * @param value - Header value.
371
+ * @param now - Current time in milliseconds.
372
+ * @returns Seconds to wait, if the header is usable.
373
+ */
374
+ export declare function parseRetryAfter(value: string | null | undefined, now?: number): number | undefined;
375
+ /**
376
+ * A 32-character lowercase hex event id.
377
+ *
378
+ * @returns Event id.
379
+ */
380
+ export declare function randomEventId(): string;
381
+ //#endregion
382
+ export type { BreadcrumbInput, CaptureContext, CaptureOptions, Client, ClientOptions, IdentityState, Platform, Queue, QueueOptions, Transport, TransportResult };
package/dist/index.mjs ADDED
@@ -0,0 +1,586 @@
1
+ //#region src/scrub.ts
2
+ const DEFAULT_SENSITIVE_KEYS = /pass(?:word|wd)?|secret|token|api[-_]?key|auth|cookie|session|credit|card|cvv|ssn|private/iu;
3
+ const REDACTED = "[redacted]";
4
+ /**
5
+ * Replace values whose key looks sensitive. Tags, attributes, headers and breadcrumb data are all flat records.
6
+ *
7
+ * @param record - Flat record.
8
+ * @param pattern - Keys to redact.
9
+ * @returns A scrubbed copy.
10
+ */
11
+ function scrubRecord(record, pattern = DEFAULT_SENSITIVE_KEYS) {
12
+ const result = {};
13
+ for (const [key, value] of Object.entries(record)) result[key] = pattern.test(key) ? REDACTED : value;
14
+ return result;
15
+ }
16
+ /**
17
+ * Redact query values in a URL whose parameter names look sensitive.
18
+ *
19
+ * @param url - Absolute or relative URL.
20
+ * @param pattern - Parameter names to redact.
21
+ * @returns URL with sensitive values redacted.
22
+ */
23
+ function scrubUrl(url, pattern = DEFAULT_SENSITIVE_KEYS) {
24
+ const queryStart = url.indexOf("?");
25
+ if (queryStart === -1) return url;
26
+ const hashStart = url.indexOf("#", queryStart);
27
+ const cleaned = url.slice(queryStart + 1, hashStart === -1 ? void 0 : hashStart).split("&").map(function redact(pair) {
28
+ const [name = ""] = pair.split("=");
29
+ let decoded = name;
30
+ try {
31
+ decoded = decodeURIComponent(name.replaceAll("+", " "));
32
+ } catch {
33
+ decoded = name;
34
+ }
35
+ return pattern.test(decoded) ? `${name}=${encodeURIComponent(REDACTED)}` : pair;
36
+ }).join("&");
37
+ return `${url.slice(0, queryStart)}?${cleaned}${hashStart === -1 ? "" : url.slice(hashStart)}`;
38
+ }
39
+ //#endregion
40
+ //#region src/stack.ts
41
+ const MAX_FRAMES = 100;
42
+ const MAX_CAUSES = 10;
43
+ const MAX_VALUE = 8192;
44
+ const V8_WITH_POSITION = /^\s*at (?:async )?(.+?) \((.+):(\d+):(\d+)\)\s*$/u;
45
+ const V8_ANONYMOUS = /^\s*at (?:async )?(.+):(\d+):(\d+)\s*$/u;
46
+ const V8_NO_POSITION = /^\s*at (?:async )?(.+?) \((.+)\)\s*$/u;
47
+ const GECKO = /^\s*(.*?)@(.+):(\d+):(\d+)\s*$/u;
48
+ /**
49
+ * Parse a JavaScript `error.stack` string from V8 (Chrome, Edge, Node, Deno, Bun), SpiderMonkey (Firefox) or JavaScriptCore (Safari).
50
+ *
51
+ * @param stack - Stack string.
52
+ * @returns Frames, oldest call first.
53
+ */
54
+ function parseStack(stack) {
55
+ if (!stack) return [];
56
+ const frames = [];
57
+ for (const line of stack.split("\n")) {
58
+ const positioned = V8_WITH_POSITION.exec(line);
59
+ if (positioned) {
60
+ frames.push(frame(positioned[1], positioned[2], positioned[3], positioned[4]));
61
+ continue;
62
+ }
63
+ const anonymous = V8_ANONYMOUS.exec(line);
64
+ if (anonymous) {
65
+ frames.push(frame(void 0, anonymous[1], anonymous[2], anonymous[3]));
66
+ continue;
67
+ }
68
+ const native = V8_NO_POSITION.exec(line);
69
+ if (native) {
70
+ frames.push(frame(native[1], native[2], void 0, void 0));
71
+ continue;
72
+ }
73
+ const gecko = GECKO.exec(line);
74
+ if (gecko) frames.push(frame(gecko[1] || void 0, gecko[2], gecko[3], gecko[4]));
75
+ }
76
+ return frames.slice(0, MAX_FRAMES).reverse();
77
+ }
78
+ /**
79
+ * Build one frame from regex parts.
80
+ *
81
+ * @param fn - Function name.
82
+ * @param filename - File or URL.
83
+ * @param line - Line number text.
84
+ * @param column - Column number text.
85
+ * @returns Frame.
86
+ */
87
+ function frame(fn, filename, line, column) {
88
+ const name = fn?.replace(/^Object\.|^Module\./u, "").trim();
89
+ return {
90
+ filename,
91
+ ...name && name !== "<anonymous>" ? { function: name } : {},
92
+ ...line ? { lineno: Number(line) } : {},
93
+ ...column ? { colno: Number(column) } : {}
94
+ };
95
+ }
96
+ /**
97
+ * Read a property from an unknown thrown value.
98
+ *
99
+ * @param value - Thrown value.
100
+ * @param key - Property name.
101
+ * @returns The property, if the value is an object.
102
+ */
103
+ function property(value, key) {
104
+ return typeof value === "object" && value !== null ? Reflect.get(value, key) : void 0;
105
+ }
106
+ /**
107
+ * Describe a non-Error thrown value.
108
+ *
109
+ * @param value - Thrown value.
110
+ * @returns Readable text.
111
+ */
112
+ function describe(value) {
113
+ if (typeof value === "string") return value;
114
+ try {
115
+ return JSON.stringify(value) ?? String(value);
116
+ } catch {
117
+ return Object.prototype.toString.call(value);
118
+ }
119
+ }
120
+ /**
121
+ * Convert a thrown value and its `cause` chain into protocol exceptions, outermost first.
122
+ *
123
+ * @param error - Anything that was thrown or rejected.
124
+ * @param mechanism - How it was captured.
125
+ * @returns Exceptions.
126
+ */
127
+ function exceptionsFrom(error, mechanism) {
128
+ const exceptions = [];
129
+ const seen = /* @__PURE__ */ new Set();
130
+ let current = error;
131
+ while (current !== void 0 && current !== null && exceptions.length < MAX_CAUSES && !seen.has(current)) {
132
+ seen.add(current);
133
+ const isError = current instanceof Error || typeof property(current, "message") === "string" && typeof property(current, "name") === "string";
134
+ const rawName = property(current, "name");
135
+ const name = isError ? typeof rawName === "string" && rawName ? rawName : "Error" : typeof current === "object" ? "NonError" : "Error";
136
+ const message = isError ? String(property(current, "message")) : describe(current);
137
+ const stack = property(current, "stack");
138
+ exceptions.push({
139
+ type: name.slice(0, 256),
140
+ value: message.slice(0, MAX_VALUE),
141
+ frames: typeof stack === "string" ? parseStack(stack) : [],
142
+ ...exceptions.length === 0 ? { mechanism } : {}
143
+ });
144
+ current = property(current, "cause");
145
+ }
146
+ return exceptions;
147
+ }
148
+ //#endregion
149
+ //#region src/transport.ts
150
+ const MAX_BACKOFF_MS = 6e4;
151
+ /**
152
+ * Batches items into envelopes, sends them and honours server back-pressure: 429/503 pause sending until `Retry-After`, network failures
153
+ * retry with exponential backoff, and permanent rejections (4xx) drop the batch.
154
+ *
155
+ * @param options - Transport, limits and clock.
156
+ * @returns Queue controls.
157
+ */
158
+ function createQueue(options) {
159
+ const pending = [];
160
+ let pausedUntil = 0;
161
+ let failures = 0;
162
+ let timer;
163
+ let inFlight;
164
+ /**
165
+ * Arrange the next flush.
166
+ *
167
+ * @param delayMs - Delay before flushing.
168
+ */
169
+ function scheduleFlush(delayMs) {
170
+ if (timer !== void 0) return;
171
+ timer = options.schedule(function run() {
172
+ timer = void 0;
173
+ flush();
174
+ }, delayMs);
175
+ }
176
+ /**
177
+ * Send one batch.
178
+ *
179
+ * @param batch - Items.
180
+ */
181
+ async function sendBatch(batch) {
182
+ const body = JSON.stringify(options.envelope(batch));
183
+ let result;
184
+ try {
185
+ result = await options.transport.send(body);
186
+ } catch {
187
+ result = { status: 0 };
188
+ }
189
+ if (result.status >= 200 && result.status < 300) {
190
+ failures = 0;
191
+ return;
192
+ }
193
+ if (result.status === 429 || result.status === 503 || result.status === 0 || result.status >= 500) {
194
+ failures += 1;
195
+ const backoff = Math.min(MAX_BACKOFF_MS, 1e3 * 2 ** (failures - 1));
196
+ const wait = result.retryAfterSeconds === void 0 ? backoff : result.retryAfterSeconds * 1e3;
197
+ pausedUntil = options.now() + wait;
198
+ if (failures >= options.maxAttempts) {
199
+ options.debug?.("transport.gave_up", {
200
+ status: result.status,
201
+ items: batch.length
202
+ });
203
+ failures = 0;
204
+ if (result.status === 0) options.onUndeliverable?.(body);
205
+ return;
206
+ }
207
+ pending.unshift(...batch);
208
+ pending.splice(options.maxQueue);
209
+ scheduleFlush(wait);
210
+ return;
211
+ }
212
+ options.debug?.("transport.rejected", {
213
+ status: result.status,
214
+ items: batch.length
215
+ });
216
+ }
217
+ /**
218
+ * Send everything that can be sent now.
219
+ *
220
+ * @returns Resolves when the current flush finishes.
221
+ */
222
+ async function flush() {
223
+ if (inFlight) return inFlight;
224
+ inFlight = (async function drain() {
225
+ while (pending.length > 0) {
226
+ const wait = pausedUntil - options.now();
227
+ if (wait > 0) {
228
+ scheduleFlush(wait);
229
+ return;
230
+ }
231
+ const before = pending.length;
232
+ await sendBatch(pending.splice(0, options.maxBatch));
233
+ if (pending.length >= before) return;
234
+ }
235
+ })().finally(function done() {
236
+ inFlight = void 0;
237
+ });
238
+ return inFlight;
239
+ }
240
+ return {
241
+ /**
242
+ * Add an item; the oldest item is dropped when the queue is full.
243
+ *
244
+ * @param item - Envelope item.
245
+ */
246
+ push(item) {
247
+ pending.push(item);
248
+ if (pending.length > options.maxQueue) pending.shift();
249
+ scheduleFlush(Math.max(options.flushDelayMs, pausedUntil - options.now()));
250
+ },
251
+ flush,
252
+ /**
253
+ * Wait for pending items to be sent, up to a timeout.
254
+ *
255
+ * @param timeoutMs - Maximum wait.
256
+ * @returns Whether the queue drained in time.
257
+ */
258
+ async drain(timeoutMs) {
259
+ if (timer !== void 0) {
260
+ options.cancel(timer);
261
+ timer = void 0;
262
+ }
263
+ const deadline = options.now() + timeoutMs;
264
+ while (pending.length > 0 && options.now() < deadline && pausedUntil <= options.now()) await flush();
265
+ await inFlight;
266
+ return pending.length === 0;
267
+ },
268
+ /**
269
+ * Hand everything to the transport's final send (page unload).
270
+ *
271
+ * @returns Whether the transport accepted it.
272
+ */
273
+ sendFinal() {
274
+ if (pending.length === 0 || !options.transport.sendFinal) return pending.length === 0;
275
+ const accepted = options.transport.sendFinal(JSON.stringify(options.envelope(pending.slice(0, options.maxBatch))));
276
+ if (accepted) pending.splice(0, options.maxBatch);
277
+ return accepted;
278
+ },
279
+ size() {
280
+ return pending.length;
281
+ }
282
+ };
283
+ }
284
+ //#endregion
285
+ //#region src/client.ts
286
+ const DEFAULT_MAX_BREADCRUMBS = 50;
287
+ const MAX_TEXT = 8192;
288
+ /**
289
+ * Create an SDK client. Platform packages supply the transport, ids, clocks and enrichment.
290
+ *
291
+ * @param options - User options.
292
+ * @param platform - Platform hooks.
293
+ * @returns The client.
294
+ */
295
+ function createClient(options, platform) {
296
+ const pattern = options.sensitiveKeys ?? DEFAULT_SENSITIVE_KEYS;
297
+ const maxBreadcrumbs = options.maxBreadcrumbs ?? DEFAULT_MAX_BREADCRUMBS;
298
+ const dedupeWindowMs = options.dedupeWindowMs ?? 1e3;
299
+ const breadcrumbs = [];
300
+ const tags = {};
301
+ const attributes = {};
302
+ const captured = /* @__PURE__ */ new WeakSet();
303
+ const recent = /* @__PURE__ */ new Map();
304
+ let user;
305
+ let traceProvider;
306
+ /**
307
+ * A new anonymous person, saved so the next page load keeps it.
308
+ *
309
+ * @returns Identity.
310
+ */
311
+ function anonymous() {
312
+ const fresh = {
313
+ distinctId: platform.eventId(),
314
+ identified: false
315
+ };
316
+ platform.identity?.save(fresh);
317
+ return fresh;
318
+ }
319
+ let identity = platform.identity?.load() ?? anonymous();
320
+ if (identity.identified) user = { id: identity.distinctId };
321
+ const resource = {
322
+ "service.name": options.service,
323
+ ...options.release ? { "service.version": options.release } : {},
324
+ ...options.environment ? { "deployment.environment": options.environment } : {},
325
+ ...options.resourceAttributes ? { attributes: options.resourceAttributes } : {}
326
+ };
327
+ const queue = createQueue({
328
+ transport: platform.transport,
329
+ envelope(items) {
330
+ return {
331
+ version: 1,
332
+ sentAt: platform.now(),
333
+ sdk: platform.sdk,
334
+ resource,
335
+ items
336
+ };
337
+ },
338
+ maxQueue: 100,
339
+ maxBatch: 20,
340
+ flushDelayMs: 500,
341
+ maxAttempts: 6,
342
+ now: platform.now,
343
+ schedule: platform.schedule,
344
+ cancel: platform.cancel,
345
+ ...platform.onUndeliverable ? { onUndeliverable: platform.onUndeliverable } : {},
346
+ ...platform.debug ? { debug: platform.debug } : {}
347
+ });
348
+ /**
349
+ * Whether this event repeats one sent moments ago.
350
+ *
351
+ * @param item - Candidate item.
352
+ * @returns True when it should be skipped.
353
+ */
354
+ function isDuplicate(item) {
355
+ const exception = item.exceptions?.[0];
356
+ const top = exception?.frames.at(-1);
357
+ const key = exception ? `${exception.type}|${exception.value}|${top?.filename ?? ""}:${top?.lineno ?? ""}` : `message|${item.message ?? ""}`;
358
+ const now = platform.now();
359
+ const last = recent.get(key);
360
+ for (const [entry, time] of recent) if (now - time > dedupeWindowMs) recent.delete(entry);
361
+ recent.set(key, now);
362
+ return last !== void 0 && now - last <= dedupeWindowMs;
363
+ }
364
+ /**
365
+ * Apply scope, scrubbing, sampling and hooks, then queue.
366
+ *
367
+ * @param base - Item content.
368
+ * @param context - Per-call context.
369
+ * @returns Event id, or undefined when dropped.
370
+ */
371
+ function capture(base, context = {}) {
372
+ if (options.enabled === false || platform.random() >= (options.sampleRate ?? 1)) return;
373
+ const trace = context.trace ?? traceProvider?.();
374
+ const mergedUser = context.user ?? user;
375
+ let item = {
376
+ type: "error",
377
+ eventId: platform.eventId(),
378
+ timestamp: platform.now(),
379
+ platform: platform.platform,
380
+ ...base,
381
+ ...options.release ? { release: options.release } : {},
382
+ ...options.environment ? { environment: options.environment } : {},
383
+ tags: scrubRecord({
384
+ ...tags,
385
+ ...context.tags
386
+ }, pattern),
387
+ attributes: {
388
+ ...scrubRecord({
389
+ ...attributes,
390
+ ...context.attributes
391
+ }, pattern),
392
+ ...sessionAttributes()
393
+ },
394
+ breadcrumbs: [...breadcrumbs],
395
+ ...mergedUser ? { user: mergedUser } : {},
396
+ ...context.fingerprint ? { fingerprint: context.fingerprint } : {},
397
+ ...trace ? { trace } : {},
398
+ ...context.request ? { request: context.request } : {}
399
+ };
400
+ if (platform.enrich) item = platform.enrich(item);
401
+ if (item.request) item = {
402
+ ...item,
403
+ request: {
404
+ ...item.request,
405
+ ...item.request.url ? { url: scrubUrl(item.request.url, pattern) } : {},
406
+ ...item.request.headers ? { headers: scrubRecord(item.request.headers, pattern) } : {}
407
+ }
408
+ };
409
+ if (isDuplicate(item)) return;
410
+ const final = options.beforeSend ? options.beforeSend(item) : item;
411
+ if (!final) return;
412
+ queue.push(final);
413
+ return final.eventId;
414
+ }
415
+ /**
416
+ * The session id as an error attribute, so an error links to the session it happened in.
417
+ *
418
+ * @returns Attributes.
419
+ */
420
+ function sessionAttributes() {
421
+ const session = platform.session?.current();
422
+ return session ? { "session.id": session } : {};
423
+ }
424
+ /**
425
+ * Queue one analytics event.
426
+ *
427
+ * @param name - Event name.
428
+ * @param properties - Event properties.
429
+ * @param fields - Person, identify and person-property fields.
430
+ * @returns Event id, if queued.
431
+ */
432
+ function sendEvent(name, properties, fields) {
433
+ if (options.enabled === false || options.analytics === false) return;
434
+ const session = platform.session?.current();
435
+ const item = {
436
+ type: "event",
437
+ eventId: platform.eventId(),
438
+ timestamp: platform.now(),
439
+ name: name.slice(0, 256),
440
+ ...fields,
441
+ ...session ? { sessionId: session } : {},
442
+ properties: scrubRecord({
443
+ ...platform.eventProperties?.(),
444
+ ...properties
445
+ }, pattern)
446
+ };
447
+ queue.push(item);
448
+ return item.eventId;
449
+ }
450
+ /**
451
+ * Remember who the current person is.
452
+ *
453
+ * @param next - New identity.
454
+ */
455
+ function remember(next) {
456
+ identity = next;
457
+ platform.identity?.save(next);
458
+ }
459
+ return {
460
+ options,
461
+ captureException(error, context = {}) {
462
+ if (typeof error === "object" && error !== null) {
463
+ if (captured.has(error)) return;
464
+ captured.add(error);
465
+ }
466
+ return capture({
467
+ exceptions: exceptionsFrom(error, context.mechanism ?? {
468
+ type: "manual",
469
+ handled: true
470
+ }),
471
+ level: context.level ?? "error"
472
+ }, context);
473
+ },
474
+ captureMessage(message, level = "info", context = {}) {
475
+ return capture({
476
+ message: message.slice(0, MAX_TEXT),
477
+ level
478
+ }, context);
479
+ },
480
+ addBreadcrumb(breadcrumb) {
481
+ breadcrumbs.push({
482
+ timestamp: breadcrumb.timestamp ?? platform.now(),
483
+ category: breadcrumb.category.slice(0, 256),
484
+ level: breadcrumb.level ?? "info",
485
+ ...breadcrumb.message === void 0 ? {} : { message: scrubUrl(breadcrumb.message.slice(0, MAX_TEXT), pattern) },
486
+ ...breadcrumb.data === void 0 ? {} : { data: scrubRecord(breadcrumb.data, pattern) }
487
+ });
488
+ if (breadcrumbs.length > maxBreadcrumbs) breadcrumbs.shift();
489
+ },
490
+ setUser(next) {
491
+ user = next ?? void 0;
492
+ },
493
+ setTag(key, value) {
494
+ tags[key] = value;
495
+ },
496
+ setTags(next) {
497
+ Object.assign(tags, next);
498
+ },
499
+ setAttributes(next) {
500
+ Object.assign(attributes, next);
501
+ },
502
+ setTraceProvider(provider) {
503
+ traceProvider = provider;
504
+ },
505
+ capture(name, properties = {}, captureOptions = {}) {
506
+ return sendEvent(name, properties, {
507
+ distinctId: captureOptions.distinctId ?? identity.distinctId,
508
+ ...captureOptions.set ? { set: captureOptions.set } : {},
509
+ ...captureOptions.setOnce ? { setOnce: captureOptions.setOnce } : {}
510
+ });
511
+ },
512
+ identify(distinctId, set, setOnce) {
513
+ const id = distinctId.trim().slice(0, 256);
514
+ if (!id) return;
515
+ const people = {
516
+ ...set ? { set } : {},
517
+ ...setOnce ? { setOnce } : {}
518
+ };
519
+ if (identity.identified && identity.distinctId === id) {
520
+ if (set || setOnce) sendEvent("$set", {}, {
521
+ distinctId: id,
522
+ ...people
523
+ });
524
+ return;
525
+ }
526
+ const previous = identity.identified ? void 0 : identity.distinctId;
527
+ remember({
528
+ distinctId: id,
529
+ identified: true
530
+ });
531
+ user = {
532
+ ...user,
533
+ id
534
+ };
535
+ sendEvent("$identify", {}, {
536
+ distinctId: id,
537
+ ...previous ? { anonymousId: previous } : {},
538
+ ...people
539
+ });
540
+ },
541
+ reset() {
542
+ identity = anonymous();
543
+ user = void 0;
544
+ platform.session?.reset();
545
+ },
546
+ getDistinctId() {
547
+ return identity.distinctId;
548
+ },
549
+ getSessionId() {
550
+ return platform.session?.current();
551
+ },
552
+ flush(timeoutMs = 2e3) {
553
+ return queue.drain(timeoutMs);
554
+ },
555
+ sendFinal() {
556
+ return queue.sendFinal();
557
+ }
558
+ };
559
+ }
560
+ //#endregion
561
+ //#region src/index.ts
562
+ const SDK_VERSION = "0.1.0";
563
+ /**
564
+ * Parse a `Retry-After` header given in seconds or as an HTTP date.
565
+ *
566
+ * @param value - Header value.
567
+ * @param now - Current time in milliseconds.
568
+ * @returns Seconds to wait, if the header is usable.
569
+ */
570
+ function parseRetryAfter(value, now = Date.now()) {
571
+ if (!value) return;
572
+ const seconds = Number(value);
573
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds;
574
+ const date = Date.parse(value);
575
+ return Number.isNaN(date) ? void 0 : Math.max(0, Math.ceil((date - now) / 1e3));
576
+ }
577
+ /**
578
+ * A 32-character lowercase hex event id.
579
+ *
580
+ * @returns Event id.
581
+ */
582
+ function randomEventId() {
583
+ return globalThis.crypto.randomUUID().replaceAll("-", "");
584
+ }
585
+ //#endregion
586
+ export { DEFAULT_SENSITIVE_KEYS, REDACTED, SDK_VERSION, createClient, createQueue, exceptionsFrom, parseRetryAfter, parseStack, randomEventId, scrubRecord, scrubUrl };
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@chaja/sdk-core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Shared client for the Chajá JavaScript SDKs: capture, scrubbing, sampling, batching and retries.",
5
+ "keywords": [
6
+ "chaja",
7
+ "error-tracking",
8
+ "observability",
9
+ "sdk"
10
+ ],
11
+ "homepage": "https://github.com/emiliodominguez/chaja#readme",
12
+ "bugs": "https://github.com/emiliodominguez/chaja/issues",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/emiliodominguez/chaja.git",
16
+ "directory": "packages/sdk-core"
17
+ },
18
+ "type": "module",
19
+ "nx": {
20
+ "tags": [
21
+ "scope:sdk",
22
+ "type:sdk"
23
+ ]
24
+ },
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.mts",
28
+ "default": "./dist/index.mjs"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "sideEffects": false,
35
+ "devDependencies": {
36
+ "@chaja/protocol": "0.0.0",
37
+ "tsdown": "0.23.0"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "scripts": {
43
+ "build": "tsdown src/index.ts --format esm --dts --clean --out-dir dist",
44
+ "typecheck": "tsc --noEmit -p ."
45
+ }
6
46
  }