crashrelay 0.1.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 +150 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +79 -0
- package/dist/collectors/httpErrors.d.ts +21 -0
- package/dist/collectors/httpErrors.js +53 -0
- package/dist/collectors/logTail.d.ts +27 -0
- package/dist/collectors/logTail.js +86 -0
- package/dist/collectors/processCrash.d.ts +24 -0
- package/dist/collectors/processCrash.js +41 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +69 -0
- package/dist/daemon.d.ts +13 -0
- package/dist/daemon.js +37 -0
- package/dist/dedup.d.ts +18 -0
- package/dist/dedup.js +72 -0
- package/dist/fetcher.d.ts +10 -0
- package/dist/fetcher.js +5 -0
- package/dist/fingerprint.d.ts +7 -0
- package/dist/fingerprint.js +57 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +42 -0
- package/dist/ingestion/auth.d.ts +7 -0
- package/dist/ingestion/auth.js +21 -0
- package/dist/ingestion/cors.d.ts +7 -0
- package/dist/ingestion/cors.js +23 -0
- package/dist/ingestion/rateLimit.d.ts +11 -0
- package/dist/ingestion/rateLimit.js +24 -0
- package/dist/ingestion/server.d.ts +15 -0
- package/dist/ingestion/server.js +84 -0
- package/dist/init.d.ts +30 -0
- package/dist/init.js +58 -0
- package/dist/pipeline.d.ts +14 -0
- package/dist/pipeline.js +53 -0
- package/dist/providers/adf.d.ts +24 -0
- package/dist/providers/adf.js +28 -0
- package/dist/providers/format.d.ts +3 -0
- package/dist/providers/format.js +23 -0
- package/dist/providers/github.d.ts +4 -0
- package/dist/providers/github.js +47 -0
- package/dist/providers/index.d.ts +7 -0
- package/dist/providers/index.js +19 -0
- package/dist/providers/jira.d.ts +4 -0
- package/dist/providers/jira.js +53 -0
- package/dist/providers/types.d.ts +8 -0
- package/dist/providers/types.js +2 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +2 -0
- package/package.json +41 -0
package/dist/dedup.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readCache = readCache;
|
|
4
|
+
exports.writeCache = writeCache;
|
|
5
|
+
exports.decide = decide;
|
|
6
|
+
exports.recordCreate = recordCreate;
|
|
7
|
+
exports.recordSeenAgain = recordSeenAgain;
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
async function readCache(path) {
|
|
10
|
+
try {
|
|
11
|
+
const raw = await node_fs_1.promises.readFile(path, 'utf8');
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
if (typeof parsed.entries !== 'object' || parsed.entries === null)
|
|
14
|
+
throw new Error('malformed cache');
|
|
15
|
+
return { entries: parsed.entries };
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
if (err.code === 'ENOENT')
|
|
19
|
+
return { entries: {} };
|
|
20
|
+
throw new Error(`Could not read dedup cache at ${path}: ${err instanceof Error ? err.message : err}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Writes via a temp file + rename so a reader never observes a partially-written (truncated/corrupt) cache file, even under concurrent access. */
|
|
24
|
+
async function writeCache(path, cache) {
|
|
25
|
+
const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
26
|
+
await node_fs_1.promises.writeFile(tmpPath, JSON.stringify(cache, null, 2) + '\n', 'utf8');
|
|
27
|
+
await node_fs_1.promises.rename(tmpPath, path);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Caps tickets to at most one per fingerprint per cooldown window — not "at
|
|
31
|
+
* most one ticket ever". Within the window, a recurrence gets a comment on
|
|
32
|
+
* the existing ticket (throttled separately so a tight crash loop doesn't
|
|
33
|
+
* spam comments either), not a fresh ticket.
|
|
34
|
+
*/
|
|
35
|
+
function decide(cache, fp, options) {
|
|
36
|
+
const now = (options.now ?? (() => new Date()))();
|
|
37
|
+
const entry = cache.entries[fp];
|
|
38
|
+
if (!entry || new Date(entry.cooldownUntil) <= now) {
|
|
39
|
+
return { action: 'create' };
|
|
40
|
+
}
|
|
41
|
+
const lastComment = entry.lastCommentAt ? new Date(entry.lastCommentAt) : undefined;
|
|
42
|
+
const commentCooldownMs = options.commentCooldownMinutes * 60 * 1000;
|
|
43
|
+
if (!lastComment || now.getTime() - lastComment.getTime() >= commentCooldownMs) {
|
|
44
|
+
return { action: 'comment', entry };
|
|
45
|
+
}
|
|
46
|
+
return { action: 'skip', entry };
|
|
47
|
+
}
|
|
48
|
+
function recordCreate(cache, fp, ticket, options) {
|
|
49
|
+
const now = (options.now ?? (() => new Date()))();
|
|
50
|
+
const cooldownUntil = new Date(now.getTime() + options.cooldownHours * 60 * 60 * 1000);
|
|
51
|
+
const entry = {
|
|
52
|
+
firstSeenAt: now.toISOString(),
|
|
53
|
+
lastSeenAt: now.toISOString(),
|
|
54
|
+
count: 1,
|
|
55
|
+
cooldownUntil: cooldownUntil.toISOString(),
|
|
56
|
+
ticket,
|
|
57
|
+
};
|
|
58
|
+
return { entries: { ...cache.entries, [fp]: entry } };
|
|
59
|
+
}
|
|
60
|
+
function recordSeenAgain(cache, fp, options, commented) {
|
|
61
|
+
const now = (options.now ?? (() => new Date()))();
|
|
62
|
+
const existing = cache.entries[fp];
|
|
63
|
+
if (!existing)
|
|
64
|
+
return cache;
|
|
65
|
+
const entry = {
|
|
66
|
+
...existing,
|
|
67
|
+
lastSeenAt: now.toISOString(),
|
|
68
|
+
count: existing.count + 1,
|
|
69
|
+
lastCommentAt: commented ? now.toISOString() : existing.lastCommentAt,
|
|
70
|
+
};
|
|
71
|
+
return { entries: { ...cache.entries, [fp]: entry } };
|
|
72
|
+
}
|
package/dist/fetcher.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Defect } from './types';
|
|
2
|
+
/** Collapses variable data (IDs, timestamps, addresses) so repeated errors with different payloads hash identically. */
|
|
3
|
+
export declare function normalizeMessage(message: string): string;
|
|
4
|
+
/** Keeps only function name + relative-ish file path from the top N application frames, dropping line/column so unrelated line shifts don't fragment the fingerprint. */
|
|
5
|
+
export declare function normalizeStack(stack: string | undefined, maxFrames?: number): string;
|
|
6
|
+
export declare function fingerprint(defect: Defect): string;
|
|
7
|
+
export declare function fingerprintLogLine(matcherId: string, line: string): string;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeMessage = normalizeMessage;
|
|
4
|
+
exports.normalizeStack = normalizeStack;
|
|
5
|
+
exports.fingerprint = fingerprint;
|
|
6
|
+
exports.fingerprintLogLine = fingerprintLogLine;
|
|
7
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
|
|
9
|
+
const HEX_ADDR_RE = /\b0x[0-9a-f]{4,}\b/gi;
|
|
10
|
+
const ISO_TIMESTAMP_RE = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?\b/g;
|
|
11
|
+
const LONG_NUMBER_RE = /\b\d{4,}\b/g;
|
|
12
|
+
/** Collapses variable data (IDs, timestamps, addresses) so repeated errors with different payloads hash identically. */
|
|
13
|
+
function normalizeMessage(message) {
|
|
14
|
+
return message
|
|
15
|
+
.replace(UUID_RE, '<uuid>')
|
|
16
|
+
.replace(HEX_ADDR_RE, '<addr>')
|
|
17
|
+
.replace(ISO_TIMESTAMP_RE, '<timestamp>')
|
|
18
|
+
.replace(LONG_NUMBER_RE, '<num>')
|
|
19
|
+
.trim();
|
|
20
|
+
}
|
|
21
|
+
const STACK_FRAME_RE = /^\s*at\s+(?:(.+?)\s+\()?(.+?):\d+:\d+\)?$/;
|
|
22
|
+
/**
|
|
23
|
+
* Frames from Node's own internals (`node:internal/...`, event-loop/
|
|
24
|
+
* microtask-queue plumbing like `processTicksAndRejections`). Whether these
|
|
25
|
+
* trail the real stack depends on incidental async/microtask timing at the
|
|
26
|
+
* moment the Error was constructed, not on the actual bug — leaving them in
|
|
27
|
+
* would make the same logical crash hash differently between occurrences
|
|
28
|
+
* and silently defeat dedup.
|
|
29
|
+
*/
|
|
30
|
+
const INTERNAL_FRAME_RE = /node:internal|\bprocessTicksAndRejections\b|\binternal\/(?:process|timers|modules)\//;
|
|
31
|
+
/** Keeps only function name + relative-ish file path from the top N application frames, dropping line/column so unrelated line shifts don't fragment the fingerprint. */
|
|
32
|
+
function normalizeStack(stack, maxFrames = 5) {
|
|
33
|
+
if (!stack)
|
|
34
|
+
return '';
|
|
35
|
+
const lines = stack
|
|
36
|
+
.split('\n')
|
|
37
|
+
.slice(1)
|
|
38
|
+
.filter((line) => !INTERNAL_FRAME_RE.test(line))
|
|
39
|
+
.slice(0, maxFrames);
|
|
40
|
+
return lines
|
|
41
|
+
.map((line) => {
|
|
42
|
+
const match = STACK_FRAME_RE.exec(line.trim());
|
|
43
|
+
if (!match)
|
|
44
|
+
return line.trim();
|
|
45
|
+
const [, fn, file] = match;
|
|
46
|
+
const shortFile = file.split('/').slice(-2).join('/');
|
|
47
|
+
return `${fn ?? '<anonymous>'}@${shortFile}`;
|
|
48
|
+
})
|
|
49
|
+
.join('|');
|
|
50
|
+
}
|
|
51
|
+
function fingerprint(defect) {
|
|
52
|
+
const parts = [defect.type, normalizeMessage(defect.message), normalizeStack(defect.stack)];
|
|
53
|
+
return (0, node_crypto_1.createHash)('sha256').update(parts.join('|')).digest('hex');
|
|
54
|
+
}
|
|
55
|
+
function fingerprintLogLine(matcherId, line) {
|
|
56
|
+
return (0, node_crypto_1.createHash)('sha256').update(`${matcherId}|${normalizeMessage(line)}`).digest('hex');
|
|
57
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from './types';
|
|
2
|
+
export { loadConfig, ConfigError } from './config';
|
|
3
|
+
export { realFetcher } from './fetcher';
|
|
4
|
+
export type { Fetcher } from './fetcher';
|
|
5
|
+
export { resolveProviders } from './providers';
|
|
6
|
+
export type { TicketProvider } from './providers/types';
|
|
7
|
+
export { createPipeline } from './pipeline';
|
|
8
|
+
export type { Pipeline } from './pipeline';
|
|
9
|
+
export { installCrashHandlers } from './collectors/processCrash';
|
|
10
|
+
export type { CrashTarget, DefectHandler, InstallCrashHandlersOptions } from './collectors/processCrash';
|
|
11
|
+
export { httpStatusMiddleware, expressErrorHandler, reportDefect } from './collectors/httpErrors';
|
|
12
|
+
export { tailLog } from './collectors/logTail';
|
|
13
|
+
export { createIngestionServer } from './ingestion/server';
|
|
14
|
+
export { startDaemon } from './daemon';
|
|
15
|
+
export type { DaemonHandle } from './daemon';
|
|
16
|
+
export { fingerprint, fingerprintLogLine } from './fingerprint';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.fingerprintLogLine = exports.fingerprint = exports.startDaemon = exports.createIngestionServer = exports.tailLog = exports.reportDefect = exports.expressErrorHandler = exports.httpStatusMiddleware = exports.installCrashHandlers = exports.createPipeline = exports.resolveProviders = exports.realFetcher = exports.ConfigError = exports.loadConfig = void 0;
|
|
18
|
+
__exportStar(require("./types"), exports);
|
|
19
|
+
var config_1 = require("./config");
|
|
20
|
+
Object.defineProperty(exports, "loadConfig", { enumerable: true, get: function () { return config_1.loadConfig; } });
|
|
21
|
+
Object.defineProperty(exports, "ConfigError", { enumerable: true, get: function () { return config_1.ConfigError; } });
|
|
22
|
+
var fetcher_1 = require("./fetcher");
|
|
23
|
+
Object.defineProperty(exports, "realFetcher", { enumerable: true, get: function () { return fetcher_1.realFetcher; } });
|
|
24
|
+
var providers_1 = require("./providers");
|
|
25
|
+
Object.defineProperty(exports, "resolveProviders", { enumerable: true, get: function () { return providers_1.resolveProviders; } });
|
|
26
|
+
var pipeline_1 = require("./pipeline");
|
|
27
|
+
Object.defineProperty(exports, "createPipeline", { enumerable: true, get: function () { return pipeline_1.createPipeline; } });
|
|
28
|
+
var processCrash_1 = require("./collectors/processCrash");
|
|
29
|
+
Object.defineProperty(exports, "installCrashHandlers", { enumerable: true, get: function () { return processCrash_1.installCrashHandlers; } });
|
|
30
|
+
var httpErrors_1 = require("./collectors/httpErrors");
|
|
31
|
+
Object.defineProperty(exports, "httpStatusMiddleware", { enumerable: true, get: function () { return httpErrors_1.httpStatusMiddleware; } });
|
|
32
|
+
Object.defineProperty(exports, "expressErrorHandler", { enumerable: true, get: function () { return httpErrors_1.expressErrorHandler; } });
|
|
33
|
+
Object.defineProperty(exports, "reportDefect", { enumerable: true, get: function () { return httpErrors_1.reportDefect; } });
|
|
34
|
+
var logTail_1 = require("./collectors/logTail");
|
|
35
|
+
Object.defineProperty(exports, "tailLog", { enumerable: true, get: function () { return logTail_1.tailLog; } });
|
|
36
|
+
var server_1 = require("./ingestion/server");
|
|
37
|
+
Object.defineProperty(exports, "createIngestionServer", { enumerable: true, get: function () { return server_1.createIngestionServer; } });
|
|
38
|
+
var daemon_1 = require("./daemon");
|
|
39
|
+
Object.defineProperty(exports, "startDaemon", { enumerable: true, get: function () { return daemon_1.startDaemon; } });
|
|
40
|
+
var fingerprint_1 = require("./fingerprint");
|
|
41
|
+
Object.defineProperty(exports, "fingerprint", { enumerable: true, get: function () { return fingerprint_1.fingerprint; } });
|
|
42
|
+
Object.defineProperty(exports, "fingerprintLogLine", { enumerable: true, get: function () { return fingerprint_1.fingerprintLogLine; } });
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* `timingSafeEqual` requires equal-length buffers, so both sides are hashed
|
|
4
|
+
* to a fixed 32 bytes first — this also means an attacker learns nothing
|
|
5
|
+
* from a length mismatch on the raw token.
|
|
6
|
+
*/
|
|
7
|
+
export declare function checkBearerToken(req: IncomingMessage, expectedToken: string): boolean;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkBearerToken = checkBearerToken;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
function digest(value) {
|
|
6
|
+
return (0, node_crypto_1.createHash)('sha256').update(value).digest();
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* `timingSafeEqual` requires equal-length buffers, so both sides are hashed
|
|
10
|
+
* to a fixed 32 bytes first — this also means an attacker learns nothing
|
|
11
|
+
* from a length mismatch on the raw token.
|
|
12
|
+
*/
|
|
13
|
+
function checkBearerToken(req, expectedToken) {
|
|
14
|
+
const header = req.headers.authorization;
|
|
15
|
+
if (!header?.startsWith('Bearer '))
|
|
16
|
+
return false;
|
|
17
|
+
const provided = header.slice('Bearer '.length).trim();
|
|
18
|
+
if (!provided)
|
|
19
|
+
return false;
|
|
20
|
+
return (0, node_crypto_1.timingSafeEqual)(digest(provided), digest(expectedToken));
|
|
21
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* Reflects the request's Origin header into Access-Control-Allow-Origin
|
|
4
|
+
* only when it's in the configured allowlist. Returns true if this request
|
|
5
|
+
* was a handled OPTIONS preflight (caller should stop processing).
|
|
6
|
+
*/
|
|
7
|
+
export declare function applyCors(req: IncomingMessage, res: ServerResponse, allowedOrigins: string[]): boolean;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyCors = applyCors;
|
|
4
|
+
/**
|
|
5
|
+
* Reflects the request's Origin header into Access-Control-Allow-Origin
|
|
6
|
+
* only when it's in the configured allowlist. Returns true if this request
|
|
7
|
+
* was a handled OPTIONS preflight (caller should stop processing).
|
|
8
|
+
*/
|
|
9
|
+
function applyCors(req, res, allowedOrigins) {
|
|
10
|
+
const origin = req.headers.origin;
|
|
11
|
+
if (origin && allowedOrigins.includes(origin)) {
|
|
12
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
13
|
+
res.setHeader('Vary', 'Origin');
|
|
14
|
+
}
|
|
15
|
+
if (req.method === 'OPTIONS') {
|
|
16
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
17
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
18
|
+
res.statusCode = 204;
|
|
19
|
+
res.end();
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface RateLimiterOptions {
|
|
2
|
+
windowMs?: number;
|
|
3
|
+
max?: number;
|
|
4
|
+
now?: () => number;
|
|
5
|
+
}
|
|
6
|
+
export interface RateLimiter {
|
|
7
|
+
/** Returns true if the request for `key` is allowed under the current window. */
|
|
8
|
+
allow(key: string): boolean;
|
|
9
|
+
}
|
|
10
|
+
/** In-memory sliding-window limiter, keyed by an arbitrary string (typically source IP). */
|
|
11
|
+
export declare function createRateLimiter(options?: RateLimiterOptions): RateLimiter;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRateLimiter = createRateLimiter;
|
|
4
|
+
/** In-memory sliding-window limiter, keyed by an arbitrary string (typically source IP). */
|
|
5
|
+
function createRateLimiter(options = {}) {
|
|
6
|
+
const windowMs = options.windowMs ?? 60000;
|
|
7
|
+
const max = options.max ?? 30;
|
|
8
|
+
const now = options.now ?? (() => Date.now());
|
|
9
|
+
const hits = new Map();
|
|
10
|
+
return {
|
|
11
|
+
allow(key) {
|
|
12
|
+
const currentTime = now();
|
|
13
|
+
const windowStart = currentTime - windowMs;
|
|
14
|
+
const timestamps = (hits.get(key) ?? []).filter((t) => t > windowStart);
|
|
15
|
+
if (timestamps.length >= max) {
|
|
16
|
+
hits.set(key, timestamps);
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
timestamps.push(currentTime);
|
|
20
|
+
hits.set(key, timestamps);
|
|
21
|
+
return true;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Server } from 'node:http';
|
|
2
|
+
import type { IngestionConfig } from '../types';
|
|
3
|
+
import type { DefectHandler } from '../collectors/processCrash';
|
|
4
|
+
import { type RateLimiter } from './rateLimit';
|
|
5
|
+
export interface IngestionServerOptions {
|
|
6
|
+
now?: () => Date;
|
|
7
|
+
rateLimiter?: RateLimiter;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Exposes POST /report for the crashrelay-browser client SDK and GET
|
|
11
|
+
* /health for uptime checks. INGESTION_TOKEN is a public "write key" (like
|
|
12
|
+
* a Sentry DSN, readable in devtools) — its abuse-mitigation is this rate
|
|
13
|
+
* limiter + the dedup pipeline, not confidentiality.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createIngestionServer(config: IngestionConfig, handler: DefectHandler, options?: IngestionServerOptions): Server;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createIngestionServer = createIngestionServer;
|
|
4
|
+
const node_http_1 = require("node:http");
|
|
5
|
+
const auth_1 = require("./auth");
|
|
6
|
+
const cors_1 = require("./cors");
|
|
7
|
+
const rateLimit_1 = require("./rateLimit");
|
|
8
|
+
const MAX_BODY_BYTES = 16 * 1024;
|
|
9
|
+
function sourceIp(req) {
|
|
10
|
+
return req.socket.remoteAddress ?? 'unknown';
|
|
11
|
+
}
|
|
12
|
+
async function readBody(req) {
|
|
13
|
+
const chunks = [];
|
|
14
|
+
let total = 0;
|
|
15
|
+
for await (const chunk of req) {
|
|
16
|
+
total += chunk.length;
|
|
17
|
+
if (total > MAX_BODY_BYTES)
|
|
18
|
+
return undefined;
|
|
19
|
+
chunks.push(chunk);
|
|
20
|
+
}
|
|
21
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Exposes POST /report for the crashrelay-browser client SDK and GET
|
|
25
|
+
* /health for uptime checks. INGESTION_TOKEN is a public "write key" (like
|
|
26
|
+
* a Sentry DSN, readable in devtools) — its abuse-mitigation is this rate
|
|
27
|
+
* limiter + the dedup pipeline, not confidentiality.
|
|
28
|
+
*/
|
|
29
|
+
function createIngestionServer(config, handler, options = {}) {
|
|
30
|
+
const now = options.now ?? (() => new Date());
|
|
31
|
+
const rateLimiter = options.rateLimiter ?? (0, rateLimit_1.createRateLimiter)();
|
|
32
|
+
return (0, node_http_1.createServer)(async (req, res) => {
|
|
33
|
+
if ((0, cors_1.applyCors)(req, res, config.allowedOrigins))
|
|
34
|
+
return;
|
|
35
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
36
|
+
res.statusCode = 200;
|
|
37
|
+
res.end('ok');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (req.method !== 'POST' || req.url !== '/report') {
|
|
41
|
+
res.statusCode = 404;
|
|
42
|
+
res.end();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!(0, auth_1.checkBearerToken)(req, config.token)) {
|
|
46
|
+
res.statusCode = 401;
|
|
47
|
+
res.end();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!rateLimiter.allow(sourceIp(req))) {
|
|
51
|
+
res.statusCode = 429;
|
|
52
|
+
res.end();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const raw = await readBody(req);
|
|
56
|
+
if (raw === undefined) {
|
|
57
|
+
res.statusCode = 413;
|
|
58
|
+
res.end();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
let payload;
|
|
62
|
+
try {
|
|
63
|
+
payload = JSON.parse(raw);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
res.statusCode = 400;
|
|
67
|
+
res.end();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Respond immediately — the client fired via fetch(keepalive)/sendBeacon and isn't waiting on ticket creation.
|
|
71
|
+
res.statusCode = 202;
|
|
72
|
+
res.end();
|
|
73
|
+
if (payload.message) {
|
|
74
|
+
const defect = {
|
|
75
|
+
type: 'client-error',
|
|
76
|
+
message: payload.message,
|
|
77
|
+
stack: payload.stack,
|
|
78
|
+
context: { url: payload.url, userAgent: payload.userAgent },
|
|
79
|
+
occurredAt: now().toISOString(),
|
|
80
|
+
};
|
|
81
|
+
void handler(defect);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface Prompter {
|
|
2
|
+
checkbox(config: {
|
|
3
|
+
message: string;
|
|
4
|
+
choices: Array<{
|
|
5
|
+
name: string;
|
|
6
|
+
value: string;
|
|
7
|
+
}>;
|
|
8
|
+
}): Promise<string[]>;
|
|
9
|
+
input(config: {
|
|
10
|
+
message: string;
|
|
11
|
+
default?: string;
|
|
12
|
+
}): Promise<string>;
|
|
13
|
+
password(config: {
|
|
14
|
+
message: string;
|
|
15
|
+
}): Promise<string>;
|
|
16
|
+
confirm(config: {
|
|
17
|
+
message: string;
|
|
18
|
+
default?: boolean;
|
|
19
|
+
}): Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
export interface InitOptions {
|
|
22
|
+
generateToken?: () => string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Prompts for provider credentials and prints an env-var export block —
|
|
26
|
+
* this never touches the filesystem. The user is responsible for loading
|
|
27
|
+
* the output into their own process manager / systemd unit / Docker
|
|
28
|
+
* secrets, which is the only place this tool trusts credentials to live.
|
|
29
|
+
*/
|
|
30
|
+
export declare function runInitWizard(prompter: Prompter, options?: InitOptions): Promise<string>;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runInitWizard = runInitWizard;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
/**
|
|
6
|
+
* Prompts for provider credentials and prints an env-var export block —
|
|
7
|
+
* this never touches the filesystem. The user is responsible for loading
|
|
8
|
+
* the output into their own process manager / systemd unit / Docker
|
|
9
|
+
* secrets, which is the only place this tool trusts credentials to live.
|
|
10
|
+
*/
|
|
11
|
+
async function runInitWizard(prompter, options = {}) {
|
|
12
|
+
const generateToken = options.generateToken ?? (() => (0, node_crypto_1.randomBytes)(24).toString('hex'));
|
|
13
|
+
const providers = await prompter.checkbox({
|
|
14
|
+
message: 'Which ticket provider(s)?',
|
|
15
|
+
choices: [
|
|
16
|
+
{ name: 'Jira', value: 'jira' },
|
|
17
|
+
{ name: 'GitHub Issues', value: 'github' },
|
|
18
|
+
],
|
|
19
|
+
});
|
|
20
|
+
if (providers.length === 0) {
|
|
21
|
+
throw new Error('At least one provider must be selected.');
|
|
22
|
+
}
|
|
23
|
+
const lines = [
|
|
24
|
+
'# --- crashrelay environment variables ---',
|
|
25
|
+
'# Add these to your process manager / systemd unit / Docker secrets.',
|
|
26
|
+
'# Do NOT commit this output to source control.',
|
|
27
|
+
];
|
|
28
|
+
if (providers.includes('jira')) {
|
|
29
|
+
const baseUrl = await prompter.input({ message: 'Jira base URL (e.g. https://yourco.atlassian.net)' });
|
|
30
|
+
const email = await prompter.input({ message: 'Jira account email' });
|
|
31
|
+
const apiToken = await prompter.password({ message: 'Jira API token' });
|
|
32
|
+
const projectKey = await prompter.input({ message: 'Jira project key (e.g. OPS)' });
|
|
33
|
+
const issueType = await prompter.input({ message: 'Jira issue type', default: 'Bug' });
|
|
34
|
+
lines.push(`export JIRA_BASE_URL="${baseUrl}"`, `export JIRA_EMAIL="${email}"`, `export JIRA_API_TOKEN="${apiToken}"`, `export JIRA_PROJECT_KEY="${projectKey}"`, `export JIRA_ISSUE_TYPE="${issueType}"`);
|
|
35
|
+
}
|
|
36
|
+
if (providers.includes('github')) {
|
|
37
|
+
const token = await prompter.password({ message: 'GitHub personal access token' });
|
|
38
|
+
const owner = await prompter.input({ message: 'GitHub repo owner' });
|
|
39
|
+
const repo = await prompter.input({ message: 'GitHub repo name' });
|
|
40
|
+
lines.push(`export GITHUB_TOKEN="${token}"`, `export GITHUB_OWNER="${owner}"`, `export GITHUB_REPO="${repo}"`);
|
|
41
|
+
}
|
|
42
|
+
const enableIngestion = await prompter.confirm({ message: 'Enable the frontend ingestion endpoint (/report)?', default: true });
|
|
43
|
+
if (enableIngestion) {
|
|
44
|
+
const port = await prompter.input({ message: 'Ingestion port', default: '4318' });
|
|
45
|
+
const allowedOrigins = await prompter.input({ message: 'Allowed CORS origin(s), comma-separated' });
|
|
46
|
+
const token = generateToken();
|
|
47
|
+
lines.push(`export INGESTION_TOKEN="${token}"`, `export INGESTION_PORT="${port}"`, `export INGESTION_ALLOWED_ORIGINS="${allowedOrigins}"`);
|
|
48
|
+
}
|
|
49
|
+
const dedupCooldownHours = await prompter.input({ message: 'Dedup cooldown hours (skip re-filing the same defect within this window)', default: '24' });
|
|
50
|
+
lines.push(`export DEDUP_COOLDOWN_HOURS="${dedupCooldownHours}"`);
|
|
51
|
+
const logPath = await prompter.input({ message: 'Log file path to tail (leave blank to skip)', default: '' });
|
|
52
|
+
if (logPath) {
|
|
53
|
+
const logPattern = await prompter.input({ message: 'Log line match pattern', default: 'ERROR' });
|
|
54
|
+
lines.push(`export LOG_TAIL_PATH="${logPath}"`, `export LOG_TAIL_PATTERN="${logPattern}"`);
|
|
55
|
+
}
|
|
56
|
+
lines.push('', '# Next: load these into your environment, then run `crashrelay test-ticket` to verify connectivity.');
|
|
57
|
+
return lines.join('\n') + '\n';
|
|
58
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Config } from './types';
|
|
2
|
+
import type { TicketProvider } from './providers/types';
|
|
3
|
+
import type { DefectHandler } from './collectors/processCrash';
|
|
4
|
+
export interface Pipeline {
|
|
5
|
+
handleDefect: DefectHandler;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Only the first configured provider actually creates/owns the ticket —
|
|
9
|
+
* the dedup cache stores one ticket ref per fingerprint, so "create in
|
|
10
|
+
* every configured provider" would need a different cache shape. If both
|
|
11
|
+
* Jira and GitHub are configured, Jira takes priority (resolveProviders
|
|
12
|
+
* order); this is a deliberate v1 simplification, not an oversight.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createPipeline(config: Config, providers: TicketProvider[], now?: () => Date): Pipeline;
|
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createPipeline = createPipeline;
|
|
4
|
+
const dedup_1 = require("./dedup");
|
|
5
|
+
const fingerprint_1 = require("./fingerprint");
|
|
6
|
+
/**
|
|
7
|
+
* Only the first configured provider actually creates/owns the ticket —
|
|
8
|
+
* the dedup cache stores one ticket ref per fingerprint, so "create in
|
|
9
|
+
* every configured provider" would need a different cache shape. If both
|
|
10
|
+
* Jira and GitHub are configured, Jira takes priority (resolveProviders
|
|
11
|
+
* order); this is a deliberate v1 simplification, not an oversight.
|
|
12
|
+
*/
|
|
13
|
+
function createPipeline(config, providers, now = () => new Date()) {
|
|
14
|
+
const provider = providers[0];
|
|
15
|
+
// Multiple defects can arrive concurrently (e.g. a burst of frontend error
|
|
16
|
+
// beacons, or a crash-loop firing faster than one read-modify-write cycle
|
|
17
|
+
// completes). Without serializing access, two concurrent handlers can both
|
|
18
|
+
// read the cache before either writes, and the second write silently loses
|
|
19
|
+
// the first's update — or, worse, race a write of the same file if it were
|
|
20
|
+
// ever run across multiple processes. This queue only protects a single
|
|
21
|
+
// process's concurrent calls, not multiple daemon replicas sharing one
|
|
22
|
+
// cache file (see README limitations).
|
|
23
|
+
let queue = Promise.resolve();
|
|
24
|
+
function serialized(fn) {
|
|
25
|
+
const result = queue.then(fn, fn);
|
|
26
|
+
queue = result.catch(() => undefined);
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
async function handleDefect(defect) {
|
|
30
|
+
if (!provider) {
|
|
31
|
+
throw new Error('No ticket provider configured — run `crashrelay init` first.');
|
|
32
|
+
}
|
|
33
|
+
return serialized(async () => {
|
|
34
|
+
const fp = (0, fingerprint_1.fingerprint)(defect);
|
|
35
|
+
const cache = await (0, dedup_1.readCache)(config.cacheFilePath);
|
|
36
|
+
const options = { cooldownHours: config.dedupCooldownHours, commentCooldownMinutes: config.commentCooldownMinutes, now };
|
|
37
|
+
const decision = (0, dedup_1.decide)(cache, fp, options);
|
|
38
|
+
if (decision.action === 'create') {
|
|
39
|
+
const ticket = await provider.createTicket(defect);
|
|
40
|
+
await (0, dedup_1.writeCache)(config.cacheFilePath, (0, dedup_1.recordCreate)(cache, fp, ticket, options));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (decision.action === 'comment') {
|
|
44
|
+
await provider.addComment(decision.entry.ticket, `Seen again (now ${decision.entry.count + 1} times). Last seen: ${defect.occurredAt}`);
|
|
45
|
+
await (0, dedup_1.writeCache)(config.cacheFilePath, (0, dedup_1.recordSeenAgain)(cache, fp, options, true));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// 'skip' — within both the ticket cooldown and the comment cooldown; just tally the occurrence.
|
|
49
|
+
await (0, dedup_1.writeCache)(config.cacheFilePath, (0, dedup_1.recordSeenAgain)(cache, fp, options, false));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return { handleDefect };
|
|
53
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface AdfTextNode {
|
|
2
|
+
type: 'text';
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
interface AdfHardBreak {
|
|
6
|
+
type: 'hardBreak';
|
|
7
|
+
}
|
|
8
|
+
interface AdfParagraph {
|
|
9
|
+
type: 'paragraph';
|
|
10
|
+
content: Array<AdfTextNode | AdfHardBreak>;
|
|
11
|
+
}
|
|
12
|
+
export interface AdfDoc {
|
|
13
|
+
type: 'doc';
|
|
14
|
+
version: 1;
|
|
15
|
+
content: AdfParagraph[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Jira Cloud's v3 issue API rejects a plain-text `description` — it must be
|
|
19
|
+
* Atlassian Document Format. This wraps plain text into the minimal valid
|
|
20
|
+
* shape: blank lines become paragraph breaks, single newlines become
|
|
21
|
+
* hardBreak nodes within a paragraph.
|
|
22
|
+
*/
|
|
23
|
+
export declare function textToAdf(text: string): AdfDoc;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.textToAdf = textToAdf;
|
|
4
|
+
/**
|
|
5
|
+
* Jira Cloud's v3 issue API rejects a plain-text `description` — it must be
|
|
6
|
+
* Atlassian Document Format. This wraps plain text into the minimal valid
|
|
7
|
+
* shape: blank lines become paragraph breaks, single newlines become
|
|
8
|
+
* hardBreak nodes within a paragraph.
|
|
9
|
+
*/
|
|
10
|
+
function textToAdf(text) {
|
|
11
|
+
const paragraphs = text.split(/\n{2,}/).filter((p) => p.length > 0);
|
|
12
|
+
const content = paragraphs.map((paragraph) => {
|
|
13
|
+
const lines = paragraph.split('\n');
|
|
14
|
+
const nodes = [];
|
|
15
|
+
lines.forEach((line, index) => {
|
|
16
|
+
if (index > 0)
|
|
17
|
+
nodes.push({ type: 'hardBreak' });
|
|
18
|
+
if (line.length > 0)
|
|
19
|
+
nodes.push({ type: 'text', text: line });
|
|
20
|
+
});
|
|
21
|
+
return { type: 'paragraph', content: nodes };
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
type: 'doc',
|
|
25
|
+
version: 1,
|
|
26
|
+
content: content.length > 0 ? content : [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }],
|
|
27
|
+
};
|
|
28
|
+
}
|