@gurulu/node 0.1.1 → 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
  }
package/dist/client.d.ts CHANGED
@@ -19,11 +19,46 @@
19
19
  */
20
20
  import type { ServerEvent } from '@gurulu/shared-core';
21
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
+ }
22
55
  export interface GuruluClient {
23
56
  track(event: ServerEvent): void;
57
+ identify(params: IdentifyParams): Promise<IdentifyResponse>;
24
58
  flush(): Promise<void>;
25
59
  shutdown(): Promise<void>;
26
60
  captureException(error: Error, context?: Record<string, unknown>): void;
61
+ addBreadcrumb(category: string, message: string, data?: Record<string, unknown>): void;
27
62
  installGlobalHandlers(): void;
28
63
  /** Current queue length — exposed for tests and observability. */
29
64
  readonly queueSize: number;
@@ -45,18 +80,32 @@ export declare class Gurulu implements GuruluClient {
45
80
  private readonly onDeadLetter?;
46
81
  private readonly fetchImpl;
47
82
  private readonly debug;
83
+ private readonly release;
48
84
  private queue;
85
+ private breadcrumbs;
86
+ private readonly maxBreadcrumbs;
49
87
  private flushTimer;
50
88
  private inFlight;
51
89
  private shutdownHandlers;
52
90
  constructor(config: GuruluClientConfig);
53
91
  get queueSize(): number;
92
+ private static readonly REVENUE_EVENTS;
54
93
  /**
55
94
  * Enqueue a server event. Auto-flushes synchronously when the queue reaches
56
95
  * `batchSize`. Each event is stamped with a timestamp if missing so the
57
96
  * idempotency key is stable across retries.
58
97
  */
59
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>;
60
109
  /**
61
110
  * POST all queued events in one batch. On 5xx / network error retries with
62
111
  * exponential backoff (200ms, 600ms, 1800ms). On 4xx drops the batch and
@@ -64,8 +113,14 @@ export declare class Gurulu implements GuruluClient {
64
113
  */
65
114
  flush(): Promise<void>;
66
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;
67
121
  /**
68
122
  * Capture an exception and send it as an error event.
123
+ * Includes any accumulated breadcrumbs for debugging context.
69
124
  */
70
125
  captureException(error: Error, context?: Record<string, unknown>): void;
71
126
  /**
@@ -88,3 +143,8 @@ export declare function setDefaultInstance(instance: Gurulu): void;
88
143
  * Call `setDefaultInstance()` first (or create a Gurulu client).
89
144
  */
90
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;
package/dist/client.js CHANGED
@@ -23,8 +23,10 @@ exports.Gurulu = void 0;
23
23
  exports.createIdempotencyKey = createIdempotencyKey;
24
24
  exports.setDefaultInstance = setDefaultInstance;
25
25
  exports.captureException = captureException;
26
+ exports.addBreadcrumb = addBreadcrumb;
26
27
  const crypto_1 = require("crypto");
27
28
  const DEFAULT_ENDPOINT = 'https://app.gurulu.io/api/ingest/v1/server';
29
+ const SDK_VERSION = 'node@0.1.0';
28
30
  const DEFAULT_FLUSH_INTERVAL = 5000;
29
31
  const DEFAULT_BATCH_SIZE = 50;
30
32
  const DEFAULT_TIMEOUT = 10000;
@@ -54,7 +56,10 @@ class Gurulu {
54
56
  onDeadLetter;
55
57
  fetchImpl;
56
58
  debug;
59
+ release;
57
60
  queue = [];
61
+ breadcrumbs = [];
62
+ maxBreadcrumbs = 50;
58
63
  flushTimer = null;
59
64
  inFlight = null;
60
65
  shutdownHandlers = [];
@@ -72,6 +77,7 @@ class Gurulu {
72
77
  this.onError = config.onError;
73
78
  this.onDeadLetter = config.onDeadLetter;
74
79
  this.debug = config.debug ?? false;
80
+ this.release = config.release || process.env.GURULU_RELEASE || '';
75
81
  const injected = config.fetchImpl;
76
82
  if (injected) {
77
83
  this.fetchImpl = injected;
@@ -111,6 +117,14 @@ class Gurulu {
111
117
  get queueSize() {
112
118
  return this.queue.length;
113
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
+ ]);
114
128
  /**
115
129
  * Enqueue a server event. Auto-flushes synchronously when the queue reaches
116
130
  * `batchSize`. Each event is stamped with a timestamp if missing so the
@@ -127,6 +141,19 @@ class Gurulu {
127
141
  console.warn('[gurulu] user_id is required for server events');
128
142
  return;
129
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
+ }
130
157
  const stamped = {
131
158
  ...event,
132
159
  timestamp: event.timestamp ?? new Date().toISOString(),
@@ -139,6 +166,71 @@ class Gurulu {
139
166
  void this.flush();
140
167
  }
141
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
+ }
142
234
  /**
143
235
  * POST all queued events in one batch. On 5xx / network error retries with
144
236
  * exponential backoff (200ms, 600ms, 1800ms). On 4xx drops the batch and
@@ -173,12 +265,16 @@ class Gurulu {
173
265
  };
174
266
  let lastError = null;
175
267
  for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) {
268
+ const controller = new AbortController();
269
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
176
270
  try {
177
271
  const res = await this.fetchImpl(this.endpoint, {
178
272
  method: 'POST',
179
273
  headers,
180
274
  body,
275
+ signal: controller.signal,
181
276
  });
277
+ clearTimeout(timeoutId);
182
278
  if (res.ok) {
183
279
  if (this.debug)
184
280
  console.log(`[gurulu] flushed ${batch.length} events`);
@@ -202,8 +298,16 @@ class Gurulu {
202
298
  this.onError?.(lastError);
203
299
  }
204
300
  catch (err) {
205
- lastError = err instanceof Error ? err : new Error(String(err));
206
- 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
+ }
207
311
  }
208
312
  // Not the last attempt — sleep with the fixed exponential schedule.
209
313
  if (attempt < RETRY_DELAYS_MS.length - 1) {
@@ -225,10 +329,27 @@ class Gurulu {
225
329
  console.error('[gurulu] no dead-letter callback configured; events lost');
226
330
  }
227
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
+ }
228
347
  /**
229
348
  * Capture an exception and send it as an error event.
349
+ * Includes any accumulated breadcrumbs for debugging context.
230
350
  */
231
351
  captureException(error, context) {
352
+ const breadcrumbSnapshot = [...this.breadcrumbs];
232
353
  this.track({
233
354
  event_name: '$error',
234
355
  user_id: context?.user_id || '__system__',
@@ -236,6 +357,10 @@ class Gurulu {
236
357
  error_message: error.message,
237
358
  error_stack: error.stack || '',
238
359
  error_type: error.name || 'Error',
360
+ breadcrumbs: breadcrumbSnapshot,
361
+ release: this.release,
362
+ sdk_version: SDK_VERSION,
363
+ event_source: 'server_sdk',
239
364
  ...context,
240
365
  },
241
366
  });
@@ -305,3 +430,13 @@ function captureException(error, context) {
305
430
  }
306
431
  defaultInstance.captureException(error, context);
307
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
+ }
package/dist/index.d.ts CHANGED
@@ -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, captureException, setDefaultInstance } 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';
package/dist/index.js CHANGED
@@ -6,12 +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.setDefaultInstance = exports.captureException = 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
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; } });
14
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; } });
15
22
  // Business event emitters (W4.2).
16
23
  var business_events_1 = require("./business-events");
17
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
+ }
package/dist/types.d.ts CHANGED
@@ -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.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"