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
package/src/crypto.js ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * End-to-end encryption for PeekInside Cloud, format v1.
3
+ *
4
+ * Mirrors the dashboard's implementation: ephemeral ECDH P-256, HKDF-SHA-256 and AES-256-GCM,
5
+ * all from Node's Web Crypto. Events are sealed to the project's public key, so only browsers
6
+ * holding the project's private key can read them; this process can encrypt but never decrypt.
7
+ *
8
+ * Output: "v1.<ephemeral public key>.<iv>.<ciphertext>" (base64url parts). Any change here must
9
+ * stay byte-compatible with the dashboard, or existing projects can no longer read their events.
10
+ */
11
+
12
+ import { createCipheriv, createECDH, hkdfSync, randomBytes, webcrypto } from 'crypto';
13
+
14
+ const subtle = webcrypto.subtle;
15
+ const ECDH = { name: 'ECDH', namedCurve: 'P-256' };
16
+ const encoder = new TextEncoder();
17
+
18
+ const PROJECT_ID = /^p[0-9a-z]{12}$/;
19
+ const SECRET = /^[A-Za-z0-9_-]{43}$/;
20
+ const PUBLIC_KEY = /^[A-Za-z0-9_-]{87}$/;
21
+
22
+ /**
23
+ * @param {Uint8Array|ArrayBuffer} bytes
24
+ * @returns {string} Base64url without padding
25
+ */
26
+ export function toBase64Url(bytes) {
27
+ return Buffer.from(bytes).toString('base64url');
28
+ }
29
+
30
+ /**
31
+ * @param {string} text - Base64url
32
+ * @returns {Uint8Array}
33
+ */
34
+ export function fromBase64Url(text) {
35
+ return new Uint8Array(Buffer.from(text, 'base64url'));
36
+ }
37
+
38
+ /**
39
+ * Encrypts messages to one recipient public key, with one ephemeral key pair.
40
+ *
41
+ * The key agreement is the expensive part, so one sealer serves a whole batch: each message
42
+ * still gets its own random IV. Create a new sealer per batch, so ephemeral keys are not reused
43
+ * for long.
44
+ */
45
+ export class Sealer {
46
+ /**
47
+ * Use Sealer.create(): the key agreement is asynchronous.
48
+ *
49
+ * @param {CryptoKey} key - AES-256-GCM key derived for this sealer
50
+ * @param {Uint8Array} ephemeralPublicKey - Raw ephemeral public key, sent with every message so
51
+ * the recipient can derive the same key
52
+ */
53
+ constructor(key, ephemeralPublicKey) {
54
+ this._key = key;
55
+ this._prefix = `v1.${toBase64Url(ephemeralPublicKey)}.`;
56
+ }
57
+
58
+ /**
59
+ * Agrees on a key with the recipient: ECDH with a fresh ephemeral key, then HKDF-SHA-256 with
60
+ * both public keys as salt and "peekinside/v1/<purpose>" as info, so keys derived for one
61
+ * purpose never decrypt another.
62
+ *
63
+ * @param {string} recipientPublicKey - Project public key (base64url, uncompressed P-256 point)
64
+ * @param {string} purpose - "event"
65
+ * @returns {Promise<Sealer>}
66
+ */
67
+ static async create(recipientPublicKey, purpose) {
68
+ const recipientRaw = fromBase64Url(recipientPublicKey);
69
+ const recipient = await subtle.importKey('raw', recipientRaw, ECDH, false, []);
70
+ const ephemeral = await subtle.generateKey(ECDH, true, ['deriveBits']);
71
+ const ephemeralRaw = new Uint8Array(await subtle.exportKey('raw', ephemeral.publicKey));
72
+
73
+ const shared = await subtle.deriveBits({ name: 'ECDH', public: recipient }, ephemeral.privateKey, 256);
74
+ const material = await subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
75
+ const salt = new Uint8Array(ephemeralRaw.length + recipientRaw.length);
76
+ salt.set(ephemeralRaw, 0);
77
+ salt.set(recipientRaw, ephemeralRaw.length);
78
+ const key = await subtle.deriveKey(
79
+ { name: 'HKDF', hash: 'SHA-256', salt, info: encoder.encode(`peekinside/v1/${purpose}`) },
80
+ material,
81
+ { name: 'AES-GCM', length: 256 },
82
+ false,
83
+ ['encrypt']
84
+ );
85
+ return new Sealer(key, ephemeralRaw);
86
+ }
87
+
88
+ /**
89
+ * Encrypts one message with AES-256-GCM and a random 12-byte IV.
90
+ *
91
+ * @param {string|Uint8Array} plaintext
92
+ * @param {string} aad - Additional authenticated data, such as "detail:<request id>": decryption
93
+ * fails unless the reader expects the same value, which binds the message to its context
94
+ * @returns {Promise<string>} "v1.<ephemeral public key>.<iv>.<ciphertext>"
95
+ */
96
+ async seal(plaintext, aad) {
97
+ const iv = webcrypto.getRandomValues(new Uint8Array(12));
98
+ const data = typeof plaintext === 'string' ? encoder.encode(plaintext) : plaintext;
99
+ const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv, additionalData: encoder.encode(aad) }, this._key, data);
100
+ return `${this._prefix}${toBase64Url(iv)}.${toBase64Url(new Uint8Array(ciphertext))}`;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Sealer for a process that is crashing: the same key agreement and format as Sealer, but
106
+ * synchronous (node:crypto instead of Web Crypto), because nothing asynchronous runs once the
107
+ * process has started to exit.
108
+ *
109
+ * ECDH on P-256 yields the x-coordinate of the shared point in both APIs, HKDF and AES-GCM are the
110
+ * same algorithms, and Web Crypto appends the 16-byte GCM tag to the ciphertext: output is
111
+ * interchangeable with Sealer's, and opens with the dashboard's Opener.
112
+ */
113
+ export class SyncSealer {
114
+ /**
115
+ * Use SyncSealer.create().
116
+ *
117
+ * @param {Buffer} key - AES-256-GCM key derived for this sealer
118
+ * @param {Buffer} ephemeralPublicKey - Raw (uncompressed) ephemeral public key
119
+ */
120
+ constructor(key, ephemeralPublicKey) {
121
+ this._key = key;
122
+ this._prefix = `v1.${toBase64Url(ephemeralPublicKey)}.`;
123
+ }
124
+
125
+ /**
126
+ * Agrees on a key with the recipient, exactly like Sealer.create().
127
+ *
128
+ * @param {string} recipientPublicKey - Project public key (base64url, uncompressed P-256 point)
129
+ * @param {string} purpose - "event"
130
+ * @returns {SyncSealer}
131
+ * @throws {Error} When the public key is not a valid P-256 point
132
+ */
133
+ static create(recipientPublicKey, purpose) {
134
+ const recipientRaw = Buffer.from(recipientPublicKey, 'base64url');
135
+ const ecdh = createECDH('prime256v1');
136
+ const ephemeralRaw = ecdh.generateKeys();
137
+ const shared = ecdh.computeSecret(recipientRaw);
138
+ const salt = Buffer.concat([ephemeralRaw, recipientRaw]);
139
+ const key = Buffer.from(hkdfSync('sha256', shared, salt, Buffer.from(`peekinside/v1/${purpose}`), 32));
140
+ return new SyncSealer(key, ephemeralRaw);
141
+ }
142
+
143
+ /**
144
+ * Encrypts one message with AES-256-GCM and a random 12-byte IV.
145
+ *
146
+ * @param {string|Uint8Array} plaintext
147
+ * @param {string} aad - Additional authenticated data (see Sealer#seal())
148
+ * @returns {string} "v1.<ephemeral public key>.<iv>.<ciphertext and tag>"
149
+ */
150
+ seal(plaintext, aad) {
151
+ const iv = randomBytes(12);
152
+ const cipher = createCipheriv('aes-256-gcm', this._key, iv);
153
+ cipher.setAAD(Buffer.from(aad, 'utf8'));
154
+ const data = typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : plaintext;
155
+ const ciphertext = Buffer.concat([cipher.update(data), cipher.final(), cipher.getAuthTag()]);
156
+ return `${this._prefix}${toBase64Url(iv)}.${toBase64Url(ciphertext)}`;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * A PeekInside Cloud API key: "pik1.<projectId>.<secret>.<projectPublicKey>".
162
+ *
163
+ * The secret authorizes sending events; the public key lets the SDK encrypt them. The key never
164
+ * contains anything that can decrypt events.
165
+ */
166
+ export class ApiKey {
167
+ /**
168
+ * @param {Object} parts
169
+ * @param {string} parts.projectId - Project id, such as "p0123456789ab"
170
+ * @param {string} parts.secret - Ingest secret (base64url)
171
+ * @param {string} parts.publicKey - Project public key (base64url, uncompressed P-256 point)
172
+ */
173
+ constructor({ projectId, secret, publicKey }) {
174
+ this.projectId = projectId;
175
+ this.secret = secret;
176
+ this.publicKey = publicKey;
177
+ }
178
+
179
+ /**
180
+ * @param {string} apiKey
181
+ * @returns {ApiKey|null} The key's parts, or null when it isn't a well-formed v1 key
182
+ */
183
+ static parse(apiKey) {
184
+ const [version, projectId, secret, publicKey, ...rest] = String(apiKey).trim().split('.');
185
+ if (version !== 'pik1' || rest.length) return null;
186
+ if (!PROJECT_ID.test(projectId) || !SECRET.test(secret) || !PUBLIC_KEY.test(publicKey)) return null;
187
+ return new ApiKey({ projectId, secret, publicKey });
188
+ }
189
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Tells local development apart from a deployed server, request by request.
3
+ *
4
+ * The free local dashboard records local development only. A request that reached the app
5
+ * through a public domain or IP address, or that a proxy forwarded from a public client, means
6
+ * the app runs on a server: those requests need PeekInside Cloud and are not recorded locally.
7
+ *
8
+ * Local means: localhost and *.localhost, reserved and LAN names (*.test, *.local, *.internal...),
9
+ * single-label names (Docker services, machine names), loopback and private addresses, the
10
+ * networks this machine is on, and development tunnels such as ngrok (webhooks to a laptop).
11
+ */
12
+
13
+ import net from 'net';
14
+ import os from 'os';
15
+
16
+ const LOCAL_SUFFIXES = ['.localhost', '.test', '.local', '.localdomain', '.internal', '.lan', '.home.arpa', '.example', '.invalid'];
17
+ const TUNNEL_SUFFIXES = [
18
+ '.ngrok.io', '.ngrok.app', '.ngrok.dev', '.ngrok-free.app', '.ngrok-free.dev',
19
+ '.trycloudflare.com', '.loca.lt', '.sharedwithexpose.com', '.lhr.life', '.app.github.dev', '.gitpod.io'
20
+ ];
21
+ const PRIVATE_SUBNETS = [
22
+ ['0.0.0.0', 8, 'ipv4'], ['10.0.0.0', 8, 'ipv4'], ['100.64.0.0', 10, 'ipv4'], ['127.0.0.0', 8, 'ipv4'],
23
+ ['169.254.0.0', 16, 'ipv4'], ['172.16.0.0', 12, 'ipv4'], ['192.168.0.0', 16, 'ipv4'],
24
+ ['::', 128, 'ipv6'], ['::1', 128, 'ipv6'], ['fc00::', 7, 'ipv6'], ['fe80::', 10, 'ipv6']
25
+ ];
26
+ // Network interfaces change (Wi-Fi, VPN), but reading them on every request would be wasteful.
27
+ const NETWORKS_TTL_MS = 60_000;
28
+
29
+ /**
30
+ * @typedef {Object} ServerSignal
31
+ * @property {'host'|'client'} kind - What gave the server away: the public host name the request
32
+ * was sent to, or the public client address behind a proxy
33
+ * @property {string} value - That host name or address
34
+ */
35
+
36
+ /**
37
+ * Classifies requests as local development or server traffic.
38
+ *
39
+ * Keeps the networks this machine is on (refreshed every minute), since addresses on them are
40
+ * local too.
41
+ */
42
+ export class TrafficClassifier {
43
+ /**
44
+ * The machine's networks are read on first use, not here.
45
+ */
46
+ constructor() {
47
+ this._networks = null;
48
+ this._networksAt = 0;
49
+ }
50
+
51
+ /**
52
+ * @param {import('http').IncomingMessage} req
53
+ * @returns {ServerSignal|null} What shows the request came to a server, or null when it is local
54
+ */
55
+ serverSignal(req) {
56
+ const headers = req.headers || {};
57
+ const hosts = [headers.host, first(headers['x-forwarded-host']), ...forwardedValues(headers.forwarded, 'host')]
58
+ .map(hostnameOf)
59
+ .filter(Boolean);
60
+
61
+ // A tunnel brings outside requests (webhooks, a shared link) to a developer's machine.
62
+ if (hosts.some(isTunnel)) return null;
63
+
64
+ const publicHost = hosts.find((host) => !this.isLocalHostname(host));
65
+ if (publicHost) return { kind: 'host', value: publicHost };
66
+
67
+ // Proxies often rewrite Host (nginx sends the upstream address by default), but the client
68
+ // address still shows where the request came from.
69
+ const clients = [
70
+ req.socket && req.socket.remoteAddress,
71
+ ...list(headers['x-forwarded-for']),
72
+ first(headers['x-real-ip']),
73
+ ...forwardedValues(headers.forwarded, 'for')
74
+ ];
75
+ for (const client of clients) {
76
+ const ip = ipOf(client);
77
+ if (ip && !this.isLocalAddress(ip)) return { kind: 'client', value: ip };
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * @param {string} hostname - Host name or address, without the port
84
+ * @returns {boolean} Whether the name only makes sense in local development
85
+ */
86
+ isLocalHostname(hostname) {
87
+ const host = String(hostname).toLowerCase().replace(/\.$/, '');
88
+ if (!host || host === 'localhost') return true;
89
+ if (net.isIP(host)) return this.isLocalAddress(host);
90
+ if (!host.includes('.')) return true;
91
+ return LOCAL_SUFFIXES.some((suffix) => host.endsWith(suffix)) || isTunnel(host);
92
+ }
93
+
94
+ /**
95
+ * @param {string} address - IPv4 or IPv6 address
96
+ * @returns {boolean} Whether the address is private, loopback, or on one of this machine's networks
97
+ */
98
+ isLocalAddress(address) {
99
+ const ip = unmapIPv4(address);
100
+ const family = net.isIP(ip);
101
+ if (!family) return false;
102
+ return this._localNetworks().check(ip, family === 6 ? 'ipv6' : 'ipv4');
103
+ }
104
+
105
+ // Private ranges plus the networks this machine is on (e.g. public IPv6 addresses on home Wi-Fi).
106
+ _localNetworks() {
107
+ if (this._networks && Date.now() - this._networksAt < NETWORKS_TTL_MS) return this._networks;
108
+ const list = new net.BlockList();
109
+ for (const [address, prefix, type] of PRIVATE_SUBNETS) list.addSubnet(address, prefix, type);
110
+ try {
111
+ for (const iface of Object.values(os.networkInterfaces()).flat()) {
112
+ if (!iface || !iface.cidr) continue;
113
+ const [address, prefix] = iface.cidr.split('/');
114
+ const type = net.isIP(address) === 6 ? 'ipv6' : 'ipv4';
115
+ list.addSubnet(address.split('%')[0], Number(prefix), type);
116
+ }
117
+ } catch (error) {
118
+ // Interface details are optional: private ranges still apply.
119
+ }
120
+ this._networks = list;
121
+ this._networksAt = Date.now();
122
+ return list;
123
+ }
124
+ }
125
+
126
+ function isTunnel(host) {
127
+ return TUNNEL_SUFFIXES.some((suffix) => host.endsWith(suffix));
128
+ }
129
+
130
+ // "host:port", "[::1]:port" or a bare host -> host name or address, without the port.
131
+ function hostnameOf(value) {
132
+ if (typeof value !== 'string') return '';
133
+ const host = value.trim().toLowerCase();
134
+ if (host.startsWith('[')) {
135
+ const end = host.indexOf(']');
136
+ return end > 0 ? host.slice(1, end) : '';
137
+ }
138
+ const colons = host.split(':').length - 1;
139
+ if (colons === 1) return host.slice(0, host.indexOf(':'));
140
+ return host; // a bare name, or an IPv6 address without brackets
141
+ }
142
+
143
+ // Client addresses as proxies write them: "203.0.113.7", "203.0.113.7:51234", "[2001:db8::1]:443".
144
+ function ipOf(value) {
145
+ if (typeof value !== 'string') return '';
146
+ let address = value.trim().replace(/^"|"$/g, '');
147
+ if (address.startsWith('[')) address = address.slice(1, address.indexOf(']'));
148
+ else if (address.split(':').length === 2) address = address.slice(0, address.indexOf(':'));
149
+ address = unmapIPv4(address.split('%')[0]);
150
+ return net.isIP(address) ? address : '';
151
+ }
152
+
153
+ // "::ffff:192.168.1.7" (IPv4 on a dual-stack socket) -> "192.168.1.7"
154
+ function unmapIPv4(address) {
155
+ const match = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
156
+ return match ? match[1] : address;
157
+ }
158
+
159
+ // First value of a header that may be repeated or comma-separated.
160
+ function first(value) {
161
+ const text = Array.isArray(value) ? value[0] : value;
162
+ return typeof text === 'string' ? text.split(',')[0].trim() : undefined;
163
+ }
164
+
165
+ // All values of a header that may be repeated or comma-separated.
166
+ function list(value) {
167
+ const text = Array.isArray(value) ? value.join(',') : value;
168
+ return typeof text === 'string' ? text.split(',').map((item) => item.trim()).filter(Boolean) : [];
169
+ }
170
+
171
+ // RFC 7239: Forwarded: for=192.0.2.60;proto=http;host=example.com, for=198.51.100.17
172
+ function forwardedValues(value, key) {
173
+ const values = [];
174
+ for (const element of list(value)) {
175
+ for (const pair of element.split(';')) {
176
+ const index = pair.indexOf('=');
177
+ if (index > 0 && pair.slice(0, index).trim().toLowerCase() === key) {
178
+ values.push(pair.slice(index + 1).trim().replace(/^"|"$/g, ''));
179
+ }
180
+ }
181
+ }
182
+ return values;
183
+ }