@onebun/trace 0.2.2 → 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.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",
@@ -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,6 +15,9 @@ 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,
@@ -23,6 +26,18 @@ export {
23
26
  trace,
24
27
  } from './middleware.js';
25
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
+
26
41
  // OTLP exporter
27
42
  export { OtlpFetchSpanExporter, type OtlpExporterOptions } from './otlp-exporter.js';
28
43
 
package/src/middleware.ts CHANGED
@@ -5,8 +5,86 @@ import type { HttpTraceData, TraceHeaders } from './types.js';
5
5
 
6
6
  import { HttpStatusCode } from '@onebun/requests';
7
7
 
8
+ import { ALREADY_TRACED } from './auto-trace.js';
8
9
  import { traceService } from './trace.service.js';
9
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
+
10
88
  /**
11
89
  * HTTP trace middleware for OneBun applications
12
90
  */
@@ -208,11 +286,14 @@ export function trace(operationName?: string): MethodDecorator {
208
286
  ? target.constructor
209
287
  : { name: 'Unknown' };
210
288
  const spanName = operationName || `${targetConstructor.name}.${String(propertyKey)}`;
289
+ const decoratorTarget = target;
211
290
 
212
- descriptor.value = async function (...args: unknown[]) {
291
+ const wrapped = async function (this: unknown, ...args: unknown[]) {
213
292
  const tracer = otelTrace.getTracer('@onebun/trace');
214
293
 
215
294
  return await tracer.startActiveSpan(spanName, async (activeSpan) => {
295
+ applySpanAttributes(decoratorTarget, propertyKey, args, activeSpan);
296
+
216
297
  try {
217
298
  const result = await originalMethod.apply(this, args);
218
299
  activeSpan.setStatus({ code: OtelSpanStatusCode.OK });
@@ -229,6 +310,10 @@ export function trace(operationName?: string): MethodDecorator {
229
310
  });
230
311
  };
231
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
+
232
317
  return descriptor;
233
318
  };
234
319
  }
@@ -248,12 +333,15 @@ export function span(name?: string): MethodDecorator {
248
333
  target && typeof target === 'object' && target.constructor
249
334
  ? target.constructor
250
335
  : { name: 'Unknown' };
251
- const spanName = name || `${targetConstructor.name}.${String(propertyKey)}`;
336
+ const resolvedName = name || `${targetConstructor.name}.${String(propertyKey)}`;
337
+ const decoratorTarget = target;
252
338
 
253
- descriptor.value = async function (...args: unknown[]) {
339
+ const wrapped = async function (this: unknown, ...args: unknown[]) {
254
340
  const tracer = otelTrace.getTracer('@onebun/trace');
255
341
 
256
- return await tracer.startActiveSpan(spanName, async (activeSpan) => {
342
+ return await tracer.startActiveSpan(resolvedName, async (activeSpan) => {
343
+ applySpanAttributes(decoratorTarget, propertyKey, args, activeSpan);
344
+
257
345
  try {
258
346
  const result = await originalMethod.apply(this, args);
259
347
 
@@ -268,6 +356,10 @@ export function span(name?: string): MethodDecorator {
268
356
  });
269
357
  };
270
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
+
271
363
  return descriptor;
272
364
  };
273
365
  }