autotel-plugins 0.19.2 → 0.19.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.
@@ -1,641 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- // Note: `any` is only used for dynamic method wrapping on runtime objects.
3
- // Type-safe interfaces are used for all public APIs.
4
- // Mongoose is a devDependency so we type-check against the real API; consumers use the peer.
5
-
6
- import type { Mongoose } from 'mongoose';
7
- import { SpanKind, otelTrace as trace, type Span, type Tracer } from 'autotel';
8
- import {
9
- SEMATTRS_DB_SYSTEM,
10
- SEMATTRS_DB_OPERATION,
11
- SEMATTRS_DB_MONGODB_COLLECTION,
12
- SEMATTRS_DB_NAME,
13
- SEMATTRS_NET_PEER_NAME,
14
- SEMATTRS_NET_PEER_PORT,
15
- } from '../common/constants';
16
- import {
17
- runWithSpan,
18
- finalizeSpan,
19
- getActiveSpan,
20
- } from 'autotel/trace-helpers';
21
-
22
- const DEFAULT_TRACER_NAME = 'autotel-plugins/mongoose';
23
- const DEFAULT_DB_SYSTEM = 'mongoose';
24
- const INSTRUMENTED_FLAG = '__autotelMongooseInstrumented' as const;
25
- const WRAPPED_HOOK_FLAG = '__autotelWrappedHook' as const;
26
-
27
- /**
28
- * Symbol used to store the parent span on Query/Aggregate objects.
29
- * This preserves context across chainable query methods.
30
- */
31
- export const _STORED_PARENT_SPAN: unique symbol = Symbol('stored-parent-span');
32
-
33
- /**
34
- * Configuration options for Mongoose instrumentation.
35
- * Focused on Mongoose 8+ with promise-based API only.
36
- */
37
- export interface MongooseInstrumentationConfig {
38
- /**
39
- * Database name to include in spans.
40
- */
41
- dbName?: string;
42
-
43
- /**
44
- * Remote hostname or IP address of the MongoDB server.
45
- */
46
- peerName?: string;
47
-
48
- /**
49
- * Remote port number of the MongoDB server (default: 27017).
50
- */
51
- peerPort?: number;
52
-
53
- /**
54
- * Custom tracer name (default: "autotel-plugins/mongoose").
55
- */
56
- tracerName?: string;
57
-
58
- /**
59
- * Whether to capture collection names in spans (default: true).
60
- */
61
- captureCollectionName?: boolean;
62
-
63
- /**
64
- * Whether to instrument Schema hooks (pre/post save, validate, etc).
65
- * Disabled by default because hooks interact with Mongoose plugins.
66
- * Enable only if you have user-defined hooks you want to trace.
67
- * (default: false)
68
- */
69
- instrumentHooks?: boolean;
70
- }
71
-
72
- /**
73
- * Creates a span for a Mongoose operation.
74
- */
75
- function createSpan(
76
- tracer: Tracer,
77
- operation: string,
78
- modelName: string | undefined,
79
- collectionName: string | undefined,
80
- config: Required<MongooseInstrumentationConfig>,
81
- ): Span {
82
- const spanName = collectionName
83
- ? `mongoose.${collectionName}.${operation}`
84
- : modelName
85
- ? `mongoose.${modelName}.${operation}`
86
- : `mongoose.${operation}`;
87
-
88
- const attributes: Record<string, any> = {
89
- [SEMATTRS_DB_SYSTEM]: DEFAULT_DB_SYSTEM,
90
- [SEMATTRS_DB_OPERATION]: operation,
91
- };
92
-
93
- if (collectionName && config.captureCollectionName) {
94
- attributes[SEMATTRS_DB_MONGODB_COLLECTION] = collectionName;
95
- }
96
-
97
- if (config.dbName) {
98
- attributes[SEMATTRS_DB_NAME] = config.dbName;
99
- }
100
-
101
- if (config.peerName) {
102
- attributes[SEMATTRS_NET_PEER_NAME] = config.peerName;
103
- }
104
-
105
- if (config.peerPort) {
106
- attributes[SEMATTRS_NET_PEER_PORT] = config.peerPort;
107
- }
108
-
109
- return tracer.startSpan(spanName, { kind: SpanKind.CLIENT, attributes });
110
- }
111
-
112
- /**
113
- * Wraps a method to trace Query/Aggregate execution with proper span lifecycle.
114
- * Returns the Query/Aggregate object with wrapped exec() to finalize span.
115
- */
116
- function wrapQueryMethod(
117
- target: any,
118
- methodName: string,
119
- operation: string,
120
- getCollectionName: (obj: any) => string | undefined,
121
- getModelName: (obj: any) => string | undefined,
122
- tracer: Tracer,
123
- config: Required<MongooseInstrumentationConfig>,
124
- ): void {
125
- const original = target[methodName];
126
- if (typeof original !== 'function') {
127
- return;
128
- }
129
-
130
- target[methodName] = function instrumented(this: any, ...args: any[]): any {
131
- const collectionName = getCollectionName(this);
132
- const modelName = getModelName(this);
133
- const span = createSpan(
134
- tracer,
135
- operation,
136
- modelName,
137
- collectionName,
138
- config,
139
- );
140
-
141
- return runWithSpan(span, () => {
142
- try {
143
- const result = original.apply(this, args);
144
-
145
- // If result is a Query/Aggregate, wrap exec() and preserve it
146
- if (result && typeof result.exec === 'function') {
147
- const originalExec = result.exec.bind(result);
148
-
149
- result.exec = function wrappedExec(): Promise<any> {
150
- try {
151
- const execPromise = originalExec();
152
-
153
- return Promise.resolve(execPromise)
154
- .then((value) => {
155
- finalizeSpan(span);
156
- return value;
157
- })
158
- .catch((error: unknown) => {
159
- finalizeSpan(
160
- span,
161
- error instanceof Error ? error : new Error(String(error)),
162
- );
163
- throw error;
164
- });
165
- } catch (error) {
166
- finalizeSpan(
167
- span,
168
- error instanceof Error ? error : new Error(String(error)),
169
- );
170
- throw error;
171
- }
172
- };
173
-
174
- return result; // Return Query/Aggregate, not Promise
175
- }
176
-
177
- // For direct promise results (e.g., create, insertMany)
178
- if (result && typeof result.then === 'function') {
179
- return Promise.resolve(result as Promise<any>)
180
- .then((value) => {
181
- finalizeSpan(span);
182
- return value;
183
- })
184
- .catch((error: unknown) => {
185
- finalizeSpan(
186
- span,
187
- error instanceof Error ? error : new Error(String(error)),
188
- );
189
- throw error;
190
- });
191
- }
192
-
193
- finalizeSpan(span);
194
- return result;
195
- } catch (error) {
196
- finalizeSpan(
197
- span,
198
- error instanceof Error ? error : new Error(String(error)),
199
- );
200
- throw error;
201
- }
202
- });
203
- };
204
- }
205
-
206
- /**
207
- * Wraps chainable Query methods (find, findOne, etc.) to capture span context.
208
- */
209
- function wrapChainableMethod(target: any, methodName: string): void {
210
- const original = target[methodName];
211
- if (typeof original !== 'function') {
212
- return;
213
- }
214
-
215
- target[methodName] = function captureContext(this: any, ...args: any[]): any {
216
- const currentSpan = getActiveSpan();
217
- const result = original.apply(this, args);
218
-
219
- // Store parent span on returned Query for exec() calls
220
- if (result && typeof result.exec === 'function') {
221
- (result as any)[_STORED_PARENT_SPAN] = currentSpan;
222
- }
223
-
224
- return result;
225
- };
226
- }
227
-
228
- /**
229
- * Patches Mongoose Schema hooks (pre/post) to automatically trace them.
230
- * Only wraps user-defined hooks, skipping Mongoose's internal hooks.
231
- */
232
- function patchSchemaHooks(
233
- Schema: any,
234
- tracer: Tracer,
235
- config: Required<MongooseInstrumentationConfig>,
236
- ): void {
237
- if (!Schema?.prototype) {
238
- return;
239
- }
240
-
241
- const HOOK_FLAG = '__autotelHookInstrumented' as const;
242
- if ((Schema.prototype as any)[HOOK_FLAG]) {
243
- return;
244
- }
245
-
246
- const originalPre = Schema.prototype.pre;
247
- if (typeof originalPre === 'function') {
248
- Schema.prototype.pre = function (hookName: string, ...args: any[]): any {
249
- const handler =
250
- typeof args[0] === 'function'
251
- ? args[0]
252
- : typeof args[1] === 'function'
253
- ? args[1]
254
- : null;
255
-
256
- // Only wrap user-defined hooks, skip Mongoose internals
257
- if (handler && !isMongooseInternalHook(handler)) {
258
- const wrapped = wrapHookHandler(
259
- handler,
260
- hookName,
261
- 'pre',
262
- tracer,
263
- config,
264
- );
265
- if (typeof args[0] === 'function') {
266
- args[0] = wrapped;
267
- } else if (typeof args[1] === 'function') {
268
- args[1] = wrapped;
269
- }
270
- }
271
-
272
- return Reflect.apply(originalPre, this, [hookName, ...args]);
273
- };
274
- }
275
-
276
- const originalPost = Schema.prototype.post;
277
- if (typeof originalPost === 'function') {
278
- Schema.prototype.post = function (hookName: string, ...args: any[]): any {
279
- const handler =
280
- typeof args[0] === 'function'
281
- ? args[0]
282
- : typeof args[1] === 'function'
283
- ? args[1]
284
- : null;
285
-
286
- // Only wrap user-defined hooks, skip Mongoose internals
287
- if (handler && !isMongooseInternalHook(handler)) {
288
- const wrapped = wrapHookHandler(
289
- handler,
290
- hookName,
291
- 'post',
292
- tracer,
293
- config,
294
- );
295
- if (typeof args[0] === 'function') {
296
- args[0] = wrapped;
297
- } else if (typeof args[1] === 'function') {
298
- args[1] = wrapped;
299
- }
300
- }
301
-
302
- return Reflect.apply(originalPost, this, [hookName, ...args]);
303
- };
304
- }
305
-
306
- (Schema.prototype as any)[HOOK_FLAG] = true;
307
- }
308
-
309
- /**
310
- * Detects if a hook handler is from Mongoose's internal code.
311
- * Skips private methods, known internal patterns, and functions with
312
- * Mongoose-internal source code signatures.
313
- *
314
- * Note: We intentionally allow anonymous functions because user-defined
315
- * hooks are often anonymous (e.g., `schema.pre('save', async function() {...})`).
316
- */
317
- function isMongooseInternalHook(handler: any): boolean {
318
- if (typeof handler !== 'function') {
319
- return false;
320
- }
321
-
322
- const funcName = handler.name || '';
323
-
324
- // Skip private/internal methods (starting with _ or $)
325
- if (funcName.startsWith('_') || funcName.startsWith('$')) {
326
- return true;
327
- }
328
-
329
- // Skip known Mongoose internal hook patterns by name
330
- const mongooseInternalNamePatterns = [
331
- 'shardingPlugin',
332
- 'mongooseInternalHook',
333
- 'noop',
334
- 'wrapped',
335
- 'bound ',
336
- ];
337
-
338
- if (
339
- mongooseInternalNamePatterns.some((pattern) => funcName.includes(pattern))
340
- ) {
341
- return true;
342
- }
343
-
344
- // Check function source for Mongoose-internal patterns
345
- // These patterns appear in Mongoose's auto-generated validation/transform hooks
346
- try {
347
- const source = handler.toString();
348
- const mongooseInternalSourcePatterns = [
349
- 'this.$__', // Mongoose internal document methods
350
- 'this.$isValid', // Mongoose validation
351
- 'this.$locals', // Mongoose local properties
352
- '_this.$__', // Mongoose internal with closure
353
- 'schema.s.hooks', // Mongoose hooks system
354
- 'kareem', // Mongoose's hooks library
355
- ];
356
-
357
- if (
358
- mongooseInternalSourcePatterns.some((pattern) => source.includes(pattern))
359
- ) {
360
- return true;
361
- }
362
- } catch {
363
- // If we can't get source, allow the hook through
364
- }
365
-
366
- return false;
367
- }
368
-
369
- /**
370
- * Wraps a hook handler to trace its execution.
371
- * Handles both callback-style (with next) and promise-style hooks.
372
- */
373
- function wrapHookHandler(
374
- handler: any,
375
- hookName: string,
376
- hookType: 'pre' | 'post',
377
- tracer: Tracer,
378
- config: Required<MongooseInstrumentationConfig>,
379
- ): any {
380
- if (typeof handler !== 'function') {
381
- return handler;
382
- }
383
-
384
- // Skip if already wrapped to prevent duplicate spans
385
- if ((handler as any)[WRAPPED_HOOK_FLAG]) {
386
- return handler;
387
- }
388
-
389
- const wrappedHook = function wrappedHook(this: any, ...args: any[]): any {
390
- let modelName: string | undefined;
391
- let collectionName: string | undefined;
392
-
393
- try {
394
- if (this.constructor?.modelName) {
395
- modelName = this.constructor.modelName;
396
- collectionName =
397
- this.constructor.collection?.collectionName || modelName;
398
- } else if (this.model?.modelName) {
399
- modelName = this.model.modelName;
400
- collectionName = this.model.collection?.collectionName || modelName;
401
- }
402
- } catch {
403
- // Ignore errors in extracting context
404
- }
405
-
406
- const spanName = collectionName
407
- ? `mongoose.${collectionName}.${hookType}.${hookName}`
408
- : `mongoose.hook.${hookType}.${hookName}`;
409
-
410
- const span = tracer.startSpan(spanName, { kind: SpanKind.INTERNAL });
411
- span.setAttribute('hook.type', hookType);
412
- span.setAttribute('hook.operation', hookName);
413
- if (modelName) {
414
- span.setAttribute('hook.model', modelName);
415
- }
416
- if (collectionName && config.captureCollectionName) {
417
- span.setAttribute(SEMATTRS_DB_MONGODB_COLLECTION, collectionName);
418
- }
419
- span.setAttribute(SEMATTRS_DB_SYSTEM, DEFAULT_DB_SYSTEM);
420
- if (config.dbName) {
421
- span.setAttribute(SEMATTRS_DB_NAME, config.dbName);
422
- }
423
-
424
- return runWithSpan(span, () => {
425
- try {
426
- const result = handler.apply(this, args);
427
-
428
- if (result && typeof result.then === 'function') {
429
- return Promise.resolve(result as Promise<any>)
430
- .then((value) => {
431
- finalizeSpan(span);
432
- return value;
433
- })
434
- .catch((error: unknown) => {
435
- finalizeSpan(
436
- span,
437
- error instanceof Error ? error : new Error(String(error)),
438
- );
439
- throw error;
440
- });
441
- }
442
-
443
- finalizeSpan(span);
444
- return result;
445
- } catch (error) {
446
- finalizeSpan(
447
- span,
448
- error instanceof Error ? error : new Error(String(error)),
449
- );
450
- throw error;
451
- }
452
- });
453
- };
454
-
455
- // Mark as wrapped to prevent double-wrapping
456
- (wrappedHook as any)[WRAPPED_HOOK_FLAG] = true;
457
- return wrappedHook;
458
- }
459
-
460
- /**
461
- * Instruments Mongoose with OpenTelemetry tracing.
462
- *
463
- * Supports Mongoose 8+ with promise-based API only.
464
- * Patches Model methods, Query methods, and user-defined Schema hooks to create spans.
465
- *
466
- * **IMPORTANT:** Call `instrumentMongoose()` BEFORE defining schemas/models
467
- * to ensure hooks are automatically instrumented.
468
- *
469
- * @example
470
- * ```typescript
471
- * import mongoose from 'mongoose';
472
- * import { init } from 'autotel';
473
- * import { instrumentMongoose } from 'autotel-plugins/mongoose';
474
- *
475
- * init({ service: 'my-app' });
476
- *
477
- * // Call BEFORE defining schemas
478
- * instrumentMongoose(mongoose, { dbName: 'myapp' });
479
- *
480
- * const userSchema = new mongoose.Schema({ name: String });
481
- * const User = mongoose.model('User', userSchema);
482
- *
483
- * // All operations are automatically traced
484
- * await User.findOne({}).populate('posts').exec();
485
- * ```
486
- */
487
- export function instrumentMongoose(
488
- mongoose: Mongoose,
489
- config?: MongooseInstrumentationConfig,
490
- ): Mongoose {
491
- if (!mongoose?.Model) {
492
- return mongoose;
493
- }
494
-
495
- const m = mongoose as any;
496
- if (m[INSTRUMENTED_FLAG]) {
497
- return mongoose;
498
- }
499
-
500
- const finalConfig: Required<MongooseInstrumentationConfig> = {
501
- dbName: config?.dbName || '',
502
- peerName: config?.peerName || '',
503
- peerPort: config?.peerPort || 27_017,
504
- tracerName: config?.tracerName || DEFAULT_TRACER_NAME,
505
- captureCollectionName: config?.captureCollectionName ?? true,
506
- instrumentHooks: config?.instrumentHooks ?? false,
507
- };
508
-
509
- const tracer = trace.getTracer(finalConfig.tracerName);
510
-
511
- // Patch Schema hooks only if enabled
512
- if (m.Schema && finalConfig.instrumentHooks) {
513
- patchSchemaHooks(m.Schema, tracer, finalConfig);
514
- }
515
-
516
- // Helper functions
517
- const getModelCollectionName = (model: any) => {
518
- try {
519
- return model.collection?.collectionName || model.modelName;
520
- } catch {
521
- return;
522
- }
523
- };
524
-
525
- // Patch Query methods
526
- const queryMethods: Array<{ method: string; operation: string }> = [
527
- { method: 'find', operation: 'find' },
528
- { method: 'findOne', operation: 'findOne' },
529
- { method: 'findById', operation: 'findById' },
530
- { method: 'findOneAndUpdate', operation: 'findOneAndUpdate' },
531
- { method: 'findOneAndDelete', operation: 'findOneAndDelete' },
532
- { method: 'findOneAndReplace', operation: 'findOneAndReplace' },
533
- { method: 'deleteOne', operation: 'deleteOne' },
534
- { method: 'deleteMany', operation: 'deleteMany' },
535
- { method: 'updateOne', operation: 'updateOne' },
536
- { method: 'updateMany', operation: 'updateMany' },
537
- { method: 'countDocuments', operation: 'countDocuments' },
538
- { method: 'estimatedDocumentCount', operation: 'estimatedDocumentCount' },
539
- ];
540
-
541
- for (const { method, operation } of queryMethods) {
542
- wrapQueryMethod(
543
- m.Model,
544
- method,
545
- operation,
546
- getModelCollectionName,
547
- (model: any) => model.modelName,
548
- tracer,
549
- finalConfig,
550
- );
551
-
552
- // Also patch chainable Query methods to capture context
553
- if (m.Query?.prototype?.[method]) {
554
- wrapChainableMethod(m.Query.prototype, method);
555
- }
556
- }
557
-
558
- // Patch Model instance methods
559
- const instanceMethods = ['save', 'deleteOne'];
560
- for (const method of instanceMethods) {
561
- if (m.Model.prototype[method]) {
562
- wrapQueryMethod(
563
- m.Model.prototype,
564
- method,
565
- method,
566
- (doc: any) => {
567
- try {
568
- return (
569
- doc.constructor?.collection?.collectionName ||
570
- doc.constructor?.modelName
571
- );
572
- } catch {
573
- return;
574
- }
575
- },
576
- (doc: any) => {
577
- try {
578
- return doc.constructor?.modelName;
579
- } catch {
580
- return;
581
- }
582
- },
583
- tracer,
584
- finalConfig,
585
- );
586
- }
587
- }
588
-
589
- // Patch Model static methods
590
- const staticMethods = ['create', 'insertMany', 'aggregate', 'bulkWrite'];
591
- for (const method of staticMethods) {
592
- if (m.Model[method]) {
593
- wrapQueryMethod(
594
- m.Model,
595
- method,
596
- method,
597
- (model: any) => {
598
- try {
599
- return model.collection?.collectionName;
600
- } catch {
601
- return;
602
- }
603
- },
604
- (model: any) => model.modelName,
605
- tracer,
606
- finalConfig,
607
- );
608
- }
609
- }
610
-
611
- // Patch Query chainable methods
612
- const chainableMethods = [
613
- 'populate',
614
- 'select',
615
- 'lean',
616
- 'where',
617
- 'sort',
618
- 'limit',
619
- 'skip',
620
- ];
621
- for (const method of chainableMethods) {
622
- if (m.Query?.prototype?.[method]) {
623
- wrapChainableMethod(m.Query.prototype, method);
624
- }
625
- }
626
-
627
- m[INSTRUMENTED_FLAG] = true;
628
- return mongoose;
629
- }
630
-
631
- /**
632
- * Legacy export for backwards compatibility.
633
- * @deprecated Use `instrumentMongoose` instead.
634
- */
635
- export class MongooseInstrumentation {
636
- constructor(private config?: MongooseInstrumentationConfig) {}
637
-
638
- enable(mongoose: Mongoose): void {
639
- instrumentMongoose(mongoose, this.config);
640
- }
641
- }