@squasher-ai/nextjs 0.3.0 → 0.4.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.
Files changed (50) hide show
  1. package/dist/_vendor/node-sdk/actionable-telemetry.d.ts +4 -0
  2. package/dist/_vendor/node-sdk/actionable-telemetry.js +32 -0
  3. package/dist/_vendor/node-sdk/aws-lambda.d.ts +19 -0
  4. package/dist/_vendor/node-sdk/aws-lambda.js +81 -0
  5. package/dist/_vendor/node-sdk/client.d.ts +54 -0
  6. package/dist/_vendor/node-sdk/client.js +479 -0
  7. package/dist/_vendor/node-sdk/delivery-stats.d.ts +40 -0
  8. package/dist/_vendor/node-sdk/delivery-stats.js +57 -0
  9. package/dist/_vendor/node-sdk/http.d.ts +22 -0
  10. package/dist/_vendor/node-sdk/http.js +139 -0
  11. package/dist/_vendor/node-sdk/index.d.ts +36 -0
  12. package/dist/_vendor/node-sdk/index.js +84 -0
  13. package/dist/_vendor/node-sdk/local-events/sink.d.ts +6 -0
  14. package/dist/_vendor/node-sdk/local-events/sink.js +43 -0
  15. package/dist/_vendor/node-sdk/trace-context.d.ts +8 -0
  16. package/dist/_vendor/node-sdk/trace-context.js +13 -0
  17. package/dist/_vendor/node-sdk/types.d.ts +65 -0
  18. package/dist/_vendor/node-sdk/types.js +1 -0
  19. package/dist/_vendor/sdk-runtime/batching/index.d.ts +42 -0
  20. package/dist/_vendor/sdk-runtime/batching/index.js +48 -0
  21. package/dist/_vendor/sdk-runtime/errors/headers.d.ts +6 -0
  22. package/dist/_vendor/sdk-runtime/errors/headers.js +44 -0
  23. package/dist/_vendor/sdk-runtime/errors/index.d.ts +95 -0
  24. package/dist/_vendor/sdk-runtime/errors/index.js +157 -0
  25. package/dist/_vendor/sdk-runtime/headers.d.ts +48 -0
  26. package/dist/_vendor/sdk-runtime/headers.js +67 -0
  27. package/dist/_vendor/sdk-runtime/platform.d.ts +33 -0
  28. package/dist/_vendor/sdk-runtime/platform.js +173 -0
  29. package/dist/_vendor/sdk-runtime/retry.d.ts +50 -0
  30. package/dist/_vendor/sdk-runtime/retry.js +104 -0
  31. package/dist/_vendor/sdk-runtime/runtime/actionable-error.d.ts +37 -0
  32. package/dist/_vendor/sdk-runtime/runtime/actionable-error.js +123 -0
  33. package/dist/_vendor/sdk-runtime/runtime/environment.d.ts +18 -0
  34. package/dist/_vendor/sdk-runtime/runtime/environment.js +81 -0
  35. package/dist/_vendor/sdk-runtime/runtime/public-sdk-runtime.d.ts +12 -0
  36. package/dist/_vendor/sdk-runtime/runtime/public-sdk-runtime.js +38 -0
  37. package/dist/_vendor/sdk-runtime/runtime/redaction.d.ts +14 -0
  38. package/dist/_vendor/sdk-runtime/runtime/redaction.js +120 -0
  39. package/dist/_vendor/sdk-runtime/runtime/release.d.ts +35 -0
  40. package/dist/_vendor/sdk-runtime/runtime/release.js +120 -0
  41. package/dist/_vendor/sdk-runtime/sampling/index.d.ts +43 -0
  42. package/dist/_vendor/sdk-runtime/sampling/index.js +70 -0
  43. package/dist/api-handler.d.ts +1 -1
  44. package/dist/api-handler.js +1 -1
  45. package/dist/client.d.ts +1 -1
  46. package/dist/client.js +1 -1
  47. package/dist/index.d.ts +2 -2
  48. package/dist/index.js +1 -1
  49. package/dist/middleware.js +1 -1
  50. package/package.json +1 -4
