@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.
@@ -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?: number;
53
- currency?: string;
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
  }
@@ -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
- lastError = err instanceof Error ? err : new Error(String(err));
204
- this.onError?.(lastError);
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.0",
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"