@lensmcp/nest-instrumentation 1.0.0

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.
@@ -0,0 +1,315 @@
1
+ import { __decorate, __metadata } from "tslib";
2
+ import { Injectable } from '@nestjs/common';
3
+ import { ModuleRef } from '@nestjs/core';
4
+ import { fingerprint, ulid } from '@lensmcp/core';
5
+ import { trackContainer } from '@lensmcp/memory-tracker';
6
+ import { activeOptions, currentGeneration } from './state.js';
7
+ import { readTraceProvider } from './trace-provider.js';
8
+ import { traceWrap, isTraced, isIgnored } from './instrument.js';
9
+ // Nest lifecycle hooks + base Object methods we never auto-wrap.
10
+ const SKIP_METHODS = new Set([
11
+ 'constructor',
12
+ 'onModuleInit',
13
+ 'onApplicationBootstrap',
14
+ 'onModuleDestroy',
15
+ 'beforeApplicationShutdown',
16
+ 'onApplicationShutdown',
17
+ ]);
18
+ // Provider "class" names that are really built-ins (value/factory providers).
19
+ const BUILTIN_CLASS = /^(_|Object|String|Number|Array|Promise|Function|RegExp|Map|Set)$/;
20
+ // Marks a shared prototype already auto-instrumented, so request-scoped /
21
+ // transient providers (instantiated per request) are wrapped exactly once.
22
+ const LENSMCP_PROTO_WRAPPED = Symbol.for('@lensmcp/nest-instrumentation/proto-wrapped');
23
+ /**
24
+ * On application bootstrap, walks the Nest container and emits one
25
+ * `singleton-instance` event per provider so the `apps/nest` reducer
26
+ * can materialise `nest://providers`. On module destroy (HMR / shutdown)
27
+ * marks each instance as `disposed`.
28
+ *
29
+ * `generation` is the LensMCP module-instance counter (incremented each
30
+ * time `LensmcpModule.forRoot` is constructed), which mirrors a hot
31
+ * reload. Lets the agent ask "is the new gen serving requests, or did
32
+ * an old one stick around?".
33
+ */
34
+ let LensmcpProviderTracker = class LensmcpProviderTracker {
35
+ constructor(moduleRef) {
36
+ this.moduleRef = moduleRef;
37
+ this.active = new Map(); // logicalId → instanceId
38
+ }
39
+ onApplicationBootstrap() {
40
+ const opts = activeOptions();
41
+ const gen = currentGeneration();
42
+ // Nest's ModuleRef doesn't expose the full provider list publicly.
43
+ // Phase 3 uses a small reflection trick: ModuleRef has an internal
44
+ // `container` accessor used by interceptors. We treat it as opaque
45
+ // and use the public `get` for known classes — the deeper enumeration
46
+ // lands in Phase 3.5 once we ship a `@TraceProvider` decorator.
47
+ const internalContainer = this.moduleRef.container;
48
+ if (!internalContainer?.getModules)
49
+ return;
50
+ for (const [, module] of internalContainer.getModules()) {
51
+ // Controllers live in their OWN map (module.controllers), not in
52
+ // providers — without this loop the trace jumps from server-request
53
+ // straight to the service, hiding the controller hop the request
54
+ // actually goes through.
55
+ const controllers = module?.controllers;
56
+ if (controllers && typeof controllers.forEach === 'function') {
57
+ controllers.forEach((wrapper) => {
58
+ const instance = wrapper?.instance;
59
+ if (!instance || typeof instance !== 'object')
60
+ return;
61
+ const ctor = instance.constructor;
62
+ const cls = ctor?.name;
63
+ if (!cls || BUILTIN_CLASS.test(cls))
64
+ return;
65
+ const logicalId = `nest:controller:${cls}`;
66
+ const instanceId = `${cls}#gen${gen}#${shortHash(`${cls}:${gen}`)}`;
67
+ this.active.set(logicalId, instanceId);
68
+ opts.emit(makeProviderEvent({
69
+ logicalId,
70
+ instanceId,
71
+ generation: gen,
72
+ module: module?.metatype?.name,
73
+ scope: scopeLabel(wrapper?.scope),
74
+ lifecycle: 'active',
75
+ }));
76
+ if (opts.trace.autoInstrumentMethods && ctor) {
77
+ autoInstrumentProviderMethods(instance, ctor, cls);
78
+ }
79
+ });
80
+ }
81
+ const providers = module?.providers;
82
+ if (!providers || typeof providers.forEach !== 'function')
83
+ continue;
84
+ providers.forEach((wrapper) => {
85
+ const instance = wrapper?.instance;
86
+ // Request-scoped / transient providers get a fresh instance per
87
+ // request/injection. Nest does create a *static-context* instance at
88
+ // bootstrap, but wrapping that one instance wouldn't cover the
89
+ // per-request ones — so for any non-singleton scope we wrap the
90
+ // shared prototype (traceWrap keeps `this`, so every per-request
91
+ // instance is traced). Gate on scope, not instance presence.
92
+ if (scopeLabel(wrapper?.scope) !== 'SINGLETON') {
93
+ this.trackScopedProvider(wrapper, module, gen, opts);
94
+ return;
95
+ }
96
+ if (!instance || typeof instance !== 'object')
97
+ return;
98
+ const ctor = instance.constructor;
99
+ const cls = ctor?.name;
100
+ if (!cls || BUILTIN_CLASS.test(cls))
101
+ return;
102
+ const logicalId = `nest:provider:${cls}`;
103
+ const instanceId = `${cls}#gen${gen}#${shortHash(`${cls}:${gen}`)}`;
104
+ this.active.set(logicalId, instanceId);
105
+ opts.emit(makeProviderEvent({
106
+ logicalId,
107
+ instanceId,
108
+ generation: gen,
109
+ module: module?.metatype?.name,
110
+ scope: scopeLabel(wrapper?.scope),
111
+ lifecycle: 'active',
112
+ }));
113
+ // Phase 7: memory container tracking. Wrap Map/Set/Array fields
114
+ // on tagged providers (or all providers when scope === 'all').
115
+ if (opts.memory.mode !== 'off') {
116
+ const tag = readTraceProvider(ctor);
117
+ const shouldTrack = opts.memory.scope === 'all' || (tag?.memory ?? false);
118
+ if (shouldTrack) {
119
+ trackProviderContainers(instance, instanceId);
120
+ }
121
+ }
122
+ // Phase 8: zero-config — auto-wrap this provider's methods with
123
+ // span + db-call tracing (no @TraceMethod needed). Skips methods
124
+ // already wrapped (@TraceMethod) or marked @LensmcpIgnore.
125
+ if (opts.trace.autoInstrumentMethods && ctor) {
126
+ autoInstrumentProviderMethods(instance, ctor, cls);
127
+ }
128
+ });
129
+ }
130
+ }
131
+ onModuleDestroy() {
132
+ const opts = activeOptions();
133
+ const gen = currentGeneration();
134
+ for (const [logicalId, instanceId] of this.active) {
135
+ opts.emit(makeProviderEvent({
136
+ logicalId,
137
+ instanceId,
138
+ generation: gen,
139
+ lifecycle: 'disposed',
140
+ }));
141
+ }
142
+ }
143
+ /**
144
+ * Handle a provider with no bootstrap instance. Request-scoped and
145
+ * transient providers fall here; value/factory providers (no class
146
+ * `metatype`) are ignored. Wraps the class's shared prototype once and
147
+ * emits a provider event with the real scope.
148
+ */
149
+ trackScopedProvider(wrapper, module, gen, opts) {
150
+ // Prefer the class metatype; fall back to the static-context instance's
151
+ // constructor when the wrapper doesn't expose a metatype.
152
+ let ctorUnknown;
153
+ if (typeof wrapper?.metatype === 'function') {
154
+ ctorUnknown = wrapper.metatype;
155
+ }
156
+ else if (wrapper?.instance && typeof wrapper.instance === 'object') {
157
+ ctorUnknown = wrapper.instance.constructor;
158
+ }
159
+ if (typeof ctorUnknown !== 'function')
160
+ return; // value/factory provider
161
+ const ctor = ctorUnknown;
162
+ if (!ctor.prototype)
163
+ return;
164
+ const cls = ctor.name;
165
+ if (!cls || BUILTIN_CLASS.test(cls))
166
+ return;
167
+ const scope = scopeLabel(wrapper?.scope);
168
+ const logicalId = `nest:provider:${cls}`;
169
+ const instanceId = `${cls}#gen${gen}#${scope.toLowerCase()}`;
170
+ if (!this.active.has(logicalId))
171
+ this.active.set(logicalId, instanceId);
172
+ opts.emit(makeProviderEvent({
173
+ logicalId,
174
+ instanceId,
175
+ generation: gen,
176
+ module: module?.metatype?.name,
177
+ scope,
178
+ lifecycle: 'active',
179
+ }));
180
+ if (opts.trace.autoInstrumentMethods) {
181
+ autoInstrumentPrototypeMethods(ctor, cls);
182
+ }
183
+ }
184
+ };
185
+ LensmcpProviderTracker = __decorate([
186
+ Injectable(),
187
+ __metadata("design:paramtypes", [ModuleRef])
188
+ ], LensmcpProviderTracker);
189
+ export { LensmcpProviderTracker };
190
+ /** Normalise Nest's scope (enum number or string) to a stable label. */
191
+ function scopeLabel(scope) {
192
+ if (scope === 1 || scope === 'TRANSIENT')
193
+ return 'TRANSIENT';
194
+ if (scope === 2 || scope === 'REQUEST')
195
+ return 'REQUEST';
196
+ return 'SINGLETON';
197
+ }
198
+ function makeProviderEvent(p) {
199
+ const opts = activeOptions();
200
+ return {
201
+ id: ulid(),
202
+ sessionId: opts.sessionId,
203
+ timestamp: Date.now(),
204
+ source: 'nestjs',
205
+ category: 'backend',
206
+ severity: 'info',
207
+ context: { sessionId: opts.sessionId },
208
+ fingerprint: fingerprint({ kind: 'nest-provider', identity: p.logicalId }),
209
+ title: `Nest provider ${p.logicalId} ${p.lifecycle}`,
210
+ raw: {
211
+ kind: 'singleton-instance',
212
+ provider: { ...p, createdAt: Date.now() },
213
+ },
214
+ };
215
+ }
216
+ function shortHash(input) {
217
+ let h = 5381;
218
+ for (let i = 0; i < input.length; i++)
219
+ h = ((h << 5) + h + input.charCodeAt(i)) | 0;
220
+ return (h >>> 0).toString(16).slice(0, 6);
221
+ }
222
+ /**
223
+ * Scan an instance's own enumerable fields for Map/Set/Array containers
224
+ * and wrap them so mutations emit memory-mutation events.
225
+ */
226
+ function trackProviderContainers(instance, ownerInstanceId) {
227
+ for (const fieldName of Object.keys(instance)) {
228
+ const value = instance[fieldName];
229
+ if (value instanceof Map || value instanceof Set || Array.isArray(value)) {
230
+ trackContainer({ ownerInstanceId, fieldName, container: value });
231
+ }
232
+ }
233
+ }
234
+ /**
235
+ * Replace each of a provider's own prototype methods with a traced
236
+ * wrapper, in place on the instance. Wraps on the instance (not the
237
+ * shared prototype) so two providers of the same class don't collide and
238
+ * `this` stays bound. Skips lifecycle hooks, getters/setters,
239
+ * non-functions, already-traced (@TraceMethod), and @LensmcpIgnore.
240
+ */
241
+ function autoInstrumentProviderMethods(instance, ctor, cls) {
242
+ if (isIgnored(ctor))
243
+ return;
244
+ const proto = Object.getPrototypeOf(instance);
245
+ if (!proto || proto === Object.prototype)
246
+ return;
247
+ for (const name of Object.getOwnPropertyNames(proto)) {
248
+ if (SKIP_METHODS.has(name))
249
+ continue;
250
+ const desc = Object.getOwnPropertyDescriptor(proto, name);
251
+ if (!desc || typeof desc.value !== 'function' || desc.get || desc.set)
252
+ continue;
253
+ const original = desc.value;
254
+ if (isTraced(original) || isIgnored(original))
255
+ continue;
256
+ const wrapped = traceWrap(original, `${cls}.${name}`);
257
+ try {
258
+ // Define on the instance so we don't mutate the shared prototype.
259
+ Object.defineProperty(instance, name, {
260
+ value: wrapped,
261
+ writable: true,
262
+ enumerable: false,
263
+ configurable: true,
264
+ });
265
+ }
266
+ catch {
267
+ /* read-only / exotic — skip */
268
+ }
269
+ }
270
+ }
271
+ /**
272
+ * Auto-wrap the methods on a class's shared prototype, in place. Used for
273
+ * request-scoped / transient providers, which have no instance at bootstrap
274
+ * — every per-request instance resolves these methods from the prototype,
275
+ * and `traceWrap` preserves `this`, so each call is traced correctly.
276
+ * Marked once on the prototype so HMR generations don't re-wrap.
277
+ */
278
+ function autoInstrumentPrototypeMethods(metatype, cls) {
279
+ if (isIgnored(metatype))
280
+ return;
281
+ const proto = metatype.prototype;
282
+ if (!proto || proto === Object.prototype)
283
+ return;
284
+ const marker = proto;
285
+ if (marker[LENSMCP_PROTO_WRAPPED])
286
+ return;
287
+ const wrappedNames = [];
288
+ for (const name of Object.getOwnPropertyNames(proto)) {
289
+ if (SKIP_METHODS.has(name))
290
+ continue;
291
+ const desc = Object.getOwnPropertyDescriptor(proto, name);
292
+ if (!desc || typeof desc.value !== 'function' || desc.get || desc.set)
293
+ continue;
294
+ const original = desc.value;
295
+ if (isTraced(original) || isIgnored(original))
296
+ continue;
297
+ const wrapped = traceWrap(original, `${cls}.${name}`);
298
+ try {
299
+ Object.defineProperty(proto, name, {
300
+ value: wrapped,
301
+ writable: true,
302
+ enumerable: false,
303
+ configurable: true,
304
+ });
305
+ wrappedNames.push(name);
306
+ }
307
+ catch {
308
+ /* read-only / exotic — skip */
309
+ }
310
+ }
311
+ marker[LENSMCP_PROTO_WRAPPED] = true;
312
+ if (process.env['LENSMCP_DEBUG']) {
313
+ console.error(`[lensmcp] proto-wrapped ${cls}: [${wrappedNames.join(', ')}]`);
314
+ }
315
+ }
package/lib/state.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { LensmcpModuleOptions, ResolvedOptions } from './types.js';
2
+ export declare function setActiveOptions(opts: LensmcpModuleOptions): ResolvedOptions;
3
+ export declare function activeOptions(): ResolvedOptions;
4
+ export declare function currentGeneration(): number;
5
+ //# sourceMappingURL=state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../src/lib/state.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAUxE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,oBAAoB,GAAG,eAAe,CAuB5E;AAED,wBAAgB,aAAa,IAAI,eAAe,CAQ/C;AAED,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C"}
package/lib/state.js ADDED
@@ -0,0 +1,43 @@
1
+ import { ulid } from '@lensmcp/core';
2
+ import { defaultEventSink } from './event-sink.js';
3
+ /**
4
+ * Per-process singleton holding the resolved instrumentation options.
5
+ * Tied to `LensmcpModule.forRoot()` — Nest constructs the module once
6
+ * per app boot, so `setActiveOptions` runs exactly once per generation.
7
+ */
8
+ let active;
9
+ let generation = 0;
10
+ export function setActiveOptions(opts) {
11
+ const resolved = {
12
+ projectName: opts.projectName,
13
+ sessionId: opts.sessionId ?? process.env['LENSMCP_SESSION_ID'] ?? ulid(),
14
+ emit: opts.emit ?? defaultEventSink(),
15
+ trace: {
16
+ requests: opts.trace?.requests ?? true,
17
+ guards: opts.trace?.guards ?? true,
18
+ controllers: opts.trace?.controllers ?? true,
19
+ services: opts.trace?.services ?? true,
20
+ db: opts.trace?.db ?? false,
21
+ redis: opts.trace?.redis ?? false,
22
+ queues: opts.trace?.queues ?? false,
23
+ autoInstrumentMethods: opts.trace?.autoInstrumentMethods ?? false,
24
+ },
25
+ memory: {
26
+ mode: opts.memory?.mode ?? 'off',
27
+ scope: opts.memory?.scope ?? 'tagged',
28
+ },
29
+ };
30
+ active = resolved;
31
+ generation += 1;
32
+ return resolved;
33
+ }
34
+ export function activeOptions() {
35
+ if (!active) {
36
+ throw new Error('@lensmcp/nest-instrumentation: LensmcpModule.forRoot(...) has not been registered. ' +
37
+ 'Did you forget to add it to your AppModule?');
38
+ }
39
+ return active;
40
+ }
41
+ export function currentGeneration() {
42
+ return generation;
43
+ }
@@ -0,0 +1,16 @@
1
+ import { type CallHandler, type ExecutionContext, type NestInterceptor } from '@nestjs/common';
2
+ import { Observable } from 'rxjs';
3
+ /**
4
+ * Wraps every Nest HTTP request:
5
+ * 1. Reads `x-request-id`, `x-lensmcp-flow-id`, `x-lensmcp-origin-node-id`,
6
+ * `traceparent` headers from the incoming request.
7
+ * 2. Runs the rest of the request inside a new
8
+ * `LensmcpRequestContext` so any `@TraceMethod`-decorated service
9
+ * call, DB query, or queue job downstream inherits the same flow.
10
+ * 3. Emits a `server-request` event on completion with method, route,
11
+ * status, and duration.
12
+ */
13
+ export declare class TraceInterceptor implements NestInterceptor {
14
+ intercept(execCtx: ExecutionContext, next: CallHandler): Observable<unknown>;
15
+ }
16
+ //# sourceMappingURL=trace-interceptor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trace-interceptor.d.ts","sourceRoot":"","sources":["../../src/lib/trace-interceptor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,WAAW,EAAE,KAAK,gBAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAC3G,OAAO,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAQlC;;;;;;;;;GASG;AACH,qBACa,gBAAiB,YAAW,eAAe;IACtD,SAAS,CAAC,OAAO,EAAE,gBAAgB,EAAE,IAAI,EAAE,WAAW,GAAG,UAAU,CAAC,OAAO,CAAC;CA6E7E"}
@@ -0,0 +1,194 @@
1
+ import { __decorate } from "tslib";
2
+ import { Injectable } from '@nestjs/common';
3
+ import { Observable } from 'rxjs';
4
+ import { catchError, tap } from 'rxjs/operators';
5
+ import { fingerprint, ulid } from '@lensmcp/core';
6
+ import { activeMemoryOptions, onFlowStart, onFlowSettled } from '@lensmcp/memory-tracker';
7
+ import { LENSMCP_CONTEXT_STORAGE } from './context.js';
8
+ import { activeOptions } from './state.js';
9
+ /**
10
+ * Wraps every Nest HTTP request:
11
+ * 1. Reads `x-request-id`, `x-lensmcp-flow-id`, `x-lensmcp-origin-node-id`,
12
+ * `traceparent` headers from the incoming request.
13
+ * 2. Runs the rest of the request inside a new
14
+ * `LensmcpRequestContext` so any `@TraceMethod`-decorated service
15
+ * call, DB query, or queue job downstream inherits the same flow.
16
+ * 3. Emits a `server-request` event on completion with method, route,
17
+ * status, and duration.
18
+ */
19
+ let TraceInterceptor = class TraceInterceptor {
20
+ intercept(execCtx, next) {
21
+ const opts = activeOptions();
22
+ if (!opts.trace.requests)
23
+ return next.handle();
24
+ const http = execCtx.switchToHttp();
25
+ const req = http.getRequest();
26
+ const res = http.getResponse();
27
+ const requestId = stringHeader(req.headers?.['x-request-id']) ?? ulid();
28
+ const ctx = {
29
+ sessionId: opts.sessionId,
30
+ requestId,
31
+ flowId: stringHeader(req.headers?.['x-lensmcp-flow-id']),
32
+ originNodeId: stringHeader(req.headers?.['x-lensmcp-origin-node-id']),
33
+ traceparent: stringHeader(req.headers?.['traceparent']),
34
+ dbCallCount: 0,
35
+ };
36
+ const method = (req.method ?? 'GET').toUpperCase();
37
+ const route = req.route?.path ?? req.url ?? '<unknown>';
38
+ // The controller hop, by name. Wrapping controller instances at bootstrap
39
+ // is too late — Nest's router captured the raw method reference when the
40
+ // routes were mapped — so the interceptor (which brackets the handler)
41
+ // is the seam that can see and time the controller call.
42
+ const controllerCls = execCtx.getClass?.()?.name;
43
+ const handlerName = execCtx.getHandler?.()?.name;
44
+ const startedAt = Date.now();
45
+ // Phase 7.5: bracket the request as a memory "flow" so the
46
+ // suspect-leak detector can compare tracked-container sizes at
47
+ // request start vs after it settles. Keyed by requestId.
48
+ const memoryOn = opts.memory.mode !== 'off';
49
+ if (memoryOn)
50
+ onFlowStart(requestId);
51
+ const settleAndCheck = () => {
52
+ if (!memoryOn)
53
+ return;
54
+ const settleMs = activeMemoryOptions()?.settleMs ?? 1000;
55
+ const t = setTimeout(() => onFlowSettled(requestId), settleMs);
56
+ t.unref?.();
57
+ };
58
+ return new Observable((subscriber) => {
59
+ LENSMCP_CONTEXT_STORAGE.run(ctx, () => {
60
+ next
61
+ .handle()
62
+ .pipe(tap({
63
+ next: (v) => subscriber.next(v),
64
+ complete: () => {
65
+ emitRequest(opts.projectName, ctx, method, route, res.statusCode ?? 200, startedAt);
66
+ emitControllerSpan(ctx, controllerCls, handlerName, startedAt, 'ok');
67
+ settleAndCheck();
68
+ subscriber.complete();
69
+ },
70
+ }), catchError((err) => {
71
+ // The exception filter hasn't written the response yet — Nest's
72
+ // default (201 for POST) is still on res.statusCode. The thrown
73
+ // error knows the real status: HttpException via getStatus(),
74
+ // domain errors via the `status`/`statusCode` field convention.
75
+ const e = err;
76
+ const errStatus = typeof e?.getStatus === 'function' ? e.getStatus()
77
+ : typeof e?.status === 'number' && e.status >= 100 ? e.status
78
+ : typeof e?.statusCode === 'number' && e.statusCode >= 100 ? e.statusCode
79
+ : res.statusCode && res.statusCode >= 400 ? res.statusCode
80
+ : 500;
81
+ emitRequest(opts.projectName, ctx, method, route, errStatus, startedAt, err);
82
+ emitControllerSpan(ctx, controllerCls, handlerName, startedAt, 'error');
83
+ settleAndCheck();
84
+ subscriber.error(err);
85
+ return new Observable();
86
+ }))
87
+ .subscribe();
88
+ });
89
+ });
90
+ }
91
+ };
92
+ TraceInterceptor = __decorate([
93
+ Injectable()
94
+ ], TraceInterceptor);
95
+ export { TraceInterceptor };
96
+ function stringHeader(v) {
97
+ if (typeof v === 'string')
98
+ return v;
99
+ if (Array.isArray(v) && typeof v[0] === 'string')
100
+ return v[0];
101
+ return undefined;
102
+ }
103
+ /** The controller hop as a span — `CompanyController.create (12ms)`. */
104
+ function emitControllerSpan(ctx, controllerCls, handlerName, startedAt, status) {
105
+ if (!controllerCls || !handlerName)
106
+ return;
107
+ const opts = activeOptions();
108
+ const durationMs = Date.now() - startedAt;
109
+ const name = `${controllerCls}.${handlerName}`;
110
+ opts.emit({
111
+ id: ulid(),
112
+ sessionId: ctx.sessionId,
113
+ timestamp: Date.now(),
114
+ source: 'nestjs',
115
+ category: 'backend',
116
+ severity: status === 'error' ? 'error' : 'info',
117
+ context: { sessionId: ctx.sessionId, flowId: ctx.flowId, requestId: ctx.requestId },
118
+ fingerprint: fingerprint({ kind: 'method-call', identity: name }),
119
+ title: `${name} (${durationMs}ms)${status === 'error' ? ' ✗' : ''}`,
120
+ raw: {
121
+ kind: 'span',
122
+ span: {
123
+ name,
124
+ role: 'controller',
125
+ flowId: ctx.flowId,
126
+ requestId: ctx.requestId,
127
+ startTime: startedAt,
128
+ endTime: Date.now(),
129
+ durationMs,
130
+ status,
131
+ },
132
+ },
133
+ });
134
+ }
135
+ function emitRequest(projectName, ctx, method, route, status, startedAt, err) {
136
+ const opts = activeOptions();
137
+ const durationMs = Date.now() - startedAt;
138
+ const isError = status >= 500 || err !== undefined;
139
+ const event = {
140
+ id: ulid(),
141
+ sessionId: ctx.sessionId,
142
+ timestamp: Date.now(),
143
+ source: 'nestjs',
144
+ category: 'backend',
145
+ severity: isError ? 'error' : 'info',
146
+ context: {
147
+ sessionId: ctx.sessionId,
148
+ flowId: ctx.flowId,
149
+ requestId: ctx.requestId,
150
+ originNodeId: ctx.originNodeId,
151
+ traceparent: ctx.traceparent,
152
+ },
153
+ fingerprint: fingerprint({
154
+ kind: 'server-request',
155
+ identity: `${method}:${route}:${Math.floor(status / 100)}xx`,
156
+ }),
157
+ title: `${method} ${route} → ${status} (${durationMs}ms)`,
158
+ message: err instanceof Error ? err.message : undefined,
159
+ raw: {
160
+ kind: 'server-request',
161
+ request: {
162
+ method,
163
+ route,
164
+ status,
165
+ durationMs,
166
+ startedAt,
167
+ endedAt: Date.now(),
168
+ flowId: ctx.flowId,
169
+ requestId: ctx.requestId,
170
+ },
171
+ },
172
+ };
173
+ // Also emit a span-shaped event so trace:// picks it up.
174
+ const spanEvent = {
175
+ ...event,
176
+ id: ulid(),
177
+ raw: {
178
+ kind: 'span',
179
+ span: {
180
+ name: `nest.request ${method} ${route}`,
181
+ traceId: ctx.traceparent,
182
+ flowId: ctx.flowId,
183
+ requestId: ctx.requestId,
184
+ startTime: startedAt,
185
+ endTime: Date.now(),
186
+ durationMs,
187
+ status: isError ? 'error' : 'ok',
188
+ },
189
+ },
190
+ };
191
+ void projectName; // reserved for per-project namespacing
192
+ opts.emit(event);
193
+ opts.emit(spanEvent);
194
+ }
@@ -0,0 +1,28 @@
1
+ export interface TraceMethodOptions {
2
+ /** Override the span name (default `Class.method`). */
3
+ name?: string;
4
+ /**
5
+ * Mark this method as a DB query. Each call emits a `db-query` event
6
+ * (feeding N+1 detection) and bumps the request's `dbCallCount` so an
7
+ * enclosing method is flagged db-in-loop.
8
+ */
9
+ db?: boolean;
10
+ }
11
+ /**
12
+ * `@TraceMethod()` — decorate a service/controller method so every
13
+ * invocation emits a `span` event with method name + duration. Inherits
14
+ * the request's `flowId`/`requestId` from the `AsyncLocalStorage`
15
+ * context set by `TraceInterceptor`.
16
+ *
17
+ * @Injectable()
18
+ * class AuthService {
19
+ * @TraceMethod() login(dto) { … }
20
+ * @TraceMethod({ db: true }) findUser(id) { … }
21
+ * }
22
+ *
23
+ * Wraps sync + async methods; sync throws / async rejections become
24
+ * `status=error` spans. Uses the same `traceWrap` the bootstrap
25
+ * auto-instrumenter uses, so a method is never double-wrapped.
26
+ */
27
+ export declare function TraceMethod(nameOverrideOrOptions?: string | TraceMethodOptions): MethodDecorator;
28
+ //# sourceMappingURL=trace-method.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trace-method.d.ts","sourceRoot":"","sources":["../../src/lib/trace-method.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,kBAAkB;IACjC,uDAAuD;IACvD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,CAAC,EAAE,OAAO,CAAC;CACd;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,WAAW,CACzB,qBAAqB,CAAC,EAAE,MAAM,GAAG,kBAAkB,GAClD,eAAe,CAwBjB"}
@@ -0,0 +1,31 @@
1
+ import { traceWrap } from './instrument.js';
2
+ /**
3
+ * `@TraceMethod()` — decorate a service/controller method so every
4
+ * invocation emits a `span` event with method name + duration. Inherits
5
+ * the request's `flowId`/`requestId` from the `AsyncLocalStorage`
6
+ * context set by `TraceInterceptor`.
7
+ *
8
+ * @Injectable()
9
+ * class AuthService {
10
+ * @TraceMethod() login(dto) { … }
11
+ * @TraceMethod({ db: true }) findUser(id) { … }
12
+ * }
13
+ *
14
+ * Wraps sync + async methods; sync throws / async rejections become
15
+ * `status=error` spans. Uses the same `traceWrap` the bootstrap
16
+ * auto-instrumenter uses, so a method is never double-wrapped.
17
+ */
18
+ export function TraceMethod(nameOverrideOrOptions) {
19
+ const options = typeof nameOverrideOrOptions === 'string'
20
+ ? { name: nameOverrideOrOptions }
21
+ : nameOverrideOrOptions ?? {};
22
+ return function (target, propertyKey, descriptor) {
23
+ const fn = descriptor.value;
24
+ if (typeof fn !== 'function')
25
+ return descriptor;
26
+ const className = target.constructor?.name ?? 'Anonymous';
27
+ const spanName = options.name ?? `${className}.${String(propertyKey)}`;
28
+ descriptor.value = traceWrap(fn, spanName, { db: options.db });
29
+ return descriptor;
30
+ };
31
+ }
@@ -0,0 +1,7 @@
1
+ export interface TraceProviderOptions {
2
+ /** Track this provider's container fields for memory growth. */
3
+ memory?: boolean;
4
+ }
5
+ export declare function TraceProvider(options?: TraceProviderOptions): ClassDecorator;
6
+ export declare function readTraceProvider(ctor: unknown): TraceProviderOptions | undefined;
7
+ //# sourceMappingURL=trace-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trace-provider.d.ts","sourceRoot":"","sources":["../../src/lib/trace-provider.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,oBAAoB;IACnC,gEAAgE;IAChE,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,wBAAgB,aAAa,CAAC,OAAO,GAAE,oBAAyB,GAAG,cAAc,CAMhF;AAED,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,OAAO,GACZ,oBAAoB,GAAG,SAAS,CAOlC"}