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/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
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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/daemon.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/dedup.d.ts
ADDED
|
@@ -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;
|