peekinside 1.2.5 → 1.3.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/README.md +5 -2
- package/index.d.ts +41 -12
- package/package.json +10 -1
- package/src/PeekInside.js +70 -14
- package/src/capture/exchange-recorder.js +5 -12
- package/src/capture/http-message.js +17 -0
- package/src/dumps/dump-capture.js +100 -0
- package/src/dumps/dump-record.js +57 -0
- package/src/dumps/dump-value.js +428 -0
- package/src/exceptions/exception-capture.js +5 -4
- package/src/exceptions/request-context.js +118 -10
- package/src/hub/ingest-payload.js +66 -6
- package/src/hub/project-log.js +71 -4
- package/src/hub/server.js +18 -7
- package/src/hub/spool-ingester.js +1 -0
- package/src/hub/store.js +44 -1
- package/src/instrumentation/index.js +48 -3
- package/src/instrumentation/jobs.js +93 -2
- package/src/instrumentation/loggers.js +78 -4
- package/src/integrations/fastify.js +18 -1
- package/src/integrations/hono.js +209 -0
- package/src/integrations/nest.js +277 -0
- package/src/integrations/server-binder.js +9 -6
- package/src/logs/log-capture.js +5 -5
- package/src/logs/log-record.js +2 -2
- package/src/record.js +6 -2
- package/src/timeline/timeline.js +24 -3
- package/src/transport/batch.js +4 -1
- package/src/transport/cloud.js +12 -0
- package/src/transport/local.js +7 -5
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NestJS integration (Nest 10 and later, on Express or Fastify).
|
|
3
|
+
*
|
|
4
|
+
* Nest answers errors itself: its exception filters run before Express or Fastify see an error,
|
|
5
|
+
* and its default filter answers 500 and logs it. So PeekInside hooks into Nest's own pieces,
|
|
6
|
+
* reached through the app object, which makes the copy of Nest the app runs the one that is
|
|
7
|
+
* patched, whichever module system loaded it:
|
|
8
|
+
*
|
|
9
|
+
* - Requests are recorded from the HTTP server Nest created, before Express or Fastify runs, so
|
|
10
|
+
* bodies are captured as they arrive and middleware order doesn't matter.
|
|
11
|
+
* - Each route handler Nest builds (RouterExplorer#createCallbackProxy) is wrapped to note the
|
|
12
|
+
* controller method and the route pattern on the request, before guards, pipes and
|
|
13
|
+
* interceptors run.
|
|
14
|
+
* - Every error of an HTTP request passes through ExceptionsHandler#next. One that an exception
|
|
15
|
+
* filter of the app catches is handled, unless the filter hands it to Nest's default answer
|
|
16
|
+
* (BaseExceptionFilter#catch, what `super.catch()` calls); an error that gets the default answer
|
|
17
|
+
* is unhandled. Errors with a 4xx status (NotFoundException, BadRequestException...) are how an
|
|
18
|
+
* app answers bad requests, and aren't recorded.
|
|
19
|
+
* - Nest's Logger lines and @nestjs/schedule's jobs are recorded too (see
|
|
20
|
+
* instrumentation/loggers.js and instrumentation/jobs.js).
|
|
21
|
+
*
|
|
22
|
+
* Nest registers routes and scheduled methods when the app initializes, so attach before
|
|
23
|
+
* app.listen() (or app.init()). A worker made with NestFactory.createApplicationContext() is
|
|
24
|
+
* initialized by then: for its scheduled methods, PeekInside must exist before the worker does
|
|
25
|
+
* (instrumentation/index.js patches @nestjs/schedule when PeekInside is created). Every step is
|
|
26
|
+
* feature-detected: a Nest version that doesn't fit is left alone.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { LOG_PREFIX } from '../config.js';
|
|
30
|
+
import { RequestContext } from '../exceptions/request-context.js';
|
|
31
|
+
import { JobInstrumentation } from '../instrumentation/jobs.js';
|
|
32
|
+
import { LoggerInstrumentation } from '../instrumentation/loggers.js';
|
|
33
|
+
import { FrameworkAdapter } from './framework-adapter.js';
|
|
34
|
+
|
|
35
|
+
const kRoutes = Symbol('peekInside.nestRoutes');
|
|
36
|
+
const kErrors = Symbol('peekInside.nestErrors');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Records a Nest app's requests from its HTTP server, and hooks into Nest for the rest.
|
|
40
|
+
*/
|
|
41
|
+
export class NestAdapter extends FrameworkAdapter {
|
|
42
|
+
static framework = 'nest';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {*} app
|
|
46
|
+
* @returns {boolean} Whether `app` looks like a Nest application (NestFactory.create()) or
|
|
47
|
+
* application context (NestFactory.createApplicationContext(), such as a worker)
|
|
48
|
+
*/
|
|
49
|
+
static matches(app) {
|
|
50
|
+
return Boolean(app) && typeof app.select === 'function' && typeof app.useLogger === 'function' && typeof app.resolve === 'function';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Binds the app's HTTP server (an application context has none) and installs the hooks. Logs and
|
|
55
|
+
* jobs are only hooked for an instance that records.
|
|
56
|
+
*
|
|
57
|
+
* @param {Object} app - What NestFactory.create() returned
|
|
58
|
+
* @returns {void}
|
|
59
|
+
*/
|
|
60
|
+
attach(app) {
|
|
61
|
+
const { options } = this.peekInside;
|
|
62
|
+
const records = Boolean(this.peekInside._transport);
|
|
63
|
+
const installed = NestHooks.install(app, {
|
|
64
|
+
exceptions: options.captureExceptions !== false,
|
|
65
|
+
logs: records && options.captureLogs !== false,
|
|
66
|
+
jobs: records && options.captureJobs !== false
|
|
67
|
+
});
|
|
68
|
+
if (installed.lateJobs) {
|
|
69
|
+
console.warn(`${LOG_PREFIX} This NestJS app set up its scheduled jobs before PeekInside existed, so their failures and their @Interval and @Timeout runs aren't recorded. Create PeekInside first (new PeekInside()), then attach it with peekInside.attachTo(app).`);
|
|
70
|
+
}
|
|
71
|
+
const server = httpServerOf(app);
|
|
72
|
+
if (server) this.servers.bind(server, { interceptRequests: true, integration: 'nest' });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Installs the hooks into a Nest app.
|
|
78
|
+
*/
|
|
79
|
+
export class NestHooks {
|
|
80
|
+
/**
|
|
81
|
+
* @param {Object} app
|
|
82
|
+
* @param {Object} [options]
|
|
83
|
+
* @param {boolean} [options.exceptions=true] - Record the errors Nest answers
|
|
84
|
+
* @param {boolean} [options.logs=true] - Record Nest's Logger lines (ConsoleLogger)
|
|
85
|
+
* @param {boolean} [options.jobs=true] - Record @nestjs/schedule's jobs
|
|
86
|
+
* @returns {{ routes: boolean, errors: boolean, logs: boolean, jobs: boolean, lateJobs: boolean }}
|
|
87
|
+
* What was hooked, and whether scheduled methods were registered before the scheduler was
|
|
88
|
+
*/
|
|
89
|
+
static install(app, { exceptions = true, logs = true, jobs = true } = {}) {
|
|
90
|
+
const installed = { routes: false, errors: false, logs: false, jobs: false, lateJobs: false };
|
|
91
|
+
try {
|
|
92
|
+
const resolver = read(app, 'routesResolver');
|
|
93
|
+
installed.routes = patchRoutes(read(resolver, 'routerExplorer'));
|
|
94
|
+
if (exceptions) installed.errors = patchErrors(read(resolver, 'routerExceptionsFilter'));
|
|
95
|
+
if (logs) installed.logs = LoggerInstrumentation.nest(activeLogger(app));
|
|
96
|
+
const explorer = jobs ? scheduleExplorer(app) : null;
|
|
97
|
+
if (explorer) {
|
|
98
|
+
installed.jobs = JobInstrumentation.nestSchedule(explorer);
|
|
99
|
+
installed.lateJobs = registeredBefore(explorer);
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// Whatever was hooked stays; the rest is left alone.
|
|
103
|
+
}
|
|
104
|
+
return installed;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Wraps the handler Nest builds for each route: it notes the controller method and the route the
|
|
109
|
+
// request matched before Nest runs guards, pipes, interceptors and the method.
|
|
110
|
+
function patchRoutes(explorer) {
|
|
111
|
+
const proto = explorer ? Object.getPrototypeOf(explorer) : null;
|
|
112
|
+
if (!proto || typeof proto.createCallbackProxy !== 'function') return false;
|
|
113
|
+
if (proto[kRoutes]) return true;
|
|
114
|
+
const original = proto.createCallbackProxy;
|
|
115
|
+
proto.createCallbackProxy = function (instance, callback, methodName) {
|
|
116
|
+
const proxy = original.apply(this, arguments);
|
|
117
|
+
if (typeof proxy !== 'function') return proxy;
|
|
118
|
+
const handler = handlerName(instance, methodName);
|
|
119
|
+
return function (req, res, next) {
|
|
120
|
+
noteRoute(req, handler);
|
|
121
|
+
return proxy.apply(this, arguments);
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
Object.defineProperty(proto, kRoutes, { value: true });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Observes ExceptionsHandler#next and BaseExceptionFilter#catch. ExceptionsHandler isn't exported
|
|
129
|
+
// by every Nest version: the app's RouterExceptionFilters creates one, which gives its prototype.
|
|
130
|
+
function patchErrors(filters) {
|
|
131
|
+
let sample = null;
|
|
132
|
+
try {
|
|
133
|
+
sample = filters && typeof filters.create === 'function' ? filters.create({}, function handler() {}, undefined) : null;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
sample = null;
|
|
136
|
+
}
|
|
137
|
+
const proto = sample ? Object.getPrototypeOf(sample) : null;
|
|
138
|
+
if (!proto || typeof proto.next !== 'function') return false;
|
|
139
|
+
if (proto[kErrors]) return true;
|
|
140
|
+
|
|
141
|
+
const originalNext = proto.next;
|
|
142
|
+
proto.next = function (exception, host) {
|
|
143
|
+
try {
|
|
144
|
+
if (catchesIt(this.filters, exception)) noteError(host, exception, true);
|
|
145
|
+
} catch (failure) {
|
|
146
|
+
// Never in the way of the app's error handling.
|
|
147
|
+
}
|
|
148
|
+
return originalNext.apply(this, arguments);
|
|
149
|
+
};
|
|
150
|
+
Object.defineProperty(proto, kErrors, { value: true });
|
|
151
|
+
|
|
152
|
+
const base = Object.getPrototypeOf(proto);
|
|
153
|
+
if (base && base !== Object.prototype && typeof base.catch === 'function' && !base[kErrors]) {
|
|
154
|
+
const originalCatch = base.catch;
|
|
155
|
+
base.catch = function (exception, host) {
|
|
156
|
+
noteError(host, exception, false);
|
|
157
|
+
return originalCatch.apply(this, arguments);
|
|
158
|
+
};
|
|
159
|
+
Object.defineProperty(base, kErrors, { value: true });
|
|
160
|
+
}
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Whether one of the app's exception filters catches the error, as Nest picks them: a filter
|
|
165
|
+
// with no exception types catches everything.
|
|
166
|
+
function catchesIt(filters, exception) {
|
|
167
|
+
if (!Array.isArray(filters) || !filters.length) return false;
|
|
168
|
+
return filters.some((filter) => {
|
|
169
|
+
const types = filter && Array.isArray(filter.exceptionMetatypes) ? filter.exceptionMetatypes : [];
|
|
170
|
+
return !types.length || types.some((type) => typeof type === 'function' && exception instanceof type);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// An error a filter catches stays open: the filter may still hand it to Nest's default answer.
|
|
175
|
+
function noteError(host, exception, handled) {
|
|
176
|
+
try {
|
|
177
|
+
const req = host && typeof host.getArgByIndex === 'function' ? host.getArgByIndex(0) : null;
|
|
178
|
+
const context = RequestContext.of(nodeRequest(req)) || RequestContext.current();
|
|
179
|
+
if (context && context.capture) {
|
|
180
|
+
context.capture.frameworkError(context, exception, { mechanism: 'nest', handled, open: handled });
|
|
181
|
+
}
|
|
182
|
+
} catch (failure) {
|
|
183
|
+
// Recording must never change the error the app sees.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function noteRoute(req, handler) {
|
|
188
|
+
try {
|
|
189
|
+
const raw = nodeRequest(req);
|
|
190
|
+
const context = RequestContext.of(raw);
|
|
191
|
+
if (!context) return;
|
|
192
|
+
// Express keeps the route parameters on the Node.js request; Fastify on its own request.
|
|
193
|
+
context.noteRoute({ route: routeOf(req), handler, ...(raw !== req ? { params: () => req.params } : {}) });
|
|
194
|
+
} catch (error) {
|
|
195
|
+
// The request is recorded without them.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The route pattern the platform matched: Express's Route, Fastify's route options (routerPath
|
|
200
|
+
// before Fastify 4.10).
|
|
201
|
+
function routeOf(req) {
|
|
202
|
+
if (!req) return null;
|
|
203
|
+
if (req.route && typeof req.route.path === 'string') return req.route.path;
|
|
204
|
+
const options = req.routeOptions;
|
|
205
|
+
if (options && typeof options === 'object') return typeof options.url === 'string' ? options.url : null;
|
|
206
|
+
return typeof req.routerPath === 'string' ? req.routerPath : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// "OrdersController.create"
|
|
210
|
+
function handlerName(instance, methodName) {
|
|
211
|
+
const owner = instance && instance.constructor && typeof instance.constructor.name === 'string' ? instance.constructor.name : '';
|
|
212
|
+
const method = typeof methodName === 'string' ? methodName : '';
|
|
213
|
+
return owner && method ? `${owner}.${method}` : method || null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The Node.js request behind what Nest passes around: Express's request is the Node.js one,
|
|
217
|
+
// Fastify's keeps it in `raw`.
|
|
218
|
+
function nodeRequest(req) {
|
|
219
|
+
return req && typeof req === 'object' && req.raw && typeof req.raw === 'object' ? req.raw : req;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The logger Nest's Logger writes through: its ConsoleLogger, unless the app set another one.
|
|
223
|
+
function activeLogger(app) {
|
|
224
|
+
const logger = read(app, 'logger');
|
|
225
|
+
const Logger = logger && logger.constructor;
|
|
226
|
+
return Logger && typeof Logger === 'function' ? Logger.staticInstanceRef || null : null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// @nestjs/schedule's ScheduleExplorer, one of the app's providers.
|
|
230
|
+
function scheduleExplorer(app) {
|
|
231
|
+
const container = read(app, 'container');
|
|
232
|
+
const modules = container && typeof container.getModules === 'function' ? container.getModules() : null;
|
|
233
|
+
if (!modules || typeof modules.values !== 'function') return null;
|
|
234
|
+
for (const module of modules.values()) {
|
|
235
|
+
const providers = module && module.providers;
|
|
236
|
+
if (!providers || typeof providers.values !== 'function') continue;
|
|
237
|
+
for (const wrapper of providers.values()) {
|
|
238
|
+
let instance = null;
|
|
239
|
+
try {
|
|
240
|
+
instance = wrapper ? wrapper.instance : null;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
instance = null;
|
|
243
|
+
}
|
|
244
|
+
if (instance && typeof instance.wrapFunctionInTryCatchBlocks === 'function' && typeof instance.lookupSchedulers === 'function') return instance;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Whether the scheduler registered methods before it was patched: their errors are swallowed
|
|
251
|
+
// unrecorded, and intervals and timeouts aren't job runs.
|
|
252
|
+
function registeredBefore(explorer) {
|
|
253
|
+
const orchestrator = read(explorer, 'schedulerOrchestrator');
|
|
254
|
+
return ['cronJobs', 'intervals', 'timeouts'].some((key) => {
|
|
255
|
+
const registered = read(orchestrator, key);
|
|
256
|
+
return Boolean(registered) && Object.values(registered).some((entry) => entry && typeof entry.target === 'function' && !JobInstrumentation.isRun(entry.target));
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function httpServerOf(app) {
|
|
261
|
+
try {
|
|
262
|
+
const server = typeof app.getHttpServer === 'function' ? app.getHttpServer() : null;
|
|
263
|
+
return server && typeof server.emit === 'function' ? server : null;
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// An object property of Nest's internals, or null.
|
|
270
|
+
function read(target, key) {
|
|
271
|
+
try {
|
|
272
|
+
const value = target ? target[key] : null;
|
|
273
|
+
return value && typeof value === 'object' ? value : null;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* it, and registers the app as soon as it is known) and, for plain http servers, to see every
|
|
4
4
|
* request.
|
|
5
5
|
*
|
|
6
|
-
* Servers are found three ways: passed directly (http.Server, Fastify
|
|
7
|
-
* app.listen() (Express, Koa), or discovered from the socket of a
|
|
8
|
-
* catches servers created with http.createServer(app)
|
|
6
|
+
* Servers are found three ways: passed directly (http.Server, Fastify, the server a NestJS app
|
|
7
|
+
* created), returned by a patched app.listen() (Express, Koa), or discovered from the socket of a
|
|
8
|
+
* request the app handled, which catches servers created with http.createServer(app) and Hono's
|
|
9
|
+
* serve().
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -73,10 +74,12 @@ export class ServerBinder {
|
|
|
73
74
|
* @param {import('http').Server} server
|
|
74
75
|
* @param {Object} [options]
|
|
75
76
|
* @param {boolean} [options.interceptRequests=false] - Also record every request the server
|
|
76
|
-
* emits, for plain http servers where no middleware runs
|
|
77
|
+
* emits, for plain http servers where no middleware runs, and for frameworks recorded before
|
|
78
|
+
* their own code runs (NestJS)
|
|
79
|
+
* @param {string} [options.integration='http'] - The integration recording those requests
|
|
77
80
|
* @returns {void}
|
|
78
81
|
*/
|
|
79
|
-
bind(server, { interceptRequests = false } = {}) {
|
|
82
|
+
bind(server, { interceptRequests = false, integration = 'http' } = {}) {
|
|
80
83
|
if (!server || typeof server.emit !== 'function' || this._bound.has(server)) {
|
|
81
84
|
return;
|
|
82
85
|
}
|
|
@@ -91,7 +94,7 @@ export class ServerBinder {
|
|
|
91
94
|
server.emit = function (event, req, res) {
|
|
92
95
|
if (event === 'request' && req && res) {
|
|
93
96
|
const args = arguments;
|
|
94
|
-
return peekInside._intercept(req, res, () => originalEmit.apply(this, args),
|
|
97
|
+
return peekInside._intercept(req, res, () => originalEmit.apply(this, args), integration);
|
|
95
98
|
}
|
|
96
99
|
return originalEmit.apply(this, arguments);
|
|
97
100
|
};
|
package/src/logs/log-capture.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Log lines for the Logs page: what the app writes with the console, pino
|
|
3
|
-
* request, inside a job run, or anywhere else (startup, timers), each linked to
|
|
4
|
-
* run when there is one.
|
|
2
|
+
* Log lines for the Logs page: what the app writes with the console, pino, winston or NestJS's
|
|
3
|
+
* Logger, inside a request, inside a job run, or anywhere else (startup, timers), each linked to
|
|
4
|
+
* its request or job run when there is one.
|
|
5
5
|
*
|
|
6
6
|
* Lines are rate limited per process (100 a second, bursts of 500) and below the `logLevel` option
|
|
7
7
|
* are left out, so an app that logs heavily costs a bounded amount. PeekInside's own lines are
|
|
@@ -67,8 +67,8 @@ export class LogCapture {
|
|
|
67
67
|
* @param {'debug'|'info'|'warn'|'error'} level
|
|
68
68
|
* @param {string} message - As written (formatted)
|
|
69
69
|
* @param {Object} [options]
|
|
70
|
-
* @param {'console'|'pino'|'winston'} [options.source='console']
|
|
71
|
-
* @param {*} [options.fields] - Structured data written with the line (pino, winston)
|
|
70
|
+
* @param {'console'|'pino'|'winston'|'nest'} [options.source='console']
|
|
71
|
+
* @param {*} [options.fields] - Structured data written with the line (pino, winston, nest)
|
|
72
72
|
* @returns {string|null} The record's id, or null when nothing was recorded
|
|
73
73
|
*/
|
|
74
74
|
static record(level, message, { source = 'console', fields } = {}) {
|
package/src/logs/log-record.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One log line, as the Logs page lists it (see the data contract): when, its level, where it came
|
|
3
|
-
* from (console, pino, winston), the request or job run it belongs to, and the line itself.
|
|
3
|
+
* from (console, pino, winston, nest), the request or job run it belongs to, and the line itself.
|
|
4
4
|
*
|
|
5
5
|
* PeekInside Cloud receives the line sealed: only the time, level, source and links are readable.
|
|
6
6
|
*/
|
|
@@ -18,7 +18,7 @@ export class LogRecord {
|
|
|
18
18
|
* @param {string} fields.environment
|
|
19
19
|
* @param {string|null} [fields.release=null]
|
|
20
20
|
* @param {'debug'|'info'|'warn'|'error'} fields.level
|
|
21
|
-
* @param {'console'|'pino'|'winston'} fields.source
|
|
21
|
+
* @param {'console'|'pino'|'winston'|'nest'} fields.source
|
|
22
22
|
* @param {string|null} [fields.requestId=null] - The request it was written during
|
|
23
23
|
* @param {string|null} [fields.jobRunId=null] - The job run it was written during
|
|
24
24
|
* @param {{ message: string, fields?: Object|null }} fields.summary - The line (sealed in Cloud)
|
package/src/record.js
CHANGED
|
@@ -32,8 +32,9 @@ export class RequestRecord {
|
|
|
32
32
|
* lists the first of them)
|
|
33
33
|
* @param {Object} [fields.stats] - Timeline counters: queries, queryTime, slowQueries, nPlusOne,
|
|
34
34
|
* duplicates, http, httpTime, logs (detail.timeline holds the spans); absent when the timeline is off
|
|
35
|
-
* @param {{ url: string, path: string, contentType: string, user?: Object }} fields.summary -
|
|
36
|
-
* the request list
|
|
35
|
+
* @param {{ url: string, path: string, contentType: string, route?: string, handler?: string, user?: Object }} fields.summary -
|
|
36
|
+
* Shown in the request list: with the route pattern and the handler when the framework says
|
|
37
|
+
* (NestJS, Hono, Fastify), and the user when the app set one
|
|
37
38
|
* @param {Object} fields.detail - Everything else: headers, bodies, query, client details
|
|
38
39
|
*/
|
|
39
40
|
constructor({ id, occurredAt, environment, release = null, method, status, duration, requestSize, responseSize, aborted, exceptionCount = 0, stats, summary, detail }) {
|
|
@@ -83,6 +84,8 @@ export class RequestRecord {
|
|
|
83
84
|
url: exchange.url,
|
|
84
85
|
path: exchange.path,
|
|
85
86
|
contentType: headerValue(response.headers, 'content-type'),
|
|
87
|
+
...(exchange.route ? { route: exchange.route } : {}),
|
|
88
|
+
...(exchange.handler ? { handler: exchange.handler } : {}),
|
|
86
89
|
...(exchange.user ? { user: exchange.user } : {})
|
|
87
90
|
},
|
|
88
91
|
detail: {
|
|
@@ -106,6 +109,7 @@ export class RequestRecord {
|
|
|
106
109
|
bodyEncoding: response.bodyEncoding
|
|
107
110
|
},
|
|
108
111
|
exceptions: Array.isArray(exchange.exceptions) ? exchange.exceptions.slice(0, MAX_EXCEPTION_LINKS) : [],
|
|
112
|
+
...(Array.isArray(exchange.dumps) && exchange.dumps.length ? { dumps: exchange.dumps } : {}),
|
|
109
113
|
...(exchange.timeline ? { timeline: exchange.timeline } : {})
|
|
110
114
|
}
|
|
111
115
|
});
|
package/src/timeline/timeline.js
CHANGED
|
@@ -37,7 +37,7 @@ export const TIMELINE_DEFAULTS = Object.freeze({
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
// Spans listed per request, by type.
|
|
40
|
-
const SPAN_CAPS = Object.freeze({ query: 500, http: 100, log: 200, measure: 100, exception: 10, block: 20 });
|
|
40
|
+
const SPAN_CAPS = Object.freeze({ query: 500, http: 100, log: 200, measure: 100, exception: 10, block: 20, dump: 50 });
|
|
41
41
|
const MAX_STATEMENTS = 200;
|
|
42
42
|
// Executions per statement whose parameters are kept.
|
|
43
43
|
const PARAMS_PER_STATEMENT = 20;
|
|
@@ -121,7 +121,7 @@ export class Timeline {
|
|
|
121
121
|
this.origin = origin;
|
|
122
122
|
this.closed = false;
|
|
123
123
|
this.spans = [];
|
|
124
|
-
this.listed = { query: 0, http: 0, log: 0, measure: 0, exception: 0, block: 0 };
|
|
124
|
+
this.listed = { query: 0, http: 0, log: 0, measure: 0, exception: 0, block: 0, dump: 0 };
|
|
125
125
|
this.dropped = 0;
|
|
126
126
|
this.statements = [];
|
|
127
127
|
this.byFingerprint = new Map();
|
|
@@ -263,7 +263,7 @@ export class Timeline {
|
|
|
263
263
|
*
|
|
264
264
|
* @param {'debug'|'info'|'warn'|'error'} level
|
|
265
265
|
* @param {string} message - Formatted as it was written
|
|
266
|
-
* @param {'console'|'pino'|'winston'} [source='console']
|
|
266
|
+
* @param {'console'|'pino'|'winston'|'nest'} [source='console']
|
|
267
267
|
* @returns {void}
|
|
268
268
|
*/
|
|
269
269
|
log(level, message, source = 'console') {
|
|
@@ -282,6 +282,27 @@ export class Timeline {
|
|
|
282
282
|
}
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Records a dump made during the request (local dashboard only): its label and a preview of the
|
|
287
|
+
* value; the Dumps page has the value itself.
|
|
288
|
+
*
|
|
289
|
+
* @param {{ id: string, label: string|null, summary: { preview: string } }} record - A DumpRecord
|
|
290
|
+
* @returns {void}
|
|
291
|
+
*/
|
|
292
|
+
dump(record) {
|
|
293
|
+
if (this.closed) return;
|
|
294
|
+
try {
|
|
295
|
+
if (this.listed.dump < SPAN_CAPS.dump) {
|
|
296
|
+
this.listed.dump++;
|
|
297
|
+
this.spans.push({ type: 'dump', start: this._offset(performance.now()), id: record.id, label: record.label, preview: cut(record.summary.preview, MAX_LOG) });
|
|
298
|
+
} else {
|
|
299
|
+
this.dropped++;
|
|
300
|
+
}
|
|
301
|
+
} catch (error) {
|
|
302
|
+
// The dump is still on the Dumps page.
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
285
306
|
/**
|
|
286
307
|
* Records an exception recorded during the request.
|
|
287
308
|
*
|
package/src/transport/batch.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { LOG_PREFIX } from '../config.js';
|
|
14
14
|
import { ExceptionRecord } from '../exceptions/exception-record.js';
|
|
15
15
|
import { RequestContext } from '../exceptions/request-context.js';
|
|
16
|
+
import { DumpRecord } from '../dumps/dump-record.js';
|
|
16
17
|
import { JobRunRecord } from '../jobs/job-run-record.js';
|
|
17
18
|
import { LogRecord } from '../logs/log-record.js';
|
|
18
19
|
import { RequestRecord } from '../record.js';
|
|
@@ -170,13 +171,15 @@ export class BatchTransport {
|
|
|
170
171
|
const exceptions = [];
|
|
171
172
|
const jobs = [];
|
|
172
173
|
const logs = [];
|
|
174
|
+
const dumps = [];
|
|
173
175
|
for (const record of records) {
|
|
174
176
|
if (record instanceof ExceptionRecord) exceptions.push(record);
|
|
175
177
|
else if (record instanceof JobRunRecord) jobs.push(record);
|
|
176
178
|
else if (record instanceof LogRecord) logs.push(record);
|
|
179
|
+
else if (record instanceof DumpRecord) dumps.push(record);
|
|
177
180
|
else requests.push(record);
|
|
178
181
|
}
|
|
179
|
-
return { requests, exceptions, jobs, logs };
|
|
182
|
+
return { requests, exceptions, jobs, logs, dumps };
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
/**
|
package/src/transport/cloud.js
CHANGED
|
@@ -16,6 +16,7 @@ import { settings } from '../config.js';
|
|
|
16
16
|
import { ApiKey, Sealer, SyncSealer } from '../crypto.js';
|
|
17
17
|
import { RequestContext } from '../exceptions/request-context.js';
|
|
18
18
|
import { ExceptionRecord } from '../exceptions/exception-record.js';
|
|
19
|
+
import { DumpRecord } from '../dumps/dump-record.js';
|
|
19
20
|
import { JobRunRecord } from '../jobs/job-run-record.js';
|
|
20
21
|
import { LogRecord } from '../logs/log-record.js';
|
|
21
22
|
import { RequestRecord } from '../record.js';
|
|
@@ -130,6 +131,17 @@ export class CloudTransport extends BatchTransport {
|
|
|
130
131
|
this._scheduleResend(SPOOL_RESEND_DELAY_MS);
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Queues a record for sending. Dumps are for the local dashboard: PeekInside Cloud never gets them.
|
|
136
|
+
*
|
|
137
|
+
* @param {Object} record
|
|
138
|
+
* @returns {void}
|
|
139
|
+
*/
|
|
140
|
+
enqueueRecord(record) {
|
|
141
|
+
if (record instanceof DumpRecord) return;
|
|
142
|
+
super.enqueueRecord(record);
|
|
143
|
+
}
|
|
144
|
+
|
|
133
145
|
/**
|
|
134
146
|
* Stops the background timer and the flush at exit. Sending itself stays possible: drain()
|
|
135
147
|
* still sends what is queued.
|
package/src/transport/local.js
CHANGED
|
@@ -182,14 +182,15 @@ export class LocalTransport extends BatchTransport {
|
|
|
182
182
|
const records = fatal ? [...this.sending, ...this.queue.splice(0)] : [];
|
|
183
183
|
for (const exchange of exchanges) records.push(RequestRecord.fromExchange(exchange, this.environment, this.release));
|
|
184
184
|
records.push(...exceptions);
|
|
185
|
-
const { requests, exceptions: errors, jobs, logs } = BatchTransport.split(records);
|
|
185
|
+
const { requests, exceptions: errors, jobs, logs, dumps } = BatchTransport.split(records);
|
|
186
186
|
Spool.local(settings.dataDir(), this.project.id).write({
|
|
187
187
|
project: this.project,
|
|
188
188
|
instance: this.instance,
|
|
189
189
|
requests: requests.slice(-MAX_SPOOLED_REQUESTS),
|
|
190
190
|
exceptions: errors.slice(-MAX_SPOOLED_EXCEPTIONS),
|
|
191
191
|
jobs: jobs.slice(-MAX_SPOOLED_EXCEPTIONS),
|
|
192
|
-
logs: logs.slice(-MAX_SPOOLED_REQUESTS)
|
|
192
|
+
logs: logs.slice(-MAX_SPOOLED_REQUESTS),
|
|
193
|
+
dumps: dumps.slice(-MAX_SPOOLED_EXCEPTIONS)
|
|
193
194
|
});
|
|
194
195
|
} catch (error) {
|
|
195
196
|
// A data folder that can't be written loses the records, never the app.
|
|
@@ -207,13 +208,14 @@ export class LocalTransport extends BatchTransport {
|
|
|
207
208
|
}
|
|
208
209
|
const blocked = this.blocked.take();
|
|
209
210
|
const samples = this.takeSamples();
|
|
210
|
-
const { requests, exceptions, jobs, logs } = BatchTransport.split(batch);
|
|
211
|
+
const { requests, exceptions, jobs, logs, dumps } = BatchTransport.split(batch);
|
|
211
212
|
try {
|
|
212
213
|
const answer = await this.hub.request('POST', '/api/ingest', {
|
|
213
|
-
body: { project: this.project, instance: this.instance, requests, exceptions, jobs, logs, samples, blocked: blocked.map(([, entry]) => entry) }
|
|
214
|
+
body: { project: this.project, instance: this.instance, requests, exceptions, jobs, logs, dumps, samples, blocked: blocked.map(([, entry]) => entry) }
|
|
214
215
|
});
|
|
215
216
|
if (exceptions.length && answer.acceptedExceptions === undefined) this._noteOutdatedHub();
|
|
216
217
|
else if ((jobs.length || logs.length) && answer.acceptedJobs === undefined) this._noteOutdatedHub();
|
|
218
|
+
else if (dumps.length && answer.acceptedDumps === undefined) this._noteOutdatedHub();
|
|
217
219
|
return true;
|
|
218
220
|
} catch (error) {
|
|
219
221
|
if (error.code === 'NOT_HUB') {
|
|
@@ -238,7 +240,7 @@ export class LocalTransport extends BatchTransport {
|
|
|
238
240
|
}
|
|
239
241
|
|
|
240
242
|
// A hub started before this version keeps running until it is restarted, and ignores what it
|
|
241
|
-
// doesn't know (exceptions, job runs, log lines).
|
|
243
|
+
// doesn't know (exceptions, job runs, log lines, dumps).
|
|
242
244
|
_noteOutdatedHub() {
|
|
243
245
|
if (this.outdatedHubNoted) return;
|
|
244
246
|
this.outdatedHubNoted = true;
|