peekinside 0.0.0-stage → 1.2.1
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 +561 -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,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log lines for the Logs page: what the app writes with the console, pino or winston, inside a
|
|
3
|
+
* request, inside a job run, or anywhere else (startup, timers), each linked to its request or job
|
|
4
|
+
* run when there is one.
|
|
5
|
+
*
|
|
6
|
+
* Lines are rate limited per process (100 a second, bursts of 500) and below the `logLevel` option
|
|
7
|
+
* are left out, so an app that logs heavily costs a bounded amount. PeekInside's own lines are
|
|
8
|
+
* never recorded.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from 'crypto';
|
|
12
|
+
import { ExceptionCapture } from '../exceptions/exception-capture.js';
|
|
13
|
+
import { truncate } from '../exceptions/limits.js';
|
|
14
|
+
import { TokenBucket } from '../exceptions/rate-limiter.js';
|
|
15
|
+
import { RequestContext } from '../exceptions/request-context.js';
|
|
16
|
+
import { SafeJson } from '../exceptions/safe-json.js';
|
|
17
|
+
import { LOG_LEVELS, LogRecord } from './log-record.js';
|
|
18
|
+
|
|
19
|
+
const MAX_MESSAGE = 4000;
|
|
20
|
+
const MAX_FIELDS = 4096;
|
|
21
|
+
const RANK = Object.freeze({ debug: 0, info: 1, warn: 2, error: 3 });
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One PeekInside instance's log capture.
|
|
25
|
+
*/
|
|
26
|
+
export class LogCapture {
|
|
27
|
+
/**
|
|
28
|
+
* @param {Object} options
|
|
29
|
+
* @param {'debug'|'info'|'warn'|'error'} [options.level='info'] - The lowest level recorded
|
|
30
|
+
* @param {() => Object|null} options.sink - The instance's transport
|
|
31
|
+
* @param {() => boolean} options.isEnabled - Whether the instance records right now
|
|
32
|
+
* @param {TokenBucket} [options.limiter]
|
|
33
|
+
*/
|
|
34
|
+
constructor({ level = 'info', sink, isEnabled, limiter = new TokenBucket({ rate: 100, burst: 500 }) }) {
|
|
35
|
+
this.minRank = RANK[LogCapture.level(level)];
|
|
36
|
+
this._sink = sink;
|
|
37
|
+
this._isEnabled = isEnabled;
|
|
38
|
+
this.limiter = limiter;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A valid level name, or "info".
|
|
43
|
+
*
|
|
44
|
+
* @param {*} value
|
|
45
|
+
* @returns {'debug'|'info'|'warn'|'error'}
|
|
46
|
+
*/
|
|
47
|
+
static level(value) {
|
|
48
|
+
return LOG_LEVELS.includes(value) ? value : 'info';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether a line at this level would be recorded by anyone right now: lets the console skip
|
|
53
|
+
* formatting lines nobody records.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} level
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
static wants(level) {
|
|
59
|
+
const capture = LogCapture.current();
|
|
60
|
+
return Boolean(capture && capture.recording && RANK[level] >= capture.minRank);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Records a line with the capture of the request or job running now, else the most recently
|
|
65
|
+
* created instance that records. Never throws.
|
|
66
|
+
*
|
|
67
|
+
* @param {'debug'|'info'|'warn'|'error'} level
|
|
68
|
+
* @param {string} message - As written (formatted)
|
|
69
|
+
* @param {Object} [options]
|
|
70
|
+
* @param {'console'|'pino'|'winston'} [options.source='console']
|
|
71
|
+
* @param {*} [options.fields] - Structured data written with the line (pino, winston)
|
|
72
|
+
* @returns {string|null} The record's id, or null when nothing was recorded
|
|
73
|
+
*/
|
|
74
|
+
static record(level, message, { source = 'console', fields } = {}) {
|
|
75
|
+
try {
|
|
76
|
+
const capture = LogCapture.current();
|
|
77
|
+
return capture ? capture.record(level, message, { source, fields, context: RequestContext.current() }) : null;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The capture of the instance tracking the code running now, else the newest recording one.
|
|
84
|
+
static current() {
|
|
85
|
+
const context = RequestContext.current();
|
|
86
|
+
const owner = context && context.capture ? context.capture.owner : null;
|
|
87
|
+
if (owner && owner._logs) return owner._logs;
|
|
88
|
+
const latest = ExceptionCapture.latest();
|
|
89
|
+
return latest && latest.owner ? latest.owner._logs || null : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @type {boolean}
|
|
94
|
+
*/
|
|
95
|
+
get recording() {
|
|
96
|
+
const sink = this._sink();
|
|
97
|
+
return Boolean(sink && !sink.disabled && this._isEnabled());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {string} level
|
|
102
|
+
* @param {string} message
|
|
103
|
+
* @param {Object} options
|
|
104
|
+
* @param {string} options.source
|
|
105
|
+
* @param {*} [options.fields]
|
|
106
|
+
* @param {import('../exceptions/request-context.js').RequestContext|null} options.context
|
|
107
|
+
* @returns {string|null}
|
|
108
|
+
*/
|
|
109
|
+
record(level, message, { source, fields, context }) {
|
|
110
|
+
if (!this.recording || !(RANK[level] >= this.minRank)) return null;
|
|
111
|
+
// Requests local mode doesn't record (server traffic) take their lines with them.
|
|
112
|
+
if (context && context.blocked) return null;
|
|
113
|
+
if (!this.limiter.take()) return null;
|
|
114
|
+
const sink = this._sink();
|
|
115
|
+
const record = new LogRecord({
|
|
116
|
+
id: randomUUID(),
|
|
117
|
+
occurredAt: Date.now(),
|
|
118
|
+
environment: sink.environment,
|
|
119
|
+
release: sink.release || null,
|
|
120
|
+
level,
|
|
121
|
+
source,
|
|
122
|
+
requestId: (context && context.requestId) || null,
|
|
123
|
+
jobRunId: (context && context.jobRunId) || null,
|
|
124
|
+
summary: { message: truncate(String(message), MAX_MESSAGE), ...structured(fields) }
|
|
125
|
+
});
|
|
126
|
+
sink.enqueueRecord(record);
|
|
127
|
+
return record.id;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Structured data as JSON-safe values within MAX_FIELDS; a preview when larger.
|
|
132
|
+
function structured(fields) {
|
|
133
|
+
if (fields === undefined || fields === null) return {};
|
|
134
|
+
if (typeof fields === 'object' && !Array.isArray(fields) && Object.keys(fields).length === 0) return {};
|
|
135
|
+
const { json, truncated } = SafeJson.stringify(fields, MAX_FIELDS);
|
|
136
|
+
if (json === undefined) return {};
|
|
137
|
+
if (truncated) return { fields: { truncated: true, preview: json } };
|
|
138
|
+
try {
|
|
139
|
+
return { fields: JSON.parse(json) };
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
4
|
+
*
|
|
5
|
+
* PeekInside Cloud receives the line sealed: only the time, level, source and links are readable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const LOG_LEVELS = Object.freeze(['debug', 'info', 'warn', 'error']);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A recorded log line.
|
|
12
|
+
*/
|
|
13
|
+
export class LogRecord {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} fields
|
|
16
|
+
* @param {string} fields.id - UUID
|
|
17
|
+
* @param {number} fields.occurredAt - ms since the epoch
|
|
18
|
+
* @param {string} fields.environment
|
|
19
|
+
* @param {string|null} [fields.release=null]
|
|
20
|
+
* @param {'debug'|'info'|'warn'|'error'} fields.level
|
|
21
|
+
* @param {'console'|'pino'|'winston'} fields.source
|
|
22
|
+
* @param {string|null} [fields.requestId=null] - The request it was written during
|
|
23
|
+
* @param {string|null} [fields.jobRunId=null] - The job run it was written during
|
|
24
|
+
* @param {{ message: string, fields?: Object|null }} fields.summary - The line (sealed in Cloud)
|
|
25
|
+
*/
|
|
26
|
+
constructor({ id, occurredAt, environment, release = null, level, source, requestId = null, jobRunId = null, summary }) {
|
|
27
|
+
this.id = id;
|
|
28
|
+
this.occurredAt = occurredAt;
|
|
29
|
+
this.environment = environment;
|
|
30
|
+
this.release = release;
|
|
31
|
+
this.level = level;
|
|
32
|
+
this.source = source;
|
|
33
|
+
this.requestId = requestId;
|
|
34
|
+
this.jobRunId = jobRunId;
|
|
35
|
+
this.summary = summary;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The plain form, as the local hub stores it.
|
|
40
|
+
*
|
|
41
|
+
* @returns {Object}
|
|
42
|
+
*/
|
|
43
|
+
toJSON() {
|
|
44
|
+
return { ...this };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The sealed form, for PeekInside Cloud.
|
|
49
|
+
*
|
|
50
|
+
* @param {import('../crypto.js').Sealer} sealer
|
|
51
|
+
* @returns {Promise<Object>}
|
|
52
|
+
*/
|
|
53
|
+
async seal(sealer) {
|
|
54
|
+
const { summary, ...meta } = this.toJSON();
|
|
55
|
+
return { ...meta, summary: await sealer.seal(JSON.stringify(summary), LogRecord.summaryAad(this.id)) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {import('../crypto.js').SyncSealer} sealer
|
|
60
|
+
* @returns {Object}
|
|
61
|
+
*/
|
|
62
|
+
sealSync(sealer) {
|
|
63
|
+
const { summary, ...meta } = this.toJSON();
|
|
64
|
+
return { ...meta, summary: sealer.seal(JSON.stringify(summary), LogRecord.summaryAad(this.id)) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} id
|
|
69
|
+
* @returns {string} Additional authenticated data of a sealed line
|
|
70
|
+
*/
|
|
71
|
+
static summaryAad(id) {
|
|
72
|
+
return `log:summary:${id}`;
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/project.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identifies the running app for the local dashboard, so every Node.js project on the machine
|
|
3
|
+
* gets its own entry: named after its package.json and told apart by its folder.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
const MAX_NAME_LENGTH = 80;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Which project the running app is: a stable id, a display name and the project's folder.
|
|
14
|
+
*/
|
|
15
|
+
export class ProjectIdentity {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} fields
|
|
18
|
+
* @param {string} fields.id - Stable id, also the project's folder name in the hub: a slug of
|
|
19
|
+
* the name plus a hash of the folder, so two apps with the same name stay apart
|
|
20
|
+
* @param {string} fields.name - Display name
|
|
21
|
+
* @param {string} fields.root - Project folder
|
|
22
|
+
*/
|
|
23
|
+
constructor({ id, name, root }) {
|
|
24
|
+
this.id = id;
|
|
25
|
+
this.name = name;
|
|
26
|
+
this.root = root;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Identifies the running app.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} [explicitName] - Name chosen in the options or PEEKINSIDE_PROJECT; otherwise
|
|
33
|
+
* the name in package.json, then the folder name
|
|
34
|
+
* @param {string} [from] - Folder to look for the project from; by default the entry script's
|
|
35
|
+
* (or the working directory, when the entry script is a tool in node_modules)
|
|
36
|
+
* @returns {ProjectIdentity}
|
|
37
|
+
*/
|
|
38
|
+
static detect(explicitName, from) {
|
|
39
|
+
const root = findRoot(from);
|
|
40
|
+
const name = clean(explicitName) || clean(readPackageName(root)) || clean(path.basename(root)) || 'app';
|
|
41
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '') || 'app';
|
|
42
|
+
const hash = createHash('sha256').update(`${root}\0${name}`).digest('hex').slice(0, 8);
|
|
43
|
+
return new ProjectIdentity({ id: `${slug}-${hash}`, name, root });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The folder of the nearest package.json above the entry script. Entry scripts inside
|
|
48
|
+
// node_modules (nodemon, ts-node-dev, next...) say nothing about the app, so the working
|
|
49
|
+
// directory is used for those.
|
|
50
|
+
function findRoot(from) {
|
|
51
|
+
const entry = process.argv[1];
|
|
52
|
+
const start = from
|
|
53
|
+
? path.resolve(from)
|
|
54
|
+
: entry && !entry.split(path.sep).includes('node_modules')
|
|
55
|
+
? path.dirname(path.resolve(entry))
|
|
56
|
+
: process.cwd();
|
|
57
|
+
|
|
58
|
+
for (let dir = start; ; dir = path.dirname(dir)) {
|
|
59
|
+
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
|
60
|
+
if (path.dirname(dir) === dir) return start;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readPackageName(dir) {
|
|
65
|
+
try {
|
|
66
|
+
const name = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).name;
|
|
67
|
+
return typeof name === 'string' ? name : '';
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Names end up in the dashboard and the CLI's output: no control characters, bounded length.
|
|
74
|
+
function clean(value) {
|
|
75
|
+
return typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, MAX_NAME_LENGTH) : '';
|
|
76
|
+
}
|
package/src/record.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stored form of a captured exchange, shared by the local hub and PeekInside Cloud:
|
|
3
|
+
* a few plain numbers for lists, charts and filters, a small summary for the request list,
|
|
4
|
+
* and the full detail, which the dashboard only loads when someone opens the request.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const METHOD = /^[A-Z]{1,16}$/;
|
|
8
|
+
// Exceptions listed in a request's detail, so the inspector can link to them.
|
|
9
|
+
const MAX_EXCEPTION_LINKS = 10;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One captured exchange, in the shape both destinations store.
|
|
13
|
+
*
|
|
14
|
+
* The local hub receives the plain form (toJSON()). PeekInside Cloud receives the sealed form
|
|
15
|
+
* (seal()): the numbers stay readable for charts and filters, while the summary and the detail
|
|
16
|
+
* are encrypted so that only the project's browsers can read them.
|
|
17
|
+
*/
|
|
18
|
+
export class RequestRecord {
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} fields
|
|
21
|
+
* @param {string} fields.id - Request id
|
|
22
|
+
* @param {number} fields.occurredAt - When the request started (ms since the epoch)
|
|
23
|
+
* @param {string} fields.environment - Normalized environment label
|
|
24
|
+
* @param {string|null} [fields.release=null] - The running release (see release.js)
|
|
25
|
+
* @param {string} fields.method - HTTP method, or "OTHER" when it isn't a plain token
|
|
26
|
+
* @param {number|null} fields.status - Status code, or null when no response was sent
|
|
27
|
+
* @param {number|null} fields.duration - Response time in ms
|
|
28
|
+
* @param {number} fields.requestSize - Request body size in bytes
|
|
29
|
+
* @param {number} fields.responseSize - Response body size in bytes
|
|
30
|
+
* @param {boolean} fields.aborted - Whether the connection closed before the response was sent
|
|
31
|
+
* @param {number} [fields.exceptionCount=0] - Exceptions recorded during the request (detail.exceptions
|
|
32
|
+
* lists the first of them)
|
|
33
|
+
* @param {Object} [fields.stats] - Timeline counters: queries, queryTime, slowQueries, nPlusOne,
|
|
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 - Shown in
|
|
36
|
+
* the request list (with the user, when the app set one)
|
|
37
|
+
* @param {Object} fields.detail - Everything else: headers, bodies, query, client details
|
|
38
|
+
*/
|
|
39
|
+
constructor({ id, occurredAt, environment, release = null, method, status, duration, requestSize, responseSize, aborted, exceptionCount = 0, stats, summary, detail }) {
|
|
40
|
+
this.id = id;
|
|
41
|
+
this.occurredAt = occurredAt;
|
|
42
|
+
this.environment = environment;
|
|
43
|
+
this.release = release;
|
|
44
|
+
this.method = method;
|
|
45
|
+
this.status = status;
|
|
46
|
+
this.duration = duration;
|
|
47
|
+
this.requestSize = requestSize;
|
|
48
|
+
this.responseSize = responseSize;
|
|
49
|
+
this.aborted = aborted;
|
|
50
|
+
this.exceptionCount = exceptionCount;
|
|
51
|
+
if (stats) this.stats = stats;
|
|
52
|
+
this.summary = summary;
|
|
53
|
+
this.detail = detail;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Builds the record of an exchange. Values that would break lists or charts (a method that
|
|
58
|
+
* isn't a token, a status outside 100-599, negative sizes) are replaced rather than stored.
|
|
59
|
+
*
|
|
60
|
+
* @param {import('./capture/exchange-recorder.js').Exchange} exchange - Exchange built by PeekInside
|
|
61
|
+
* @param {string} environment - Normalized environment label
|
|
62
|
+
* @param {string|null} [release=null] - The running release
|
|
63
|
+
* @returns {RequestRecord}
|
|
64
|
+
*/
|
|
65
|
+
static fromExchange(exchange, environment, release = null) {
|
|
66
|
+
const response = exchange.response || {};
|
|
67
|
+
const status = response.statusCode;
|
|
68
|
+
|
|
69
|
+
return new RequestRecord({
|
|
70
|
+
id: exchange.id,
|
|
71
|
+
occurredAt: exchange.timestamp,
|
|
72
|
+
environment,
|
|
73
|
+
release,
|
|
74
|
+
method: METHOD.test(exchange.method) ? exchange.method : 'OTHER',
|
|
75
|
+
status: Number.isInteger(status) && status >= 100 && status <= 599 ? status : null,
|
|
76
|
+
duration: typeof response.responseTime === 'number' && response.responseTime >= 0 ? response.responseTime : null,
|
|
77
|
+
requestSize: size(exchange.bodySize),
|
|
78
|
+
responseSize: size(response.size),
|
|
79
|
+
aborted: Boolean(response.aborted),
|
|
80
|
+
exceptionCount: size(exchange.exceptionCount),
|
|
81
|
+
stats: exchange.stats && typeof exchange.stats === 'object' ? exchange.stats : undefined,
|
|
82
|
+
summary: {
|
|
83
|
+
url: exchange.url,
|
|
84
|
+
path: exchange.path,
|
|
85
|
+
contentType: headerValue(response.headers, 'content-type'),
|
|
86
|
+
...(exchange.user ? { user: exchange.user } : {})
|
|
87
|
+
},
|
|
88
|
+
detail: {
|
|
89
|
+
query: exchange.query,
|
|
90
|
+
params: exchange.params,
|
|
91
|
+
headers: exchange.headers,
|
|
92
|
+
body: exchange.body,
|
|
93
|
+
bodySize: exchange.bodySize,
|
|
94
|
+
bodyTruncated: exchange.bodyTruncated,
|
|
95
|
+
bodyEncoding: exchange.bodyEncoding,
|
|
96
|
+
ip: exchange.ip,
|
|
97
|
+
userAgent: exchange.userAgent,
|
|
98
|
+
protocol: exchange.protocol,
|
|
99
|
+
httpVersion: exchange.httpVersion,
|
|
100
|
+
response: {
|
|
101
|
+
statusMessage: response.statusMessage,
|
|
102
|
+
headers: response.headers,
|
|
103
|
+
body: response.body,
|
|
104
|
+
size: response.size,
|
|
105
|
+
bodyTruncated: response.bodyTruncated,
|
|
106
|
+
bodyEncoding: response.bodyEncoding
|
|
107
|
+
},
|
|
108
|
+
exceptions: Array.isArray(exchange.exceptions) ? exchange.exceptions.slice(0, MAX_EXCEPTION_LINKS) : [],
|
|
109
|
+
...(exchange.timeline ? { timeline: exchange.timeline } : {})
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Turns an environment label into the form records store: lowercase letters, digits, "-" and
|
|
116
|
+
* "_", at most 32 characters. Dashboards group and filter by it, so "Staging " and "staging"
|
|
117
|
+
* must be the same label.
|
|
118
|
+
*
|
|
119
|
+
* @param {*} value - Label chosen by the app
|
|
120
|
+
* @param {string} fallback - Label used when nothing usable remains
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
static normalizeEnvironment(value, fallback) {
|
|
124
|
+
const normalized = String(value || '')
|
|
125
|
+
.toLowerCase()
|
|
126
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
127
|
+
.replace(/^[^a-z0-9]+/, '')
|
|
128
|
+
.slice(0, 32);
|
|
129
|
+
return normalized || fallback;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The plain form, as the local hub stores it. Also used by JSON.stringify().
|
|
134
|
+
*
|
|
135
|
+
* @returns {Object}
|
|
136
|
+
*/
|
|
137
|
+
toJSON() {
|
|
138
|
+
return { ...this };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The sealed form, as PeekInside Cloud stores it: the numbers stay readable for charts and
|
|
143
|
+
* filters, while the summary and the detail are encrypted to the project's public key. Each is
|
|
144
|
+
* bound to the request id, so they can't be swapped between requests.
|
|
145
|
+
*
|
|
146
|
+
* @param {import('./crypto.js').Sealer} sealer - Sealer for the batch this record is sent in
|
|
147
|
+
* @returns {Promise<Object>} The record, with `summary` and `detail` as sealed strings
|
|
148
|
+
*/
|
|
149
|
+
async seal(sealer) {
|
|
150
|
+
const { summary, detail, ...meta } = this;
|
|
151
|
+
return {
|
|
152
|
+
...meta,
|
|
153
|
+
summary: await sealer.seal(JSON.stringify(summary), `summary:${meta.id}`),
|
|
154
|
+
detail: await sealer.seal(JSON.stringify(detail), `detail:${meta.id}`)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* seal() for a process that is crashing, where nothing asynchronous runs anymore.
|
|
160
|
+
*
|
|
161
|
+
* @param {import('./crypto.js').SyncSealer} sealer
|
|
162
|
+
* @returns {Object}
|
|
163
|
+
*/
|
|
164
|
+
sealSync(sealer) {
|
|
165
|
+
const { summary, detail, ...meta } = this;
|
|
166
|
+
return {
|
|
167
|
+
...meta,
|
|
168
|
+
summary: sealer.seal(JSON.stringify(summary), `summary:${meta.id}`),
|
|
169
|
+
detail: sealer.seal(JSON.stringify(detail), `detail:${meta.id}`)
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function headerValue(headers, name) {
|
|
175
|
+
const value = headers && headers[name];
|
|
176
|
+
return Array.isArray(value) ? String(value[0]) : value === undefined ? '' : String(value);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function size(value) {
|
|
180
|
+
return Number.isInteger(value) && value >= 0 && value < 2 ** 31 ? value : 0;
|
|
181
|
+
}
|
package/src/release.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which release of the app is running: the deploy a request or an exception came from, so the
|
|
3
|
+
* dashboard can say when an issue first appeared and what broke after a deploy.
|
|
4
|
+
*
|
|
5
|
+
* In order: the `release` option or PEEKINSIDE_RELEASE; else the commit the hosting platform
|
|
6
|
+
* deployed (Vercel, Netlify, Render, Railway, Heroku, Cloudflare Pages, Google Cloud Run, Fly.io,
|
|
7
|
+
* and the CI services); else the commit checked out in the project folder; else none.
|
|
8
|
+
*
|
|
9
|
+
* Releases are labels, not secrets: PeekInside Cloud stores them readable, like the environment,
|
|
10
|
+
* so they can be filtered and counted.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
|
|
16
|
+
// Commit (or deploy) variables the platforms set, most specific first.
|
|
17
|
+
const PLATFORM_VARIABLES = [
|
|
18
|
+
'VERCEL_GIT_COMMIT_SHA',
|
|
19
|
+
'COMMIT_REF', // Netlify
|
|
20
|
+
'RENDER_GIT_COMMIT',
|
|
21
|
+
'RAILWAY_GIT_COMMIT_SHA',
|
|
22
|
+
'HEROKU_BUILD_COMMIT',
|
|
23
|
+
'HEROKU_SLUG_COMMIT',
|
|
24
|
+
'SOURCE_VERSION', // Heroku builds
|
|
25
|
+
'CF_PAGES_COMMIT_SHA',
|
|
26
|
+
'K_REVISION', // Google Cloud Run
|
|
27
|
+
'FLY_IMAGE_REF',
|
|
28
|
+
'GITHUB_SHA',
|
|
29
|
+
'CI_COMMIT_SHA', // GitLab
|
|
30
|
+
'BITBUCKET_COMMIT',
|
|
31
|
+
'CIRCLE_SHA1',
|
|
32
|
+
'CODEBUILD_RESOLVED_SOURCE_VERSION', // AWS CodeBuild
|
|
33
|
+
'COMMIT_SHA' // Google Cloud Build
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// What a stored release may contain: the characters of commits, versions and image tags.
|
|
37
|
+
const UNSAFE = /[^A-Za-z0-9._:@/+-]+/g;
|
|
38
|
+
export const MAX_RELEASE_LENGTH = 64;
|
|
39
|
+
const COMMIT = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
|
|
40
|
+
// How far up from the project folder a repository is looked for (monorepos).
|
|
41
|
+
const MAX_GIT_DEPTH = 6;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Finds and normalizes the running release.
|
|
45
|
+
*/
|
|
46
|
+
export class Release {
|
|
47
|
+
/**
|
|
48
|
+
* The running release.
|
|
49
|
+
*
|
|
50
|
+
* @param {Object} [options]
|
|
51
|
+
* @param {string} [options.explicit] - The `release` option
|
|
52
|
+
* @param {Object<string, string|undefined>} [options.env=process.env]
|
|
53
|
+
* @param {string} [options.root] - The project folder, where the checked-out commit is read
|
|
54
|
+
* @returns {string|null}
|
|
55
|
+
*/
|
|
56
|
+
static detect({ explicit, env = process.env, root } = {}) {
|
|
57
|
+
const chosen = Release.normalize(explicit) || Release.normalize(env.PEEKINSIDE_RELEASE);
|
|
58
|
+
if (chosen) return chosen;
|
|
59
|
+
for (const name of PLATFORM_VARIABLES) {
|
|
60
|
+
const value = Release.normalize(env[name]);
|
|
61
|
+
if (value) return value;
|
|
62
|
+
}
|
|
63
|
+
return root ? Release.normalize(Release.gitCommit(root)) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Turns a label into the form records store: at most 64 characters among letters, digits and
|
|
68
|
+
* `. _ : @ / + -`; anything else becomes "-".
|
|
69
|
+
*
|
|
70
|
+
* @param {*} value
|
|
71
|
+
* @returns {string|null} null when nothing usable remains
|
|
72
|
+
*/
|
|
73
|
+
static normalize(value) {
|
|
74
|
+
if (typeof value !== 'string') return null;
|
|
75
|
+
const clean = value.trim().replace(UNSAFE, '-').replace(/^-+|-+$/g, '').slice(0, MAX_RELEASE_LENGTH);
|
|
76
|
+
return clean || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The commit checked out in the git repository holding a folder, read from its files (no git
|
|
81
|
+
* process is started). Handles branches, detached heads, packed refs and worktrees.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} root - A folder inside the repository
|
|
84
|
+
* @returns {string|null} The commit hash, or null outside a repository
|
|
85
|
+
*/
|
|
86
|
+
static gitCommit(root) {
|
|
87
|
+
try {
|
|
88
|
+
const gitDir = findGitDir(path.resolve(root));
|
|
89
|
+
if (!gitDir) return null;
|
|
90
|
+
const head = readText(path.join(gitDir, 'HEAD'));
|
|
91
|
+
if (!head) return null;
|
|
92
|
+
if (COMMIT.test(head)) return head;
|
|
93
|
+
const ref = /^ref:\s*(\S+)$/.exec(head);
|
|
94
|
+
if (!ref) return null;
|
|
95
|
+
// A worktree keeps its HEAD; the refs live in the main repository ("commondir").
|
|
96
|
+
const common = readText(path.join(gitDir, 'commondir'));
|
|
97
|
+
const refsDir = common ? path.resolve(gitDir, common) : gitDir;
|
|
98
|
+
for (const dir of new Set([gitDir, refsDir])) {
|
|
99
|
+
const loose = readText(path.join(dir, ...ref[1].split('/')));
|
|
100
|
+
if (loose && COMMIT.test(loose)) return loose;
|
|
101
|
+
}
|
|
102
|
+
const packed = readText(path.join(refsDir, 'packed-refs')) || '';
|
|
103
|
+
for (const line of packed.split('\n')) {
|
|
104
|
+
const [hash, name] = line.trim().split(/\s+/);
|
|
105
|
+
if (name === ref[1] && COMMIT.test(hash)) return hash;
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// Not a repository we can read: no release from git.
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* How the dashboard shows a release: commits as their first 7 characters, other labels as they are.
|
|
115
|
+
*
|
|
116
|
+
* @param {string|null} release
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
static short(release) {
|
|
120
|
+
if (!release) return '';
|
|
121
|
+
return COMMIT.test(release) ? release.slice(0, 7) : release;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The .git folder of the repository holding `start`: a folder, or a file pointing at one
|
|
126
|
+
// (worktrees, submodules).
|
|
127
|
+
function findGitDir(start) {
|
|
128
|
+
let dir = start;
|
|
129
|
+
for (let depth = 0; depth < MAX_GIT_DEPTH; depth++) {
|
|
130
|
+
const candidate = path.join(dir, '.git');
|
|
131
|
+
let stat = null;
|
|
132
|
+
try {
|
|
133
|
+
stat = fs.statSync(candidate);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
stat = null;
|
|
136
|
+
}
|
|
137
|
+
if (stat && stat.isDirectory()) return candidate;
|
|
138
|
+
if (stat && stat.isFile()) {
|
|
139
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec(readText(candidate) || '');
|
|
140
|
+
return pointer ? path.resolve(dir, pointer[1].trim()) : null;
|
|
141
|
+
}
|
|
142
|
+
const parent = path.dirname(dir);
|
|
143
|
+
if (parent === dir) return null;
|
|
144
|
+
dir = parent;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readText(file) {
|
|
150
|
+
try {
|
|
151
|
+
const stat = fs.statSync(file);
|
|
152
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) return null;
|
|
153
|
+
return fs.readFileSync(file, 'utf8').trim();
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|