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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +79 -0
  5. package/dist/collectors/httpErrors.d.ts +21 -0
  6. package/dist/collectors/httpErrors.js +53 -0
  7. package/dist/collectors/logTail.d.ts +27 -0
  8. package/dist/collectors/logTail.js +86 -0
  9. package/dist/collectors/processCrash.d.ts +24 -0
  10. package/dist/collectors/processCrash.js +41 -0
  11. package/dist/config.d.ts +10 -0
  12. package/dist/config.js +69 -0
  13. package/dist/daemon.d.ts +13 -0
  14. package/dist/daemon.js +37 -0
  15. package/dist/dedup.d.ts +18 -0
  16. package/dist/dedup.js +72 -0
  17. package/dist/fetcher.d.ts +10 -0
  18. package/dist/fetcher.js +5 -0
  19. package/dist/fingerprint.d.ts +7 -0
  20. package/dist/fingerprint.js +57 -0
  21. package/dist/index.d.ts +16 -0
  22. package/dist/index.js +42 -0
  23. package/dist/ingestion/auth.d.ts +7 -0
  24. package/dist/ingestion/auth.js +21 -0
  25. package/dist/ingestion/cors.d.ts +7 -0
  26. package/dist/ingestion/cors.js +23 -0
  27. package/dist/ingestion/rateLimit.d.ts +11 -0
  28. package/dist/ingestion/rateLimit.js +24 -0
  29. package/dist/ingestion/server.d.ts +15 -0
  30. package/dist/ingestion/server.js +84 -0
  31. package/dist/init.d.ts +30 -0
  32. package/dist/init.js +58 -0
  33. package/dist/pipeline.d.ts +14 -0
  34. package/dist/pipeline.js +53 -0
  35. package/dist/providers/adf.d.ts +24 -0
  36. package/dist/providers/adf.js +28 -0
  37. package/dist/providers/format.d.ts +3 -0
  38. package/dist/providers/format.js +23 -0
  39. package/dist/providers/github.d.ts +4 -0
  40. package/dist/providers/github.js +47 -0
  41. package/dist/providers/index.d.ts +7 -0
  42. package/dist/providers/index.js +19 -0
  43. package/dist/providers/jira.d.ts +4 -0
  44. package/dist/providers/jira.js +53 -0
  45. package/dist/providers/types.d.ts +8 -0
  46. package/dist/providers/types.js +2 -0
  47. package/dist/types.d.ts +62 -0
  48. package/dist/types.js +2 -0
  49. package/package.json +41 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SNR
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # crashrelay
2
+
3
+ Catch backend crashes, HTTP 5xx responses, and error-log lines — plus
4
+ frontend JS errors via [`crashrelay-browser`](https://github.com/Rifat024/crashrelay-browser)
5
+ — and auto-file tickets in Jira or GitHub Issues.
6
+
7
+ ## The one thing to understand before using this
8
+
9
+ **Crash/HTTP-5xx capture is a library you embed in your own app, not a
10
+ process that watches another process from the outside.** A separate daemon
11
+ cannot hook `uncaughtException`/`unhandledRejection` in a *different* OS
12
+ process — that's a Node/OS limitation, not a design choice. So:
13
+
14
+ - `require('crashrelay').installCrashHandlers(...)` / `httpStatusMiddleware(...)`
15
+ / `expressErrorHandler(...)` run **inside your own server's process**.
16
+ - `crashrelay start` runs the two pieces that genuinely *can* be a separate
17
+ always-on daemon: log-file tailing, and the ingestion endpoint that
18
+ receives frontend error beacons from `crashrelay-browser`.
19
+
20
+ Both pieces ship in this one package — it's just that "backend crash
21
+ detection" and "standalone daemon" are different things wearing the same
22
+ npm install.
23
+
24
+ **On "24/7"**: `crashrelay start` is a foreground Node process. Surviving a
25
+ crash, an OOM kill, or a server reboot needs an external supervisor —
26
+ `pm2`, `systemd`, or a Docker restart policy. This package reports crashes;
27
+ it doesn't resurrect its own killed process.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ npm install crashrelay
33
+ ```
34
+
35
+ ## Setup
36
+
37
+ ```bash
38
+ npx crashrelay init
39
+ ```
40
+
41
+ Interactive wizard — prompts for Jira and/or GitHub Issues credentials, and
42
+ prints an `export KEY="value"` block. **It never writes a file.** Load the
43
+ output into your process manager, systemd unit, or Docker secrets — this
44
+ tool only ever reads credentials from environment variables.
45
+
46
+ ```bash
47
+ npx crashrelay test-ticket # dry-run connectivity check
48
+ npx crashrelay test-ticket --live # creates one real test ticket
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ### 1. Embed crash + HTTP-5xx capture in your own app
54
+
55
+ ```ts
56
+ import { installCrashHandlers, loadConfig, resolveProviders, realFetcher, createPipeline } from 'crashrelay';
57
+
58
+ const config = loadConfig();
59
+ const pipeline = createPipeline(config, resolveProviders(config, realFetcher));
60
+
61
+ installCrashHandlers(pipeline.handleDefect);
62
+ ```
63
+
64
+ For HTTP 5xx (Express/Connect):
65
+
66
+ ```ts
67
+ import { httpStatusMiddleware, expressErrorHandler } from 'crashrelay';
68
+
69
+ app.use(httpStatusMiddleware(pipeline.handleDefect)); // status-code-only, works with any framework
70
+ app.use(expressErrorHandler(pipeline.handleDefect)); // captures the actual Error, Express/Connect only — mount last
71
+ ```
72
+
73
+ Fastify doesn't support 4-arg error middleware at all — call `reportDefect`
74
+ directly from `fastify.setErrorHandler()` instead:
75
+
76
+ ```ts
77
+ import { reportDefect } from 'crashrelay';
78
+ fastify.setErrorHandler((err, req) => reportDefect(pipeline.handleDefect, err, { route: req.url }));
79
+ ```
80
+
81
+ ### 2. Run the standalone daemon (log tailing + frontend ingestion)
82
+
83
+ ```bash
84
+ npx crashrelay start
85
+ ```
86
+
87
+ Runs under whatever supervisor you use for the rest of your stack (pm2,
88
+ systemd, Docker).
89
+
90
+ ### 3. Check status
91
+
92
+ ```bash
93
+ npx crashrelay status
94
+ ```
95
+
96
+ ## Environment variables
97
+
98
+ | Variable | Required for | Notes |
99
+ | --- | --- | --- |
100
+ | `JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN`, `JIRA_PROJECT_KEY` | Jira | All four or none |
101
+ | `JIRA_ISSUE_TYPE` | Jira | Default `Bug` |
102
+ | `GITHUB_TOKEN`, `GITHUB_OWNER`, `GITHUB_REPO` | GitHub Issues | All three or none |
103
+ | `INGESTION_TOKEN` | Frontend ingestion | Enables the endpoint when set. Public "write key" (like a Sentry DSN) — not confidential, readable in browser devtools |
104
+ | `INGESTION_PORT` | Frontend ingestion | Default `4318` |
105
+ | `INGESTION_ALLOWED_ORIGINS` | Frontend ingestion | Comma-separated CORS allowlist |
106
+ | `DEDUP_COOLDOWN_HOURS` | — | Default `24` |
107
+ | `COMMENT_COOLDOWN_MINUTES` | — | Default `60` |
108
+ | `LOG_TAIL_PATH`, `LOG_TAIL_PATTERN` | Log tailing | Pattern default `ERROR` |
109
+
110
+ If both Jira and GitHub are configured, Jira is used — the dedup cache
111
+ stores one ticket per fingerprint, not one per provider, so only the first
112
+ configured provider actually creates/owns tickets.
113
+
114
+ ## Deduplication
115
+
116
+ Repeated identical crashes don't spam a ticket per occurrence. Each defect
117
+ is fingerprinted (message + top stack frames, with IDs/timestamps/addresses
118
+ normalized out) and checked against a local cache
119
+ (`.crashrelay-dedup-cache.json`):
120
+
121
+ - New fingerprint, or past its cooldown (`DEDUP_COOLDOWN_HOURS`, default
122
+ 24h) → creates a ticket.
123
+ - Same fingerprint within the cooldown → adds a "seen again ×N" comment on
124
+ the existing ticket (throttled separately by `COMMENT_COOLDOWN_MINUTES` so
125
+ a tight crash loop doesn't spam comments either).
126
+
127
+ This caps tickets **per fingerprint per window** — distinct crash
128
+ signatures each still get their own ticket. If a human closes a ticket and
129
+ the same fingerprint recurs inside the cooldown, v1 comments on the closed
130
+ ticket rather than reopening or filing a new one.
131
+
132
+ ## Frontend errors
133
+
134
+ Pair with [`crashrelay-browser`](https://github.com/Rifat024/crashrelay-browser):
135
+
136
+ ```ts
137
+ import { initErrorReporter } from 'crashrelay-browser';
138
+
139
+ initErrorReporter({
140
+ endpoint: 'https://your-api.example.com/report',
141
+ token: process.env.CRASHRELAY_INGESTION_TOKEN, // the public write key from `crashrelay init`
142
+ });
143
+ ```
144
+
145
+ ## Limitations
146
+
147
+ - Not a guarantee of 24/7 uptime — that's an infra/supervisor concern layered on top.
148
+ - Log tailing is single-instance; running multiple daemon replicas without a shared store multiplies first-occurrence tickets by replica count.
149
+ - No ticket-status awareness — a closed ticket that recurs within its cooldown gets a comment, not a reopen.
150
+ - `INGESTION_TOKEN` is a public write key, not a secret — its abuse-mitigation is rate-limiting and dedup, not confidentiality.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const commander_1 = require("commander");
5
+ const prompts_1 = require("@inquirer/prompts");
6
+ const init_1 = require("./init");
7
+ const config_1 = require("./config");
8
+ const providers_1 = require("./providers");
9
+ const fetcher_1 = require("./fetcher");
10
+ const pipeline_1 = require("./pipeline");
11
+ const daemon_1 = require("./daemon");
12
+ const dedup_1 = require("./dedup");
13
+ const program = new commander_1.Command();
14
+ program
15
+ .name('crashrelay')
16
+ .description('Catch backend crashes, HTTP 5xx responses, log errors, and frontend JS errors, and auto-file tickets in Jira or GitHub Issues.')
17
+ .version('0.1.0');
18
+ const realPrompter = { checkbox: prompts_1.checkbox, input: prompts_1.input, password: prompts_1.password, confirm: prompts_1.confirm };
19
+ program
20
+ .command('init')
21
+ .description('Interactive setup wizard — prints an environment-variable block, never writes a file.')
22
+ .action(async () => {
23
+ const output = await (0, init_1.runInitWizard)(realPrompter);
24
+ console.log('\n' + output);
25
+ });
26
+ program
27
+ .command('start')
28
+ .description('Run the standalone daemon: log-file tailing + the frontend-error ingestion endpoint.')
29
+ .action(() => {
30
+ const config = (0, config_1.loadConfig)();
31
+ const providers = (0, providers_1.resolveProviders)(config, fetcher_1.realFetcher);
32
+ const pipeline = (0, pipeline_1.createPipeline)(config, providers);
33
+ (0, daemon_1.startDaemon)(config, pipeline.handleDefect);
34
+ console.log('[crashrelay] daemon running. Remember: crash/HTTP-5xx capture must be required inside your own app — see the README.');
35
+ });
36
+ program
37
+ .command('test-ticket')
38
+ .description('Verify ticket-provider connectivity. Dry-run by default; --live creates one real test ticket.')
39
+ .option('--live', 'create a real test ticket instead of just checking the connection', false)
40
+ .action(async (opts) => {
41
+ const config = (0, config_1.loadConfig)();
42
+ const providers = (0, providers_1.resolveProviders)(config, fetcher_1.realFetcher);
43
+ const provider = providers[0];
44
+ if (!provider) {
45
+ console.error('No ticket provider configured.');
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ if (!opts.live) {
50
+ await provider.checkConnection();
51
+ console.log(`✓ ${provider.name} connection OK.`);
52
+ return;
53
+ }
54
+ const ticket = await provider.createTicket({
55
+ type: 'process-crash',
56
+ message: 'crashrelay test-ticket — safe to close',
57
+ occurredAt: new Date().toISOString(),
58
+ });
59
+ console.log(`✓ Created test ticket: ${ticket.url}`);
60
+ });
61
+ program
62
+ .command('status')
63
+ .description('Show the dedup cache: recently seen defects and their tickets.')
64
+ .action(async () => {
65
+ const config = (0, config_1.loadConfig)();
66
+ const cache = await (0, dedup_1.readCache)(config.cacheFilePath);
67
+ const entries = Object.entries(cache.entries);
68
+ if (entries.length === 0) {
69
+ console.log('No defects recorded yet.');
70
+ return;
71
+ }
72
+ for (const [fp, entry] of entries) {
73
+ console.log(`${fp.slice(0, 12)} count=${entry.count} ${entry.ticket.provider}:${entry.ticket.id} ${entry.ticket.url}`);
74
+ }
75
+ });
76
+ program.parseAsync(process.argv).catch((err) => {
77
+ console.error(err instanceof Error ? err.message : err);
78
+ process.exitCode = 1;
79
+ });
@@ -0,0 +1,21 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { DefectHandler } from './processCrash';
3
+ export interface HttpErrorOptions {
4
+ now?: () => Date;
5
+ }
6
+ /**
7
+ * Status-code-only detection. Works identically across Express/Connect/
8
+ * Fastify's Node-compat layer because they all share the underlying
9
+ * `ServerResponse` — genuinely framework-agnostic. Does not capture the
10
+ * thrown Error object; use `expressErrorHandler` (or the framework's own
11
+ * error hook) for that.
12
+ */
13
+ export declare function httpStatusMiddleware(handler: DefectHandler, options?: HttpErrorOptions): (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => void;
14
+ /**
15
+ * Express/Connect-style 4-arg error middleware. Fastify does not support
16
+ * this signature at all — Fastify users must call `reportDefect` directly
17
+ * from `fastify.setErrorHandler()` instead.
18
+ */
19
+ export declare function expressErrorHandler(handler: DefectHandler, options?: HttpErrorOptions): (err: Error, req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => void;
20
+ /** For frameworks (e.g. Fastify) with their own error-hook signature — call this directly from that hook. */
21
+ export declare function reportDefect(handler: DefectHandler, err: Error, context?: Record<string, string | number | undefined>, options?: HttpErrorOptions): void;
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.httpStatusMiddleware = httpStatusMiddleware;
4
+ exports.expressErrorHandler = expressErrorHandler;
5
+ exports.reportDefect = reportDefect;
6
+ function buildStatusDefect(req, res, now) {
7
+ return {
8
+ type: 'http-5xx',
9
+ message: `HTTP ${res.statusCode} ${req.method ?? 'UNKNOWN'} ${req.url ?? ''}`.trim(),
10
+ context: { method: req.method, url: req.url, statusCode: res.statusCode },
11
+ occurredAt: now().toISOString(),
12
+ };
13
+ }
14
+ /**
15
+ * Status-code-only detection. Works identically across Express/Connect/
16
+ * Fastify's Node-compat layer because they all share the underlying
17
+ * `ServerResponse` — genuinely framework-agnostic. Does not capture the
18
+ * thrown Error object; use `expressErrorHandler` (or the framework's own
19
+ * error hook) for that.
20
+ */
21
+ function httpStatusMiddleware(handler, options = {}) {
22
+ const now = options.now ?? (() => new Date());
23
+ return (req, res, next) => {
24
+ res.on('finish', () => {
25
+ if (res.statusCode >= 500)
26
+ void handler(buildStatusDefect(req, res, now));
27
+ });
28
+ next();
29
+ };
30
+ }
31
+ /**
32
+ * Express/Connect-style 4-arg error middleware. Fastify does not support
33
+ * this signature at all — Fastify users must call `reportDefect` directly
34
+ * from `fastify.setErrorHandler()` instead.
35
+ */
36
+ function expressErrorHandler(handler, options = {}) {
37
+ const now = options.now ?? (() => new Date());
38
+ return (err, req, res, next) => {
39
+ void handler({
40
+ type: 'http-5xx',
41
+ message: err.message,
42
+ stack: err.stack,
43
+ context: { method: req.method, url: req.url, statusCode: res.statusCode },
44
+ occurredAt: now().toISOString(),
45
+ });
46
+ next(err);
47
+ };
48
+ }
49
+ /** For frameworks (e.g. Fastify) with their own error-hook signature — call this directly from that hook. */
50
+ function reportDefect(handler, err, context, options = {}) {
51
+ const now = options.now ?? (() => new Date());
52
+ void handler({ type: 'http-5xx', message: err.message, stack: err.stack, context, occurredAt: now().toISOString() });
53
+ }
@@ -0,0 +1,27 @@
1
+ import type { DefectHandler } from './processCrash';
2
+ export interface FileState {
3
+ offset: number;
4
+ inode: number;
5
+ }
6
+ export interface PollResult {
7
+ lines: string[];
8
+ state: FileState;
9
+ }
10
+ /**
11
+ * Reads only the bytes appended since `state.offset`. Detects rotation
12
+ * (inode changed — the old file was replaced) and truncation (size shrank
13
+ * below the last offset) and resets to the start of the new/truncated file
14
+ * in either case — naive tailing breaks silently after logrotate/Docker log
15
+ * truncation otherwise, and a crash-looping service is exactly the kind
16
+ * whose logs rotate often.
17
+ */
18
+ export declare function readNewLines(path: string, state: FileState): Promise<PollResult>;
19
+ export declare function buildMatcher(pattern: string | RegExp): (line: string) => boolean;
20
+ export interface TailLogOptions {
21
+ pollIntervalMs?: number;
22
+ now?: () => Date;
23
+ /** Injected in tests to bypass the real filesystem entirely. */
24
+ poll?: (state: FileState) => Promise<PollResult>;
25
+ }
26
+ /** Starts polling `path` for new lines matching `pattern`, reporting each match as a `log-error` defect. Returns a dispose function. */
27
+ export declare function tailLog(path: string, pattern: string | RegExp, handler: DefectHandler, options?: TailLogOptions): () => void;
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readNewLines = readNewLines;
4
+ exports.buildMatcher = buildMatcher;
5
+ exports.tailLog = tailLog;
6
+ const node_fs_1 = require("node:fs");
7
+ /**
8
+ * Reads only the bytes appended since `state.offset`. Detects rotation
9
+ * (inode changed — the old file was replaced) and truncation (size shrank
10
+ * below the last offset) and resets to the start of the new/truncated file
11
+ * in either case — naive tailing breaks silently after logrotate/Docker log
12
+ * truncation otherwise, and a crash-looping service is exactly the kind
13
+ * whose logs rotate often.
14
+ */
15
+ async function readNewLines(path, state) {
16
+ const stat = await node_fs_1.promises.stat(path);
17
+ let offset = state.offset;
18
+ if (stat.ino !== state.inode || stat.size < offset) {
19
+ offset = 0;
20
+ }
21
+ if (stat.size <= offset) {
22
+ return { lines: [], state: { offset, inode: stat.ino } };
23
+ }
24
+ const handle = await node_fs_1.promises.open(path, 'r');
25
+ try {
26
+ const length = stat.size - offset;
27
+ const buffer = Buffer.alloc(length);
28
+ await handle.read(buffer, 0, length, offset);
29
+ const text = buffer.toString('utf8');
30
+ const lastNewline = text.lastIndexOf('\n');
31
+ if (lastNewline === -1) {
32
+ // No complete line yet — don't advance the offset past a partial line.
33
+ return { lines: [], state: { offset, inode: stat.ino } };
34
+ }
35
+ const complete = text.slice(0, lastNewline);
36
+ const lines = complete.split('\n').filter((line) => line.length > 0);
37
+ const newOffset = offset + Buffer.byteLength(text.slice(0, lastNewline + 1), 'utf8');
38
+ return { lines, state: { offset: newOffset, inode: stat.ino } };
39
+ }
40
+ finally {
41
+ await handle.close();
42
+ }
43
+ }
44
+ function buildMatcher(pattern) {
45
+ if (typeof pattern === 'string') {
46
+ return (line) => line.includes(pattern);
47
+ }
48
+ return (line) => pattern.test(line);
49
+ }
50
+ /** Starts polling `path` for new lines matching `pattern`, reporting each match as a `log-error` defect. Returns a dispose function. */
51
+ function tailLog(path, pattern, handler, options = {}) {
52
+ const pollIntervalMs = options.pollIntervalMs ?? 1000;
53
+ const now = options.now ?? (() => new Date());
54
+ const matches = buildMatcher(pattern);
55
+ const matcherId = typeof pattern === 'string' ? pattern : pattern.source;
56
+ const poll = options.poll ?? ((state) => readNewLines(path, state));
57
+ let state = { offset: 0, inode: -1 };
58
+ let disposed = false;
59
+ const tick = async () => {
60
+ if (disposed)
61
+ return;
62
+ try {
63
+ const result = await poll(state);
64
+ state = result.state;
65
+ for (const line of result.lines) {
66
+ if (matches(line)) {
67
+ const defect = {
68
+ type: 'log-error',
69
+ message: line,
70
+ context: { matcher: matcherId, path },
71
+ occurredAt: now().toISOString(),
72
+ };
73
+ await handler(defect);
74
+ }
75
+ }
76
+ }
77
+ catch {
78
+ // File may not exist yet (app hasn't started logging) — retry next tick rather than crashing the daemon.
79
+ }
80
+ };
81
+ const timer = setInterval(() => void tick(), pollIntervalMs);
82
+ return () => {
83
+ disposed = true;
84
+ clearInterval(timer);
85
+ };
86
+ }
@@ -0,0 +1,24 @@
1
+ import type { Defect } from '../types';
2
+ export interface CrashTarget {
3
+ on(event: 'uncaughtException', listener: (err: Error) => void): unknown;
4
+ on(event: 'unhandledRejection', listener: (reason: unknown) => void): unknown;
5
+ off(event: string, listener: (...args: unknown[]) => void): unknown;
6
+ exit(code: number): void;
7
+ }
8
+ export type DefectHandler = (defect: Defect) => Promise<void>;
9
+ export interface InstallCrashHandlersOptions {
10
+ /** Defaults to the real `process` — inject a fake EventEmitter-like target in tests so the real process is never touched. */
11
+ target?: CrashTarget;
12
+ /** Node's own guidance: process state is undefined after an uncaughtException, so the default is report-then-exit, not swallow-and-continue. */
13
+ exitOnUncaughtException?: boolean;
14
+ /** How long to wait for the report to flush before exiting. */
15
+ exitGraceMs?: number;
16
+ now?: () => Date;
17
+ }
18
+ /**
19
+ * Installs process-level crash handlers. `unhandledRejection` is
20
+ * report-only (Node doesn't treat it as fatal by default, and many apps
21
+ * intentionally don't either); `uncaughtException` is report-then-exit,
22
+ * since process state is undefined afterward.
23
+ */
24
+ export declare function installCrashHandlers(handler: DefectHandler, options?: InstallCrashHandlersOptions): () => void;
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.installCrashHandlers = installCrashHandlers;
4
+ function reasonToDefect(type, reason, now) {
5
+ if (reason instanceof Error) {
6
+ return { type, message: reason.message, stack: reason.stack, occurredAt: now().toISOString() };
7
+ }
8
+ return { type, message: typeof reason === 'string' ? reason : JSON.stringify(reason), occurredAt: now().toISOString() };
9
+ }
10
+ /**
11
+ * Installs process-level crash handlers. `unhandledRejection` is
12
+ * report-only (Node doesn't treat it as fatal by default, and many apps
13
+ * intentionally don't either); `uncaughtException` is report-then-exit,
14
+ * since process state is undefined afterward.
15
+ */
16
+ function installCrashHandlers(handler, options = {}) {
17
+ const target = options.target ?? process;
18
+ const exitOnUncaughtException = options.exitOnUncaughtException ?? true;
19
+ const exitGraceMs = options.exitGraceMs ?? 1000;
20
+ const now = options.now ?? (() => new Date());
21
+ const onUncaughtException = (err) => {
22
+ const defect = reasonToDefect('process-crash', err, now);
23
+ const reportPromise = handler(defect).catch(() => undefined);
24
+ if (exitOnUncaughtException) {
25
+ const timer = setTimeout(() => target.exit(1), exitGraceMs);
26
+ reportPromise.finally(() => {
27
+ clearTimeout(timer);
28
+ target.exit(1);
29
+ });
30
+ }
31
+ };
32
+ const onUnhandledRejection = (reason) => {
33
+ void handler(reasonToDefect('unhandled-rejection', reason, now));
34
+ };
35
+ target.on('uncaughtException', onUncaughtException);
36
+ target.on('unhandledRejection', onUnhandledRejection);
37
+ return () => {
38
+ target.off('uncaughtException', onUncaughtException);
39
+ target.off('unhandledRejection', onUnhandledRejection);
40
+ };
41
+ }
@@ -0,0 +1,10 @@
1
+ import type { Config } from './types';
2
+ export declare class ConfigError extends Error {
3
+ }
4
+ /**
5
+ * Reads configuration from environment variables only — this tool never
6
+ * writes credentials to a file it manages itself. At least one of
7
+ * JIRA_* / GITHUB_* must be fully configured, or nothing can ever create a
8
+ * ticket.
9
+ */
10
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
package/dist/config.js ADDED
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConfigError = void 0;
4
+ exports.loadConfig = loadConfig;
5
+ class ConfigError extends Error {
6
+ }
7
+ exports.ConfigError = ConfigError;
8
+ function envInt(env, key, fallback) {
9
+ const raw = env[key];
10
+ if (raw === undefined || raw === '')
11
+ return fallback;
12
+ const value = Number(raw);
13
+ if (!Number.isFinite(value))
14
+ throw new ConfigError(`${key} must be a number, got "${raw}"`);
15
+ return value;
16
+ }
17
+ /**
18
+ * Reads configuration from environment variables only — this tool never
19
+ * writes credentials to a file it manages itself. At least one of
20
+ * JIRA_* / GITHUB_* must be fully configured, or nothing can ever create a
21
+ * ticket.
22
+ */
23
+ function loadConfig(env = process.env) {
24
+ const jiraFields = ['JIRA_BASE_URL', 'JIRA_EMAIL', 'JIRA_API_TOKEN', 'JIRA_PROJECT_KEY'];
25
+ const hasJira = jiraFields.some((key) => env[key]);
26
+ if (hasJira && !jiraFields.every((key) => env[key])) {
27
+ throw new ConfigError(`Partial Jira config detected — set all of ${jiraFields.join(', ')} or none of them.`);
28
+ }
29
+ const githubFields = ['GITHUB_TOKEN', 'GITHUB_OWNER', 'GITHUB_REPO'];
30
+ const hasGithub = githubFields.some((key) => env[key]);
31
+ if (hasGithub && !githubFields.every((key) => env[key])) {
32
+ throw new ConfigError(`Partial GitHub config detected — set all of ${githubFields.join(', ')} or none of them.`);
33
+ }
34
+ if (!hasJira && !hasGithub) {
35
+ throw new ConfigError('No ticket provider configured. Run `crashrelay init` first, or set JIRA_* / GITHUB_* environment variables.');
36
+ }
37
+ const ingestionEnabled = env.INGESTION_TOKEN !== undefined;
38
+ return {
39
+ jira: hasJira
40
+ ? {
41
+ baseUrl: env.JIRA_BASE_URL,
42
+ email: env.JIRA_EMAIL,
43
+ apiToken: env.JIRA_API_TOKEN,
44
+ projectKey: env.JIRA_PROJECT_KEY,
45
+ issueType: env.JIRA_ISSUE_TYPE || 'Bug',
46
+ }
47
+ : undefined,
48
+ github: hasGithub
49
+ ? {
50
+ token: env.GITHUB_TOKEN,
51
+ owner: env.GITHUB_OWNER,
52
+ repo: env.GITHUB_REPO,
53
+ }
54
+ : undefined,
55
+ ingestion: ingestionEnabled
56
+ ? {
57
+ enabled: true,
58
+ port: envInt(env, 'INGESTION_PORT', 4318),
59
+ token: env.INGESTION_TOKEN,
60
+ allowedOrigins: (env.INGESTION_ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean),
61
+ }
62
+ : undefined,
63
+ dedupCooldownHours: envInt(env, 'DEDUP_COOLDOWN_HOURS', 24),
64
+ commentCooldownMinutes: envInt(env, 'COMMENT_COOLDOWN_MINUTES', 60),
65
+ logTailPath: env.LOG_TAIL_PATH || undefined,
66
+ logTailPattern: env.LOG_TAIL_PATTERN || 'ERROR',
67
+ cacheFilePath: env.CRASHRELAY_CACHE_PATH || '.crashrelay-dedup-cache.json',
68
+ };
69
+ }
@@ -0,0 +1,13 @@
1
+ import type { Config } from './types';
2
+ import type { DefectHandler } from './collectors/processCrash';
3
+ export interface DaemonHandle {
4
+ stop(): Promise<void>;
5
+ }
6
+ /**
7
+ * Runs the two pieces of crashrelay that genuinely can live in a separate
8
+ * always-on process: log-file tailing and the frontend-error ingestion
9
+ * endpoint. Crash/HTTP-5xx capture is a library embedded in the monitored
10
+ * app itself (see index.ts) — a separate process cannot hook another
11
+ * process's exceptions, that's a Node/OS limitation.
12
+ */
13
+ export declare function startDaemon(config: Config, handler: DefectHandler): DaemonHandle;
package/dist/daemon.js ADDED
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startDaemon = startDaemon;
4
+ const logTail_1 = require("./collectors/logTail");
5
+ const server_1 = require("./ingestion/server");
6
+ /**
7
+ * Runs the two pieces of crashrelay that genuinely can live in a separate
8
+ * always-on process: log-file tailing and the frontend-error ingestion
9
+ * endpoint. Crash/HTTP-5xx capture is a library embedded in the monitored
10
+ * app itself (see index.ts) — a separate process cannot hook another
11
+ * process's exceptions, that's a Node/OS limitation.
12
+ */
13
+ function startDaemon(config, handler) {
14
+ const disposers = [];
15
+ if (config.logTailPath) {
16
+ console.log(`[crashrelay] tailing ${config.logTailPath} for pattern "${config.logTailPattern}"`);
17
+ disposers.push((0, logTail_1.tailLog)(config.logTailPath, config.logTailPattern, handler));
18
+ }
19
+ else {
20
+ console.log('[crashrelay] no LOG_TAIL_PATH configured — skipping log tailing');
21
+ }
22
+ if (config.ingestion?.enabled) {
23
+ const server = (0, server_1.createIngestionServer)(config.ingestion, handler);
24
+ server.listen(config.ingestion.port);
25
+ console.log(`[crashrelay] ingestion endpoint listening on port ${config.ingestion.port}`);
26
+ disposers.push(() => new Promise((resolve) => server.close(() => resolve())));
27
+ }
28
+ else {
29
+ console.log('[crashrelay] no INGESTION_TOKEN configured — frontend ingestion endpoint disabled');
30
+ }
31
+ return {
32
+ async stop() {
33
+ for (const dispose of disposers)
34
+ await dispose();
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,18 @@
1
+ import type { DedupCache, DedupDecision, TicketRef } from './types';
2
+ export declare function readCache(path: string): Promise<DedupCache>;
3
+ /** Writes via a temp file + rename so a reader never observes a partially-written (truncated/corrupt) cache file, even under concurrent access. */
4
+ export declare function writeCache(path: string, cache: DedupCache): Promise<void>;
5
+ export interface DecideOptions {
6
+ cooldownHours: number;
7
+ commentCooldownMinutes: number;
8
+ now?: () => Date;
9
+ }
10
+ /**
11
+ * Caps tickets to at most one per fingerprint per cooldown window — not "at
12
+ * most one ticket ever". Within the window, a recurrence gets a comment on
13
+ * the existing ticket (throttled separately so a tight crash loop doesn't
14
+ * spam comments either), not a fresh ticket.
15
+ */
16
+ export declare function decide(cache: DedupCache, fp: string, options: DecideOptions): DedupDecision;
17
+ export declare function recordCreate(cache: DedupCache, fp: string, ticket: TicketRef, options: DecideOptions): DedupCache;
18
+ export declare function recordSeenAgain(cache: DedupCache, fp: string, options: DecideOptions, commented: boolean): DedupCache;