peekinside 0.0.0-stage → 1.2.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 +21 -0
- package/README.md +45 -2
- package/bin/peekinside.js +8 -0
- package/index.d.ts +389 -0
- package/index.js +3 -0
- package/package.json +77 -4
- package/src/PeekInside.js +731 -0
- package/src/capture/body-capture.js +86 -0
- package/src/capture/body-decoder.js +188 -0
- package/src/capture/exchange-recorder.js +263 -0
- package/src/capture/http-message.js +138 -0
- package/src/capture/ignored-paths.js +49 -0
- package/src/capture/request-body-tap.js +40 -0
- package/src/capture/response-tap.js +83 -0
- package/src/cli.js +254 -0
- package/src/config.js +183 -0
- package/src/crypto.js +189 -0
- package/src/deployment.js +183 -0
- package/src/exceptions/exception-builder.js +339 -0
- package/src/exceptions/exception-capture.js +407 -0
- package/src/exceptions/exception-record.js +118 -0
- package/src/exceptions/fingerprint.js +60 -0
- package/src/exceptions/frame-classifier.js +114 -0
- package/src/exceptions/limits.js +95 -0
- package/src/exceptions/process-monitor.js +192 -0
- package/src/exceptions/rate-limiter.js +50 -0
- package/src/exceptions/request-context.js +337 -0
- package/src/exceptions/safe-json.js +186 -0
- package/src/exceptions/source-context.js +144 -0
- package/src/exceptions/source-maps.js +313 -0
- package/src/exceptions/stack-parser.js +179 -0
- package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
- package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
- package/src/hub/client.js +184 -0
- package/src/hub/entry.js +4 -0
- package/src/hub/exception-state.js +97 -0
- package/src/hub/ingest-payload.js +362 -0
- package/src/hub/process.js +175 -0
- package/src/hub/project-log.js +559 -0
- package/src/hub/segment-log.js +282 -0
- package/src/hub/server.js +538 -0
- package/src/hub/spool-ingester.js +110 -0
- package/src/hub/store.js +404 -0
- package/src/instrumentation/console.js +83 -0
- package/src/instrumentation/http.js +146 -0
- package/src/instrumentation/index.js +234 -0
- package/src/instrumentation/jobs.js +186 -0
- package/src/instrumentation/loggers.js +135 -0
- package/src/instrumentation/mongodb.js +221 -0
- package/src/instrumentation/mysql2.js +161 -0
- package/src/instrumentation/operation.js +70 -0
- package/src/instrumentation/patch.js +72 -0
- package/src/instrumentation/pg.js +218 -0
- package/src/instrumentation/prisma.js +181 -0
- package/src/integrations/express-errors.js +135 -0
- package/src/integrations/express.js +57 -0
- package/src/integrations/fastify.js +72 -0
- package/src/integrations/framework-adapter.js +53 -0
- package/src/integrations/http-server.js +32 -0
- package/src/integrations/koa.js +72 -0
- package/src/integrations/server-binder.js +120 -0
- package/src/jobs/job-context.js +40 -0
- package/src/jobs/job-run-record.js +104 -0
- package/src/jobs/job-runner.js +173 -0
- package/src/logs/log-capture.js +143 -0
- package/src/logs/log-record.js +74 -0
- package/src/project.js +76 -0
- package/src/record.js +181 -0
- package/src/release.js +157 -0
- package/src/runtime/loop-monitor.js +162 -0
- package/src/runtime/sampler.js +93 -0
- package/src/spool.js +195 -0
- package/src/timeline/caller.js +129 -0
- package/src/timeline/execution-hash.js +110 -0
- package/src/timeline/lru.js +50 -0
- package/src/timeline/masking.js +266 -0
- package/src/timeline/shape.js +77 -0
- package/src/timeline/sql.js +396 -0
- package/src/timeline/timeline.js +642 -0
- package/src/transport/batch.js +324 -0
- package/src/transport/cloud.js +363 -0
- package/src/transport/local.js +354 -0
- package/src/user.js +83 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One project's requests and exceptions on disk, in its folder under <data dir>/projects:
|
|
3
|
+
*
|
|
4
|
+
* project.json name, folder, timestamps
|
|
5
|
+
* 00000001.jsonl request details, one JSON object per line
|
|
6
|
+
* 00000001.idx one request summary per line, with the offset and length of its detail
|
|
7
|
+
* exceptions/00000001.* exceptions, in the same format
|
|
8
|
+
* exception-state.json fingerprints the dashboard marked resolved
|
|
9
|
+
* jobs/00000001.* job runs, in the same format
|
|
10
|
+
* logs/00000001.* log lines: the index keeps the start of each line, the details the rest
|
|
11
|
+
*
|
|
12
|
+
* Process samples (memory, CPU, event-loop delay) are kept in memory only, for the Runtime page:
|
|
13
|
+
* the last six hours of each of the project's recent processes.
|
|
14
|
+
*
|
|
15
|
+
* Both logs are SegmentLogs (see segment-log.js): summaries in memory, details read on demand,
|
|
16
|
+
* retention by deleting whole segment files. The project keeps its newest `maxRequests` requests
|
|
17
|
+
* and `maxExceptions` exceptions.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'fs';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import { ExceptionState } from './exception-state.js';
|
|
23
|
+
import { SegmentLog } from './segment-log.js';
|
|
24
|
+
|
|
25
|
+
// A project is online while one of its processes has checked in recently.
|
|
26
|
+
const ONLINE_MS = 25_000;
|
|
27
|
+
// Processes that stopped checking in are forgotten after this long.
|
|
28
|
+
const INSTANCE_TTL_MS = 60_000;
|
|
29
|
+
// How long the dashboard keeps saying that server requests were not recorded.
|
|
30
|
+
const BLOCKED_NOTICE_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
const MAX_BLOCKED_ENTRIES = 10;
|
|
32
|
+
// Samples kept per process (six hours at one every 10 seconds), and processes kept per project.
|
|
33
|
+
const MAX_RUNTIME_SAMPLES = 2160;
|
|
34
|
+
const MAX_RUNTIME_PROCESSES = 8;
|
|
35
|
+
// Fields a summary may contain, in the order they are written.
|
|
36
|
+
const SUMMARY_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'method', 'status', 'duration', 'requestSize', 'responseSize', 'aborted', 'exceptionCount', 'stats', 'url', 'path', 'contentType', 'user'];
|
|
37
|
+
const EXCEPTION_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'handled', 'fatal', 'requestId', 'jobRunId', 'name', 'message', 'code', 'fingerprint', 'mechanism', 'location', 'request', 'job', 'user'];
|
|
38
|
+
const JOB_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'name', 'queue', 'schedule', 'timezone', 'trigger', 'status', 'duration', 'attempt', 'exceptionCount', 'stats', 'error', 'user'];
|
|
39
|
+
const LOG_FIELDS = ['seq', 'id', 'occurredAt', 'environment', 'release', 'level', 'source', 'requestId', 'jobRunId', 'message'];
|
|
40
|
+
const EXCEPTIONS_FOLDER = 'exceptions';
|
|
41
|
+
const JOBS_FOLDER = 'jobs';
|
|
42
|
+
const LOGS_FOLDER = 'logs';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {Object} ProjectInfo
|
|
46
|
+
* @property {string} id
|
|
47
|
+
* @property {string} name
|
|
48
|
+
* @property {string} root - The project's folder on this machine
|
|
49
|
+
* @property {number} createdAt
|
|
50
|
+
* @property {number} lastSeenAt - When one of its processes last checked in
|
|
51
|
+
* @property {number} seqBase - Lowest sequence number the next request may get, kept so
|
|
52
|
+
* numbering continues after the requests are cleared
|
|
53
|
+
* @property {number} exceptionSeqBase - The same, for exceptions
|
|
54
|
+
* @property {{ total: number, firstAt: number, lastAt: number, entries: Object[] }} [blocked] -
|
|
55
|
+
* Server requests that were not recorded
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The requests, exceptions, metadata and running processes of one project.
|
|
60
|
+
*/
|
|
61
|
+
export class ProjectLog {
|
|
62
|
+
/**
|
|
63
|
+
* Use ProjectLog.create() or ProjectLog.load().
|
|
64
|
+
*
|
|
65
|
+
* @param {string} dir - The project's folder
|
|
66
|
+
* @param {ProjectInfo} info
|
|
67
|
+
* @param {Object} limits
|
|
68
|
+
* @param {number} limits.maxRequests - Requests kept
|
|
69
|
+
* @param {number} limits.segmentSize - Requests per segment file
|
|
70
|
+
* @param {number} limits.maxExceptions - Exceptions kept
|
|
71
|
+
* @param {number} limits.exceptionSegmentSize - Exceptions per segment file
|
|
72
|
+
* @param {number} limits.maxJobs - Job runs kept
|
|
73
|
+
* @param {number} limits.maxLogs - Log lines kept
|
|
74
|
+
* @param {{ requests?: SegmentLog, exceptions?: SegmentLog, jobs?: SegmentLog, logs?: SegmentLog }} [logs] - Logs
|
|
75
|
+
* read from disk
|
|
76
|
+
*/
|
|
77
|
+
constructor(dir, info, limits, logs = {}) {
|
|
78
|
+
this.dir = dir;
|
|
79
|
+
this.info = info;
|
|
80
|
+
this.maxRequests = limits.maxRequests;
|
|
81
|
+
this.requests = logs.requests || new SegmentLog(dir, ProjectLog._requestOptions(info, limits));
|
|
82
|
+
this.exceptions = logs.exceptions || new SegmentLog(path.join(dir, EXCEPTIONS_FOLDER), ProjectLog._exceptionOptions(info, limits));
|
|
83
|
+
this.jobs = logs.jobs || new SegmentLog(path.join(dir, JOBS_FOLDER), ProjectLog._jobOptions(info, limits));
|
|
84
|
+
this.logs = logs.logs || new SegmentLog(path.join(dir, LOGS_FOLDER), ProjectLog._logOptions(info, limits));
|
|
85
|
+
this.exceptionState = new ExceptionState(dir);
|
|
86
|
+
// Processes running the project, by pid. In memory only: they check in again after a restart.
|
|
87
|
+
this.instances = new Map();
|
|
88
|
+
// Recent processes and their samples, by pid, least recently seen first. In memory only.
|
|
89
|
+
this.runtime = new Map();
|
|
90
|
+
// Whether info changed since it was saved.
|
|
91
|
+
this.dirty = false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Creates a project's folder and metadata.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} dir - The project's folder
|
|
98
|
+
* @param {{ id: string, name: string, root: string }} info
|
|
99
|
+
* @param {number} now
|
|
100
|
+
* @param {Object} limits - See the constructor
|
|
101
|
+
* @returns {ProjectLog}
|
|
102
|
+
*/
|
|
103
|
+
static create(dir, info, now, limits) {
|
|
104
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
105
|
+
const project = new ProjectLog(dir, {
|
|
106
|
+
id: info.id,
|
|
107
|
+
name: info.name,
|
|
108
|
+
root: info.root,
|
|
109
|
+
createdAt: now,
|
|
110
|
+
lastSeenAt: now,
|
|
111
|
+
seqBase: 1,
|
|
112
|
+
exceptionSeqBase: 1,
|
|
113
|
+
jobSeqBase: 1,
|
|
114
|
+
logSeqBase: 1
|
|
115
|
+
}, limits);
|
|
116
|
+
project.save();
|
|
117
|
+
return project;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Reads a project from its folder. Index lines cut short by a crash are skipped.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} dir - The project's folder
|
|
124
|
+
* @param {string} id - Project id (the folder name)
|
|
125
|
+
* @param {Object} limits - See the constructor
|
|
126
|
+
* @returns {ProjectLog}
|
|
127
|
+
* @throws {Error} When project.json is missing or unreadable
|
|
128
|
+
*/
|
|
129
|
+
static load(dir, id, limits) {
|
|
130
|
+
const saved = JSON.parse(fs.readFileSync(path.join(dir, 'project.json'), 'utf8'));
|
|
131
|
+
const info = {
|
|
132
|
+
id,
|
|
133
|
+
name: typeof saved.name === 'string' ? saved.name : id,
|
|
134
|
+
root: typeof saved.root === 'string' ? saved.root : '',
|
|
135
|
+
createdAt: Number(saved.createdAt) || Date.now(),
|
|
136
|
+
lastSeenAt: Number(saved.lastSeenAt) || Number(saved.createdAt) || Date.now(),
|
|
137
|
+
seqBase: Number(saved.seqBase) || 1,
|
|
138
|
+
exceptionSeqBase: Number(saved.exceptionSeqBase) || 1,
|
|
139
|
+
jobSeqBase: Number(saved.jobSeqBase) || 1,
|
|
140
|
+
logSeqBase: Number(saved.logSeqBase) || 1,
|
|
141
|
+
...(isBlockedInfo(saved.blocked) ? { blocked: saved.blocked } : {})
|
|
142
|
+
};
|
|
143
|
+
return new ProjectLog(dir, info, limits, {
|
|
144
|
+
requests: SegmentLog.load(dir, ProjectLog._requestOptions(info, limits)),
|
|
145
|
+
exceptions: SegmentLog.load(path.join(dir, EXCEPTIONS_FOLDER), ProjectLog._exceptionOptions(info, limits)),
|
|
146
|
+
jobs: SegmentLog.load(path.join(dir, JOBS_FOLDER), ProjectLog._jobOptions(info, limits)),
|
|
147
|
+
logs: SegmentLog.load(path.join(dir, LOGS_FOLDER), ProjectLog._logOptions(info, limits))
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Records that one of the project's processes checked in, and keeps the name and folder it
|
|
153
|
+
* reports. Those rarely change, so they are saved right away; the check-in time is saved
|
|
154
|
+
* later by persist().
|
|
155
|
+
*
|
|
156
|
+
* @param {{ name: string, root: string }} info
|
|
157
|
+
* @param {Object} instance - Process details: pid, port, framework, node, sdk, environment, startedAt
|
|
158
|
+
* @param {number} now
|
|
159
|
+
* @returns {void}
|
|
160
|
+
*/
|
|
161
|
+
checkIn(info, instance, now) {
|
|
162
|
+
this.rename(info);
|
|
163
|
+
this.info.lastSeenAt = now;
|
|
164
|
+
this.dirty = true;
|
|
165
|
+
this.instances.set(instance.pid, { ...instance, lastSeenAt: now });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Adds samples of one of the project's processes (memory, CPU, event-loop delay).
|
|
170
|
+
*
|
|
171
|
+
* @param {Object} instance - Process details, as checked in
|
|
172
|
+
* @param {Object[]} samples - Validated samples, oldest first
|
|
173
|
+
* @param {number} now
|
|
174
|
+
* @returns {void}
|
|
175
|
+
*/
|
|
176
|
+
addSamples(instance, samples, now) {
|
|
177
|
+
if (!samples.length) return;
|
|
178
|
+
let entry = this.runtime.get(instance.pid);
|
|
179
|
+
// A restarted process can reuse a pid: it starts its own history.
|
|
180
|
+
if (entry && entry.instance.startedAt !== instance.startedAt) entry = null;
|
|
181
|
+
if (!entry) entry = { instance: { ...instance }, samples: [] };
|
|
182
|
+
this.runtime.delete(instance.pid);
|
|
183
|
+
this.runtime.set(instance.pid, entry);
|
|
184
|
+
entry.instance = { ...instance };
|
|
185
|
+
entry.lastSeenAt = now;
|
|
186
|
+
const last = entry.samples.length ? entry.samples[entry.samples.length - 1].at : -Infinity;
|
|
187
|
+
for (const sample of samples) {
|
|
188
|
+
if (sample.at > last) entry.samples.push(sample);
|
|
189
|
+
}
|
|
190
|
+
if (entry.samples.length > MAX_RUNTIME_SAMPLES) entry.samples.splice(0, entry.samples.length - MAX_RUNTIME_SAMPLES);
|
|
191
|
+
while (this.runtime.size > MAX_RUNTIME_PROCESSES) this.runtime.delete(this.runtime.keys().next().value);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The project's recent processes with their samples, the most recently seen first.
|
|
196
|
+
*
|
|
197
|
+
* @param {number} now
|
|
198
|
+
* @returns {{ processes: Object[] }}
|
|
199
|
+
*/
|
|
200
|
+
runtimeHistory(now) {
|
|
201
|
+
const processes = [...this.runtime.values()].reverse().map((entry) => ({
|
|
202
|
+
...entry.instance,
|
|
203
|
+
lastSeenAt: entry.lastSeenAt,
|
|
204
|
+
online: now - entry.lastSeenAt <= ONLINE_MS,
|
|
205
|
+
samples: entry.samples
|
|
206
|
+
}));
|
|
207
|
+
return { processes };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Keeps the name and folder a process reports, without counting it as a check-in (records
|
|
212
|
+
* spooled by a process that has exited).
|
|
213
|
+
*
|
|
214
|
+
* @param {{ name: string, root: string }} info
|
|
215
|
+
* @returns {void}
|
|
216
|
+
*/
|
|
217
|
+
rename(info) {
|
|
218
|
+
if (this.info.name !== info.name || this.info.root !== info.root) {
|
|
219
|
+
this.info.name = info.name;
|
|
220
|
+
this.info.root = info.root;
|
|
221
|
+
this.save();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Adds counts of server requests that were not recorded. Proxied clients share one entry,
|
|
227
|
+
* holding the latest address as an example; only the largest entries are kept.
|
|
228
|
+
*
|
|
229
|
+
* @param {{ kind: 'host'|'client', value: string, count: number }[]} entries
|
|
230
|
+
* @param {number} now
|
|
231
|
+
* @returns {void}
|
|
232
|
+
*/
|
|
233
|
+
noteBlocked(entries, now) {
|
|
234
|
+
const blocked = this.info.blocked || { total: 0, firstAt: now, lastAt: now, entries: [] };
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
blocked.total += entry.count;
|
|
237
|
+
const existing = blocked.entries.find((item) => item.kind === entry.kind && (entry.kind === 'client' || item.value === entry.value));
|
|
238
|
+
if (existing) {
|
|
239
|
+
existing.count += entry.count;
|
|
240
|
+
existing.value = entry.value;
|
|
241
|
+
} else {
|
|
242
|
+
blocked.entries.push({ ...entry });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
blocked.entries.sort((a, b) => b.count - a.count).splice(MAX_BLOCKED_ENTRIES);
|
|
246
|
+
blocked.lastAt = now;
|
|
247
|
+
this.info.blocked = blocked;
|
|
248
|
+
this.dirty = true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Stores new requests. Requests already stored (same id) are skipped, so retried batches are
|
|
253
|
+
* harmless.
|
|
254
|
+
*
|
|
255
|
+
* @param {Object[]} records - Validated records, with their detail
|
|
256
|
+
* @returns {Object[]} Summaries of the stored requests
|
|
257
|
+
*/
|
|
258
|
+
append(records) {
|
|
259
|
+
return this.requests.append(records);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Request summaries, newest first; oldest first when `after` is given (for polling new requests).
|
|
264
|
+
*
|
|
265
|
+
* @param {{ after?: number, before?: number, limit: number }} query - Sequence numbers to page by
|
|
266
|
+
* @returns {{ requests: Object[], hasMore: boolean }}
|
|
267
|
+
*/
|
|
268
|
+
list(query) {
|
|
269
|
+
const { entries, hasMore } = this.requests.list(query);
|
|
270
|
+
return { requests: entries, hasMore };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The full detail of one request, read from disk.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} requestId
|
|
277
|
+
* @returns {Object|null} null when it is no longer stored
|
|
278
|
+
*/
|
|
279
|
+
detail(requestId) {
|
|
280
|
+
return this.requests.detail(requestId);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Deletes all requests, keeping the project.
|
|
285
|
+
*
|
|
286
|
+
* @returns {void}
|
|
287
|
+
*/
|
|
288
|
+
clear() {
|
|
289
|
+
this.requests.clear();
|
|
290
|
+
// Sequence numbers keep increasing, so open dashboards keep polling from the right place.
|
|
291
|
+
this.info.seqBase = this.requests.seqBase;
|
|
292
|
+
this.save();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Stores new exceptions; ones already stored (same id) are skipped.
|
|
297
|
+
*
|
|
298
|
+
* @param {Object[]} records - Validated exceptions, with their detail
|
|
299
|
+
* @returns {Object[]} Summaries of the stored exceptions
|
|
300
|
+
*/
|
|
301
|
+
appendExceptions(records) {
|
|
302
|
+
return this.exceptions.append(records);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Exception summaries, newest first; oldest first when `after` is given.
|
|
307
|
+
*
|
|
308
|
+
* @param {{ after?: number, before?: number, limit: number }} query
|
|
309
|
+
* @returns {{ exceptions: Object[], hasMore: boolean }}
|
|
310
|
+
*/
|
|
311
|
+
listExceptions(query) {
|
|
312
|
+
const { entries, hasMore } = this.exceptions.list(query);
|
|
313
|
+
return { exceptions: entries, hasMore };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The full detail of one exception, read from disk.
|
|
318
|
+
*
|
|
319
|
+
* @param {string} exceptionId
|
|
320
|
+
* @returns {Object|null} null when it is no longer stored
|
|
321
|
+
*/
|
|
322
|
+
exceptionDetail(exceptionId) {
|
|
323
|
+
return this.exceptions.detail(exceptionId);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Deletes all exceptions, keeping the project (and which issues are resolved).
|
|
328
|
+
*
|
|
329
|
+
* @returns {void}
|
|
330
|
+
*/
|
|
331
|
+
clearExceptions() {
|
|
332
|
+
this.exceptions.clear();
|
|
333
|
+
this.info.exceptionSeqBase = this.exceptions.seqBase;
|
|
334
|
+
this.save();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Stores new job runs; ones already stored (same id) are skipped.
|
|
339
|
+
*
|
|
340
|
+
* @param {Object[]} records - Validated job runs, with their detail
|
|
341
|
+
* @returns {Object[]} Summaries of the stored runs
|
|
342
|
+
*/
|
|
343
|
+
appendJobs(records) {
|
|
344
|
+
return this.jobs.append(records);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Job run summaries, newest first; oldest first when `after` is given.
|
|
349
|
+
*
|
|
350
|
+
* @param {{ after?: number, before?: number, limit: number }} query
|
|
351
|
+
* @returns {{ jobs: Object[], hasMore: boolean }}
|
|
352
|
+
*/
|
|
353
|
+
listJobs(query) {
|
|
354
|
+
const { entries, hasMore } = this.jobs.list(query);
|
|
355
|
+
return { jobs: entries, hasMore };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* @param {string} jobRunId
|
|
360
|
+
* @returns {Object|null}
|
|
361
|
+
*/
|
|
362
|
+
jobDetail(jobRunId) {
|
|
363
|
+
return this.jobs.detail(jobRunId);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Deletes all job runs.
|
|
368
|
+
*
|
|
369
|
+
* @returns {void}
|
|
370
|
+
*/
|
|
371
|
+
clearJobs() {
|
|
372
|
+
this.jobs.clear();
|
|
373
|
+
this.info.jobSeqBase = this.jobs.seqBase;
|
|
374
|
+
this.save();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Stores new log lines; ones already stored (same id) are skipped.
|
|
379
|
+
*
|
|
380
|
+
* @param {Object[]} records - Validated lines, with their detail (the whole line)
|
|
381
|
+
* @returns {Object[]}
|
|
382
|
+
*/
|
|
383
|
+
appendLogs(records) {
|
|
384
|
+
return this.logs.append(records);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Log line summaries, newest first; oldest first when `after` is given.
|
|
389
|
+
*
|
|
390
|
+
* @param {{ after?: number, before?: number, limit: number }} query
|
|
391
|
+
* @returns {{ logs: Object[], hasMore: boolean }}
|
|
392
|
+
*/
|
|
393
|
+
listLogs(query) {
|
|
394
|
+
const { entries, hasMore } = this.logs.list(query);
|
|
395
|
+
return { logs: entries, hasMore };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The whole of one log line (its message in full and its fields).
|
|
400
|
+
*
|
|
401
|
+
* @param {string} logId
|
|
402
|
+
* @returns {Object|null}
|
|
403
|
+
*/
|
|
404
|
+
logDetail(logId) {
|
|
405
|
+
return this.logs.detail(logId);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Deletes all log lines.
|
|
410
|
+
*
|
|
411
|
+
* @returns {void}
|
|
412
|
+
*/
|
|
413
|
+
clearLogs() {
|
|
414
|
+
this.logs.clear();
|
|
415
|
+
this.info.logSeqBase = this.logs.seqBase;
|
|
416
|
+
this.save();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Deletes the project's folder.
|
|
421
|
+
*
|
|
422
|
+
* @returns {void}
|
|
423
|
+
*/
|
|
424
|
+
remove() {
|
|
425
|
+
fs.rmSync(this.dir, { recursive: true, force: true });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The project as the dashboard lists it. Processes that stopped checking in long ago are
|
|
430
|
+
* forgotten on the way.
|
|
431
|
+
*
|
|
432
|
+
* @param {number} now
|
|
433
|
+
* @returns {Object} id, name, root, timestamps, request and exception counts, whether it is
|
|
434
|
+
* online, its running processes, and the notice about server requests (null once it is older
|
|
435
|
+
* than a day)
|
|
436
|
+
*/
|
|
437
|
+
describe(now) {
|
|
438
|
+
for (const [pid, instance] of this.instances) {
|
|
439
|
+
if (now - instance.lastSeenAt > INSTANCE_TTL_MS) this.instances.delete(pid);
|
|
440
|
+
}
|
|
441
|
+
const instances = [...this.instances.values()]
|
|
442
|
+
.filter((instance) => now - instance.lastSeenAt <= ONLINE_MS)
|
|
443
|
+
.sort((a, b) => b.startedAt - a.startedAt);
|
|
444
|
+
const newest = this.requests.newest;
|
|
445
|
+
const newestException = this.exceptions.newest;
|
|
446
|
+
const newestJob = this.jobs.newest;
|
|
447
|
+
return {
|
|
448
|
+
id: this.info.id,
|
|
449
|
+
name: this.info.name,
|
|
450
|
+
root: this.info.root,
|
|
451
|
+
createdAt: this.info.createdAt,
|
|
452
|
+
lastSeenAt: this.info.lastSeenAt,
|
|
453
|
+
lastRequestAt: newest ? newest.occurredAt : null,
|
|
454
|
+
requestCount: this.requests.size,
|
|
455
|
+
exceptionCount: this.exceptions.size,
|
|
456
|
+
lastExceptionAt: newestException ? newestException.occurredAt : null,
|
|
457
|
+
jobCount: this.jobs.size,
|
|
458
|
+
lastJobAt: newestJob ? newestJob.occurredAt : null,
|
|
459
|
+
logCount: this.logs.size,
|
|
460
|
+
online: instances.length > 0,
|
|
461
|
+
instances,
|
|
462
|
+
blocked: this.info.blocked && now - this.info.blocked.lastAt < BLOCKED_NOTICE_MS ? this.info.blocked : null
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Saves the metadata if it changed since the last save.
|
|
468
|
+
*
|
|
469
|
+
* @returns {void}
|
|
470
|
+
*/
|
|
471
|
+
persist() {
|
|
472
|
+
if (this.dirty) this.save();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Saves the metadata. Written to a temporary file first, so a crash never leaves half a file.
|
|
477
|
+
*
|
|
478
|
+
* @returns {void}
|
|
479
|
+
*/
|
|
480
|
+
save() {
|
|
481
|
+
const file = path.join(this.dir, 'project.json');
|
|
482
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
483
|
+
fs.writeFileSync(temp, `${JSON.stringify(this.info, null, 2)}\n`, { mode: 0o600 });
|
|
484
|
+
fs.renameSync(temp, file);
|
|
485
|
+
this.dirty = false;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Deletes the oldest segments of both logs while the newer ones still hold what is kept.
|
|
490
|
+
*
|
|
491
|
+
* @returns {void}
|
|
492
|
+
*/
|
|
493
|
+
enforceRetention() {
|
|
494
|
+
this.requests.enforceRetention();
|
|
495
|
+
this.exceptions.enforceRetention();
|
|
496
|
+
this.jobs.enforceRetention();
|
|
497
|
+
this.logs.enforceRetention();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
static _requestOptions(info, limits) {
|
|
501
|
+
return { maxEntries: limits.maxRequests, segmentSize: limits.segmentSize, seqBase: info.seqBase, pick: pickSummary };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
static _jobOptions(info, limits) {
|
|
505
|
+
return { maxEntries: limits.maxJobs, segmentSize: Math.max(10, Math.ceil(limits.maxJobs / 4)), seqBase: info.jobSeqBase || 1, pick: pickJob };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
static _logOptions(info, limits) {
|
|
509
|
+
return { maxEntries: limits.maxLogs, segmentSize: Math.max(10, Math.ceil(limits.maxLogs / 4)), seqBase: info.logSeqBase || 1, pick: pickLog };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
static _exceptionOptions(info, limits) {
|
|
513
|
+
return {
|
|
514
|
+
maxEntries: limits.maxExceptions,
|
|
515
|
+
segmentSize: limits.exceptionSegmentSize,
|
|
516
|
+
seqBase: info.exceptionSeqBase,
|
|
517
|
+
pick: pickException
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function isBlockedInfo(value) {
|
|
523
|
+
return Boolean(value) && Number.isFinite(value.total) && Number.isFinite(value.lastAt) && Array.isArray(value.entries);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Only known fields, in a fixed order: index lines and API answers never carry anything else.
|
|
527
|
+
// Requests stored before exceptions existed count none; before timelines, they have no stats.
|
|
528
|
+
function pickSummary(source) {
|
|
529
|
+
const summary = {};
|
|
530
|
+
for (const field of SUMMARY_FIELDS) summary[field] = source[field];
|
|
531
|
+
if (summary.exceptionCount === undefined) summary.exceptionCount = 0;
|
|
532
|
+
if (summary.stats === undefined) summary.stats = null;
|
|
533
|
+
// Stored before releases (or users) were recorded.
|
|
534
|
+
if (summary.release === undefined) summary.release = null;
|
|
535
|
+
if (summary.user === undefined) summary.user = null;
|
|
536
|
+
return summary;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function pickException(source) {
|
|
540
|
+
const summary = {};
|
|
541
|
+
for (const field of EXCEPTION_FIELDS) summary[field] = source[field];
|
|
542
|
+
if (summary.release === undefined) summary.release = null;
|
|
543
|
+
if (summary.user === undefined) summary.user = null;
|
|
544
|
+
if (summary.jobRunId === undefined) summary.jobRunId = null;
|
|
545
|
+
if (summary.job === undefined) summary.job = null;
|
|
546
|
+
return summary;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function pickJob(source) {
|
|
550
|
+
const summary = {};
|
|
551
|
+
for (const field of JOB_FIELDS) summary[field] = source[field] === undefined ? null : source[field];
|
|
552
|
+
return summary;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function pickLog(source) {
|
|
556
|
+
const summary = {};
|
|
557
|
+
for (const field of LOG_FIELDS) summary[field] = source[field] === undefined ? null : source[field];
|
|
558
|
+
return summary;
|
|
559
|
+
}
|