@onebun/trace 0.2.1 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/trace",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "OpenTelemetry-compatible tracing for OneBun framework - distributed tracing support",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -38,9 +38,13 @@
38
38
  "dev": "bun run --watch src/index.ts"
39
39
  },
40
40
  "dependencies": {
41
- "effect": "^3.13.10",
41
+ "@onebun/requests": "^0.2.1",
42
42
  "@opentelemetry/api": "^1.8.0",
43
- "@onebun/requests": "^0.2.1"
43
+ "@opentelemetry/core": "^2.6.1",
44
+ "@opentelemetry/resources": "^2.6.1",
45
+ "@opentelemetry/sdk-trace-base": "^2.6.1",
46
+ "@opentelemetry/semantic-conventions": "^1.40.0",
47
+ "effect": "^3.13.10"
44
48
  },
45
49
  "devDependencies": {
46
50
  "bun-types": "^1.3.8"
@@ -0,0 +1,343 @@
1
+ import { SpanStatusCode as OtelSpanStatusCode, trace as otelTrace } from '@opentelemetry/api';
2
+
3
+ import { type SpanAttributeEntry, SPAN_ATTRIBUTES } from './middleware.js';
4
+
5
+ /**
6
+ * Symbols for trace metadata on classes and methods
7
+ */
8
+ export const NO_TRACE = Symbol.for('onebun:noTrace');
9
+ export const TRACE_ALL = Symbol.for('onebun:traceAll');
10
+ export const ALREADY_TRACED = Symbol.for('onebun:traced');
11
+
12
+ /**
13
+ * Filter options for auto-tracing
14
+ */
15
+ export interface TraceFilterOptions {
16
+ asyncOnly?: boolean;
17
+ excludeMethods?: string[];
18
+ includeClasses?: string[];
19
+ excludeClasses?: string[];
20
+ }
21
+
22
+ /**
23
+ * Built-in methods that should never be auto-traced.
24
+ * Includes framework base class methods, lifecycle hooks, and internals.
25
+ */
26
+ const EXCLUDED_METHODS = new Set([
27
+ 'constructor',
28
+ // BaseService
29
+ 'initializeService',
30
+ 'runEffect',
31
+ 'formatError',
32
+ // BaseController
33
+ 'initializeController',
34
+ 'getService',
35
+ 'setService',
36
+ 'isJson',
37
+ 'parseJson',
38
+ 'success',
39
+ 'error',
40
+ 'json',
41
+ 'text',
42
+ 'sse',
43
+ // Lifecycle hooks
44
+ 'onModuleInit',
45
+ 'onApplicationInit',
46
+ 'onModuleDestroy',
47
+ 'beforeApplicationDestroy',
48
+ 'onApplicationDestroy',
49
+ 'onQueueReady',
50
+ // Middleware
51
+ 'use',
52
+ 'configureMiddleware',
53
+ // WebSocket
54
+ '_initializeBase',
55
+ 'afterInit',
56
+ 'handleConnection',
57
+ 'handleDisconnect',
58
+ // BaseService/Controller span getter
59
+ 'span',
60
+ ]);
61
+
62
+ /**
63
+ * Simple glob matching: supports `*` as wildcard.
64
+ * `*Service` matches `UserService`, `OrderService`.
65
+ * `User*` matches `UserService`, `UserController`.
66
+ * `*` matches everything.
67
+ */
68
+ function matchGlob(pattern: string, name: string): boolean {
69
+ if (pattern === '*') {
70
+ return true;
71
+ }
72
+
73
+ if (pattern.startsWith('*') && pattern.endsWith('*')) {
74
+ return name.includes(pattern.slice(1, -1));
75
+ }
76
+
77
+ if (pattern.startsWith('*')) {
78
+ return name.endsWith(pattern.slice(1));
79
+ }
80
+
81
+ if (pattern.endsWith('*')) {
82
+ return name.startsWith(pattern.slice(0, -1));
83
+ }
84
+
85
+ return name === pattern;
86
+ }
87
+
88
+ /**
89
+ * Check if a class name matches the glob filter.
90
+ */
91
+ function matchesClassFilter(
92
+ className: string,
93
+ filter?: TraceFilterOptions,
94
+ ): boolean {
95
+ if (!filter) {
96
+ return true;
97
+ }
98
+
99
+ // If includeClasses is set, class must match at least one pattern
100
+ if (filter.includeClasses && filter.includeClasses.length > 0) {
101
+ if (!filter.includeClasses.some((pattern) => matchGlob(pattern, className))) {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ // If excludeClasses is set, class must NOT match any pattern
107
+ if (filter.excludeClasses && filter.excludeClasses.length > 0) {
108
+ if (filter.excludeClasses.some((pattern) => matchGlob(pattern, className))) {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ return true;
114
+ }
115
+
116
+ /**
117
+ * Check if a method is async (its constructor is AsyncFunction).
118
+ */
119
+ function isAsyncFunction(fn: unknown): boolean {
120
+ return typeof fn === 'function' && fn.constructor.name === 'AsyncFunction';
121
+ }
122
+
123
+ /**
124
+ * Wrap a method with a span. Same pattern as @Traced() but applied at runtime.
125
+ */
126
+ function wrapMethodWithSpan(
127
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
+ prototype: any,
129
+ methodName: string,
130
+ className: string,
131
+ ): void {
132
+ const original = prototype[methodName];
133
+ const spanName = `${className}.${methodName}`;
134
+
135
+ const wrapped = async function (
136
+ this: unknown,
137
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
138
+ ...args: any[]
139
+ ) {
140
+ const tracer = otelTrace.getTracer('@onebun/trace');
141
+
142
+ return await tracer.startActiveSpan(spanName, async (activeSpan) => {
143
+ // Apply @SpanAttribute metadata if present
144
+ const meta = prototype[SPAN_ATTRIBUTES]?.[methodName] as
145
+ | SpanAttributeEntry[]
146
+ | undefined;
147
+ if (meta) {
148
+ for (const { paramIndex, attrName } of meta) {
149
+ const value = args[paramIndex];
150
+ if (value !== undefined && value !== null) {
151
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
152
+ activeSpan.setAttribute(attrName, value);
153
+ } else {
154
+ activeSpan.setAttribute(attrName, JSON.stringify(value));
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ try {
161
+ const result = await original.apply(this, args);
162
+ activeSpan.setStatus({ code: OtelSpanStatusCode.OK });
163
+
164
+ return result;
165
+ } catch (error) {
166
+ const err = error instanceof Error ? error : new Error(String(error));
167
+ activeSpan.setStatus({ code: OtelSpanStatusCode.ERROR, message: err.message });
168
+ activeSpan.recordException(err);
169
+ throw error;
170
+ } finally {
171
+ activeSpan.end();
172
+ }
173
+ });
174
+ };
175
+
176
+ // Mark as auto-traced to prevent double-wrapping
177
+ (wrapped as unknown as Record<symbol, boolean>)[ALREADY_TRACED] = true;
178
+ prototype[methodName] = wrapped;
179
+ }
180
+
181
+ /**
182
+ * Apply automatic tracing to all eligible methods on an instance.
183
+ *
184
+ * Walks the prototype chain (stopping at Object.prototype) and wraps
185
+ * methods with OpenTelemetry spans. Respects @NoTrace(), @Traced(),
186
+ * and filter options.
187
+ *
188
+ * @param instance - The service or controller instance
189
+ * @param className - The class name for span naming
190
+ * @param filter - Optional filter options
191
+ */
192
+ export function applyAutoTrace(
193
+ instance: unknown,
194
+ className: string,
195
+ filter?: TraceFilterOptions,
196
+ ): void {
197
+ if (!instance || typeof instance !== 'object') {
198
+ return;
199
+ }
200
+
201
+ const asyncOnly = filter?.asyncOnly ?? true;
202
+ const extraExcludedMethods = filter?.excludeMethods
203
+ ? new Set(filter.excludeMethods)
204
+ : null;
205
+
206
+ // Walk prototype chain, collecting methods to wrap
207
+ let proto = Object.getPrototypeOf(instance);
208
+ while (proto && proto !== Object.prototype) {
209
+ const methodNames = Object.getOwnPropertyNames(proto);
210
+
211
+ for (const methodName of methodNames) {
212
+ // Skip built-in excluded methods
213
+ if (EXCLUDED_METHODS.has(methodName)) {
214
+ continue;
215
+ }
216
+
217
+ // Skip user-specified excluded methods
218
+ if (extraExcludedMethods?.has(methodName)) {
219
+ continue;
220
+ }
221
+
222
+ const descriptor = Object.getOwnPropertyDescriptor(proto, methodName);
223
+ if (!descriptor || !descriptor.value || typeof descriptor.value !== 'function') {
224
+ continue;
225
+ }
226
+
227
+ // Skip getters/setters
228
+ if (descriptor.get || descriptor.set) {
229
+ continue;
230
+ }
231
+
232
+ const method = descriptor.value;
233
+
234
+ // Skip already-traced methods (@Traced was applied manually)
235
+ if ((method as unknown as Record<symbol, boolean>)[ALREADY_TRACED]) {
236
+ continue;
237
+ }
238
+
239
+ // Skip @NoTrace methods
240
+ if ((method as unknown as Record<symbol, boolean>)[NO_TRACE]) {
241
+ continue;
242
+ }
243
+
244
+ // If asyncOnly, skip sync methods
245
+ if (asyncOnly && !isAsyncFunction(method)) {
246
+ continue;
247
+ }
248
+
249
+ wrapMethodWithSpan(proto, methodName, className);
250
+ }
251
+
252
+ proto = Object.getPrototypeOf(proto);
253
+ }
254
+ }
255
+
256
+ /**
257
+ * @TraceAll() — class decorator.
258
+ * Marks a class for auto-tracing even when traceAll is false in config.
259
+ *
260
+ * @example
261
+ * ```typescript
262
+ * \@Service()
263
+ * \@TraceAll()
264
+ * class UserService extends BaseService {
265
+ * async findAll() { ... } // auto-traced
266
+ * }
267
+ */
268
+ // eslint-disable-next-line @typescript-eslint/naming-convention
269
+ export function TraceAll(): ClassDecorator {
270
+ return (target: Function) => {
271
+ (target as unknown as Record<symbol, boolean>)[TRACE_ALL] = true;
272
+ };
273
+ }
274
+
275
+ /**
276
+ * @NoTrace() — class or method decorator.
277
+ * Excludes a class or method from auto-tracing.
278
+ *
279
+ * On a class: prevents all methods from being auto-traced.
280
+ * On a method: prevents that specific method from being auto-traced.
281
+ * Note: @Traced() on a method takes priority over @NoTrace() on the class.
282
+ *
283
+ * @example
284
+ * ```typescript
285
+ * \@Service()
286
+ * \@NoTrace()
287
+ * class InternalService extends BaseService {
288
+ * async internalWork() { ... } // NOT auto-traced
289
+ *
290
+ * \@Traced() // Override: this method IS traced
291
+ * async importantWork() { ... }
292
+ * }
293
+ * ```
294
+ */
295
+ // eslint-disable-next-line @typescript-eslint/naming-convention
296
+ export function NoTrace(): ClassDecorator & MethodDecorator {
297
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
298
+ return (target: any, propertyKey?: string | symbol, descriptor?: PropertyDescriptor) => {
299
+ if (descriptor && propertyKey) {
300
+ // Method decorator
301
+ (descriptor.value as unknown as Record<symbol, boolean>)[NO_TRACE] = true;
302
+ } else {
303
+ // Class decorator
304
+ (target as unknown as Record<symbol, boolean>)[NO_TRACE] = true;
305
+ }
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Check if a class should be auto-traced based on global config and decorators.
311
+ *
312
+ * @param classConstructor - The class constructor
313
+ * @param className - The class name
314
+ * @param traceAll - Global traceAll setting
315
+ * @param filter - Filter options
316
+ * @returns true if the class should have auto-tracing applied
317
+ */
318
+ export function shouldAutoTrace(
319
+ classConstructor: Function,
320
+ className: string,
321
+ traceAll: boolean,
322
+ filter?: TraceFilterOptions,
323
+ ): boolean {
324
+ const hasTraceAll = (classConstructor as unknown as Record<symbol, boolean>)[TRACE_ALL] === true;
325
+ const hasNoTrace = (classConstructor as unknown as Record<symbol, boolean>)[NO_TRACE] === true;
326
+
327
+ // @NoTrace on class → skip (unless method-level @Traced, but that's handled in applyAutoTrace)
328
+ if (hasNoTrace) {
329
+ return false;
330
+ }
331
+
332
+ // @TraceAll on class → trace regardless of global setting
333
+ if (hasTraceAll) {
334
+ return matchesClassFilter(className, filter);
335
+ }
336
+
337
+ // Global traceAll
338
+ if (traceAll) {
339
+ return matchesClassFilter(className, filter);
340
+ }
341
+
342
+ return false;
343
+ }
package/src/index.ts CHANGED
@@ -15,13 +15,32 @@ export type { Context } from '@opentelemetry/api';
15
15
  export {
16
16
  createTraceMiddleware,
17
17
  Span,
18
+ SPAN_ATTRIBUTES,
19
+ SpanAttribute,
20
+ type SpanAttributeEntry,
18
21
  span,
19
- // Backward compatibility aliases
22
+ Spanned,
20
23
  Trace,
24
+ Traced,
21
25
  TraceMiddleware,
22
26
  trace,
23
27
  } from './middleware.js';
24
28
 
29
+ // Auto-trace
30
+ export {
31
+ ALREADY_TRACED,
32
+ applyAutoTrace,
33
+ NO_TRACE,
34
+ NoTrace,
35
+ shouldAutoTrace,
36
+ TRACE_ALL,
37
+ TraceAll,
38
+ type TraceFilterOptions,
39
+ } from './auto-trace.js';
40
+
41
+ // OTLP exporter
42
+ export { OtlpFetchSpanExporter, type OtlpExporterOptions } from './otlp-exporter.js';
43
+
25
44
  // Core service
26
45
  export {
27
46
  currentSpan,
package/src/middleware.ts CHANGED
@@ -1,11 +1,90 @@
1
+ import { SpanStatusCode as OtelSpanStatusCode, trace as otelTrace } from '@opentelemetry/api';
1
2
  import { Effect } from 'effect';
2
3
 
3
4
  import type { HttpTraceData, TraceHeaders } from './types.js';
4
5
 
5
6
  import { HttpStatusCode } from '@onebun/requests';
6
7
 
8
+ import { ALREADY_TRACED } from './auto-trace.js';
7
9
  import { traceService } from './trace.service.js';
8
10
 
11
+ /**
12
+ * Symbol for storing @SpanAttribute metadata on the prototype
13
+ */
14
+ export const SPAN_ATTRIBUTES = Symbol.for('onebun:spanAttributes');
15
+
16
+ /**
17
+ * Metadata entry for a @SpanAttribute parameter
18
+ */
19
+ export interface SpanAttributeEntry {
20
+ paramIndex: number;
21
+ attrName: string;
22
+ }
23
+
24
+ /**
25
+ * Read @SpanAttribute metadata and set span attributes from method arguments
26
+ */
27
+ function applySpanAttributes(
28
+ target: unknown,
29
+ methodName: string | symbol,
30
+ args: unknown[],
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ activeSpan: any,
33
+ ): void {
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ const meta = (target as any)?.[SPAN_ATTRIBUTES]?.[String(methodName)] as
36
+ | SpanAttributeEntry[]
37
+ | undefined;
38
+ if (!meta) {
39
+ return;
40
+ }
41
+
42
+ for (const { paramIndex, attrName } of meta) {
43
+ const value = args[paramIndex];
44
+ if (value === undefined || value === null) {
45
+ continue;
46
+ }
47
+
48
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
49
+ activeSpan.setAttribute(attrName, value);
50
+ } else {
51
+ activeSpan.setAttribute(attrName, JSON.stringify(value));
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @SpanAttribute() — parameter decorator.
58
+ * Automatically records the method argument as a span attribute when used
59
+ * with @Traced() or @Spanned().
60
+ *
61
+ * @param name - The span attribute name (e.g. 'user.id', 'db.operation')
62
+ *
63
+ * @example
64
+ * ```typescript
65
+ * \@Traced()
66
+ * async findById(
67
+ * \@SpanAttribute('user.id') id: string,
68
+ * ): Promise<User | null> { ... }
69
+ * ```
70
+ */
71
+ // eslint-disable-next-line @typescript-eslint/naming-convention
72
+ export function SpanAttribute(name: string): ParameterDecorator {
73
+ return (target: object, propertyKey: string | symbol | undefined, parameterIndex: number) => {
74
+ if (!propertyKey) {
75
+ return;
76
+ }
77
+
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ const existing = (target as any)[SPAN_ATTRIBUTES] ?? {};
80
+ const key = String(propertyKey);
81
+ existing[key] = existing[key] ?? [];
82
+ existing[key].push({ paramIndex: parameterIndex, attrName: name });
83
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
84
+ (target as any)[SPAN_ATTRIBUTES] = existing;
85
+ };
86
+ }
87
+
9
88
  /**
10
89
  * HTTP trace middleware for OneBun applications
11
90
  */
@@ -183,7 +262,21 @@ export const createTraceMiddleware = (): TraceMiddleware => {
183
262
  };
184
263
 
185
264
  /**
186
- * Trace decorator for controller methods
265
+ * Trace decorator for async/await controller and service methods.
266
+ *
267
+ * Creates an OpenTelemetry span around the decorated method. Works with
268
+ * methods that return Promises — the span is automatically ended when
269
+ * the Promise resolves or rejects.
270
+ *
271
+ * @param operationName - Custom span name. Defaults to `ClassName.methodName`
272
+ *
273
+ * @example
274
+ * ```typescript
275
+ * class WorkspaceService extends BaseService {
276
+ * \@Traced('workspace.findAll')
277
+ * async findAll(): Promise<Workspace[]> { ... }
278
+ * }
279
+ * ```
187
280
  */
188
281
  export function trace(operationName?: string): MethodDecorator {
189
282
  return (target: unknown, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
@@ -193,61 +286,45 @@ export function trace(operationName?: string): MethodDecorator {
193
286
  ? target.constructor
194
287
  : { name: 'Unknown' };
195
288
  const spanName = operationName || `${targetConstructor.name}.${String(propertyKey)}`;
196
-
197
- descriptor.value = function (...args: unknown[]) {
198
- return Effect.flatMap(traceService, (traceServiceInstance) =>
199
- Effect.flatMap(traceServiceInstance.startSpan(spanName), (_traceSpan) => {
200
- const startTime = Date.now();
201
-
202
- return Effect.tryPromise({
203
- try: () => originalMethod.apply(this, args),
204
- catch: (error) => error as Error,
205
- }).pipe(
206
- Effect.tap(() => {
207
- const duration = Date.now() - startTime;
208
-
209
- return Effect.flatMap(
210
- traceServiceInstance.setAttributes({
211
- // eslint-disable-next-line @typescript-eslint/naming-convention
212
- 'method.name': String(propertyKey),
213
- // eslint-disable-next-line @typescript-eslint/naming-convention
214
- 'method.duration': duration,
215
- }),
216
- () => traceServiceInstance.endSpan(_traceSpan),
217
- );
218
- }),
219
- Effect.tapError((error) => {
220
- const duration = Date.now() - startTime;
221
-
222
- return Effect.flatMap(
223
- traceServiceInstance.setAttributes({
224
- // eslint-disable-next-line @typescript-eslint/naming-convention
225
- 'method.name': String(propertyKey),
226
- // eslint-disable-next-line @typescript-eslint/naming-convention
227
- 'method.duration': duration,
228
- // eslint-disable-next-line @typescript-eslint/naming-convention
229
- 'error.type': error.constructor.name,
230
- // eslint-disable-next-line @typescript-eslint/naming-convention
231
- 'error.message': error.message,
232
- }),
233
- () =>
234
- traceServiceInstance.endSpan(_traceSpan, {
235
- code: 2, // ERROR
236
- message: error.message,
237
- }),
238
- );
239
- }),
240
- );
241
- }),
242
- );
289
+ const decoratorTarget = target;
290
+
291
+ const wrapped = async function (this: unknown, ...args: unknown[]) {
292
+ const tracer = otelTrace.getTracer('@onebun/trace');
293
+
294
+ return await tracer.startActiveSpan(spanName, async (activeSpan) => {
295
+ applySpanAttributes(decoratorTarget, propertyKey, args, activeSpan);
296
+
297
+ try {
298
+ const result = await originalMethod.apply(this, args);
299
+ activeSpan.setStatus({ code: OtelSpanStatusCode.OK });
300
+
301
+ return result;
302
+ } catch (error) {
303
+ const err = error instanceof Error ? error : new Error(String(error));
304
+ activeSpan.setStatus({ code: OtelSpanStatusCode.ERROR, message: err.message });
305
+ activeSpan.recordException(err);
306
+ throw error;
307
+ } finally {
308
+ activeSpan.end();
309
+ }
310
+ });
243
311
  };
244
312
 
313
+ // Mark as traced to prevent auto-trace from double-wrapping
314
+ (wrapped as unknown as Record<symbol, boolean>)[ALREADY_TRACED] = true;
315
+ descriptor.value = wrapped;
316
+
245
317
  return descriptor;
246
318
  };
247
319
  }
248
320
 
249
321
  /**
250
- * Span decorator for creating custom spans
322
+ * Span decorator for creating custom spans around async methods.
323
+ *
324
+ * Lighter version of \@trace — creates a span without automatic error attribute
325
+ * enrichment. Best for internal methods where you want basic span tracking.
326
+ *
327
+ * @param name - Custom span name. Defaults to `ClassName.methodName`
251
328
  */
252
329
  export function span(name?: string): MethodDecorator {
253
330
  return (target: unknown, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
@@ -256,28 +333,43 @@ export function span(name?: string): MethodDecorator {
256
333
  target && typeof target === 'object' && target.constructor
257
334
  ? target.constructor
258
335
  : { name: 'Unknown' };
259
- const spanName = name || `${targetConstructor.name}.${String(propertyKey)}`;
260
-
261
- descriptor.value = function (...args: unknown[]) {
262
- return Effect.flatMap(traceService, (traceServiceInstance) =>
263
- Effect.flatMap(traceServiceInstance.startSpan(spanName), (spanInstance) =>
264
- Effect.flatMap(
265
- Effect.try(() => originalMethod.apply(this, args)),
266
- (result) =>
267
- Effect.flatMap(traceServiceInstance.endSpan(spanInstance), () =>
268
- Effect.succeed(result),
269
- ),
270
- ),
271
- ),
272
- );
336
+ const resolvedName = name || `${targetConstructor.name}.${String(propertyKey)}`;
337
+ const decoratorTarget = target;
338
+
339
+ const wrapped = async function (this: unknown, ...args: unknown[]) {
340
+ const tracer = otelTrace.getTracer('@onebun/trace');
341
+
342
+ return await tracer.startActiveSpan(resolvedName, async (activeSpan) => {
343
+ applySpanAttributes(decoratorTarget, propertyKey, args, activeSpan);
344
+
345
+ try {
346
+ const result = await originalMethod.apply(this, args);
347
+
348
+ return result;
349
+ } catch (error) {
350
+ const err = error instanceof Error ? error : new Error(String(error));
351
+ activeSpan.setStatus({ code: OtelSpanStatusCode.ERROR, message: err.message });
352
+ throw error;
353
+ } finally {
354
+ activeSpan.end();
355
+ }
356
+ });
273
357
  };
274
358
 
359
+ // Mark as traced to prevent auto-trace from double-wrapping
360
+ (wrapped as unknown as Record<symbol, boolean>)[ALREADY_TRACED] = true;
361
+ descriptor.value = wrapped;
362
+
275
363
  return descriptor;
276
364
  };
277
365
  }
278
366
 
279
- // Backward compatibility aliases
367
+ // Aliases all point to the same async-compatible implementations
280
368
  // eslint-disable-next-line @typescript-eslint/naming-convention
281
369
  export const Trace = trace;
282
370
  // eslint-disable-next-line @typescript-eslint/naming-convention
283
371
  export const Span = span;
372
+ // eslint-disable-next-line @typescript-eslint/naming-convention
373
+ export const Traced = trace;
374
+ // eslint-disable-next-line @typescript-eslint/naming-convention
375
+ export const Spanned = span;
@@ -0,0 +1,245 @@
1
+ import { ExportResultCode } from '@opentelemetry/core';
2
+
3
+ import type { ExportResult } from '@opentelemetry/core';
4
+ import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base';
5
+
6
+ /**
7
+ * OTLP Span Exporter options
8
+ */
9
+ export interface OtlpExporterOptions {
10
+ /**
11
+ * OTLP endpoint URL (e.g. 'http://localhost:4318')
12
+ */
13
+ endpoint: string;
14
+
15
+ /**
16
+ * Custom HTTP headers for export requests
17
+ */
18
+ headers?: Record<string, string>;
19
+
20
+ /**
21
+ * Request timeout in milliseconds
22
+ * @defaultValue 10000
23
+ */
24
+ timeout?: number;
25
+ }
26
+
27
+ const DEFAULT_TIMEOUT = 10000;
28
+ const NANOSECONDS_PER_MILLISECOND = 1000000;
29
+ const NANOSECONDS_PER_SECOND = 1000000000;
30
+
31
+ /**
32
+ * Convert hrtime [seconds, nanoseconds] to nanosecond string
33
+ */
34
+ function hrTimeToNanos(hrTime: [number, number]): string {
35
+ const nanos = BigInt(hrTime[0]) * BigInt(NANOSECONDS_PER_SECOND) + BigInt(hrTime[1]);
36
+
37
+ return nanos.toString();
38
+ }
39
+
40
+ /**
41
+ * Convert OTel attribute value to OTLP JSON attribute value
42
+ */
43
+ function toOtlpValue(value: unknown): Record<string, unknown> {
44
+ if (typeof value === 'string') {
45
+ return { stringValue: value };
46
+ }
47
+ if (typeof value === 'number') {
48
+ return Number.isInteger(value) ? { intValue: value } : { doubleValue: value };
49
+ }
50
+ if (typeof value === 'boolean') {
51
+ return { boolValue: value };
52
+ }
53
+
54
+ return { stringValue: String(value) };
55
+ }
56
+
57
+ /**
58
+ * Convert OTel attributes to OTLP JSON format
59
+ */
60
+ function toOtlpAttributes(
61
+ attributes: Record<string, unknown>,
62
+ ): Array<{ key: string; value: Record<string, unknown> }> {
63
+ return Object.entries(attributes).map(([key, value]) => ({
64
+ key,
65
+ value: toOtlpValue(value),
66
+ }));
67
+ }
68
+
69
+ /**
70
+ * Map OTel SpanKind to OTLP integer
71
+ */
72
+ function mapSpanKind(kind: number): number {
73
+ // OTel SpanKind: INTERNAL=0, SERVER=1, CLIENT=2, PRODUCER=3, CONSUMER=4
74
+ // OTLP: INTERNAL=1, SERVER=2, CLIENT=3, PRODUCER=4, CONSUMER=5
75
+ return kind + 1;
76
+ }
77
+
78
+ /**
79
+ * Map OTel SpanStatusCode to OTLP status code
80
+ */
81
+ function mapStatusCode(code: number): number {
82
+ // OTel: UNSET=0, OK=1, ERROR=2
83
+ // OTLP: STATUS_CODE_UNSET=0, STATUS_CODE_OK=1, STATUS_CODE_ERROR=2
84
+ return code;
85
+ }
86
+
87
+ /**
88
+ * Convert a ReadableSpan to OTLP JSON span format
89
+ */
90
+ function spanToOtlp(span: ReadableSpan): Record<string, unknown> {
91
+ const spanContext = span.spanContext();
92
+
93
+ const otlpSpan: Record<string, unknown> = {
94
+ traceId: spanContext.traceId,
95
+ spanId: spanContext.spanId,
96
+ name: span.name,
97
+ kind: mapSpanKind(span.kind),
98
+ startTimeUnixNano: hrTimeToNanos(span.startTime),
99
+ endTimeUnixNano: hrTimeToNanos(span.endTime),
100
+ attributes: toOtlpAttributes(span.attributes as Record<string, unknown>),
101
+ status: {
102
+ code: mapStatusCode(span.status.code),
103
+ message: span.status.message || '',
104
+ },
105
+ events: span.events.map((event) => ({
106
+ timeUnixNano: hrTimeToNanos(event.time),
107
+ name: event.name,
108
+ attributes: event.attributes
109
+ ? toOtlpAttributes(event.attributes as Record<string, unknown>)
110
+ : [],
111
+ })),
112
+ links: span.links.map((link) => ({
113
+ traceId: link.context.traceId,
114
+ spanId: link.context.spanId,
115
+ attributes: link.attributes
116
+ ? toOtlpAttributes(link.attributes as Record<string, unknown>)
117
+ : [],
118
+ })),
119
+ };
120
+
121
+ if (span.parentSpanContext?.spanId) {
122
+ otlpSpan.parentSpanId = span.parentSpanContext.spanId;
123
+ }
124
+
125
+ if (span.droppedAttributesCount > 0) {
126
+ otlpSpan.droppedAttributesCount = span.droppedAttributesCount;
127
+ }
128
+
129
+ if (span.droppedEventsCount > 0) {
130
+ otlpSpan.droppedEventsCount = span.droppedEventsCount;
131
+ }
132
+
133
+ if (span.droppedLinksCount > 0) {
134
+ otlpSpan.droppedLinksCount = span.droppedLinksCount;
135
+ }
136
+
137
+ return otlpSpan;
138
+ }
139
+
140
+ /**
141
+ * Custom OTLP Span Exporter using native fetch() for Bun compatibility.
142
+ *
143
+ * Unlike @opentelemetry/exporter-trace-otlp-http which relies on XMLHttpRequest
144
+ * or Node's http module, this exporter uses the native fetch() API that is
145
+ * guaranteed to work in Bun.
146
+ */
147
+ export class OtlpFetchSpanExporter implements SpanExporter {
148
+ private readonly endpoint: string;
149
+ private readonly headers: Record<string, string>;
150
+ private readonly timeout: number;
151
+ private isShutdown = false;
152
+
153
+ constructor(options: OtlpExporterOptions) {
154
+ this.endpoint = options.endpoint.replace(/\/$/, '');
155
+ this.headers = {
156
+ // eslint-disable-next-line @typescript-eslint/naming-convention
157
+ 'Content-Type': 'application/json',
158
+ ...options.headers,
159
+ };
160
+ this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
161
+ }
162
+
163
+ export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {
164
+ if (this.isShutdown) {
165
+ resultCallback({ code: ExportResultCode.FAILED });
166
+
167
+ return;
168
+ }
169
+
170
+ this.sendSpans(spans)
171
+ .then(() => {
172
+ resultCallback({ code: ExportResultCode.SUCCESS });
173
+ })
174
+ .catch(() => {
175
+ resultCallback({ code: ExportResultCode.FAILED });
176
+ });
177
+ }
178
+
179
+ async shutdown(): Promise<void> {
180
+ this.isShutdown = true;
181
+ }
182
+
183
+ async forceFlush(): Promise<void> {
184
+ // No internal buffering — BatchSpanProcessor handles batching
185
+ }
186
+
187
+ private async sendSpans(spans: ReadableSpan[]): Promise<void> {
188
+ if (spans.length === 0) {
189
+ return;
190
+ }
191
+
192
+ // Group spans by instrumentation scope
193
+ const scopeSpans = new Map<string, Record<string, unknown>[]>();
194
+ for (const span of spans) {
195
+ const scopeName = span.instrumentationScope.name;
196
+ if (!scopeSpans.has(scopeName)) {
197
+ scopeSpans.set(scopeName, []);
198
+ }
199
+ scopeSpans.get(scopeName)!.push(spanToOtlp(span));
200
+ }
201
+
202
+ // Build resource attributes from the first span's resource
203
+ const resource = spans[0].resource;
204
+ const resourceAttributes = toOtlpAttributes(
205
+ resource.attributes as Record<string, unknown>,
206
+ );
207
+
208
+ const payload = {
209
+ resourceSpans: [
210
+ {
211
+ resource: { attributes: resourceAttributes },
212
+ scopeSpans: Array.from(scopeSpans.entries()).map(([name, scopeSpanList]) => ({
213
+ scope: { name },
214
+ spans: scopeSpanList,
215
+ })),
216
+ },
217
+ ],
218
+ };
219
+
220
+ const controller = new AbortController();
221
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
222
+
223
+ try {
224
+ const response = await fetch(`${this.endpoint}/v1/traces`, {
225
+ method: 'POST',
226
+ headers: this.headers,
227
+ body: JSON.stringify(payload),
228
+ signal: controller.signal,
229
+ });
230
+
231
+ if (!response.ok) {
232
+ throw new Error(`OTLP export failed: ${response.status} ${response.statusText}`);
233
+ }
234
+ } finally {
235
+ clearTimeout(timeoutId);
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Utility: convert milliseconds to nanosecond string
242
+ */
243
+ export function msToNanos(ms: number): string {
244
+ return (BigInt(Math.round(ms)) * BigInt(NANOSECONDS_PER_MILLISECOND)).toString();
245
+ }
@@ -0,0 +1,73 @@
1
+ import { trace } from '@opentelemetry/api';
2
+ import { resourceFromAttributes } from '@opentelemetry/resources';
3
+ import { BatchSpanProcessor, BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
4
+ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
5
+
6
+ import type { TraceOptions } from './types.js';
7
+
8
+ import { OtlpFetchSpanExporter } from './otlp-exporter.js';
9
+
10
+ /**
11
+ * Result of TracerProvider initialization
12
+ */
13
+ export interface TracerProviderResult {
14
+ /**
15
+ * The initialized BasicTracerProvider
16
+ */
17
+ provider: BasicTracerProvider;
18
+
19
+ /**
20
+ * Shutdown function that flushes pending spans and shuts down the provider
21
+ */
22
+ shutdown: () => Promise<void>;
23
+ }
24
+
25
+ /**
26
+ * Initialize and register a global TracerProvider.
27
+ *
28
+ * When `exportOptions.endpoint` is configured, creates a BatchSpanProcessor
29
+ * with a custom fetch-based OTLP exporter for Bun compatibility.
30
+ *
31
+ * @param options - Trace configuration options
32
+ * @returns Provider instance and shutdown function
33
+ */
34
+ export function initTracerProvider(options: TraceOptions): TracerProviderResult {
35
+ const resource = resourceFromAttributes({
36
+ [ATTR_SERVICE_NAME]: options.serviceName ?? 'onebun-service',
37
+ [ATTR_SERVICE_VERSION]: options.serviceVersion ?? '1.0.0',
38
+ });
39
+
40
+ const spanProcessors = [];
41
+
42
+ if (options.exportOptions?.endpoint) {
43
+ const exporter = new OtlpFetchSpanExporter({
44
+ endpoint: options.exportOptions.endpoint,
45
+ headers: options.exportOptions.headers,
46
+ timeout: options.exportOptions.timeout,
47
+ });
48
+
49
+ spanProcessors.push(
50
+ new BatchSpanProcessor(exporter, {
51
+ maxExportBatchSize: options.exportOptions.batchSize,
52
+ scheduledDelayMillis: options.exportOptions.batchTimeout,
53
+ }),
54
+ );
55
+ }
56
+
57
+ const provider = new BasicTracerProvider({
58
+ resource,
59
+ spanProcessors,
60
+ });
61
+
62
+ // Register as global TracerProvider so trace.getTracer() returns real tracers
63
+ trace.setGlobalTracerProvider(provider);
64
+
65
+ return {
66
+ provider,
67
+ async shutdown() {
68
+ await provider.shutdown();
69
+ // Disable the global provider to avoid using a shutdown provider
70
+ trace.disable();
71
+ },
72
+ };
73
+ }
@@ -13,6 +13,7 @@ import {
13
13
 
14
14
  import { HttpStatusCode } from '@onebun/requests';
15
15
 
16
+ import { initTracerProvider, type TracerProviderResult } from './provider.js';
16
17
  import {
17
18
  type HttpTraceData,
18
19
  type SpanStatus,
@@ -89,6 +90,11 @@ export interface TraceService {
89
90
  * End HTTP request tracing
90
91
  */
91
92
  endHttpTrace(span: TraceSpan, data: Partial<HttpTraceData>): Effect.Effect<void>;
93
+
94
+ /**
95
+ * Shutdown the trace service, flushing pending spans
96
+ */
97
+ shutdown(): Promise<void>;
92
98
  }
93
99
 
94
100
  /**
@@ -111,8 +117,9 @@ export const currentSpan = FiberRef.unsafeMake<TraceSpan | null>(null);
111
117
  * Implementation of TraceService
112
118
  */
113
119
  export class TraceServiceImpl implements TraceService {
114
- private readonly tracer = trace.getTracer('@onebun/trace');
120
+ private readonly tracer;
115
121
  private readonly options: Required<TraceOptions>;
122
+ private readonly providerResult: TracerProviderResult | null = null;
116
123
 
117
124
  constructor(options: TraceOptions = {}) {
118
125
  this.options = {
@@ -126,6 +133,20 @@ export class TraceServiceImpl implements TraceService {
126
133
  exportOptions: {},
127
134
  ...options,
128
135
  };
136
+
137
+ // Initialize TracerProvider BEFORE creating the tracer
138
+ // so trace.getTracer() returns a real tracer, not NoopTracer
139
+ if (this.options.enabled) {
140
+ this.providerResult = initTracerProvider(this.options);
141
+ }
142
+
143
+ this.tracer = trace.getTracer('@onebun/trace');
144
+ }
145
+
146
+ async shutdown(): Promise<void> {
147
+ if (this.providerResult) {
148
+ await this.providerResult.shutdown();
149
+ }
129
150
  }
130
151
 
131
152
  getCurrentContext(): Effect.Effect<TraceContext | null> {