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,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local hub's HTTP server, on 127.0.0.1 only.
|
|
3
|
+
*
|
|
4
|
+
* GET / Dashboard page (also /p/<project id>,
|
|
5
|
+
* /p/<project id>/exceptions and /p/<project id>/queries)
|
|
6
|
+
* POST /api/ingest Requests and exceptions from the SDK in each app, and
|
|
7
|
+
* counts of server requests it didn't record (free plan)
|
|
8
|
+
* GET /api/status Hub version and settings
|
|
9
|
+
* GET /api/projects Projects, with their running processes
|
|
10
|
+
* GET /api/projects/:id/requests Summaries. Query: limit, before or after (sequence numbers)
|
|
11
|
+
* GET /api/projects/:id/requests/:rid One request's full detail
|
|
12
|
+
* DELETE /api/projects/:id/requests Delete a project's requests
|
|
13
|
+
* GET /api/projects/:id/exceptions Exception summaries, paged like requests
|
|
14
|
+
* GET /api/projects/:id/exceptions/:eid One exception's full detail
|
|
15
|
+
* DELETE /api/projects/:id/exceptions Delete a project's exceptions
|
|
16
|
+
* GET /api/projects/:id/runtime Recent processes with their memory, CPU and event-loop samples
|
|
17
|
+
* GET /api/projects/:id/jobs Job run summaries, paged like requests
|
|
18
|
+
* GET /api/projects/:id/jobs/:jid One job run's detail
|
|
19
|
+
* DELETE /api/projects/:id/jobs Delete a project's job runs
|
|
20
|
+
* GET /api/projects/:id/logs Log lines (their start), paged like requests
|
|
21
|
+
* GET /api/projects/:id/logs/:lid One log line in full
|
|
22
|
+
* DELETE /api/projects/:id/logs Delete a project's log lines
|
|
23
|
+
* GET /api/projects/:id/exception-state Resolved issues, with a version
|
|
24
|
+
* PUT /api/projects/:id/exception-state Save them, if the version is still the current one
|
|
25
|
+
* DELETE /api/projects/:id Forget a project
|
|
26
|
+
* GET /brand/<file> The PeekInside mark, for the page's icon (also
|
|
27
|
+
* /favicon.svg and /favicon.ico)
|
|
28
|
+
*
|
|
29
|
+
* The page is a small shell that loads the dashboard interface from the PeekInside site; all
|
|
30
|
+
* request data comes from this server. The API answers only this origin: requests with a
|
|
31
|
+
* foreign Host header (DNS rebinding) or from other sites are refused, and no CORS headers
|
|
32
|
+
* are ever sent. Ingest only accepts requests that don't come from a browser.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import http from 'http';
|
|
36
|
+
import { randomBytes } from 'crypto';
|
|
37
|
+
import { readFileSync } from 'fs';
|
|
38
|
+
import { HUB_API_VERSION, HUB_APP_ID } from '../config.js';
|
|
39
|
+
import { ExceptionState } from './exception-state.js';
|
|
40
|
+
import { IngestPayload, REQUEST_ID } from './ingest-payload.js';
|
|
41
|
+
import { PROJECT_ID } from './store.js';
|
|
42
|
+
|
|
43
|
+
const MAX_INGEST_BYTES = 32 * 1024 * 1024;
|
|
44
|
+
// 5000 fingerprints with their timestamps take about 200 KB.
|
|
45
|
+
const MAX_STATE_BYTES = 1024 * 1024;
|
|
46
|
+
const DEFAULT_PAGE = 500;
|
|
47
|
+
const MAX_PAGE = 1000;
|
|
48
|
+
// /api/projects/:id, then optionally a collection and an item.
|
|
49
|
+
const PROJECT_ROUTE = /^\/api\/projects\/([^/]+)(?:\/(requests|exceptions|exception-state|runtime|jobs|logs)(?:\/([^/]+))?)?$/;
|
|
50
|
+
// The dashboard page: the project list, a project's requests, its exceptions and its queries.
|
|
51
|
+
const SHELL_ROUTE = /^\/p\/[a-z0-9-]{1,64}(?:\/(?:exceptions|queries|runtime|logs|jobs))?\/?$/;
|
|
52
|
+
|
|
53
|
+
const BASE_HEADERS = {
|
|
54
|
+
'X-Content-Type-Options': 'nosniff',
|
|
55
|
+
'Referrer-Policy': 'no-referrer',
|
|
56
|
+
'X-Frame-Options': 'DENY',
|
|
57
|
+
'Cross-Origin-Resource-Policy': 'same-origin'
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Applies the saved theme before first paint (same as the PeekInside site).
|
|
61
|
+
const THEME_SCRIPT = '(function(){try{var t=localStorage.getItem("peekinside:theme");if(t!=="light"&&t!=="dark"){t=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark"}document.documentElement.setAttribute("data-theme",t)}catch(e){document.documentElement.setAttribute("data-theme","dark")}})()';
|
|
62
|
+
|
|
63
|
+
// The PeekInside mark (Span Core): the approved files, unchanged. "-dark" is drawn for dark
|
|
64
|
+
// backgrounds and "-light" for light ones; the page links each for the matching browser theme.
|
|
65
|
+
const BRAND_FILES = new Map(
|
|
66
|
+
['peekinside-span-core-mark-dark.svg', 'peekinside-span-core-mark-light.svg'].map((name) => [
|
|
67
|
+
name,
|
|
68
|
+
readFileSync(new URL(`./brand/${name}`, import.meta.url))
|
|
69
|
+
])
|
|
70
|
+
);
|
|
71
|
+
const BRAND_ROUTE = /^\/brand\/([a-z0-9-]+\.svg)$/;
|
|
72
|
+
|
|
73
|
+
const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The hub's HTTP API and dashboard page. Owns the http.Server; each route is a method.
|
|
77
|
+
*/
|
|
78
|
+
export class HubServer {
|
|
79
|
+
/**
|
|
80
|
+
* @param {Object} options
|
|
81
|
+
* @param {import('./store.js').Store} options.store
|
|
82
|
+
* @param {number} options.port - Port the server will listen on (checked against the Host header)
|
|
83
|
+
* @param {string} options.siteUrl - Origin the dashboard interface is loaded from
|
|
84
|
+
* @param {string} options.version - Package version
|
|
85
|
+
* @param {string} options.dataDir - Data folder, shown in the dashboard
|
|
86
|
+
* @param {Function} [options.onActivity] - Called for every API request and ingest, to tell
|
|
87
|
+
* whether the hub is still in use
|
|
88
|
+
*/
|
|
89
|
+
constructor({ store, port, siteUrl, version, dataDir, onActivity = () => {} }) {
|
|
90
|
+
this.store = store;
|
|
91
|
+
this.port = port;
|
|
92
|
+
this.siteUrl = siteUrl;
|
|
93
|
+
this.version = version;
|
|
94
|
+
this.dataDir = dataDir;
|
|
95
|
+
this.onActivity = onActivity;
|
|
96
|
+
// The names this server is reached by. Any other Host means DNS rebinding: a foreign site
|
|
97
|
+
// whose domain resolves to 127.0.0.1.
|
|
98
|
+
this._hosts = new Set([`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]);
|
|
99
|
+
this._assets = `${siteUrl}/local/v${HUB_API_VERSION}`;
|
|
100
|
+
this.startedAt = Date.now();
|
|
101
|
+
|
|
102
|
+
/** @type {http.Server} */
|
|
103
|
+
this.httpServer = http.createServer((req, res) => {
|
|
104
|
+
this.handle(req, res).catch((error) => {
|
|
105
|
+
console.error(`Request failed: ${error && error.stack ? error.stack : error}`);
|
|
106
|
+
if (!res.headersSent) sendJson(res, 500, { error: 'Internal error' });
|
|
107
|
+
else res.destroy();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Starts listening on 127.0.0.1, never on other interfaces: the data is only for this machine.
|
|
114
|
+
*
|
|
115
|
+
* @param {Function} [callback] - Called once listening
|
|
116
|
+
* @returns {HubServer} This server
|
|
117
|
+
*/
|
|
118
|
+
listen(callback) {
|
|
119
|
+
this.httpServer.listen(this.port, '127.0.0.1', callback);
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Stops listening and closes open connections, including dashboards polling it.
|
|
125
|
+
*
|
|
126
|
+
* @returns {void}
|
|
127
|
+
*/
|
|
128
|
+
close() {
|
|
129
|
+
this.httpServer.close();
|
|
130
|
+
this.httpServer.closeAllConnections?.();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* What GET /api/status returns. `app` lets clients tell the hub apart from other programs.
|
|
135
|
+
*
|
|
136
|
+
* @returns {Object}
|
|
137
|
+
*/
|
|
138
|
+
status() {
|
|
139
|
+
return {
|
|
140
|
+
app: HUB_APP_ID,
|
|
141
|
+
apiVersion: HUB_API_VERSION,
|
|
142
|
+
version: this.version,
|
|
143
|
+
pid: process.pid,
|
|
144
|
+
startedAt: this.startedAt,
|
|
145
|
+
dataDir: this.dataDir,
|
|
146
|
+
siteUrl: this.siteUrl,
|
|
147
|
+
maxRequests: this.store.maxRequests,
|
|
148
|
+
maxExceptions: this.store.maxExceptions
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Answers one request.
|
|
154
|
+
*
|
|
155
|
+
* @param {http.IncomingMessage} req
|
|
156
|
+
* @param {http.ServerResponse} res
|
|
157
|
+
* @returns {Promise<void>}
|
|
158
|
+
*/
|
|
159
|
+
async handle(req, res) {
|
|
160
|
+
const host = String(req.headers.host || '').toLowerCase();
|
|
161
|
+
if (!this._hosts.has(host)) {
|
|
162
|
+
return sendText(res, 403, 'Forbidden: open this dashboard at http://localhost:' + this.port);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let url;
|
|
166
|
+
try {
|
|
167
|
+
url = new URL(req.url, `http://${host}`);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return sendText(res, 400, 'Bad Request');
|
|
170
|
+
}
|
|
171
|
+
const { pathname, searchParams } = url;
|
|
172
|
+
|
|
173
|
+
if (pathname === '/api/ingest') {
|
|
174
|
+
this.onActivity();
|
|
175
|
+
return this._ingest(req, res);
|
|
176
|
+
}
|
|
177
|
+
if (pathname.startsWith('/api/')) {
|
|
178
|
+
if (!isSameOrigin(req, host)) {
|
|
179
|
+
return sendJson(res, 403, { error: 'Only the dashboard on this port can use this API.' });
|
|
180
|
+
}
|
|
181
|
+
this.onActivity();
|
|
182
|
+
return this._api(req, res, pathname, searchParams);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
186
|
+
res.setHeader('Allow', 'GET, HEAD');
|
|
187
|
+
return sendText(res, 405, 'Method Not Allowed');
|
|
188
|
+
}
|
|
189
|
+
if (pathname === '/' || SHELL_ROUTE.test(pathname)) {
|
|
190
|
+
return this._shell(req, res);
|
|
191
|
+
}
|
|
192
|
+
const brandFile = pathname === '/favicon.svg' || pathname === '/favicon.ico'
|
|
193
|
+
? 'peekinside-span-core-mark-dark.svg'
|
|
194
|
+
: BRAND_ROUTE.exec(pathname)?.[1];
|
|
195
|
+
if (brandFile && BRAND_FILES.has(brandFile)) {
|
|
196
|
+
return send(res, 200, BRAND_FILES.get(brandFile), { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=86400' }, req.method === 'HEAD');
|
|
197
|
+
}
|
|
198
|
+
return sendText(res, 404, 'Not Found');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// POST /api/ingest: a batch from an app's SDK.
|
|
202
|
+
async _ingest(req, res) {
|
|
203
|
+
if (req.method !== 'POST') {
|
|
204
|
+
res.setHeader('Allow', 'POST');
|
|
205
|
+
return sendJson(res, 405, { error: 'Method not allowed' });
|
|
206
|
+
}
|
|
207
|
+
// Browsers always send Origin or Sec-Fetch-Site on cross-origin POSTs; the SDK sends neither.
|
|
208
|
+
if (req.headers.origin !== undefined || req.headers['sec-fetch-site'] !== undefined) {
|
|
209
|
+
return sendJson(res, 403, { error: 'Browsers cannot send requests to the hub.' });
|
|
210
|
+
}
|
|
211
|
+
if (!/^application\/json\b/i.test(String(req.headers['content-type'] || ''))) {
|
|
212
|
+
return sendJson(res, 415, { error: 'Expected application/json' });
|
|
213
|
+
}
|
|
214
|
+
if (!req.headers['x-peekinside-sdk']) {
|
|
215
|
+
return sendJson(res, 400, { error: 'Missing x-peekinside-sdk header' });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let body;
|
|
219
|
+
try {
|
|
220
|
+
body = JSON.parse(await readBody(req, MAX_INGEST_BYTES));
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (error && error.status === 413) {
|
|
223
|
+
res.setHeader('Connection', 'close');
|
|
224
|
+
return sendJson(res, 413, { error: 'Batch too large' });
|
|
225
|
+
}
|
|
226
|
+
return sendJson(res, 400, { error: 'Invalid JSON' });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const payload = IngestPayload.parse(body);
|
|
230
|
+
if (!payload) {
|
|
231
|
+
return sendJson(res, 400, { error: 'Expected { project, instance, requests }' });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this.store.touch(payload.project, payload.instance);
|
|
235
|
+
const added = this.store.append(payload.project.id, payload.records);
|
|
236
|
+
const addedExceptions = this.store.appendExceptions(payload.project.id, payload.exceptions);
|
|
237
|
+
this.store.noteBlocked(payload.project.id, payload.blocked);
|
|
238
|
+
this.store.addSamples(payload.project.id, payload.instance, payload.samples);
|
|
239
|
+
const addedJobs = this.store.appendJobs(payload.project.id, payload.jobs);
|
|
240
|
+
const addedLogs = this.store.appendLogs(payload.project.id, payload.logs);
|
|
241
|
+
// SDKs from before exceptions get the answer they know.
|
|
242
|
+
return sendJson(res, 200, payload.hasExceptions
|
|
243
|
+
? {
|
|
244
|
+
accepted: added.length,
|
|
245
|
+
rejected: payload.rejected,
|
|
246
|
+
acceptedExceptions: addedExceptions.length,
|
|
247
|
+
rejectedExceptions: payload.rejectedExceptions,
|
|
248
|
+
acceptedJobs: addedJobs.length,
|
|
249
|
+
acceptedLogs: addedLogs.length
|
|
250
|
+
}
|
|
251
|
+
: { accepted: added.length, rejected: payload.rejected, acceptedJobs: addedJobs.length, acceptedLogs: addedLogs.length });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Every /api/ route except ingest; the caller has checked the origin.
|
|
255
|
+
_api(req, res, pathname, query) {
|
|
256
|
+
const read = isRead(req);
|
|
257
|
+
|
|
258
|
+
if (pathname === '/api/status') {
|
|
259
|
+
return read ? sendJson(res, 200, this.status()) : methodNotAllowed(res, 'GET, HEAD');
|
|
260
|
+
}
|
|
261
|
+
if (pathname === '/api/projects') {
|
|
262
|
+
return read ? sendJson(res, 200, { projects: this.store.listProjects() }) : methodNotAllowed(res, 'GET, HEAD');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const match = PROJECT_ROUTE.exec(pathname);
|
|
266
|
+
const projectId = match && decode(match[1]);
|
|
267
|
+
if (!match || !PROJECT_ID.test(projectId)) {
|
|
268
|
+
return sendJson(res, 404, { error: 'Not found' });
|
|
269
|
+
}
|
|
270
|
+
if (!this.store.has(projectId)) {
|
|
271
|
+
return sendJson(res, 404, { error: 'This project is not on this machine.' });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// State changes must come from this page: browsers always send Origin with these.
|
|
275
|
+
if (!read && req.headers.origin === undefined) {
|
|
276
|
+
return sendJson(res, 403, { error: 'Missing Origin header' });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const [, , collection, item] = match;
|
|
280
|
+
if (!collection) {
|
|
281
|
+
return this._project(req, res, projectId);
|
|
282
|
+
}
|
|
283
|
+
if (collection === 'requests') {
|
|
284
|
+
return item === undefined ? this._requests(req, res, projectId, query) : this._requestDetail(req, res, projectId, item);
|
|
285
|
+
}
|
|
286
|
+
if (collection === 'exceptions') {
|
|
287
|
+
return item === undefined ? this._exceptions(req, res, projectId, query) : this._exceptionDetail(req, res, projectId, item);
|
|
288
|
+
}
|
|
289
|
+
if (collection === 'jobs') {
|
|
290
|
+
if (item !== undefined) return this._item(req, res, decode(item), (id) => this.store.jobDetail(projectId, id), 'This job run is no longer stored.');
|
|
291
|
+
return this._collection(req, res, query, () => this.store.listJobs(projectId, pageOf(query)), () => this.store.clearJobs(projectId));
|
|
292
|
+
}
|
|
293
|
+
if (collection === 'logs') {
|
|
294
|
+
if (item !== undefined) return this._item(req, res, decode(item), (id) => this.store.logDetail(projectId, id), 'This log line is no longer stored.');
|
|
295
|
+
return this._collection(req, res, query, () => this.store.listLogs(projectId, pageOf(query)), () => this.store.clearLogs(projectId));
|
|
296
|
+
}
|
|
297
|
+
if (collection === 'runtime') {
|
|
298
|
+
if (item !== undefined) return sendJson(res, 404, { error: 'Not found' });
|
|
299
|
+
return read ? sendJson(res, 200, this.store.runtime(projectId)) : methodNotAllowed(res, 'GET, HEAD');
|
|
300
|
+
}
|
|
301
|
+
return item === undefined ? this._exceptionState(req, res, projectId) : sendJson(res, 404, { error: 'Not found' });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// A paged list (GET), or all of it deleted (DELETE).
|
|
305
|
+
_collection(req, res, query, list, clear) {
|
|
306
|
+
if (isRead(req)) return sendJson(res, 200, list());
|
|
307
|
+
if (req.method === 'DELETE') {
|
|
308
|
+
clear();
|
|
309
|
+
return sendJson(res, 200, { cleared: true });
|
|
310
|
+
}
|
|
311
|
+
return methodNotAllowed(res, 'GET, HEAD, DELETE');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// One stored item's detail.
|
|
315
|
+
_item(req, res, id, detailOf, missing) {
|
|
316
|
+
if (!isRead(req)) return methodNotAllowed(res, 'GET, HEAD');
|
|
317
|
+
const detail = REQUEST_ID.test(id) ? detailOf(id) : null;
|
|
318
|
+
return detail ? sendJson(res, 200, { detail }) : sendJson(res, 404, { error: missing });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// /api/projects/:id
|
|
322
|
+
_project(req, res, projectId) {
|
|
323
|
+
if (req.method === 'DELETE') {
|
|
324
|
+
this.store.remove(projectId);
|
|
325
|
+
return sendJson(res, 200, { removed: true });
|
|
326
|
+
}
|
|
327
|
+
return methodNotAllowed(res, 'DELETE');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// /api/projects/:id/requests
|
|
331
|
+
_requests(req, res, projectId, query) {
|
|
332
|
+
if (isRead(req)) {
|
|
333
|
+
return sendJson(res, 200, this.store.list(projectId, pageOf(query)));
|
|
334
|
+
}
|
|
335
|
+
if (req.method === 'DELETE') {
|
|
336
|
+
this.store.clear(projectId);
|
|
337
|
+
return sendJson(res, 200, { cleared: true });
|
|
338
|
+
}
|
|
339
|
+
return methodNotAllowed(res, 'GET, HEAD, DELETE');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// /api/projects/:id/requests/:rid
|
|
343
|
+
_requestDetail(req, res, projectId, encodedId) {
|
|
344
|
+
if (!isRead(req)) return methodNotAllowed(res, 'GET, HEAD');
|
|
345
|
+
const requestId = decode(encodedId);
|
|
346
|
+
const detail = REQUEST_ID.test(requestId) ? this.store.detail(projectId, requestId) : null;
|
|
347
|
+
return detail
|
|
348
|
+
? sendJson(res, 200, { detail })
|
|
349
|
+
: sendJson(res, 404, { error: 'This request is no longer stored.' });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// /api/projects/:id/exceptions
|
|
353
|
+
_exceptions(req, res, projectId, query) {
|
|
354
|
+
if (isRead(req)) {
|
|
355
|
+
return sendJson(res, 200, this.store.listExceptions(projectId, pageOf(query)));
|
|
356
|
+
}
|
|
357
|
+
if (req.method === 'DELETE') {
|
|
358
|
+
this.store.clearExceptions(projectId);
|
|
359
|
+
return sendJson(res, 200, { cleared: true });
|
|
360
|
+
}
|
|
361
|
+
return methodNotAllowed(res, 'GET, HEAD, DELETE');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// /api/projects/:id/exceptions/:eid
|
|
365
|
+
_exceptionDetail(req, res, projectId, encodedId) {
|
|
366
|
+
if (!isRead(req)) return methodNotAllowed(res, 'GET, HEAD');
|
|
367
|
+
const exceptionId = decode(encodedId);
|
|
368
|
+
const detail = REQUEST_ID.test(exceptionId) ? this.store.exceptionDetail(projectId, exceptionId) : null;
|
|
369
|
+
return detail
|
|
370
|
+
? sendJson(res, 200, { detail })
|
|
371
|
+
: sendJson(res, 404, { error: 'This exception is no longer stored.' });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// /api/projects/:id/exception-state: resolved issues. A save names the version it was based
|
|
375
|
+
// on; when another dashboard saved in between, it gets the current state back to merge with.
|
|
376
|
+
async _exceptionState(req, res, projectId) {
|
|
377
|
+
if (isRead(req)) {
|
|
378
|
+
return sendJson(res, 200, this.store.exceptionState(projectId));
|
|
379
|
+
}
|
|
380
|
+
if (req.method !== 'PUT') {
|
|
381
|
+
return methodNotAllowed(res, 'GET, HEAD, PUT');
|
|
382
|
+
}
|
|
383
|
+
if (!/^application\/json\b/i.test(String(req.headers['content-type'] || ''))) {
|
|
384
|
+
return sendJson(res, 415, { error: 'Expected application/json' });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let body;
|
|
388
|
+
try {
|
|
389
|
+
body = JSON.parse(await readBody(req, MAX_STATE_BYTES));
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (error && error.status === 413) {
|
|
392
|
+
res.setHeader('Connection', 'close');
|
|
393
|
+
return sendJson(res, 413, { error: 'State too large' });
|
|
394
|
+
}
|
|
395
|
+
return sendJson(res, 400, { error: 'Invalid JSON' });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const version = body && body.version;
|
|
399
|
+
const state = body ? ExceptionState.validate(body.state) : null;
|
|
400
|
+
if (!Number.isInteger(version) || version < 0 || !state) {
|
|
401
|
+
return sendJson(res, 400, { error: 'Expected { state: { resolved }, version }, with at most 5000 fingerprints' });
|
|
402
|
+
}
|
|
403
|
+
const result = this.store.saveExceptionState(projectId, state, version);
|
|
404
|
+
if (!result.saved) {
|
|
405
|
+
return sendJson(res, 409, { error: 'The exception state changed since it was read.', state: result.state, version: result.version });
|
|
406
|
+
}
|
|
407
|
+
return sendJson(res, 200, { version: result.version });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// The dashboard page: a shell that loads the interface from the PeekInside site. The Content
|
|
411
|
+
// Security Policy allows scripts only from that site (and the inline theme script, by nonce),
|
|
412
|
+
// and lets the page fetch data only from this server.
|
|
413
|
+
_shell(req, res) {
|
|
414
|
+
const assets = this._assets;
|
|
415
|
+
const siteUrl = this.siteUrl;
|
|
416
|
+
const nonce = randomBytes(16).toString('base64');
|
|
417
|
+
const assetOrigin = new URL(assets).origin;
|
|
418
|
+
const csp = [
|
|
419
|
+
"default-src 'none'",
|
|
420
|
+
`script-src 'nonce-${nonce}' ${assetOrigin}`,
|
|
421
|
+
`style-src ${assetOrigin} 'unsafe-inline'`,
|
|
422
|
+
"img-src 'self' data:",
|
|
423
|
+
`font-src ${assetOrigin}`,
|
|
424
|
+
"connect-src 'self'",
|
|
425
|
+
"base-uri 'none'",
|
|
426
|
+
"form-action 'none'",
|
|
427
|
+
"frame-ancestors 'none'"
|
|
428
|
+
].join('; ');
|
|
429
|
+
|
|
430
|
+
const html = `<!doctype html>
|
|
431
|
+
<html lang="en" data-theme="dark">
|
|
432
|
+
<head>
|
|
433
|
+
<meta charset="utf-8">
|
|
434
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
435
|
+
<meta name="robots" content="noindex">
|
|
436
|
+
<title>PeekInside Local</title>
|
|
437
|
+
<link rel="icon" href="/brand/peekinside-span-core-mark-light.svg" type="image/svg+xml" media="(prefers-color-scheme: light)">
|
|
438
|
+
<link rel="icon" href="/brand/peekinside-span-core-mark-dark.svg" type="image/svg+xml" media="(prefers-color-scheme: dark)">
|
|
439
|
+
<link rel="stylesheet" href="${escapeHtml(assets)}/app.css">
|
|
440
|
+
<script nonce="${nonce}">${THEME_SCRIPT}</script>
|
|
441
|
+
<style>.boot{font:13px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;max-width:440px;margin:18vh auto 0;padding:0 16px;opacity:.75}.boot a{color:inherit}</style>
|
|
442
|
+
</head>
|
|
443
|
+
<body>
|
|
444
|
+
<div id="root"><div class="boot"><p><strong>Loading PeekInside…</strong></p><p>The dashboard interface loads from <a href="${escapeHtml(siteUrl)}">${escapeHtml(new URL(siteUrl).host)}</a>. Your requests stay on this machine.</p></div></div>
|
|
445
|
+
<script src="${escapeHtml(assets)}/app.js" defer></script>
|
|
446
|
+
</body>
|
|
447
|
+
</html>
|
|
448
|
+
`;
|
|
449
|
+
send(res, 200, html, {
|
|
450
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
451
|
+
'Cache-Control': 'no-store',
|
|
452
|
+
'Content-Security-Policy': csp
|
|
453
|
+
}, req.method === 'HEAD');
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isRead(req) {
|
|
458
|
+
return req.method === 'GET' || req.method === 'HEAD';
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Same-origin fetches send Sec-Fetch-Site "same-origin" (or "none" when typed in the address bar).
|
|
462
|
+
// "same-site" is refused too: other ports on localhost count as the same site.
|
|
463
|
+
function isSameOrigin(req, host) {
|
|
464
|
+
const site = req.headers['sec-fetch-site'];
|
|
465
|
+
if (site !== undefined && site !== 'same-origin' && site !== 'none') return false;
|
|
466
|
+
const origin = req.headers.origin;
|
|
467
|
+
return origin === undefined || origin === `http://${host}`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// limit, and before or after (sequence numbers), from a list's query string.
|
|
471
|
+
function pageOf(query) {
|
|
472
|
+
const limit = Math.min(MAX_PAGE, toInt(query.get('limit'), DEFAULT_PAGE, 1));
|
|
473
|
+
const after = query.has('after') ? toInt(query.get('after'), 0, 0) : undefined;
|
|
474
|
+
const before = query.has('before') ? toInt(query.get('before'), 0, 0) : undefined;
|
|
475
|
+
return { after, before, limit };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function toInt(value, fallback, min) {
|
|
479
|
+
const parsed = Number.parseInt(value, 10);
|
|
480
|
+
return Number.isFinite(parsed) ? Math.max(min, parsed) : fallback;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function decode(value) {
|
|
484
|
+
try {
|
|
485
|
+
return decodeURIComponent(value);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
return '';
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Stops collecting at `limit` bytes; the 413 response then closes the connection.
|
|
492
|
+
function readBody(req, limit) {
|
|
493
|
+
return new Promise((resolve, reject) => {
|
|
494
|
+
const chunks = [];
|
|
495
|
+
let size = 0;
|
|
496
|
+
let failed = false;
|
|
497
|
+
const fail = (error) => {
|
|
498
|
+
if (failed) return;
|
|
499
|
+
failed = true;
|
|
500
|
+
reject(error);
|
|
501
|
+
};
|
|
502
|
+
req.on('data', (chunk) => {
|
|
503
|
+
if (failed) return;
|
|
504
|
+
size += chunk.length;
|
|
505
|
+
if (size > limit) {
|
|
506
|
+
fail(Object.assign(new Error('Payload too large'), { status: 413 }));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
chunks.push(chunk);
|
|
510
|
+
});
|
|
511
|
+
req.on('end', () => {
|
|
512
|
+
if (!failed) resolve(Buffer.concat(chunks).toString('utf8'));
|
|
513
|
+
});
|
|
514
|
+
req.on('error', fail);
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function escapeHtml(value) {
|
|
519
|
+
return String(value).replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch]);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function send(res, status, body, headers, headOnly = false) {
|
|
523
|
+
res.writeHead(status, { ...BASE_HEADERS, 'Content-Length': Buffer.byteLength(body), ...headers });
|
|
524
|
+
res.end(headOnly ? undefined : body);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function sendText(res, status, body) {
|
|
528
|
+
send(res, status, body, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function sendJson(res, status, payload) {
|
|
532
|
+
send(res, status, JSON.stringify(payload), { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function methodNotAllowed(res, allow) {
|
|
536
|
+
res.setHeader('Allow', allow);
|
|
537
|
+
sendJson(res, 405, { error: 'Method not allowed' });
|
|
538
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ingests the spool files apps write when they crash (see src/spool.js): at startup, then every
|
|
3
|
+
* second while the hub runs.
|
|
4
|
+
*
|
|
5
|
+
* A spool file holds an ingest batch ({ project, instance, requests, exceptions }) and is
|
|
6
|
+
* validated exactly like a batch posted to /api/ingest. It is deleted once ingested, and also
|
|
7
|
+
* when it can never be ingested (not JSON, malformed, or filed under another project's folder).
|
|
8
|
+
* The process that wrote it has exited, so it doesn't count as a check-in.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { CLOUD_FOLDER, Spool } from '../spool.js';
|
|
14
|
+
import { IngestPayload } from './ingest-payload.js';
|
|
15
|
+
import { PROJECT_ID } from './store.js';
|
|
16
|
+
|
|
17
|
+
const INTERVAL_MS = 1000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Moves spooled records into the store.
|
|
21
|
+
*/
|
|
22
|
+
export class SpoolIngester {
|
|
23
|
+
/**
|
|
24
|
+
* @param {Object} options
|
|
25
|
+
* @param {import('./store.js').Store} options.store
|
|
26
|
+
* @param {string} options.dataDir - PeekInside data folder
|
|
27
|
+
* @param {(message: string) => void} [options.log] - Receives a line per file it can't ingest
|
|
28
|
+
* @param {() => void} [options.onIngest] - Called when records were ingested
|
|
29
|
+
*/
|
|
30
|
+
constructor({ store, dataDir, log = () => {}, onIngest = () => {} }) {
|
|
31
|
+
this.store = store;
|
|
32
|
+
this.root = Spool.root(dataDir);
|
|
33
|
+
this.log = log;
|
|
34
|
+
this.onIngest = onIngest;
|
|
35
|
+
this.timer = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ingests what is waiting now, then checks every second.
|
|
40
|
+
*
|
|
41
|
+
* @returns {SpoolIngester} This ingester
|
|
42
|
+
*/
|
|
43
|
+
start() {
|
|
44
|
+
this.ingest();
|
|
45
|
+
if (!this.timer) {
|
|
46
|
+
this.timer = setInterval(() => this.ingest(), INTERVAL_MS);
|
|
47
|
+
if (typeof this.timer.unref === 'function') this.timer.unref();
|
|
48
|
+
}
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Stops checking.
|
|
54
|
+
*
|
|
55
|
+
* @returns {void}
|
|
56
|
+
*/
|
|
57
|
+
stop() {
|
|
58
|
+
clearInterval(this.timer);
|
|
59
|
+
this.timer = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Ingests every spool file waiting.
|
|
64
|
+
*
|
|
65
|
+
* @returns {{ requests: number, exceptions: number }} Records added
|
|
66
|
+
*/
|
|
67
|
+
ingest() {
|
|
68
|
+
const added = { requests: 0, exceptions: 0 };
|
|
69
|
+
let folders;
|
|
70
|
+
try {
|
|
71
|
+
folders = fs.readdirSync(this.root);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return added;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const folder of folders) {
|
|
77
|
+
// Cloud batches are sealed for PeekInside Cloud; the SDK resends them itself.
|
|
78
|
+
if (folder === CLOUD_FOLDER || !PROJECT_ID.test(folder)) continue;
|
|
79
|
+
const spool = new Spool(path.join(this.root, folder));
|
|
80
|
+
for (const name of spool.list()) {
|
|
81
|
+
try {
|
|
82
|
+
const counts = this._ingestFile(spool, folder, name);
|
|
83
|
+
added.requests += counts.requests;
|
|
84
|
+
added.exceptions += counts.exceptions;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
// The store couldn't write: keep the file and try again next time.
|
|
87
|
+
this.log(`Could not ingest ${path.join(spool.dir, name)}: ${error.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (added.requests || added.exceptions) this.onIngest();
|
|
92
|
+
return added;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_ingestFile(spool, folder, name) {
|
|
96
|
+
const payload = IngestPayload.parse(spool.read(name));
|
|
97
|
+
if (!payload || payload.project.id !== folder) {
|
|
98
|
+
this.log(`Deleting ${path.join(spool.dir, name)}: not a valid spool file.`);
|
|
99
|
+
spool.remove(name);
|
|
100
|
+
return { requests: 0, exceptions: 0 };
|
|
101
|
+
}
|
|
102
|
+
this.store.ensure(payload.project);
|
|
103
|
+
const requests = this.store.append(payload.project.id, payload.records).length;
|
|
104
|
+
const exceptions = this.store.appendExceptions(payload.project.id, payload.exceptions).length;
|
|
105
|
+
this.store.appendJobs(payload.project.id, payload.jobs);
|
|
106
|
+
this.store.appendLogs(payload.project.id, payload.logs);
|
|
107
|
+
spool.remove(name);
|
|
108
|
+
return { requests, exceptions };
|
|
109
|
+
}
|
|
110
|
+
}
|