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