@@ -0,0 +1,4 @@
1
+ import type { ErrorEvent } from "./types.js";
2
+ export declare function mergeActionableTelemetry(event: ErrorEvent, error: Error, options?: {
3
+ attributes?: ErrorEvent["attributes"];
4
+ }): ErrorEvent;
@@ -0,0 +1,32 @@
1
+ import { getActionableErrorTelemetry } from "../sdk-runtime/runtime/public-sdk-runtime.js";
2
+ export function mergeActionableTelemetry(event, error, options) {
3
+ const telemetry = getActionableErrorTelemetry(error);
4
+ if (!telemetry) {
5
+ return options?.attributes ? { ...event, attributes: options.attributes } : event;
6
+ }
7
+ return {
8
+ ...event,
9
+ attributes: {
10
+ ...telemetry.attributes,
11
+ ...options?.attributes,
12
+ },
13
+ extra: {
14
+ squasher_error: actionableContextJson(telemetry.extra.squasher_error),
15
+ ...event.extra,
16
+ },
17
+ };
18
+ }
19
+ function actionableContextJson(context) {
20
+ const result = {};
21
+ if (context.status !== undefined)
22
+ result.status = context.status;
23
+ if (context.why)
24
+ result.why = context.why;
25
+ if (context.fix)
26
+ result.fix = context.fix;
27
+ if (context.link)
28
+ result.link = context.link;
29
+ if (context.internal)
30
+ result.internal = context.internal;
31
+ return result;
32
+ }
@@ -0,0 +1,19 @@
1
+ import type { SquasherClient } from "./client.js";
2
+ /** The small part of the AWS Lambda context that the wrapper uses. */
3
+ export interface AwsLambdaContextLike {
4
+ awsRequestId?: string;
5
+ functionName?: string;
6
+ getRemainingTimeInMillis?: () => number;
7
+ }
8
+ export type AwsLambdaHandler<Event, Context extends AwsLambdaContextLike, Result> = (event: Event, context: Context) => Result | Promise<Result>;
9
+ export interface AwsLambdaOptions {
10
+ /** Maximum time to wait for telemetry after the handler completes. Default: 1000 ms. */
11
+ flushTimeoutMs?: number;
12
+ /** Time left for the Lambda runtime after the telemetry flush. Default: 100 ms. */
13
+ remainingTimeReserveMs?: number;
14
+ }
15
+ /**
16
+ * Wrap an async AWS Lambda handler with failure capture and a deadline-aware flush.
17
+ * The wrapper has no AWS package or Lambda layer dependency.
18
+ */
19
+ export declare function withAwsLambda<Event, Context extends AwsLambdaContextLike, Result>(client: SquasherClient, handler: AwsLambdaHandler<Event, Context, Result>, options?: AwsLambdaOptions): AwsLambdaHandler<Event, Context, Result>;
@@ -0,0 +1,81 @@
1
+ const DEFAULT_FLUSH_TIMEOUT_MS = 1_000;
2
+ const DEFAULT_REMAINING_TIME_RESERVE_MS = 100;
3
+ /**
4
+ * Wrap an async AWS Lambda handler with failure capture and a deadline-aware flush.
5
+ * The wrapper has no AWS package or Lambda layer dependency.
6
+ */
7
+ export function withAwsLambda(client, handler, options = {}) {
8
+ return async (event, context) => {
9
+ try {
10
+ return await handler(event, context);
11
+ }
12
+ catch (cause) {
13
+ const error = cause instanceof Error ? cause : new Error(String(cause));
14
+ const metadata = lambdaMetadata(context);
15
+ try {
16
+ await client.captureError(error, {
17
+ aws_lambda: metadata.extra,
18
+ }, {
19
+ attributes: {
20
+ "squasher.runtime.profile": "serverless",
21
+ ...metadata.attributes,
22
+ },
23
+ });
24
+ }
25
+ catch {
26
+ // Telemetry must not replace the handler failure.
27
+ }
28
+ throw cause;
29
+ }
30
+ finally {
31
+ try {
32
+ await flushWithin(client, resolveFlushTimeout(context, options));
33
+ }
34
+ catch {
35
+ // Telemetry must not change the handler result.
36
+ }
37
+ }
38
+ };
39
+ }
40
+ function lambdaMetadata(context) {
41
+ const attributes = {};
42
+ const extra = {};
43
+ if (context.awsRequestId) {
44
+ attributes["faas.invocation_id"] = context.awsRequestId;
45
+ extra.request_id = context.awsRequestId;
46
+ }
47
+ if (context.functionName) {
48
+ attributes["faas.name"] = context.functionName;
49
+ extra.function_name = context.functionName;
50
+ }
51
+ return { attributes, extra };
52
+ }
53
+ async function flushWithin(client, timeoutMs) {
54
+ if (timeoutMs === 0) {
55
+ await client.flush(0);
56
+ return;
57
+ }
58
+ let timeout;
59
+ try {
60
+ await Promise.race([
61
+ client.flush(timeoutMs),
62
+ new Promise((resolve) => {
63
+ timeout = setTimeout(resolve, timeoutMs);
64
+ }),
65
+ ]);
66
+ }
67
+ finally {
68
+ if (timeout)
69
+ clearTimeout(timeout);
70
+ }
71
+ }
72
+ function resolveFlushTimeout(context, options) {
73
+ const configuredTimeout = nonNegative(options.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS);
74
+ if (!context.getRemainingTimeInMillis)
75
+ return configuredTimeout;
76
+ const reserve = nonNegative(options.remainingTimeReserveMs ?? DEFAULT_REMAINING_TIME_RESERVE_MS);
77
+ return Math.min(configuredTimeout, nonNegative(context.getRemainingTimeInMillis() - reserve));
78
+ }
79
+ function nonNegative(value) {
80
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
81
+ }
@@ -0,0 +1,54 @@
1
+ import { type DeliveryStats } from "./delivery-stats.js";
2
+ import type { Breadcrumb, CaptureErrorOptions, ErrorEvent, JsonObject, Level, SquasherConfig, UserContext } from "./types.js";
3
+ interface PendingRequest {
4
+ body: string;
5
+ eventCount: number;
6
+ }
7
+ export declare class SquasherClient {
8
+ private config;
9
+ private breadcrumbs;
10
+ private user;
11
+ private tags;
12
+ private queue;
13
+ private deliveryStats;
14
+ private inFlightFlushes;
15
+ protected flushTimer: ReturnType<typeof setInterval> | null;
16
+ protected disabled: boolean;
17
+ constructor(config: SquasherConfig);
18
+ setUser(user: UserContext | undefined): void;
19
+ setTag(key: string, value: string): void;
20
+ setTags(tags: Record<string, string>): void;
21
+ /** Local delivery health. Reading this snapshot never emits telemetry. */
22
+ getDeliveryStats(): DeliveryStats;
23
+ /** Ring buffer with FIFO eviction past `maxBreadcrumbs`. */
24
+ addBreadcrumb(crumb: Omit<Breadcrumb, "timestamp">): void;
25
+ captureError(error: Error, extra?: JsonObject, options?: CaptureErrorOptions): Promise<string | null>;
26
+ captureMessage(message: string, level?: Level): Promise<string | null>;
27
+ /** All capture* helpers funnel through here. Returns null if sampled out or disabled. */
28
+ captureTelemetry(event: ErrorEvent): Promise<string | null>;
29
+ track(eventName: string, properties?: JsonObject, context?: Partial<ErrorEvent>): Promise<string | null>;
30
+ identify(distinctId: string, traits?: JsonObject, context?: Partial<ErrorEvent>): Promise<string | null>;
31
+ page(name: string, properties?: JsonObject, context?: Partial<ErrorEvent>): Promise<string | null>;
32
+ screen(name: string, properties?: JsonObject, context?: Partial<ErrorEvent>): Promise<string | null>;
33
+ captureSpan(name: string, context?: Partial<ErrorEvent>): Promise<string | null>;
34
+ captureToolCall(name: string, context?: Partial<ErrorEvent>): Promise<string | null>;
35
+ captureGeneration(message: string, context?: Partial<ErrorEvent>): Promise<string | null>;
36
+ /** Drain the queue. Resolves when empty or timeout expires. Call before process exit. */
37
+ flush(timeoutMs?: number): Promise<void>;
38
+ close(): Promise<void>;
39
+ /**
40
+ * Install global error handlers (uncaughtException + unhandledRejection).
41
+ * Per spec §6: uncaughtException → capture + flush + exit(1).
42
+ * unhandledRejection → capture (no exit).
43
+ */
44
+ installGlobalHandlers(): void;
45
+ protected prepareEvent(event: ErrorEvent): ErrorEvent;
46
+ private buildErrorEvent;
47
+ protected parseStack(stack?: string): ErrorEvent["frames"];
48
+ protected buildRequests(events: ErrorEvent[]): PendingRequest[];
49
+ private enqueue;
50
+ private flushQueue;
51
+ protected sendWithRetry(request: PendingRequest): Promise<void>;
52
+ private attemptSend;
53
+ }
54
+ export {};
@@ -0,0 +1,479 @@
1
+ import { attemptAsync } from "../result-runtime/attempt.js";
2
+ import { buildBatches, DEFAULT_INGEST_ENDPOINT, detectRelease, getDefaultHeaders, nextBackoffMs, resolveDebugFlag, resolveTelemetrySampling, shouldRetry, shouldSampleTelemetry, sleep, } from "../sdk-runtime/runtime/public-sdk-runtime.js";
3
+ import { mergeActionableTelemetry } from "./actionable-telemetry.js";
4
+ import { DeliveryStatsTracker } from "./delivery-stats.js";
5
+ import { createLocalEventSink, normalizeLocalEventSinkConfig, } from "./local-events/sink.js";
6
+ const SDK_NAME = "@squasher-ai/node";
7
+ const SDK_VERSION = "0.5.0";
8
+ // Cached at module load — getDefaultHeaders is pure given package metadata
9
+ // and platform detection in sdk-core memoizes its own probe. One allocation
10
+ // per process, reused on every outbound request.
11
+ const DEFAULT_TELEMETRY_HEADERS = getDefaultHeaders({
12
+ packageName: SDK_NAME,
13
+ packageVersion: SDK_VERSION,
14
+ });
15
+ async function settleBeforeDeadline(promise, deadline) {
16
+ const remainingMs = deadline - Date.now();
17
+ if (remainingMs <= 0)
18
+ return false;
19
+ let timeout;
20
+ const deadlineReached = new Promise((resolve) => {
21
+ timeout = setTimeout(() => resolve(false), remainingMs);
22
+ timeout.unref();
23
+ });
24
+ try {
25
+ return await Promise.race([promise.then(() => true), deadlineReached]);
26
+ }
27
+ finally {
28
+ if (timeout)
29
+ clearTimeout(timeout);
30
+ }
31
+ }
32
+ function resolveConfig(config) {
33
+ return {
34
+ apiKey: config.apiKey,
35
+ projectId: config.projectId,
36
+ endpoint: config.endpoint ?? DEFAULT_INGEST_ENDPOINT,
37
+ environment: config.environment,
38
+ release: config.release ?? detectRelease() ?? undefined,
39
+ debug: resolveDebugFlag(config.debug),
40
+ sampling: resolveTelemetrySampling(config.sampling),
41
+ beforeSend: config.beforeSend,
42
+ maxBreadcrumbs: config.maxBreadcrumbs ?? 50,
43
+ batchSize: config.batchSize ?? 25,
44
+ flushIntervalMs: config.flushIntervalMs ?? 5000,
45
+ maxRetries: config.maxRetries ?? 3,
46
+ resourceAttributes: config.resourceAttributes ?? {},
47
+ localEventSink: createLocalEventSink(normalizeLocalEventSinkConfig(config.localEventSink)),
48
+ };
49
+ }
50
+ export class SquasherClient {
51
+ config;
52
+ breadcrumbs = [];
53
+ user;
54
+ tags = {};
55
+ queue = [];
56
+ deliveryStats = new DeliveryStatsTracker();
57
+ inFlightFlushes = new Set();
58
+ flushTimer = null;
59
+ disabled;
60
+ constructor(config) {
61
+ // Missing (undefined/null) = throw. Empty string = disabled mode (per spec §2).
62
+ if (config.apiKey === undefined || config.apiKey === null)
63
+ throw new Error("Squasher: apiKey is required");
64
+ if (config.projectId === undefined || config.projectId === null)
65
+ throw new Error("Squasher: projectId is required");
66
+ this.config = resolveConfig(config);
67
+ this.disabled = config.apiKey.length === 0 || config.projectId.length === 0;
68
+ if (this.disabled && this.config.debug) {
69
+ console.warn("[squasher] SDK initialized in disabled mode (empty apiKey or projectId). Events will not be sent.");
70
+ }
71
+ if (!this.disabled) {
72
+ this.flushTimer = setInterval(() => {
73
+ void this.flushQueue();
74
+ }, this.config.flushIntervalMs);
75
+ // Unref so the interval doesn't keep the Node process alive.
76
+ this.flushTimer.unref();
77
+ }
78
+ }
79
+ setUser(user) {
80
+ this.user = user;
81
+ }
82
+ setTag(key, value) {
83
+ this.tags[key] = value;
84
+ }
85
+ setTags(tags) {
86
+ Object.assign(this.tags, tags);
87
+ }
88
+ /** Local delivery health. Reading this snapshot never emits telemetry. */
89
+ getDeliveryStats() {
90
+ return this.deliveryStats.snapshot(this.queue.length);
91
+ }
92
+ /** Ring buffer with FIFO eviction past `maxBreadcrumbs`. */
93
+ addBreadcrumb(crumb) {
94
+ this.breadcrumbs.push({
95
+ ...crumb,
96
+ timestamp: new Date().toISOString(),
97
+ });
98
+ if (this.breadcrumbs.length > this.config.maxBreadcrumbs) {
99
+ this.breadcrumbs = this.breadcrumbs.slice(-this.config.maxBreadcrumbs);
100
+ }
101
+ }
102
+ async captureError(error, extra, options) {
103
+ return this.captureTelemetry(this.buildErrorEvent(error, extra, options));
104
+ }
105
+ async captureMessage(message, level = "info") {
106
+ return this.captureTelemetry({
107
+ message,
108
+ level,
109
+ });
110
+ }
111
+ /** All capture* helpers funnel through here. Returns null if sampled out or disabled. */
112
+ async captureTelemetry(event) {
113
+ if (this.disabled)
114
+ return null;
115
+ let prepared = this.prepareEvent(event);
116
+ if (!this.config.debug && !shouldSampleTelemetry(prepared, this.config.sampling)) {
117
+ if (!prepared.measurements?.length)
118
+ return null;
119
+ prepared = markSampledOut(prepared);
120
+ }
121
+ if (this.config.beforeSend) {
122
+ const filtered = this.config.beforeSend(prepared);
123
+ if (!filtered)
124
+ return null;
125
+ prepared = filtered;
126
+ }
127
+ this.config.localEventSink?.write(prepared);
128
+ return this.enqueue(prepared);
129
+ }
130
+ async track(eventName, properties, context = {}) {
131
+ return this.captureTelemetry({
132
+ ...context,
133
+ message: context.message ?? eventName,
134
+ level: context.level ?? "info",
135
+ kind: context.kind ?? "analytics",
136
+ event_name: context.event_name ?? eventName,
137
+ analytics: {
138
+ ...context.analytics,
139
+ event: context.analytics?.event ?? eventName,
140
+ properties: properties ?? context.analytics?.properties,
141
+ },
142
+ });
143
+ }
144
+ async identify(distinctId, traits, context = {}) {
145
+ this.user = {
146
+ ...this.user,
147
+ ...context.user,
148
+ id: distinctId,
149
+ };
150
+ return this.captureTelemetry({
151
+ ...context,
152
+ message: context.message ?? `identify:${distinctId}`,
153
+ level: context.level ?? "info",
154
+ kind: "identify",
155
+ event_name: context.event_name ?? "identify",
156
+ distinct_id: context.distinct_id ?? distinctId,
157
+ analytics: {
158
+ ...context.analytics,
159
+ event: context.analytics?.event ?? "identify",
160
+ properties: traits ?? context.analytics?.properties,
161
+ },
162
+ user: {
163
+ ...context.user,
164
+ id: distinctId,
165
+ },
166
+ });
167
+ }
168
+ async page(name, properties, context = {}) {
169
+ return this.captureTelemetry({
170
+ ...context,
171
+ message: context.message ?? `page:${name}`,
172
+ level: context.level ?? "info",
173
+ kind: "page",
174
+ event_name: context.event_name ?? name,
175
+ analytics: {
176
+ ...context.analytics,
177
+ event: context.analytics?.event ?? name,
178
+ properties: properties ?? context.analytics?.properties,
179
+ },
180
+ page: {
181
+ ...context.page,
182
+ name: context.page?.name ?? name,
183
+ },
184
+ });
185
+ }
186
+ async screen(name, properties, context = {}) {
187
+ return this.captureTelemetry({
188
+ ...context,
189
+ message: context.message ?? `screen:${name}`,
190
+ level: context.level ?? "info",
191
+ kind: "screen",
192
+ event_name: context.event_name ?? name,
193
+ analytics: {
194
+ ...context.analytics,
195
+ event: context.analytics?.event ?? name,
196
+ properties: properties ?? context.analytics?.properties,
197
+ },
198
+ page: {
199
+ ...context.page,
200
+ name: context.page?.name ?? name,
201
+ screen_class: context.page?.screen_class ?? name,
202
+ },
203
+ });
204
+ }
205
+ async captureSpan(name, context = {}) {
206
+ return this.captureTelemetry({
207
+ ...context,
208
+ message: context.message ?? `span:${name}`,
209
+ level: context.level ?? "info",
210
+ kind: context.kind ?? "agent_span",
211
+ event_name: context.event_name ?? name,
212
+ trace: {
213
+ ...context.trace,
214
+ span_name: context.trace?.span_name ?? name,
215
+ },
216
+ });
217
+ }
218
+ async captureToolCall(name, context = {}) {
219
+ return this.captureTelemetry({
220
+ ...context,
221
+ message: context.message ?? `tool:${name}`,
222
+ level: context.level ?? "info",
223
+ kind: "tool_call",
224
+ event_name: context.event_name ?? name,
225
+ tool_call: {
226
+ ...context.tool_call,
227
+ name: context.tool_call?.name ?? name,
228
+ },
229
+ });
230
+ }
231
+ async captureGeneration(message, context = {}) {
232
+ return this.captureTelemetry({
233
+ ...context,
234
+ message,
235
+ level: context.level ?? "info",
236
+ kind: "llm_generation",
237
+ event_name: context.event_name ?? context.llm?.model ?? "llm_generation",
238
+ });
239
+ }
240
+ /** Drain the queue. Resolves when empty or timeout expires. Call before process exit. */
241
+ async flush(timeoutMs = 5000) {
242
+ const deadline = Date.now() + timeoutMs;
243
+ while ((this.queue.length > 0 || this.inFlightFlushes.size > 0) && Date.now() < deadline) {
244
+ const pending = this.queue.length > 0
245
+ ? this.flushQueue()
246
+ : Promise.all(this.inFlightFlushes).then(() => undefined);
247
+ if (!(await settleBeforeDeadline(pending, deadline)))
248
+ return;
249
+ }
250
+ }
251
+ async close() {
252
+ if (this.flushTimer) {
253
+ clearInterval(this.flushTimer);
254
+ this.flushTimer = null;
255
+ }
256
+ await this.flush(5000);
257
+ }
258
+ /**
259
+ * Install global error handlers (uncaughtException + unhandledRejection).
260
+ * Per spec §6: uncaughtException → capture + flush + exit(1).
261
+ * unhandledRejection → capture (no exit).
262
+ */
263
+ installGlobalHandlers() {
264
+ process.on("uncaughtException", async (error) => {
265
+ await this.captureError(error);
266
+ await this.flush(5000);
267
+ process.exit(1);
268
+ });
269
+ process.on("unhandledRejection", (reason) => {
270
+ const error = reason instanceof Error ? reason : new Error(String(reason));
271
+ void this.captureError(error);
272
+ });
273
+ }
274
+ prepareEvent(event) {
275
+ const kind = event.kind ?? (event.level === "error" || event.level === "fatal" ? "error" : "log");
276
+ const mergedTags = { ...this.tags, ...event.tags };
277
+ const tags = Object.keys(mergedTags).length > 0 ? mergedTags : undefined;
278
+ const mergedResourceAttributes = {
279
+ ...this.config.resourceAttributes,
280
+ ...event.resource_attributes,
281
+ };
282
+ const resourceAttributes = Object.keys(mergedResourceAttributes).length > 0 ? mergedResourceAttributes : undefined;
283
+ return {
284
+ ...event,
285
+ level: event.level ?? "info",
286
+ kind,
287
+ distinct_id: event.distinct_id ?? event.user?.id ?? this.user?.id,
288
+ visitor: event.visitor ??
289
+ (event.session_id && ["analytics", "identify", "page", "screen", "visitor"].includes(kind)
290
+ ? { anonymous_id: event.session_id }
291
+ : undefined),
292
+ tags,
293
+ sdk: event.sdk ?? { name: SDK_NAME, version: SDK_VERSION },
294
+ timestamp: event.timestamp ?? new Date().toISOString(),
295
+ release: event.release ?? this.config.release,
296
+ environment: event.environment ?? this.config.environment,
297
+ user: event.user ?? (this.user ? { ...this.user } : undefined),
298
+ breadcrumbs: event.breadcrumbs ?? (this.breadcrumbs.length > 0 ? [...this.breadcrumbs] : undefined),
299
+ resource_attributes: resourceAttributes,
300
+ };
301
+ }
302
+ buildErrorEvent(error, extra, options) {
303
+ return mergeActionableTelemetry({
304
+ message: error.message,
305
+ type: error.name,
306
+ stack: error.stack,
307
+ frames: this.parseStack(error.stack),
308
+ level: options?.level ?? "error",
309
+ request: options?.request,
310
+ extra,
311
+ }, error, options);
312
+ }
313
+ parseStack(stack) {
314
+ if (!stack)
315
+ return undefined;
316
+ const lines = stack.split("\n").slice(1);
317
+ const frames = lines
318
+ .map((line) => {
319
+ const match = line.match(/\s+at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?/);
320
+ if (!match)
321
+ return null;
322
+ const [, fn, filename, lineno, colno] = match;
323
+ const isNodeInternal = filename?.includes("node_modules") || filename?.includes("node:") || false;
324
+ return {
325
+ function: fn || "<anonymous>",
326
+ filename,
327
+ lineno: lineno ? parseInt(lineno, 10) : undefined,
328
+ colno: colno ? parseInt(colno, 10) : undefined,
329
+ in_app: !isNodeInternal,
330
+ };
331
+ })
332
+ .filter((f) => f !== null);
333
+ return frames.length > 0 ? frames : undefined;
334
+ }
335
+ buildRequests(events) {
336
+ return buildBatches(events, {
337
+ batchPrefix: '{"events":[',
338
+ batchSuffix: "]}",
339
+ serializeSingle: (item) => JSON.stringify(item),
340
+ }).map((batch) => ({ body: batch.body, eventCount: batch.itemCount }));
341
+ }
342
+ enqueue(event) {
343
+ const eventId = crypto.randomUUID();
344
+ this.queue.push(event);
345
+ this.deliveryStats.recordQueueDepth(this.queue.length);
346
+ if (this.queue.length >= this.config.batchSize) {
347
+ void this.flushQueue();
348
+ }
349
+ return eventId;
350
+ }
351
+ async flushQueue() {
352
+ if (this.queue.length === 0)
353
+ return;
354
+ const startedAt = Date.now();
355
+ const events = this.queue.splice(0);
356
+ const requests = this.buildRequests(events);
357
+ const pending = Promise.allSettled(requests.map((request) => this.sendWithRetry(request))).then(() => undefined);
358
+ this.inFlightFlushes.add(pending);
359
+ try {
360
+ await pending;
361
+ }
362
+ finally {
363
+ this.inFlightFlushes.delete(pending);
364
+ this.deliveryStats.recordFlushDuration(Date.now() - startedAt);
365
+ }
366
+ }
367
+ async sendWithRetry(request) {
368
+ const url = `${this.config.endpoint}/v1/ingest/${this.config.projectId}`;
369
+ let attempt = 0;
370
+ this.deliveryStats.recordAttempt(request.eventCount);
371
+ while (true) {
372
+ const response = await this.attemptSend(url, request);
373
+ if (!response.res) {
374
+ attempt++;
375
+ if (attempt > this.config.maxRetries) {
376
+ this.deliveryStats.recordDropped(request.eventCount, "network_error", null);
377
+ if (this.config.debug) {
378
+ console.error(`[squasher] Event dropped after ${attempt} retries:`, response.cause);
379
+ }
380
+ return;
381
+ }
382
+ this.deliveryStats.recordRetry(request.eventCount);
383
+ const backoff = Math.round(nextBackoffMs({ attempt: attempt - 1 }));
384
+ if (this.config.debug) {
385
+ console.warn(`[squasher] Network error, retry ${attempt}/${this.config.maxRetries} in ${backoff}ms:`, response.cause);
386
+ }
387
+ await sleep(backoff);
388
+ continue;
389
+ }
390
+ if (response.res.ok) {
391
+ this.deliveryStats.recordSent(request.eventCount, response.res.status);
392
+ if (this.config.debug) {
393
+ const label = request.eventCount === 1 ? "Event" : `${request.eventCount} events`;
394
+ console.log(`[squasher] ${label} sent: ${response.data?.id ?? ""}`);
395
+ }
396
+ return;
397
+ }
398
+ if (response.res.status === 401) {
399
+ this.deliveryStats.recordDropped(request.eventCount, "unauthorized", 401);
400
+ console.error("[squasher] Invalid API key (401). Events will be dropped.");
401
+ this.disabled = true;
402
+ return;
403
+ }
404
+ if (response.res.status === 429) {
405
+ // Stay parity with prior behavior: stop after a single 429 so we
406
+ // don't pile on a rate-limited backend, but use the server-supplied
407
+ // Retry-After hint when available (capped at sdk-core's 8s).
408
+ const wait = Math.round(nextBackoffMs({
409
+ attempt: attempt,
410
+ headers: response.res.headers,
411
+ }));
412
+ if (this.config.debug) {
413
+ console.warn("[squasher] Rate limited (429), backing off");
414
+ }
415
+ await sleep(wait);
416
+ this.deliveryStats.recordDropped(request.eventCount, "rate_limited", 429);
417
+ return;
418
+ }
419
+ const retryable = shouldRetry({
420
+ status: response.res.status,
421
+ headers: response.res.headers,
422
+ });
423
+ if (!retryable) {
424
+ this.deliveryStats.recordDropped(request.eventCount, "rejected", response.res.status);
425
+ if (this.config.debug) {
426
+ console.error(`[squasher] Event rejected (HTTP ${response.res.status}), not retrying`);
427
+ }
428
+ return;
429
+ }
430
+ attempt++;
431
+ if (attempt > this.config.maxRetries) {
432
+ this.deliveryStats.recordDropped(request.eventCount, "server_error", response.res.status);
433
+ if (this.config.debug) {
434
+ console.error(`[squasher] Event dropped after ${attempt} retries (HTTP ${response.res.status})`);
435
+ }
436
+ return;
437
+ }
438
+ this.deliveryStats.recordRetry(request.eventCount);
439
+ const backoff = Math.round(nextBackoffMs({
440
+ attempt: attempt - 1,
441
+ headers: response.res.headers,
442
+ }));
443
+ if (this.config.debug) {
444
+ console.warn(`[squasher] Server error (HTTP ${response.res.status}), retry ${attempt}/${this.config.maxRetries} in ${backoff}ms`);
445
+ }
446
+ await sleep(backoff);
447
+ }
448
+ }
449
+ async attemptSend(url, request) {
450
+ return attemptAsync({
451
+ try: async () => {
452
+ const res = await fetch(url, {
453
+ method: "POST",
454
+ headers: {
455
+ ...DEFAULT_TELEMETRY_HEADERS,
456
+ "Content-Type": "application/json",
457
+ "x-squasher-key": this.config.apiKey,
458
+ },
459
+ body: request.body,
460
+ });
461
+ return {
462
+ data: res.ok ? await res.json() : null,
463
+ res,
464
+ };
465
+ },
466
+ catch: ({ cause }) => ({
467
+ cause: cause instanceof Error ? cause : new Error(String(cause)),
468
+ data: null,
469
+ res: null,
470
+ }),
471
+ });
472
+ }
473
+ }
474
+ function markSampledOut(event) {
475
+ return {
476
+ ...event,
477
+ attributes: { ...event.attributes, "squasher.sampled": false },
478
+ };
479
+ }