@onebun/trace 0.2.0 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/trace",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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.0"
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"
package/src/index.ts CHANGED
@@ -16,12 +16,16 @@ export {
16
16
  createTraceMiddleware,
17
17
  Span,
18
18
  span,
19
- // Backward compatibility aliases
19
+ Spanned,
20
20
  Trace,
21
+ Traced,
21
22
  TraceMiddleware,
22
23
  trace,
23
24
  } from './middleware.js';
24
25
 
26
+ // OTLP exporter
27
+ export { OtlpFetchSpanExporter, type OtlpExporterOptions } from './otlp-exporter.js';
28
+
25
29
  // Core service
26
30
  export {
27
31
  currentSpan,
package/src/middleware.ts CHANGED
@@ -1,3 +1,4 @@
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';
@@ -183,7 +184,21 @@ export const createTraceMiddleware = (): TraceMiddleware => {
183
184
  };
184
185
 
185
186
  /**
186
- * Trace decorator for controller methods
187
+ * Trace decorator for async/await controller and service methods.
188
+ *
189
+ * Creates an OpenTelemetry span around the decorated method. Works with
190
+ * methods that return Promises — the span is automatically ended when
191
+ * the Promise resolves or rejects.
192
+ *
193
+ * @param operationName - Custom span name. Defaults to `ClassName.methodName`
194
+ *
195
+ * @example
196
+ * ```typescript
197
+ * class WorkspaceService extends BaseService {
198
+ * \@Traced('workspace.findAll')
199
+ * async findAll(): Promise<Workspace[]> { ... }
200
+ * }
201
+ * ```
187
202
  */
188
203
  export function trace(operationName?: string): MethodDecorator {
189
204
  return (target: unknown, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
@@ -194,52 +209,24 @@ export function trace(operationName?: string): MethodDecorator {
194
209
  : { name: 'Unknown' };
195
210
  const spanName = operationName || `${targetConstructor.name}.${String(propertyKey)}`;
196
211
 
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
- );
212
+ descriptor.value = async function (...args: unknown[]) {
213
+ const tracer = otelTrace.getTracer('@onebun/trace');
214
+
215
+ return await tracer.startActiveSpan(spanName, async (activeSpan) => {
216
+ try {
217
+ const result = await originalMethod.apply(this, args);
218
+ activeSpan.setStatus({ code: OtelSpanStatusCode.OK });
219
+
220
+ return result;
221
+ } catch (error) {
222
+ const err = error instanceof Error ? error : new Error(String(error));
223
+ activeSpan.setStatus({ code: OtelSpanStatusCode.ERROR, message: err.message });
224
+ activeSpan.recordException(err);
225
+ throw error;
226
+ } finally {
227
+ activeSpan.end();
228
+ }
229
+ });
243
230
  };
244
231
 
245
232
  return descriptor;
@@ -247,7 +234,12 @@ export function trace(operationName?: string): MethodDecorator {
247
234
  }
248
235
 
249
236
  /**
250
- * Span decorator for creating custom spans
237
+ * Span decorator for creating custom spans around async methods.
238
+ *
239
+ * Lighter version of \@trace — creates a span without automatic error attribute
240
+ * enrichment. Best for internal methods where you want basic span tracking.
241
+ *
242
+ * @param name - Custom span name. Defaults to `ClassName.methodName`
251
243
  */
252
244
  export function span(name?: string): MethodDecorator {
253
245
  return (target: unknown, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
@@ -258,26 +250,34 @@ export function span(name?: string): MethodDecorator {
258
250
  : { name: 'Unknown' };
259
251
  const spanName = name || `${targetConstructor.name}.${String(propertyKey)}`;
260
252
 
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
- );
253
+ descriptor.value = async function (...args: unknown[]) {
254
+ const tracer = otelTrace.getTracer('@onebun/trace');
255
+
256
+ return await tracer.startActiveSpan(spanName, async (activeSpan) => {
257
+ try {
258
+ const result = await originalMethod.apply(this, args);
259
+
260
+ return result;
261
+ } catch (error) {
262
+ const err = error instanceof Error ? error : new Error(String(error));
263
+ activeSpan.setStatus({ code: OtelSpanStatusCode.ERROR, message: err.message });
264
+ throw error;
265
+ } finally {
266
+ activeSpan.end();
267
+ }
268
+ });
273
269
  };
274
270
 
275
271
  return descriptor;
276
272
  };
277
273
  }
278
274
 
279
- // Backward compatibility aliases
275
+ // Aliases all point to the same async-compatible implementations
280
276
  // eslint-disable-next-line @typescript-eslint/naming-convention
281
277
  export const Trace = trace;
282
278
  // eslint-disable-next-line @typescript-eslint/naming-convention
283
279
  export const Span = span;
280
+ // eslint-disable-next-line @typescript-eslint/naming-convention
281
+ export const Traced = trace;
282
+ // eslint-disable-next-line @typescript-eslint/naming-convention
283
+ 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> {