@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.
- package/LICENSE +202 -0
- package/README.md +158 -0
- package/index.d.ts +11 -0
- package/index.d.ts.map +1 -0
- package/index.js +9 -0
- package/lib/context.d.ts +37 -0
- package/lib/context.d.ts.map +1 -0
- package/lib/context.js +8 -0
- package/lib/event-sink.d.ts +23 -0
- package/lib/event-sink.d.ts.map +1 -0
- package/lib/event-sink.js +110 -0
- package/lib/instrument.d.ts +17 -0
- package/lib/instrument.d.ts.map +1 -0
- package/lib/instrument.js +160 -0
- package/lib/lensmcp-app.d.ts +24 -0
- package/lib/lensmcp-app.d.ts.map +1 -0
- package/lib/lensmcp-app.js +43 -0
- package/lib/lensmcp.module.d.ts +27 -0
- package/lib/lensmcp.module.d.ts.map +1 -0
- package/lib/lensmcp.module.js +70 -0
- package/lib/provider-tracker.d.ts +28 -0
- package/lib/provider-tracker.d.ts.map +1 -0
- package/lib/provider-tracker.js +315 -0
- package/lib/state.d.ts +5 -0
- package/lib/state.d.ts.map +1 -0
- package/lib/state.js +43 -0
- package/lib/trace-interceptor.d.ts +16 -0
- package/lib/trace-interceptor.d.ts.map +1 -0
- package/lib/trace-interceptor.js +194 -0
- package/lib/trace-method.d.ts +28 -0
- package/lib/trace-method.d.ts.map +1 -0
- package/lib/trace-method.js +31 -0
- package/lib/trace-provider.d.ts +7 -0
- package/lib/trace-provider.d.ts.map +1 -0
- package/lib/trace-provider.js +25 -0
- package/lib/types.d.ts +52 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +1 -0
- package/package.json +53 -0
|
@@ -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"}
|