@gurulu/node 0.1.0 → 0.1.2
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/dist/{node-sdk/src/business-events.d.ts → business-events.d.ts} +2 -2
- package/dist/{node-sdk/src/business-events.js → business-events.js} +2 -4
- package/dist/client.d.ts +150 -0
- package/dist/{node-sdk/src/client.js → client.js} +189 -2
- package/dist/{node-sdk/src/index.d.ts → index.d.ts} +3 -2
- package/dist/{node-sdk/src/index.js → index.js} +10 -1
- package/dist/middleware.d.ts +31 -0
- package/dist/middleware.js +138 -0
- package/dist/{node-sdk/src/types.d.ts → types.d.ts} +2 -0
- package/package.json +14 -1
- package/dist/node-sdk/src/client.d.ts +0 -70
- package/dist/shared-core/src/canonical-events.d.ts +0 -35
- package/dist/shared-core/src/canonical-events.js +0 -225
- package/dist/shared-core/src/envelope.d.ts +0 -91
- package/dist/shared-core/src/envelope.js +0 -341
- package/dist/shared-core/src/index.d.ts +0 -6
- package/dist/shared-core/src/index.js +0 -22
- package/dist/shared-core/src/server-event.d.ts +0 -37
- package/dist/shared-core/src/server-event.js +0 -6
- /package/dist/{node-sdk/src/types.js → types.js} +0 -0
|
@@ -49,8 +49,8 @@ export declare function paymentSucceeded(args: PaymentSucceededArgs): BusinessEv
|
|
|
49
49
|
export interface SubscriptionStartedArgs {
|
|
50
50
|
userId: string;
|
|
51
51
|
plan: string;
|
|
52
|
-
amount
|
|
53
|
-
currency
|
|
52
|
+
amount: number;
|
|
53
|
+
currency: string;
|
|
54
54
|
}
|
|
55
55
|
export declare function subscriptionStarted(args: SubscriptionStartedArgs): BusinessEvent;
|
|
56
56
|
export interface OrderPlacedArgs {
|
|
@@ -80,11 +80,9 @@ function paymentSucceeded(args) {
|
|
|
80
80
|
function subscriptionStarted(args) {
|
|
81
81
|
const props = {
|
|
82
82
|
plan: args.plan,
|
|
83
|
+
amount: args.amount,
|
|
84
|
+
currency: args.currency,
|
|
83
85
|
};
|
|
84
|
-
if (args.amount !== undefined)
|
|
85
|
-
props.amount = args.amount;
|
|
86
|
-
if (args.currency !== undefined)
|
|
87
|
-
props.currency = args.currency;
|
|
88
86
|
const env = baseEnvelope('subscription_started', args.userId, props);
|
|
89
87
|
return toBusinessEvent(env, args.userId);
|
|
90
88
|
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gurulu/node — server-side SDK client.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities (Phase 10 W4.2):
|
|
5
|
+
* - Queue + batch server events.
|
|
6
|
+
* - Auto-flush at batchSize boundary and on a periodic timer.
|
|
7
|
+
* - Exponential-backoff retry (200ms, 600ms, 1800ms — 3 attempts total) on
|
|
8
|
+
* 5xx / network errors. No retry on 4xx.
|
|
9
|
+
* - Attach deterministic Idempotency-Key headers so the server can dedupe
|
|
10
|
+
* replayed batches across retries.
|
|
11
|
+
* - Dead-letter callback for batches that exhaust retries or are rejected
|
|
12
|
+
* with a client error.
|
|
13
|
+
* - Flush on process.beforeExit + SIGTERM.
|
|
14
|
+
*
|
|
15
|
+
* Event shapes are pulled from @gurulu/shared-core (W1.2). Only the transport
|
|
16
|
+
* config (`GuruluClientConfig`) is local.
|
|
17
|
+
*
|
|
18
|
+
* Related docs: PHASE-10-ROADMAP.md §W4.2
|
|
19
|
+
*/
|
|
20
|
+
import type { ServerEvent } from '@gurulu/shared-core';
|
|
21
|
+
import type { GuruluClientConfig } from './types';
|
|
22
|
+
export interface IdentifyParams {
|
|
23
|
+
/** The known user ID (e.g. database ID, email, etc.) */
|
|
24
|
+
userId: string;
|
|
25
|
+
/** An anonymous/session ID to link with this user. Required by the identify endpoint. */
|
|
26
|
+
anonymousId: string;
|
|
27
|
+
/** User traits (email, name, plan, etc.) to persist on the identity profile. */
|
|
28
|
+
traits?: Record<string, unknown>;
|
|
29
|
+
/** Device ID for cross-device identity resolution. */
|
|
30
|
+
deviceId?: string;
|
|
31
|
+
/** OAuth provider name (e.g. 'google', 'github'). */
|
|
32
|
+
oauthProvider?: string;
|
|
33
|
+
/** OAuth user ID from the provider. */
|
|
34
|
+
oauthId?: string;
|
|
35
|
+
/** Consent level: 'none' | 'analytics' | 'marketing' | 'full'. */
|
|
36
|
+
consentLevel?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface IdentifyResponse {
|
|
39
|
+
status: string;
|
|
40
|
+
canonical_id: string | null;
|
|
41
|
+
consent_skipped?: boolean;
|
|
42
|
+
merges?: Array<{
|
|
43
|
+
winner: string;
|
|
44
|
+
loser: string;
|
|
45
|
+
reason: string;
|
|
46
|
+
claim_types: string[];
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
49
|
+
export interface Breadcrumb {
|
|
50
|
+
timestamp: string;
|
|
51
|
+
category: string;
|
|
52
|
+
message: string;
|
|
53
|
+
data?: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
export interface GuruluClient {
|
|
56
|
+
track(event: ServerEvent): void;
|
|
57
|
+
identify(params: IdentifyParams): Promise<IdentifyResponse>;
|
|
58
|
+
flush(): Promise<void>;
|
|
59
|
+
shutdown(): Promise<void>;
|
|
60
|
+
captureException(error: Error, context?: Record<string, unknown>): void;
|
|
61
|
+
addBreadcrumb(category: string, message: string, data?: Record<string, unknown>): void;
|
|
62
|
+
installGlobalHandlers(): void;
|
|
63
|
+
/** Current queue length — exposed for tests and observability. */
|
|
64
|
+
readonly queueSize: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Deterministic idempotency key for a server event. Hashes the tuple
|
|
68
|
+
* (site_id, event_name, timestamp, anonymous_id/user_id) so replayed events
|
|
69
|
+
* across retries produce the same key and the server can dedupe.
|
|
70
|
+
*/
|
|
71
|
+
export declare function createIdempotencyKey(event: ServerEvent, siteId: string): string;
|
|
72
|
+
export declare class Gurulu implements GuruluClient {
|
|
73
|
+
private readonly siteId;
|
|
74
|
+
private readonly apiKey;
|
|
75
|
+
private readonly endpoint;
|
|
76
|
+
private readonly flushInterval;
|
|
77
|
+
private readonly batchSize;
|
|
78
|
+
private readonly timeout;
|
|
79
|
+
private readonly onError?;
|
|
80
|
+
private readonly onDeadLetter?;
|
|
81
|
+
private readonly fetchImpl;
|
|
82
|
+
private readonly debug;
|
|
83
|
+
private readonly release;
|
|
84
|
+
private queue;
|
|
85
|
+
private breadcrumbs;
|
|
86
|
+
private readonly maxBreadcrumbs;
|
|
87
|
+
private flushTimer;
|
|
88
|
+
private inFlight;
|
|
89
|
+
private shutdownHandlers;
|
|
90
|
+
constructor(config: GuruluClientConfig);
|
|
91
|
+
get queueSize(): number;
|
|
92
|
+
private static readonly REVENUE_EVENTS;
|
|
93
|
+
/**
|
|
94
|
+
* Enqueue a server event. Auto-flushes synchronously when the queue reaches
|
|
95
|
+
* `batchSize`. Each event is stamped with a timestamp if missing so the
|
|
96
|
+
* idempotency key is stable across retries.
|
|
97
|
+
*/
|
|
98
|
+
track(event: ServerEvent): void;
|
|
99
|
+
/**
|
|
100
|
+
* Identify a user — links an anonymous session to a known user ID and
|
|
101
|
+
* persists traits on the identity profile. Sends immediately to the
|
|
102
|
+
* dedicated `/api/ingest/v1/identify` endpoint (not batched with track
|
|
103
|
+
* events) because identity resolution is latency-sensitive and returns
|
|
104
|
+
* the canonical_id synchronously.
|
|
105
|
+
*
|
|
106
|
+
* Retries with the same exponential-backoff strategy as batch flushes.
|
|
107
|
+
*/
|
|
108
|
+
identify(params: IdentifyParams): Promise<IdentifyResponse>;
|
|
109
|
+
/**
|
|
110
|
+
* POST all queued events in one batch. On 5xx / network error retries with
|
|
111
|
+
* exponential backoff (200ms, 600ms, 1800ms). On 4xx drops the batch and
|
|
112
|
+
* fires the dead-letter callback. Concurrent flush calls are serialized.
|
|
113
|
+
*/
|
|
114
|
+
flush(): Promise<void>;
|
|
115
|
+
private sendBatch;
|
|
116
|
+
/**
|
|
117
|
+
* Add a custom breadcrumb to the trail. Breadcrumbs are sent with error
|
|
118
|
+
* events to provide context about what happened before the error occurred.
|
|
119
|
+
*/
|
|
120
|
+
addBreadcrumb(category: string, message: string, data?: Record<string, unknown>): void;
|
|
121
|
+
/**
|
|
122
|
+
* Capture an exception and send it as an error event.
|
|
123
|
+
* Includes any accumulated breadcrumbs for debugging context.
|
|
124
|
+
*/
|
|
125
|
+
captureException(error: Error, context?: Record<string, unknown>): void;
|
|
126
|
+
/**
|
|
127
|
+
* Install global error handlers for uncaught exceptions and unhandled rejections.
|
|
128
|
+
* Call once at app startup.
|
|
129
|
+
*/
|
|
130
|
+
installGlobalHandlers(): void;
|
|
131
|
+
/**
|
|
132
|
+
* Stop the periodic flush timer, detach process listeners, and perform a
|
|
133
|
+
* final flush. Safe to call multiple times.
|
|
134
|
+
*/
|
|
135
|
+
shutdown(): Promise<void>;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Set the default Gurulu instance used by the convenience `captureException`.
|
|
139
|
+
*/
|
|
140
|
+
export declare function setDefaultInstance(instance: Gurulu): void;
|
|
141
|
+
/**
|
|
142
|
+
* Convenience function — captures an exception via the default instance.
|
|
143
|
+
* Call `setDefaultInstance()` first (or create a Gurulu client).
|
|
144
|
+
*/
|
|
145
|
+
export declare function captureException(error: Error, context?: Record<string, unknown>): void;
|
|
146
|
+
/**
|
|
147
|
+
* Convenience function — adds a breadcrumb via the default instance.
|
|
148
|
+
* Call `setDefaultInstance()` first (or create a Gurulu client).
|
|
149
|
+
*/
|
|
150
|
+
export declare function addBreadcrumb(category: string, message: string, data?: Record<string, unknown>): void;
|
|
@@ -21,8 +21,12 @@
|
|
|
21
21
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
22
|
exports.Gurulu = void 0;
|
|
23
23
|
exports.createIdempotencyKey = createIdempotencyKey;
|
|
24
|
+
exports.setDefaultInstance = setDefaultInstance;
|
|
25
|
+
exports.captureException = captureException;
|
|
26
|
+
exports.addBreadcrumb = addBreadcrumb;
|
|
24
27
|
const crypto_1 = require("crypto");
|
|
25
28
|
const DEFAULT_ENDPOINT = 'https://app.gurulu.io/api/ingest/v1/server';
|
|
29
|
+
const SDK_VERSION = 'node@0.1.0';
|
|
26
30
|
const DEFAULT_FLUSH_INTERVAL = 5000;
|
|
27
31
|
const DEFAULT_BATCH_SIZE = 50;
|
|
28
32
|
const DEFAULT_TIMEOUT = 10000;
|
|
@@ -52,7 +56,10 @@ class Gurulu {
|
|
|
52
56
|
onDeadLetter;
|
|
53
57
|
fetchImpl;
|
|
54
58
|
debug;
|
|
59
|
+
release;
|
|
55
60
|
queue = [];
|
|
61
|
+
breadcrumbs = [];
|
|
62
|
+
maxBreadcrumbs = 50;
|
|
56
63
|
flushTimer = null;
|
|
57
64
|
inFlight = null;
|
|
58
65
|
shutdownHandlers = [];
|
|
@@ -70,6 +77,7 @@ class Gurulu {
|
|
|
70
77
|
this.onError = config.onError;
|
|
71
78
|
this.onDeadLetter = config.onDeadLetter;
|
|
72
79
|
this.debug = config.debug ?? false;
|
|
80
|
+
this.release = config.release || process.env.GURULU_RELEASE || '';
|
|
73
81
|
const injected = config.fetchImpl;
|
|
74
82
|
if (injected) {
|
|
75
83
|
this.fetchImpl = injected;
|
|
@@ -109,6 +117,14 @@ class Gurulu {
|
|
|
109
117
|
get queueSize() {
|
|
110
118
|
return this.queue.length;
|
|
111
119
|
}
|
|
120
|
+
static REVENUE_EVENTS = new Set([
|
|
121
|
+
'purchase', '$purchase', 'order_placed', '$order_placed',
|
|
122
|
+
'subscription_started', '$subscription_started', 'subscription_created', '$subscription_created',
|
|
123
|
+
'refund', '$refund', 'order_refunded', '$order_refunded',
|
|
124
|
+
'payment_succeeded', '$payment_succeeded', 'checkout_completed', '$checkout_completed',
|
|
125
|
+
'deposit_completed', '$deposit_completed', 'bet_placed', '$bet_placed',
|
|
126
|
+
'subscribe', '$subscribe',
|
|
127
|
+
]);
|
|
112
128
|
/**
|
|
113
129
|
* Enqueue a server event. Auto-flushes synchronously when the queue reaches
|
|
114
130
|
* `batchSize`. Each event is stamped with a timestamp if missing so the
|
|
@@ -125,6 +141,19 @@ class Gurulu {
|
|
|
125
141
|
console.warn('[gurulu] user_id is required for server events');
|
|
126
142
|
return;
|
|
127
143
|
}
|
|
144
|
+
// Warn when a known revenue event is missing value/amount or currency
|
|
145
|
+
const eName = event.event_name;
|
|
146
|
+
if (Gurulu.REVENUE_EVENTS.has(eName) ||
|
|
147
|
+
Gurulu.REVENUE_EVENTS.has(eName.replace(/^\$/, ''))) {
|
|
148
|
+
const props = event.properties;
|
|
149
|
+
const hasValue = props?.value !== undefined || props?.amount !== undefined;
|
|
150
|
+
if (!hasValue) {
|
|
151
|
+
console.warn(`[gurulu] Revenue event "${eName}" is missing "value"/"amount" property. Revenue tracking will be incomplete.`);
|
|
152
|
+
}
|
|
153
|
+
if (!props?.currency) {
|
|
154
|
+
console.warn(`[gurulu] Revenue event "${eName}" is missing "currency" property (expected ISO 4217 code like "USD", "EUR").`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
128
157
|
const stamped = {
|
|
129
158
|
...event,
|
|
130
159
|
timestamp: event.timestamp ?? new Date().toISOString(),
|
|
@@ -137,6 +166,71 @@ class Gurulu {
|
|
|
137
166
|
void this.flush();
|
|
138
167
|
}
|
|
139
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Identify a user — links an anonymous session to a known user ID and
|
|
171
|
+
* persists traits on the identity profile. Sends immediately to the
|
|
172
|
+
* dedicated `/api/ingest/v1/identify` endpoint (not batched with track
|
|
173
|
+
* events) because identity resolution is latency-sensitive and returns
|
|
174
|
+
* the canonical_id synchronously.
|
|
175
|
+
*
|
|
176
|
+
* Retries with the same exponential-backoff strategy as batch flushes.
|
|
177
|
+
*/
|
|
178
|
+
async identify(params) {
|
|
179
|
+
if (!params.userId)
|
|
180
|
+
throw new Error('userId is required for identify()');
|
|
181
|
+
if (!params.anonymousId)
|
|
182
|
+
throw new Error('anonymousId is required for identify()');
|
|
183
|
+
const identifyEndpoint = this.endpoint.replace(/\/server\/?$/, '/identify');
|
|
184
|
+
const body = JSON.stringify({
|
|
185
|
+
site_id: this.siteId,
|
|
186
|
+
anonymous_id: params.anonymousId,
|
|
187
|
+
user_id: params.userId,
|
|
188
|
+
traits: params.traits,
|
|
189
|
+
...(params.deviceId ? { device_id: params.deviceId } : {}),
|
|
190
|
+
...(params.oauthProvider ? { oauth_provider: params.oauthProvider } : {}),
|
|
191
|
+
...(params.oauthId ? { oauth_id: params.oauthId } : {}),
|
|
192
|
+
...(params.consentLevel ? { consent_level: params.consentLevel } : {}),
|
|
193
|
+
});
|
|
194
|
+
const headers = {
|
|
195
|
+
'Content-Type': 'application/json',
|
|
196
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
197
|
+
};
|
|
198
|
+
let lastError = null;
|
|
199
|
+
for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) {
|
|
200
|
+
try {
|
|
201
|
+
const res = await this.fetchImpl(identifyEndpoint, {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers,
|
|
204
|
+
body,
|
|
205
|
+
});
|
|
206
|
+
if (res.ok) {
|
|
207
|
+
const data = (await res.json());
|
|
208
|
+
if (this.debug)
|
|
209
|
+
console.log(`[gurulu] identify success: canonical_id=${data.canonical_id}`);
|
|
210
|
+
return data;
|
|
211
|
+
}
|
|
212
|
+
if (res.status >= 400 && res.status < 500) {
|
|
213
|
+
const text = await safeText(res);
|
|
214
|
+
const err = new Error(`identify client_error ${res.status}: ${text}`);
|
|
215
|
+
this.onError?.(err);
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
lastError = new Error(`identify server_error ${res.status}`);
|
|
219
|
+
this.onError?.(lastError);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
if (err instanceof Error && err.message.startsWith('identify client_error')) {
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
226
|
+
this.onError?.(lastError);
|
|
227
|
+
}
|
|
228
|
+
if (attempt < RETRY_DELAYS_MS.length - 1) {
|
|
229
|
+
await sleep(RETRY_DELAYS_MS[attempt]);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
throw lastError ?? new Error('identify failed after retries');
|
|
233
|
+
}
|
|
140
234
|
/**
|
|
141
235
|
* POST all queued events in one batch. On 5xx / network error retries with
|
|
142
236
|
* exponential backoff (200ms, 600ms, 1800ms). On 4xx drops the batch and
|
|
@@ -171,12 +265,16 @@ class Gurulu {
|
|
|
171
265
|
};
|
|
172
266
|
let lastError = null;
|
|
173
267
|
for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) {
|
|
268
|
+
const controller = new AbortController();
|
|
269
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
174
270
|
try {
|
|
175
271
|
const res = await this.fetchImpl(this.endpoint, {
|
|
176
272
|
method: 'POST',
|
|
177
273
|
headers,
|
|
178
274
|
body,
|
|
275
|
+
signal: controller.signal,
|
|
179
276
|
});
|
|
277
|
+
clearTimeout(timeoutId);
|
|
180
278
|
if (res.ok) {
|
|
181
279
|
if (this.debug)
|
|
182
280
|
console.log(`[gurulu] flushed ${batch.length} events`);
|
|
@@ -200,8 +298,16 @@ class Gurulu {
|
|
|
200
298
|
this.onError?.(lastError);
|
|
201
299
|
}
|
|
202
300
|
catch (err) {
|
|
203
|
-
|
|
204
|
-
|
|
301
|
+
clearTimeout(timeoutId);
|
|
302
|
+
// Treat AbortError (timeout) as retryable network error
|
|
303
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
304
|
+
lastError = new Error(`timeout after ${this.timeout}ms`);
|
|
305
|
+
this.onError?.(lastError);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
309
|
+
this.onError?.(lastError);
|
|
310
|
+
}
|
|
205
311
|
}
|
|
206
312
|
// Not the last attempt — sleep with the fixed exponential schedule.
|
|
207
313
|
if (attempt < RETRY_DELAYS_MS.length - 1) {
|
|
@@ -223,6 +329,57 @@ class Gurulu {
|
|
|
223
329
|
console.error('[gurulu] no dead-letter callback configured; events lost');
|
|
224
330
|
}
|
|
225
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* Add a custom breadcrumb to the trail. Breadcrumbs are sent with error
|
|
334
|
+
* events to provide context about what happened before the error occurred.
|
|
335
|
+
*/
|
|
336
|
+
addBreadcrumb(category, message, data) {
|
|
337
|
+
this.breadcrumbs.push({
|
|
338
|
+
timestamp: new Date().toISOString(),
|
|
339
|
+
category,
|
|
340
|
+
message,
|
|
341
|
+
data,
|
|
342
|
+
});
|
|
343
|
+
if (this.breadcrumbs.length > this.maxBreadcrumbs) {
|
|
344
|
+
this.breadcrumbs = this.breadcrumbs.slice(-this.maxBreadcrumbs);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Capture an exception and send it as an error event.
|
|
349
|
+
* Includes any accumulated breadcrumbs for debugging context.
|
|
350
|
+
*/
|
|
351
|
+
captureException(error, context) {
|
|
352
|
+
const breadcrumbSnapshot = [...this.breadcrumbs];
|
|
353
|
+
this.track({
|
|
354
|
+
event_name: '$error',
|
|
355
|
+
user_id: context?.user_id || '__system__',
|
|
356
|
+
properties: {
|
|
357
|
+
error_message: error.message,
|
|
358
|
+
error_stack: error.stack || '',
|
|
359
|
+
error_type: error.name || 'Error',
|
|
360
|
+
breadcrumbs: breadcrumbSnapshot,
|
|
361
|
+
release: this.release,
|
|
362
|
+
sdk_version: SDK_VERSION,
|
|
363
|
+
event_source: 'server_sdk',
|
|
364
|
+
...context,
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Install global error handlers for uncaught exceptions and unhandled rejections.
|
|
370
|
+
* Call once at app startup.
|
|
371
|
+
*/
|
|
372
|
+
installGlobalHandlers() {
|
|
373
|
+
process.on('uncaughtException', (error) => {
|
|
374
|
+
this.captureException(error, { source: 'uncaughtException' });
|
|
375
|
+
// Flush before exit
|
|
376
|
+
this.flush().finally(() => process.exit(1));
|
|
377
|
+
});
|
|
378
|
+
process.on('unhandledRejection', (reason) => {
|
|
379
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
380
|
+
this.captureException(error, { source: 'unhandledRejection' });
|
|
381
|
+
});
|
|
382
|
+
}
|
|
226
383
|
/**
|
|
227
384
|
* Stop the periodic flush timer, detach process listeners, and perform a
|
|
228
385
|
* final flush. Safe to call multiple times.
|
|
@@ -253,3 +410,33 @@ async function safeText(res) {
|
|
|
253
410
|
function sleep(ms) {
|
|
254
411
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
255
412
|
}
|
|
413
|
+
/* ------------------------------------------------------------------ */
|
|
414
|
+
/* Singleton / default instance */
|
|
415
|
+
/* ------------------------------------------------------------------ */
|
|
416
|
+
let defaultInstance = null;
|
|
417
|
+
/**
|
|
418
|
+
* Set the default Gurulu instance used by the convenience `captureException`.
|
|
419
|
+
*/
|
|
420
|
+
function setDefaultInstance(instance) {
|
|
421
|
+
defaultInstance = instance;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Convenience function — captures an exception via the default instance.
|
|
425
|
+
* Call `setDefaultInstance()` first (or create a Gurulu client).
|
|
426
|
+
*/
|
|
427
|
+
function captureException(error, context) {
|
|
428
|
+
if (!defaultInstance) {
|
|
429
|
+
throw new Error('No default Gurulu instance. Call setDefaultInstance() first.');
|
|
430
|
+
}
|
|
431
|
+
defaultInstance.captureException(error, context);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Convenience function — adds a breadcrumb via the default instance.
|
|
435
|
+
* Call `setDefaultInstance()` first (or create a Gurulu client).
|
|
436
|
+
*/
|
|
437
|
+
function addBreadcrumb(category, message, data) {
|
|
438
|
+
if (!defaultInstance) {
|
|
439
|
+
throw new Error('No default Gurulu instance. Call setDefaultInstance() first.');
|
|
440
|
+
}
|
|
441
|
+
defaultInstance.addBreadcrumb(category, message, data);
|
|
442
|
+
}
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* Phase 10 W4.2: server SDK hardening adds batch+retry, deterministic
|
|
5
5
|
* idempotency keys, dead-letter callbacks, and business event emitters.
|
|
6
6
|
*/
|
|
7
|
-
export { Gurulu, createIdempotencyKey } from './client';
|
|
8
|
-
export type { GuruluClient } from './client';
|
|
7
|
+
export { Gurulu, createIdempotencyKey, captureException, addBreadcrumb, setDefaultInstance } from './client';
|
|
8
|
+
export type { GuruluClient, IdentifyParams, IdentifyResponse, Breadcrumb } from './client';
|
|
9
9
|
export type { GuruluClientConfig, DeadLetterCallback, ErrorCallback, } from './types';
|
|
10
10
|
export type { Envelope, ServerEvent, EventTier, EventSource, ConsentLevel, } from '@gurulu/shared-core';
|
|
11
|
+
export { expressErrorHandler, expressHandler, guruluFastifyPlugin, honoErrorHandler, } from './middleware';
|
|
11
12
|
export { userCreated, paymentSucceeded, subscriptionStarted, orderPlaced, leadCaptured, } from './business-events';
|
|
12
13
|
export type { BusinessEvent, UserCreatedArgs, PaymentSucceededArgs, SubscriptionStartedArgs, OrderPlacedArgs, LeadCapturedArgs, } from './business-events';
|
|
@@ -6,10 +6,19 @@
|
|
|
6
6
|
* idempotency keys, dead-letter callbacks, and business event emitters.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.leadCaptured = exports.orderPlaced = exports.subscriptionStarted = exports.paymentSucceeded = exports.userCreated = exports.createIdempotencyKey = exports.Gurulu = void 0;
|
|
9
|
+
exports.leadCaptured = exports.orderPlaced = exports.subscriptionStarted = exports.paymentSucceeded = exports.userCreated = exports.honoErrorHandler = exports.guruluFastifyPlugin = exports.expressHandler = exports.expressErrorHandler = exports.setDefaultInstance = exports.addBreadcrumb = exports.captureException = exports.createIdempotencyKey = exports.Gurulu = void 0;
|
|
10
10
|
var client_1 = require("./client");
|
|
11
11
|
Object.defineProperty(exports, "Gurulu", { enumerable: true, get: function () { return client_1.Gurulu; } });
|
|
12
12
|
Object.defineProperty(exports, "createIdempotencyKey", { enumerable: true, get: function () { return client_1.createIdempotencyKey; } });
|
|
13
|
+
Object.defineProperty(exports, "captureException", { enumerable: true, get: function () { return client_1.captureException; } });
|
|
14
|
+
Object.defineProperty(exports, "addBreadcrumb", { enumerable: true, get: function () { return client_1.addBreadcrumb; } });
|
|
15
|
+
Object.defineProperty(exports, "setDefaultInstance", { enumerable: true, get: function () { return client_1.setDefaultInstance; } });
|
|
16
|
+
// Framework error-handler middleware.
|
|
17
|
+
var middleware_1 = require("./middleware");
|
|
18
|
+
Object.defineProperty(exports, "expressErrorHandler", { enumerable: true, get: function () { return middleware_1.expressErrorHandler; } });
|
|
19
|
+
Object.defineProperty(exports, "expressHandler", { enumerable: true, get: function () { return middleware_1.expressHandler; } });
|
|
20
|
+
Object.defineProperty(exports, "guruluFastifyPlugin", { enumerable: true, get: function () { return middleware_1.guruluFastifyPlugin; } });
|
|
21
|
+
Object.defineProperty(exports, "honoErrorHandler", { enumerable: true, get: function () { return middleware_1.honoErrorHandler; } });
|
|
13
22
|
// Business event emitters (W4.2).
|
|
14
23
|
var business_events_1 = require("./business-events");
|
|
15
24
|
Object.defineProperty(exports, "userCreated", { enumerable: true, get: function () { return business_events_1.userCreated; } });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gurulu/node — framework error-handler middleware.
|
|
3
|
+
*
|
|
4
|
+
* Provides drop-in error handlers for Express, Fastify, and Hono.
|
|
5
|
+
* Each handler captures the exception via `client.captureException` and
|
|
6
|
+
* then delegates to the framework's default error-handling flow.
|
|
7
|
+
*/
|
|
8
|
+
import type { GuruluClient } from './client';
|
|
9
|
+
/**
|
|
10
|
+
* Express error-handling middleware.
|
|
11
|
+
* Place AFTER all routes: app.use(guruluErrorHandler(gurulu))
|
|
12
|
+
*/
|
|
13
|
+
export declare function expressErrorHandler(client: GuruluClient): (err: Error, req: any, res: any, next: any) => void;
|
|
14
|
+
/**
|
|
15
|
+
* Express request handler wrapper.
|
|
16
|
+
* Wraps async handlers to catch thrown errors:
|
|
17
|
+
* app.get('/x', guruluHandler(gurulu, handler))
|
|
18
|
+
*/
|
|
19
|
+
export declare function expressHandler(client: GuruluClient, handler: Function): (req: any, res: any, next: any) => Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Fastify error handler plugin.
|
|
22
|
+
* Register: fastify.register(guruluFastifyPlugin, { client: gurulu })
|
|
23
|
+
*/
|
|
24
|
+
export declare function guruluFastifyPlugin(fastify: any, opts: {
|
|
25
|
+
client: GuruluClient;
|
|
26
|
+
}, done: Function): void;
|
|
27
|
+
/**
|
|
28
|
+
* Hono error handler middleware.
|
|
29
|
+
* Usage: app.onError(guruluHonoErrorHandler(gurulu))
|
|
30
|
+
*/
|
|
31
|
+
export declare function honoErrorHandler(client: GuruluClient): (err: Error, c: any) => any;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @gurulu/node — framework error-handler middleware.
|
|
4
|
+
*
|
|
5
|
+
* Provides drop-in error handlers for Express, Fastify, and Hono.
|
|
6
|
+
* Each handler captures the exception via `client.captureException` and
|
|
7
|
+
* then delegates to the framework's default error-handling flow.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.expressErrorHandler = expressErrorHandler;
|
|
11
|
+
exports.expressHandler = expressHandler;
|
|
12
|
+
exports.guruluFastifyPlugin = guruluFastifyPlugin;
|
|
13
|
+
exports.honoErrorHandler = honoErrorHandler;
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Express
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
/**
|
|
18
|
+
* Express error-handling middleware.
|
|
19
|
+
* Place AFTER all routes: app.use(guruluErrorHandler(gurulu))
|
|
20
|
+
*/
|
|
21
|
+
function expressErrorHandler(client) {
|
|
22
|
+
return function guruluExpressErrorHandler(err, req, res, next) {
|
|
23
|
+
client.captureException(err, {
|
|
24
|
+
mechanism: 'express_middleware',
|
|
25
|
+
request: {
|
|
26
|
+
method: req.method,
|
|
27
|
+
url: req.originalUrl || req.url,
|
|
28
|
+
headers: sanitizeHeaders(req.headers),
|
|
29
|
+
query: req.query,
|
|
30
|
+
body: truncate(req.body),
|
|
31
|
+
ip: req.ip || req.connection?.remoteAddress,
|
|
32
|
+
},
|
|
33
|
+
user: req.user ? { id: req.user.id, email: req.user.email } : undefined,
|
|
34
|
+
});
|
|
35
|
+
next(err);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Express request handler wrapper.
|
|
40
|
+
* Wraps async handlers to catch thrown errors:
|
|
41
|
+
* app.get('/x', guruluHandler(gurulu, handler))
|
|
42
|
+
*/
|
|
43
|
+
function expressHandler(client, handler) {
|
|
44
|
+
return async function guruluWrappedHandler(req, res, next) {
|
|
45
|
+
try {
|
|
46
|
+
await handler(req, res, next);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
client.captureException(err, {
|
|
50
|
+
mechanism: 'express_handler_wrapper',
|
|
51
|
+
request: {
|
|
52
|
+
method: req.method,
|
|
53
|
+
url: req.originalUrl || req.url,
|
|
54
|
+
headers: sanitizeHeaders(req.headers),
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
next(err);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Fastify
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
/**
|
|
65
|
+
* Fastify error handler plugin.
|
|
66
|
+
* Register: fastify.register(guruluFastifyPlugin, { client: gurulu })
|
|
67
|
+
*/
|
|
68
|
+
function guruluFastifyPlugin(fastify, opts, done) {
|
|
69
|
+
fastify.setErrorHandler(function guruluFastifyErrorHandler(error, request, reply) {
|
|
70
|
+
opts.client.captureException(error, {
|
|
71
|
+
mechanism: 'fastify_error_handler',
|
|
72
|
+
request: {
|
|
73
|
+
method: request.method,
|
|
74
|
+
url: request.url,
|
|
75
|
+
headers: sanitizeHeaders(request.headers),
|
|
76
|
+
query: request.query,
|
|
77
|
+
body: truncate(request.body),
|
|
78
|
+
ip: request.ip,
|
|
79
|
+
},
|
|
80
|
+
user: request.user
|
|
81
|
+
? { id: request.user.id, email: request.user.email }
|
|
82
|
+
: undefined,
|
|
83
|
+
});
|
|
84
|
+
// Let Fastify's default handler send the response
|
|
85
|
+
reply.status(500).send({ error: 'Internal Server Error' });
|
|
86
|
+
});
|
|
87
|
+
done();
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Hono
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
/**
|
|
93
|
+
* Hono error handler middleware.
|
|
94
|
+
* Usage: app.onError(guruluHonoErrorHandler(gurulu))
|
|
95
|
+
*/
|
|
96
|
+
function honoErrorHandler(client) {
|
|
97
|
+
return function guruluHonoErrorHandler(err, c) {
|
|
98
|
+
client.captureException(err, {
|
|
99
|
+
mechanism: 'hono_error_handler',
|
|
100
|
+
request: {
|
|
101
|
+
method: c.req.method,
|
|
102
|
+
url: c.req.url,
|
|
103
|
+
headers: sanitizeHeaders(Object.fromEntries(c.req.raw.headers || [])),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
return c.json({ error: 'Internal Server Error' }, 500);
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Helpers
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
const SENSITIVE_HEADERS = new Set([
|
|
113
|
+
'authorization',
|
|
114
|
+
'cookie',
|
|
115
|
+
'set-cookie',
|
|
116
|
+
'x-api-key',
|
|
117
|
+
'x-auth-token',
|
|
118
|
+
]);
|
|
119
|
+
function sanitizeHeaders(headers) {
|
|
120
|
+
if (!headers || typeof headers !== 'object')
|
|
121
|
+
return {};
|
|
122
|
+
const sanitized = {};
|
|
123
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
124
|
+
const lower = key.toLowerCase();
|
|
125
|
+
sanitized[lower] = SENSITIVE_HEADERS.has(lower)
|
|
126
|
+
? '[Filtered]'
|
|
127
|
+
: String(value);
|
|
128
|
+
}
|
|
129
|
+
return sanitized;
|
|
130
|
+
}
|
|
131
|
+
function truncate(body, maxLen = 2048) {
|
|
132
|
+
if (body === undefined || body === null)
|
|
133
|
+
return undefined;
|
|
134
|
+
const str = typeof body === 'string' ? body : JSON.stringify(body);
|
|
135
|
+
if (!str)
|
|
136
|
+
return undefined;
|
|
137
|
+
return str.length > maxLen ? str.slice(0, maxLen) + '...' : str;
|
|
138
|
+
}
|
|
@@ -27,6 +27,8 @@ export type ErrorCallback = (err: Error) => void;
|
|
|
27
27
|
export interface GuruluClientConfig {
|
|
28
28
|
siteId: string;
|
|
29
29
|
apiKey: string;
|
|
30
|
+
/** Release version or git SHA for source map resolution. Falls back to GURULU_RELEASE env var. */
|
|
31
|
+
release?: string;
|
|
30
32
|
endpoint?: string;
|
|
31
33
|
/** Periodic flush interval in ms. Default 5000. */
|
|
32
34
|
flushInterval?: number;
|
package/package.json
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gurulu/node",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Gurulu.io server-side analytics SDK for Node.js",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"files": ["dist"],
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./middleware": {
|
|
15
|
+
"types": "./dist/middleware.d.ts",
|
|
16
|
+
"import": "./dist/middleware.js",
|
|
17
|
+
"require": "./dist/middleware.js"
|
|
18
|
+
},
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
8
21
|
"scripts": {
|
|
9
22
|
"build": "tsc",
|
|
10
23
|
"dev": "tsc --watch"
|