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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/bin/peekinside.js +8 -0
  4. package/index.d.ts +389 -0
  5. package/index.js +3 -0
  6. package/package.json +77 -4
  7. package/src/PeekInside.js +731 -0
  8. package/src/capture/body-capture.js +86 -0
  9. package/src/capture/body-decoder.js +188 -0
  10. package/src/capture/exchange-recorder.js +263 -0
  11. package/src/capture/http-message.js +138 -0
  12. package/src/capture/ignored-paths.js +49 -0
  13. package/src/capture/request-body-tap.js +40 -0
  14. package/src/capture/response-tap.js +83 -0
  15. package/src/cli.js +254 -0
  16. package/src/config.js +183 -0
  17. package/src/crypto.js +189 -0
  18. package/src/deployment.js +183 -0
  19. package/src/exceptions/exception-builder.js +339 -0
  20. package/src/exceptions/exception-capture.js +407 -0
  21. package/src/exceptions/exception-record.js +118 -0
  22. package/src/exceptions/fingerprint.js +60 -0
  23. package/src/exceptions/frame-classifier.js +114 -0
  24. package/src/exceptions/limits.js +95 -0
  25. package/src/exceptions/process-monitor.js +192 -0
  26. package/src/exceptions/rate-limiter.js +50 -0
  27. package/src/exceptions/request-context.js +337 -0
  28. package/src/exceptions/safe-json.js +186 -0
  29. package/src/exceptions/source-context.js +144 -0
  30. package/src/exceptions/source-maps.js +313 -0
  31. package/src/exceptions/stack-parser.js +179 -0
  32. package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
  33. package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
  34. package/src/hub/client.js +184 -0
  35. package/src/hub/entry.js +4 -0
  36. package/src/hub/exception-state.js +97 -0
  37. package/src/hub/ingest-payload.js +362 -0
  38. package/src/hub/process.js +175 -0
  39. package/src/hub/project-log.js +559 -0
  40. package/src/hub/segment-log.js +282 -0
  41. package/src/hub/server.js +561 -0
  42. package/src/hub/spool-ingester.js +110 -0
  43. package/src/hub/store.js +404 -0
  44. package/src/instrumentation/console.js +83 -0
  45. package/src/instrumentation/http.js +146 -0
  46. package/src/instrumentation/index.js +234 -0
  47. package/src/instrumentation/jobs.js +186 -0
  48. package/src/instrumentation/loggers.js +135 -0
  49. package/src/instrumentation/mongodb.js +221 -0
  50. package/src/instrumentation/mysql2.js +161 -0
  51. package/src/instrumentation/operation.js +70 -0
  52. package/src/instrumentation/patch.js +72 -0
  53. package/src/instrumentation/pg.js +218 -0
  54. package/src/instrumentation/prisma.js +181 -0
  55. package/src/integrations/express-errors.js +135 -0
  56. package/src/integrations/express.js +57 -0
  57. package/src/integrations/fastify.js +72 -0
  58. package/src/integrations/framework-adapter.js +53 -0
  59. package/src/integrations/http-server.js +32 -0
  60. package/src/integrations/koa.js +72 -0
  61. package/src/integrations/server-binder.js +120 -0
  62. package/src/jobs/job-context.js +40 -0
  63. package/src/jobs/job-run-record.js +104 -0
  64. package/src/jobs/job-runner.js +173 -0
  65. package/src/logs/log-capture.js +143 -0
  66. package/src/logs/log-record.js +74 -0
  67. package/src/project.js +76 -0
  68. package/src/record.js +181 -0
  69. package/src/release.js +157 -0
  70. package/src/runtime/loop-monitor.js +162 -0
  71. package/src/runtime/sampler.js +93 -0
  72. package/src/spool.js +195 -0
  73. package/src/timeline/caller.js +129 -0
  74. package/src/timeline/execution-hash.js +110 -0
  75. package/src/timeline/lru.js +50 -0
  76. package/src/timeline/masking.js +266 -0
  77. package/src/timeline/shape.js +77 -0
  78. package/src/timeline/sql.js +396 -0
  79. package/src/timeline/timeline.js +642 -0
  80. package/src/transport/batch.js +324 -0
  81. package/src/transport/cloud.js +363 -0
  82. package/src/transport/local.js +354 -0
  83. 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
+ }