@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 +7 -3
- package/src/auto-trace.ts +343 -0
- package/src/index.ts +20 -1
- package/src/middleware.ts +156 -64
- package/src/otlp-exporter.ts +245 -0
- package/src/provider.ts +73 -0
- package/src/trace.service.ts +22 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onebun/trace",
|
|
3
|
-
"version": "0.2.
|
|
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
|
-
"
|
|
41
|
+
"@onebun/requests": "^0.2.1",
|
|
42
42
|
"@opentelemetry/api": "^1.8.0",
|
|
43
|
-
"@
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -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
|
+
}
|
package/src/trace.service.ts
CHANGED
|
@@ -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
|
|
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> {
|