@deeptracer/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -21,6 +21,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  Logger: () => Logger,
24
+ SDK_NAME: () => SDK_NAME,
25
+ SDK_VERSION: () => SDK_VERSION,
24
26
  createLogger: () => createLogger
25
27
  });
26
28
  module.exports = __toCommonJS(index_exports);
@@ -52,118 +54,167 @@ var Batcher = class {
52
54
  startTimer() {
53
55
  this.timer = setInterval(() => this.flush(), this.flushIntervalMs);
54
56
  }
55
- destroy() {
57
+ async destroy() {
56
58
  if (this.timer) clearInterval(this.timer);
59
+ this.timer = null;
57
60
  this.flush();
58
61
  }
59
62
  };
60
63
 
64
+ // src/version.ts
65
+ var SDK_VERSION = "0.3.0";
66
+ var SDK_NAME = "core";
67
+
61
68
  // src/transport.ts
62
69
  var Transport = class {
63
70
  constructor(config) {
64
71
  this.config = config;
65
72
  }
66
- async sendLogs(logs) {
67
- try {
68
- const res = await fetch(`${this.config.endpoint}/ingest/logs`, {
69
- method: "POST",
70
- headers: {
71
- "Content-Type": "application/json",
72
- Authorization: `Bearer ${this.config.apiKey}`
73
- },
74
- body: JSON.stringify({
75
- product: this.config.product,
76
- service: this.config.service,
77
- environment: this.config.environment,
78
- logs
79
- })
80
- });
81
- if (!res.ok) {
73
+ inFlightRequests = /* @__PURE__ */ new Set();
74
+ /**
75
+ * Send a request with automatic retry and exponential backoff.
76
+ * Retries up to `maxRetries` times on network errors and 5xx responses.
77
+ * Does NOT retry on 4xx (client errors — bad payload, auth failure, etc.).
78
+ */
79
+ async sendWithRetry(url, body, label, maxRetries = 3) {
80
+ const baseDelays = [1e3, 2e3, 4e3];
81
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
82
+ try {
83
+ const res = await fetch(url, {
84
+ method: "POST",
85
+ headers: {
86
+ "Content-Type": "application/json",
87
+ Authorization: `Bearer ${this.config.apiKey}`,
88
+ "x-deeptracer-sdk": `${SDK_NAME}/${SDK_VERSION}`
89
+ },
90
+ body: JSON.stringify(body)
91
+ });
92
+ if (res.ok) return;
93
+ if (res.status >= 400 && res.status < 500) {
94
+ console.warn(
95
+ `[@deeptracer/core] Failed to send ${label}: ${res.status} ${res.statusText}`
96
+ );
97
+ return;
98
+ }
99
+ if (attempt < maxRetries) {
100
+ await this.sleep(this.jitter(baseDelays[attempt]));
101
+ continue;
102
+ }
82
103
  console.warn(
83
- `[@deeptracer/core] Failed to send logs: ${res.status} ${res.statusText}`
104
+ `[@deeptracer/core] Failed to send ${label}: ${res.status} ${res.statusText} (exhausted ${maxRetries} retries)`
84
105
  );
85
- }
86
- } catch {
87
- console.warn(
88
- "[@deeptracer/core] Failed to send logs, falling back to console"
89
- );
90
- }
91
- }
92
- async sendError(error) {
93
- try {
94
- const res = await fetch(`${this.config.endpoint}/ingest/errors`, {
95
- method: "POST",
96
- headers: {
97
- "Content-Type": "application/json",
98
- Authorization: `Bearer ${this.config.apiKey}`
99
- },
100
- body: JSON.stringify({
101
- ...error,
102
- product: this.config.product,
103
- service: this.config.service,
104
- environment: this.config.environment
105
- })
106
- });
107
- if (!res.ok) {
106
+ } catch {
107
+ if (attempt < maxRetries) {
108
+ await this.sleep(this.jitter(baseDelays[attempt]));
109
+ continue;
110
+ }
108
111
  console.warn(
109
- `[@deeptracer/core] Failed to send error: ${res.status} ${res.statusText}`
112
+ `[@deeptracer/core] Failed to send ${label} (exhausted ${maxRetries} retries)`
110
113
  );
111
114
  }
112
- } catch {
113
- console.warn("[@deeptracer/core] Failed to send error report");
114
- console.error(error.error_message);
115
115
  }
116
116
  }
117
+ /** Add +/- 20% jitter to a delay to prevent thundering herd. */
118
+ jitter(ms) {
119
+ const factor = 0.8 + Math.random() * 0.4;
120
+ return Math.round(ms * factor);
121
+ }
122
+ sleep(ms) {
123
+ return new Promise((resolve) => setTimeout(resolve, ms));
124
+ }
125
+ /** Track an in-flight request and remove it when done. */
126
+ track(promise) {
127
+ this.inFlightRequests.add(promise);
128
+ promise.finally(() => this.inFlightRequests.delete(promise));
129
+ }
130
+ async sendLogs(logs) {
131
+ const p = this.sendWithRetry(
132
+ `${this.config.endpoint}/ingest/logs`,
133
+ {
134
+ product: this.config.product,
135
+ service: this.config.service,
136
+ environment: this.config.environment,
137
+ logs
138
+ },
139
+ "logs"
140
+ );
141
+ this.track(p);
142
+ return p;
143
+ }
144
+ async sendError(error) {
145
+ const p = this.sendWithRetry(
146
+ `${this.config.endpoint}/ingest/errors`,
147
+ {
148
+ ...error,
149
+ product: this.config.product,
150
+ service: this.config.service,
151
+ environment: this.config.environment
152
+ },
153
+ "error"
154
+ );
155
+ this.track(p);
156
+ return p;
157
+ }
117
158
  async sendTrace(span) {
118
- try {
119
- const res = await fetch(`${this.config.endpoint}/ingest/traces`, {
120
- method: "POST",
121
- headers: {
122
- "Content-Type": "application/json",
123
- Authorization: `Bearer ${this.config.apiKey}`
124
- },
125
- body: JSON.stringify({
126
- ...span,
127
- product: this.config.product,
128
- service: this.config.service,
129
- environment: this.config.environment
130
- })
131
- });
132
- if (!res.ok) {
133
- console.warn(
134
- `[@deeptracer/core] Failed to send trace: ${res.status} ${res.statusText}`
135
- );
136
- }
137
- } catch {
138
- console.warn("[@deeptracer/core] Failed to send trace span");
139
- }
159
+ const p = this.sendWithRetry(
160
+ `${this.config.endpoint}/ingest/traces`,
161
+ {
162
+ ...span,
163
+ product: this.config.product,
164
+ service: this.config.service,
165
+ environment: this.config.environment
166
+ },
167
+ "trace"
168
+ );
169
+ this.track(p);
170
+ return p;
140
171
  }
141
172
  async sendLLMUsage(report) {
142
- try {
143
- const res = await fetch(`${this.config.endpoint}/ingest/llm`, {
144
- method: "POST",
145
- headers: {
146
- "Content-Type": "application/json",
147
- Authorization: `Bearer ${this.config.apiKey}`
148
- },
149
- body: JSON.stringify({
150
- ...report,
151
- product: this.config.product,
152
- service: this.config.service,
153
- environment: this.config.environment
154
- })
155
- });
156
- if (!res.ok) {
157
- console.warn(
158
- `[@deeptracer/core] Failed to send LLM usage: ${res.status} ${res.statusText}`
159
- );
160
- }
161
- } catch {
162
- console.warn("[@deeptracer/core] Failed to send LLM usage report");
163
- }
173
+ const p = this.sendWithRetry(
174
+ `${this.config.endpoint}/ingest/llm`,
175
+ {
176
+ ...report,
177
+ product: this.config.product,
178
+ service: this.config.service,
179
+ environment: this.config.environment
180
+ },
181
+ "LLM usage"
182
+ );
183
+ this.track(p);
184
+ return p;
185
+ }
186
+ /**
187
+ * Wait for all in-flight requests to complete, with a timeout.
188
+ * Used by `logger.destroy()` to ensure data is sent before process exit.
189
+ *
190
+ * @param timeoutMs - Maximum time to wait (default: 2000ms)
191
+ */
192
+ async drain(timeoutMs = 2e3) {
193
+ if (this.inFlightRequests.size === 0) return;
194
+ const allDone = Promise.all(this.inFlightRequests).then(() => {
195
+ });
196
+ const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs));
197
+ await Promise.race([allDone, timeout]);
164
198
  }
165
199
  };
166
200
 
201
+ // src/state.ts
202
+ function createLoggerState(maxBreadcrumbs) {
203
+ return {
204
+ user: null,
205
+ tags: {},
206
+ contexts: {},
207
+ breadcrumbs: [],
208
+ maxBreadcrumbs
209
+ };
210
+ }
211
+ function addBreadcrumb(state, breadcrumb) {
212
+ state.breadcrumbs.push(breadcrumb);
213
+ if (state.breadcrumbs.length > state.maxBreadcrumbs) {
214
+ state.breadcrumbs.shift();
215
+ }
216
+ }
217
+
167
218
  // src/logger.ts
168
219
  function generateId() {
169
220
  const bytes = new Uint8Array(8);
@@ -182,11 +233,13 @@ var Logger = class _Logger {
182
233
  transport;
183
234
  contextName;
184
235
  config;
236
+ state;
185
237
  requestMeta;
186
- constructor(config, contextName, requestMeta) {
238
+ constructor(config, contextName, requestMeta, state) {
187
239
  this.config = config;
188
240
  this.contextName = contextName;
189
241
  this.requestMeta = requestMeta;
242
+ this.state = state ?? createLoggerState(config.maxBreadcrumbs ?? 20);
190
243
  this.transport = new Transport(config);
191
244
  this.batcher = new Batcher(
192
245
  { batchSize: config.batchSize, flushIntervalMs: config.flushIntervalMs },
@@ -195,6 +248,111 @@ var Logger = class _Logger {
195
248
  }
196
249
  );
197
250
  }
251
+ // ---------------------------------------------------------------------------
252
+ // User context
253
+ // ---------------------------------------------------------------------------
254
+ /**
255
+ * Set the current user context. Attached to all subsequent logs, errors, spans, and LLM reports.
256
+ * Shared across all child loggers (withContext, forRequest).
257
+ *
258
+ * @example
259
+ * ```ts
260
+ * logger.setUser({ id: "u_123", email: "user@example.com", plan: "pro" })
261
+ * ```
262
+ */
263
+ setUser(user) {
264
+ this.state.user = user;
265
+ }
266
+ /** Clear the current user context. */
267
+ clearUser() {
268
+ this.state.user = null;
269
+ }
270
+ // ---------------------------------------------------------------------------
271
+ // Tags & Context
272
+ // ---------------------------------------------------------------------------
273
+ /**
274
+ * Set global tags (flat string key-values). Merged into all events' metadata as `_tags`.
275
+ * Tags are indexed and searchable on the dashboard.
276
+ *
277
+ * @example
278
+ * ```ts
279
+ * logger.setTags({ release: "1.2.3", region: "us-east-1" })
280
+ * ```
281
+ */
282
+ setTags(tags) {
283
+ Object.assign(this.state.tags, tags);
284
+ }
285
+ /** Clear all global tags. */
286
+ clearTags() {
287
+ this.state.tags = {};
288
+ }
289
+ /**
290
+ * Set a named context block. Merged into metadata as `_contexts.{name}`.
291
+ * Contexts are structured objects attached for reference (not necessarily indexed).
292
+ *
293
+ * @example
294
+ * ```ts
295
+ * logger.setContext("server", { hostname: "web-3", memory: "4gb" })
296
+ * ```
297
+ */
298
+ setContext(name, data) {
299
+ this.state.contexts[name] = data;
300
+ }
301
+ /** Clear a specific context block, or all contexts if no name is given. */
302
+ clearContext(name) {
303
+ if (name) {
304
+ delete this.state.contexts[name];
305
+ } else {
306
+ this.state.contexts = {};
307
+ }
308
+ }
309
+ // ---------------------------------------------------------------------------
310
+ // Breadcrumbs
311
+ // ---------------------------------------------------------------------------
312
+ /**
313
+ * Manually add a breadcrumb to the trail.
314
+ * Breadcrumbs are also recorded automatically for every log, span, and error.
315
+ *
316
+ * @example
317
+ * ```ts
318
+ * logger.addBreadcrumb({ type: "http", message: "POST /api/checkout" })
319
+ * ```
320
+ */
321
+ addBreadcrumb(breadcrumb) {
322
+ addBreadcrumb(this.state, {
323
+ type: breadcrumb.type,
324
+ message: breadcrumb.message,
325
+ timestamp: breadcrumb.timestamp || (/* @__PURE__ */ new Date()).toISOString()
326
+ });
327
+ }
328
+ // ---------------------------------------------------------------------------
329
+ // Internal helpers
330
+ // ---------------------------------------------------------------------------
331
+ /** Merge user, tags, and contexts from shared state into event metadata. */
332
+ mergeStateMetadata(metadata) {
333
+ const { user, tags, contexts } = this.state;
334
+ const hasUser = user !== null;
335
+ const hasTags = Object.keys(tags).length > 0;
336
+ const hasContexts = Object.keys(contexts).length > 0;
337
+ if (!hasUser && !hasTags && !hasContexts && !metadata) return void 0;
338
+ const result = { ...metadata };
339
+ if (hasUser) result.user = user;
340
+ if (hasTags) result._tags = { ...tags };
341
+ if (hasContexts) result._contexts = { ...contexts };
342
+ return result;
343
+ }
344
+ /** Run the beforeSend hook. If the hook throws, pass the event through. */
345
+ applyBeforeSend(event) {
346
+ if (!this.config.beforeSend) return event;
347
+ try {
348
+ return this.config.beforeSend(event);
349
+ } catch {
350
+ return event;
351
+ }
352
+ }
353
+ // ---------------------------------------------------------------------------
354
+ // Logging
355
+ // ---------------------------------------------------------------------------
198
356
  log(level, message, dataOrError, maybeError) {
199
357
  let metadata;
200
358
  let error;
@@ -216,6 +374,7 @@ var Logger = class _Logger {
216
374
  }
217
375
  };
218
376
  }
377
+ metadata = this.mergeStateMetadata(metadata);
219
378
  const entry = {
220
379
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
221
380
  level,
@@ -227,17 +386,25 @@ var Logger = class _Logger {
227
386
  request_id: this.requestMeta?.request_id,
228
387
  vercel_id: this.requestMeta?.vercel_id
229
388
  };
389
+ const hookResult = this.applyBeforeSend({ type: "log", data: entry });
390
+ if (hookResult === null) return;
391
+ const finalEntry = hookResult.data;
230
392
  if (this.config.debug) {
231
393
  const prefix = this.contextName ? `[${this.contextName}]` : "";
232
394
  const lvl = level.toUpperCase().padEnd(5);
233
395
  const consoleFn = level === "error" ? _originalConsole.error : level === "warn" ? _originalConsole.warn : level === "debug" ? _originalConsole.debug : _originalConsole.log;
234
- if (metadata) {
235
- consoleFn(`${lvl} ${prefix} ${message}`, metadata);
396
+ if (finalEntry.metadata) {
397
+ consoleFn(`${lvl} ${prefix} ${message}`, finalEntry.metadata);
236
398
  } else {
237
399
  consoleFn(`${lvl} ${prefix} ${message}`);
238
400
  }
239
401
  }
240
- this.batcher.add(entry);
402
+ addBreadcrumb(this.state, {
403
+ type: "log",
404
+ message: `[${level}] ${message}`,
405
+ timestamp: entry.timestamp
406
+ });
407
+ this.batcher.add(finalEntry);
241
408
  }
242
409
  /** Log a debug message. */
243
410
  debug(message, dataOrError, error) {
@@ -255,11 +422,14 @@ var Logger = class _Logger {
255
422
  error(message, dataOrError, error) {
256
423
  this.log("error", message, dataOrError, error);
257
424
  }
258
- /** Create a context-scoped logger. All logs include the context name. */
425
+ // ---------------------------------------------------------------------------
426
+ // Child loggers
427
+ // ---------------------------------------------------------------------------
428
+ /** Create a context-scoped logger. All logs include the context name. Shares state with parent. */
259
429
  withContext(name) {
260
- return new _Logger(this.config, name, this.requestMeta);
430
+ return new _Logger(this.config, name, this.requestMeta, this.state);
261
431
  }
262
- /** Create a request-scoped logger that extracts trace context from headers. */
432
+ /** Create a request-scoped logger that extracts trace context from headers. Shares state with parent. */
263
433
  forRequest(request) {
264
434
  const vercelId = request.headers.get("x-vercel-id") || void 0;
265
435
  const requestId = request.headers.get("x-request-id") || void 0;
@@ -270,25 +440,46 @@ var Logger = class _Logger {
270
440
  span_id: spanId,
271
441
  request_id: requestId || (vercelId ? vercelId.split("::").pop() : void 0),
272
442
  vercel_id: vercelId
273
- });
443
+ }, this.state);
274
444
  }
275
- /** Capture and report an error immediately (not batched). */
445
+ // ---------------------------------------------------------------------------
446
+ // Error capture
447
+ // ---------------------------------------------------------------------------
448
+ /**
449
+ * Capture and report an error immediately (not batched).
450
+ * Automatically attaches breadcrumbs from the buffer and user context.
451
+ */
276
452
  captureError(error, context) {
277
453
  const err = error instanceof Error ? error : new Error(String(error));
454
+ addBreadcrumb(this.state, {
455
+ type: "error",
456
+ message: err.message,
457
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
458
+ });
459
+ const enrichedContext = { ...context?.context };
460
+ if (this.state.user) enrichedContext.user = this.state.user;
461
+ if (Object.keys(this.state.tags).length > 0) enrichedContext._tags = { ...this.state.tags };
462
+ if (Object.keys(this.state.contexts).length > 0) enrichedContext._contexts = { ...this.state.contexts };
278
463
  const report = {
279
464
  error_message: err.message,
280
465
  stack_trace: err.stack || "",
281
466
  severity: context?.severity || "medium",
282
- context: context?.context,
467
+ context: Object.keys(enrichedContext).length > 0 ? enrichedContext : void 0,
283
468
  trace_id: this.requestMeta?.trace_id,
284
- user_id: context?.userId,
285
- breadcrumbs: context?.breadcrumbs
469
+ user_id: context?.userId || this.state.user?.id,
470
+ breadcrumbs: context?.breadcrumbs || [...this.state.breadcrumbs]
286
471
  };
287
- this.transport.sendError(report);
472
+ const hookResult = this.applyBeforeSend({ type: "error", data: report });
473
+ if (hookResult === null) return;
474
+ this.transport.sendError(hookResult.data);
288
475
  }
476
+ // ---------------------------------------------------------------------------
477
+ // LLM usage
478
+ // ---------------------------------------------------------------------------
289
479
  /** Track LLM usage. Sends to /ingest/llm and logs for visibility. */
290
480
  llmUsage(report) {
291
- this.transport.sendLLMUsage({
481
+ const metadata = this.mergeStateMetadata(report.metadata);
482
+ const payload = {
292
483
  model: report.model,
293
484
  provider: report.provider,
294
485
  operation: report.operation,
@@ -296,8 +487,11 @@ var Logger = class _Logger {
296
487
  output_tokens: report.outputTokens,
297
488
  cost_usd: report.costUsd || 0,
298
489
  latency_ms: report.latencyMs,
299
- metadata: report.metadata
300
- });
490
+ metadata
491
+ };
492
+ const hookResult = this.applyBeforeSend({ type: "llm", data: report });
493
+ if (hookResult === null) return;
494
+ this.transport.sendLLMUsage(payload);
301
495
  this.log("info", `LLM call: ${report.model} (${report.operation})`, {
302
496
  llm_usage: {
303
497
  model: report.model,
@@ -309,6 +503,9 @@ var Logger = class _Logger {
309
503
  }
310
504
  });
311
505
  }
506
+ // ---------------------------------------------------------------------------
507
+ // Tracing
508
+ // ---------------------------------------------------------------------------
312
509
  /** Start a span with automatic lifecycle (callback-based, recommended). */
313
510
  startSpan(operation, fn) {
314
511
  const inactive = this.startInactiveSpan(operation);
@@ -349,6 +546,11 @@ var Logger = class _Logger {
349
546
  const startTime = (/* @__PURE__ */ new Date()).toISOString();
350
547
  const startMs = Date.now();
351
548
  const childMeta = { ...this.requestMeta, trace_id: traceId, span_id: spanId };
549
+ addBreadcrumb(this.state, {
550
+ type: "function",
551
+ message: operation,
552
+ timestamp: startTime
553
+ });
352
554
  const span = {
353
555
  traceId,
354
556
  spanId,
@@ -364,16 +566,18 @@ var Logger = class _Logger {
364
566
  start_time: startTime,
365
567
  duration_ms: durationMs,
366
568
  status: options?.status || "ok",
367
- metadata: options?.metadata
569
+ metadata: this.mergeStateMetadata(options?.metadata)
368
570
  };
369
- this.transport.sendTrace(spanData);
571
+ const hookResult = this.applyBeforeSend({ type: "trace", data: spanData });
572
+ if (hookResult === null) return;
573
+ this.transport.sendTrace(hookResult.data);
370
574
  },
371
575
  startSpan: (childOp, fn) => {
372
- const childLogger = new _Logger(this.config, this.contextName, childMeta);
576
+ const childLogger = new _Logger(this.config, this.contextName, childMeta, this.state);
373
577
  return childLogger.startSpan(childOp, fn);
374
578
  },
375
579
  startInactiveSpan: (childOp) => {
376
- const childLogger = new _Logger(this.config, this.contextName, childMeta);
580
+ const childLogger = new _Logger(this.config, this.contextName, childMeta, this.state);
377
581
  return childLogger.startInactiveSpan(childOp);
378
582
  },
379
583
  getHeaders: () => ({
@@ -389,13 +593,28 @@ var Logger = class _Logger {
389
593
  return this.startSpan(operation, () => fn(...args));
390
594
  };
391
595
  }
596
+ // ---------------------------------------------------------------------------
597
+ // Lifecycle
598
+ // ---------------------------------------------------------------------------
392
599
  /** Immediately flush all batched log entries. */
393
600
  flush() {
394
601
  this.batcher.flush();
395
602
  }
396
- /** Stop the batch timer and flush remaining logs. */
397
- destroy() {
398
- this.batcher.destroy();
603
+ /**
604
+ * Stop the batch timer, flush remaining logs, and wait for in-flight requests.
605
+ *
606
+ * @param timeoutMs - Max time to wait for in-flight requests (default: 2000ms)
607
+ * @returns Promise that resolves when all data is sent or timeout is reached
608
+ *
609
+ * @example
610
+ * ```ts
611
+ * await logger.destroy()
612
+ * process.exit(0) // safe — data is confirmed sent
613
+ * ```
614
+ */
615
+ async destroy(timeoutMs) {
616
+ await this.batcher.destroy();
617
+ await this.transport.drain(timeoutMs);
399
618
  }
400
619
  };
401
620
  function createLogger(config) {
@@ -404,5 +623,7 @@ function createLogger(config) {
404
623
  // Annotate the CommonJS export names for ESM import in node:
405
624
  0 && (module.exports = {
406
625
  Logger,
626
+ SDK_NAME,
627
+ SDK_VERSION,
407
628
  createLogger
408
629
  });