gh-issue-tracker 1.0.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 +193 -0
- package/dist/index.cjs +363 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +67 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +333 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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,193 @@
|
|
|
1
|
+
# gh-issue-tracker
|
|
2
|
+
|
|
3
|
+
Lightweight error tracking that creates GitHub Issues instead of sending to SaaS. Deduplication, fingerprinting, and rate limiting built-in.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
- **Zero SaaS cost** — errors go directly to GitHub Issues
|
|
8
|
+
- **Deduplication built-in** — same error creates one issue, not N duplicates
|
|
9
|
+
- **Fingerprinting** — stable error identity across deploys (line number changes don't matter)
|
|
10
|
+
- **Rate limiting** — prevents GitHub API spam during error storms
|
|
11
|
+
- **Simple API** — `init()` once, `captureException()` anywhere
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Error thrown
|
|
17
|
+
→ Generate fingerprint (SHA-256 of name + message + top 3 normalized stack frames)
|
|
18
|
+
→ Check rate limiter (sliding window + dedup)
|
|
19
|
+
→ Search GitHub Issues by fingerprint label
|
|
20
|
+
→ Open issue found? → Add thumbs-up reaction (count = frequency)
|
|
21
|
+
→ Closed issue found? → Reopen + add comment
|
|
22
|
+
→ No issue found? → Create new issue with error-report + fingerprint labels
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install gh-issue-tracker
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { init, captureException, flush } from 'gh-issue-tracker'
|
|
33
|
+
|
|
34
|
+
init({
|
|
35
|
+
githubToken: process.env.GITHUB_TOKEN!,
|
|
36
|
+
githubRepo: 'myorg/myapp',
|
|
37
|
+
environment: 'production',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
riskyOperation()
|
|
42
|
+
} catch (error) {
|
|
43
|
+
captureException(error instanceof Error ? error : new Error(String(error)))
|
|
44
|
+
await flush() // wait for GitHub API call (important in serverless)
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
| Option | Type | Default | Description |
|
|
51
|
+
|--------|------|---------|-------------|
|
|
52
|
+
| `githubToken` | `string` | — | **Required.** GitHub PAT with Issues read/write permission |
|
|
53
|
+
| `githubRepo` | `string` | — | **Required.** Repository in `owner/repo` format |
|
|
54
|
+
| `environment` | `string` | `"development"` | Environment name shown in issue body |
|
|
55
|
+
| `labels` | `string[]` | `[]` | Additional labels applied to every issue |
|
|
56
|
+
| `enabled` | `boolean` | `true` | Kill switch to disable tracking |
|
|
57
|
+
| `onError` | `(err) => void` | `console.error` | Called when the GitHub API fails |
|
|
58
|
+
| `rateLimitPerMinute` | `number` | `10` | Max new issues created per minute |
|
|
59
|
+
| `dedupeWindowMs` | `number` | `60000` | Suppress same fingerprint within this window (ms) |
|
|
60
|
+
| `reopenClosed` | `boolean` | `true` | Reopen closed issues on error recurrence |
|
|
61
|
+
|
|
62
|
+
## API
|
|
63
|
+
|
|
64
|
+
### `init(config: ErrorTrackerConfig): void`
|
|
65
|
+
|
|
66
|
+
Initialize the error tracker. Call once at app startup. Must be called before `captureException` or `captureMessage`.
|
|
67
|
+
|
|
68
|
+
### `captureException(error: Error, context?: ErrorContext): void`
|
|
69
|
+
|
|
70
|
+
Capture an exception. Fire-and-forget — the GitHub API call happens in the background.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
captureException(error, {
|
|
74
|
+
tags: { component: 'auth', severity: 'critical' },
|
|
75
|
+
extras: { userId: '123', action: 'login' },
|
|
76
|
+
user: { id: '123', email: 'user@example.com' },
|
|
77
|
+
requestUrl: '/api/login',
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### `captureMessage(message: string, level?: 'error' | 'warning', context?: ErrorContext): void`
|
|
82
|
+
|
|
83
|
+
Capture a plain message as an error event.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
captureMessage('Payment processing timeout', 'warning', {
|
|
87
|
+
tags: { provider: 'stripe' },
|
|
88
|
+
})
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### `flush(): Promise<void>`
|
|
92
|
+
|
|
93
|
+
Wait for all pending error reports to complete. **Always call before serverless functions return.**
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
captureException(error)
|
|
97
|
+
await flush() // don't return until the GitHub API call finishes
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### `ErrorContext`
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
interface ErrorContext {
|
|
104
|
+
tags?: Record<string, string> // Key-value pairs shown in the issue
|
|
105
|
+
extras?: Record<string, unknown> // JSON metadata in a collapsible section
|
|
106
|
+
user?: { id: string; email?: string }
|
|
107
|
+
requestUrl?: string
|
|
108
|
+
serverName?: string
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Framework guides
|
|
113
|
+
|
|
114
|
+
| Framework | Example | What it sets up |
|
|
115
|
+
|-----------|---------|----------------|
|
|
116
|
+
| **Next.js App Router** | [`examples/nextjs-instrumentation/`](examples/nextjs-instrumentation/) | Server-side `register()` + `onRequestError()` |
|
|
117
|
+
| **Next.js (client errors)** | [`examples/nextjs-error-proxy/`](examples/nextjs-error-proxy/) | Proxy endpoint for browser error boundaries |
|
|
118
|
+
| **Next.js (error UI)** | [`examples/nextjs-error-boundaries/`](examples/nextjs-error-boundaries/) | `error.tsx` and `global-error.tsx` components |
|
|
119
|
+
| **Express** | [`examples/express-middleware/`](examples/express-middleware/) | Error handler middleware |
|
|
120
|
+
|
|
121
|
+
### Full Next.js setup (recommended)
|
|
122
|
+
|
|
123
|
+
For complete Next.js coverage, combine all three Next.js examples:
|
|
124
|
+
|
|
125
|
+
1. **Server errors**: `instrumentation.ts` catches unhandled request errors
|
|
126
|
+
2. **Client errors**: Error boundaries catch React errors and POST to the proxy
|
|
127
|
+
3. **Proxy**: Server-side endpoint receives client errors and reports them (keeps token safe)
|
|
128
|
+
|
|
129
|
+
## GitHub token setup
|
|
130
|
+
|
|
131
|
+
1. Go to **GitHub → Settings → Developer settings → Fine-grained personal access tokens**
|
|
132
|
+
2. Click **Generate new token**
|
|
133
|
+
3. Set:
|
|
134
|
+
- **Repository access**: Only select repositories → choose your target repo
|
|
135
|
+
- **Permissions**: Issues → Read and write
|
|
136
|
+
4. Copy the token and set it as `GITHUB_TOKEN` in your environment
|
|
137
|
+
|
|
138
|
+
> For classic tokens, the `repo` scope works but grants broader access than needed.
|
|
139
|
+
|
|
140
|
+
## GitHub Issue structure
|
|
141
|
+
|
|
142
|
+
Issues created by the tracker look like this:
|
|
143
|
+
|
|
144
|
+
**Title**: `[Error] TypeError: Cannot read properties of undefined (reading 'map')`
|
|
145
|
+
|
|
146
|
+
**Labels**: `error-report`, `fingerprint:a1b2c3d4e5f6`, plus any custom labels
|
|
147
|
+
|
|
148
|
+
**Body**:
|
|
149
|
+
- Environment, fingerprint, and timestamp
|
|
150
|
+
- Error message
|
|
151
|
+
- Stack trace (code block)
|
|
152
|
+
- Tags, request URL, user info (if provided)
|
|
153
|
+
- Additional metadata (collapsible JSON)
|
|
154
|
+
|
|
155
|
+
## Architecture
|
|
156
|
+
|
|
157
|
+
### Fingerprinting
|
|
158
|
+
|
|
159
|
+
Errors are fingerprinted using SHA-256 of:
|
|
160
|
+
- Error name (e.g., `TypeError`)
|
|
161
|
+
- Message (first 100 characters)
|
|
162
|
+
- Top 3 normalized stack frames (line/column numbers, webpack hashes, and query strings stripped)
|
|
163
|
+
|
|
164
|
+
This produces a stable 12-character hex ID. The same logical error across different deploys produces the same fingerprint.
|
|
165
|
+
|
|
166
|
+
### Deduplication
|
|
167
|
+
|
|
168
|
+
Two layers:
|
|
169
|
+
1. **In-memory rate limiter**: Sliding window (max N new issues/min) + dedup window (suppress same fingerprint within 60s)
|
|
170
|
+
2. **GitHub search**: Before creating an issue, search for existing issues by `fingerprint:<hash>` label
|
|
171
|
+
|
|
172
|
+
### Rate limiting
|
|
173
|
+
|
|
174
|
+
- **Sliding window**: Max 10 new issues per minute (configurable)
|
|
175
|
+
- **Dedup window**: Same fingerprint suppressed for 60 seconds (configurable)
|
|
176
|
+
- Cleanup timer is `unref()`'d — never prevents Node.js process exit
|
|
177
|
+
|
|
178
|
+
## Limitations
|
|
179
|
+
|
|
180
|
+
- **Node.js only**: Uses `node:crypto` for fingerprinting. Not compatible with browser or edge runtimes.
|
|
181
|
+
- **No session replay**: Unlike Sentry, there's no UI recording for debugging.
|
|
182
|
+
- **No performance tracing**: No APM, transaction monitoring, or request timing.
|
|
183
|
+
- **GitHub API rate limits**: 5,000 requests/hour for authenticated tokens. The in-memory rate limiter prevents hitting this in practice.
|
|
184
|
+
- **Dynamic error messages**: Errors with timestamps or IDs in the message may create separate issues. Keep the first 100 characters stable.
|
|
185
|
+
|
|
186
|
+
## Requirements
|
|
187
|
+
|
|
188
|
+
- Node.js >= 18
|
|
189
|
+
- GitHub PAT with Issues read/write permission
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
captureException: () => captureException,
|
|
24
|
+
captureMessage: () => captureMessage,
|
|
25
|
+
flush: () => flush,
|
|
26
|
+
init: () => init
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
|
|
30
|
+
// src/fingerprint.ts
|
|
31
|
+
var import_node_crypto = require("crypto");
|
|
32
|
+
|
|
33
|
+
// src/normalizer.ts
|
|
34
|
+
var LINE_COL_RE = /:\d+:\d+/g;
|
|
35
|
+
var WEBPACK_PREFIX_RE = /webpack-internal:\/\/\//g;
|
|
36
|
+
var QUERY_STRING_RE = /\?[^\s)]+/g;
|
|
37
|
+
var FRAME_RE = /^\s+at\s+/;
|
|
38
|
+
var NODE_MODULES_RE = /node_modules/;
|
|
39
|
+
function extractFrames(stack, maxFrames = 3) {
|
|
40
|
+
if (!stack) return [];
|
|
41
|
+
const lines = stack.split("\n");
|
|
42
|
+
const frames = [];
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
if (!FRAME_RE.test(line)) continue;
|
|
45
|
+
if (NODE_MODULES_RE.test(line)) continue;
|
|
46
|
+
const normalized = line.replace(WEBPACK_PREFIX_RE, "").replace(QUERY_STRING_RE, "").replace(LINE_COL_RE, "").trim();
|
|
47
|
+
frames.push(normalized);
|
|
48
|
+
if (frames.length >= maxFrames) break;
|
|
49
|
+
}
|
|
50
|
+
return frames;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/fingerprint.ts
|
|
54
|
+
var MESSAGE_TRUNCATE_LENGTH = 100;
|
|
55
|
+
function generateFingerprint(input) {
|
|
56
|
+
let name;
|
|
57
|
+
let message;
|
|
58
|
+
let stack;
|
|
59
|
+
if (typeof input === "string") {
|
|
60
|
+
name = "Error";
|
|
61
|
+
message = input;
|
|
62
|
+
stack = void 0;
|
|
63
|
+
} else {
|
|
64
|
+
name = input.name || "Error";
|
|
65
|
+
message = input.message || "";
|
|
66
|
+
stack = input.stack;
|
|
67
|
+
}
|
|
68
|
+
const truncatedMessage = message.slice(0, MESSAGE_TRUNCATE_LENGTH);
|
|
69
|
+
const frames = extractFrames(stack);
|
|
70
|
+
const payload = `${name}
|
|
71
|
+
${truncatedMessage}
|
|
72
|
+
${frames.join("\n")}`;
|
|
73
|
+
const hash = (0, import_node_crypto.createHash)("sha256").update(payload).digest("hex");
|
|
74
|
+
return hash.slice(0, 12);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/rate-limiter.ts
|
|
78
|
+
var ONE_MINUTE = 6e4;
|
|
79
|
+
var CLEANUP_INTERVAL = 5 * 6e4;
|
|
80
|
+
var RateLimiter = class {
|
|
81
|
+
config;
|
|
82
|
+
timestamps = [];
|
|
83
|
+
dedup = /* @__PURE__ */ new Map();
|
|
84
|
+
cleanupTimer = null;
|
|
85
|
+
constructor(config2) {
|
|
86
|
+
this.config = config2;
|
|
87
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
|
|
88
|
+
if (this.cleanupTimer && typeof this.cleanupTimer === "object" && "unref" in this.cleanupTimer) {
|
|
89
|
+
this.cleanupTimer.unref();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Check whether a fingerprint can be processed right now.
|
|
94
|
+
* Returns false if rate-limited or deduped.
|
|
95
|
+
*/
|
|
96
|
+
canProcess(fingerprint) {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const lastSeen = this.dedup.get(fingerprint);
|
|
99
|
+
if (lastSeen !== void 0 && now - lastSeen < this.config.dedupeWindowMs) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
this.pruneOldTimestamps(now);
|
|
103
|
+
return this.timestamps.length < this.config.maxPerMinute;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Record that a fingerprint was processed.
|
|
107
|
+
* Call this after successfully processing (or starting to process) an error.
|
|
108
|
+
*/
|
|
109
|
+
recordProcessed(fingerprint) {
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
this.timestamps.push(now);
|
|
112
|
+
this.dedup.set(fingerprint, now);
|
|
113
|
+
}
|
|
114
|
+
/** Stop the cleanup timer. Call on shutdown. */
|
|
115
|
+
destroy() {
|
|
116
|
+
if (this.cleanupTimer) {
|
|
117
|
+
clearInterval(this.cleanupTimer);
|
|
118
|
+
this.cleanupTimer = null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
pruneOldTimestamps(now) {
|
|
122
|
+
const cutoff = now - ONE_MINUTE;
|
|
123
|
+
while (this.timestamps.length > 0 && this.timestamps[0] < cutoff) {
|
|
124
|
+
this.timestamps.shift();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
cleanup() {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
this.pruneOldTimestamps(now);
|
|
130
|
+
for (const [fp, ts] of this.dedup.entries()) {
|
|
131
|
+
if (now - ts >= this.config.dedupeWindowMs) {
|
|
132
|
+
this.dedup.delete(fp);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// src/github.ts
|
|
139
|
+
var import_octokit = require("octokit");
|
|
140
|
+
function parseRepo(repo) {
|
|
141
|
+
const [owner, name] = repo.split("/");
|
|
142
|
+
if (!owner || !name) {
|
|
143
|
+
throw new Error(`Invalid repo format "${repo}". Expected "owner/repo".`);
|
|
144
|
+
}
|
|
145
|
+
return { owner, repo: name };
|
|
146
|
+
}
|
|
147
|
+
function createGitHubClient(config2) {
|
|
148
|
+
const octokit = new import_octokit.Octokit({ auth: config2.token });
|
|
149
|
+
const { owner, repo } = parseRepo(config2.repo);
|
|
150
|
+
return {
|
|
151
|
+
async searchExistingIssue(fingerprint) {
|
|
152
|
+
try {
|
|
153
|
+
const { data } = await octokit.rest.issues.listForRepo({
|
|
154
|
+
owner,
|
|
155
|
+
repo,
|
|
156
|
+
labels: `fingerprint:${fingerprint}`,
|
|
157
|
+
state: "all",
|
|
158
|
+
per_page: 1
|
|
159
|
+
});
|
|
160
|
+
const issue = data[0];
|
|
161
|
+
if (!issue) return null;
|
|
162
|
+
return {
|
|
163
|
+
number: issue.number,
|
|
164
|
+
state: issue.state,
|
|
165
|
+
title: issue.title
|
|
166
|
+
};
|
|
167
|
+
} catch (err) {
|
|
168
|
+
config2.onError(err);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
async createIssue(title, body, labels) {
|
|
173
|
+
try {
|
|
174
|
+
const { data } = await octokit.rest.issues.create({
|
|
175
|
+
owner,
|
|
176
|
+
repo,
|
|
177
|
+
title,
|
|
178
|
+
body,
|
|
179
|
+
labels
|
|
180
|
+
});
|
|
181
|
+
return data.number;
|
|
182
|
+
} catch (err) {
|
|
183
|
+
config2.onError(err);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
async addReaction(issueNumber) {
|
|
188
|
+
try {
|
|
189
|
+
await octokit.rest.reactions.createForIssue({
|
|
190
|
+
owner,
|
|
191
|
+
repo,
|
|
192
|
+
issue_number: issueNumber,
|
|
193
|
+
content: "+1"
|
|
194
|
+
});
|
|
195
|
+
} catch (err) {
|
|
196
|
+
config2.onError(err);
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
async reopenIssue(issueNumber, comment) {
|
|
200
|
+
try {
|
|
201
|
+
await octokit.rest.issues.update({
|
|
202
|
+
owner,
|
|
203
|
+
repo,
|
|
204
|
+
issue_number: issueNumber,
|
|
205
|
+
state: "open"
|
|
206
|
+
});
|
|
207
|
+
await octokit.rest.issues.createComment({
|
|
208
|
+
owner,
|
|
209
|
+
repo,
|
|
210
|
+
issue_number: issueNumber,
|
|
211
|
+
body: comment
|
|
212
|
+
});
|
|
213
|
+
} catch (err) {
|
|
214
|
+
config2.onError(err);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/client.ts
|
|
221
|
+
var config = null;
|
|
222
|
+
var github = null;
|
|
223
|
+
var limiter = null;
|
|
224
|
+
var pending = [];
|
|
225
|
+
function init(cfg) {
|
|
226
|
+
config = {
|
|
227
|
+
environment: "development",
|
|
228
|
+
enabled: true,
|
|
229
|
+
reopenClosed: true,
|
|
230
|
+
rateLimitPerMinute: 10,
|
|
231
|
+
dedupeWindowMs: 6e4,
|
|
232
|
+
labels: [],
|
|
233
|
+
onError: console.error,
|
|
234
|
+
...cfg
|
|
235
|
+
};
|
|
236
|
+
if (!config.enabled) return;
|
|
237
|
+
github = createGitHubClient({
|
|
238
|
+
token: config.githubToken,
|
|
239
|
+
repo: config.githubRepo,
|
|
240
|
+
onError: config.onError
|
|
241
|
+
});
|
|
242
|
+
limiter = new RateLimiter({
|
|
243
|
+
maxPerMinute: config.rateLimitPerMinute,
|
|
244
|
+
dedupeWindowMs: config.dedupeWindowMs
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
function captureException(error, context) {
|
|
248
|
+
if (!config?.enabled || !github || !limiter) return;
|
|
249
|
+
const fingerprint = generateFingerprint(error);
|
|
250
|
+
if (!limiter.canProcess(fingerprint)) {
|
|
251
|
+
console.error(`[error-tracker] Rate limited or deduped: ${fingerprint}`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
limiter.recordProcessed(fingerprint);
|
|
255
|
+
const promise = processError(error, fingerprint, context).catch((err) => {
|
|
256
|
+
config?.onError?.(err);
|
|
257
|
+
});
|
|
258
|
+
pending.push(promise);
|
|
259
|
+
}
|
|
260
|
+
function captureMessage(message, level = "error", context) {
|
|
261
|
+
if (!config?.enabled || !github || !limiter) return;
|
|
262
|
+
const fingerprint = generateFingerprint(message);
|
|
263
|
+
if (!limiter.canProcess(fingerprint)) return;
|
|
264
|
+
limiter.recordProcessed(fingerprint);
|
|
265
|
+
const title = `[${level === "warning" ? "Warning" : "Error"}] ${message.slice(0, 80)}`;
|
|
266
|
+
const body = formatBody(message, void 0, fingerprint, context);
|
|
267
|
+
const labels = buildLabels(fingerprint);
|
|
268
|
+
const promise = github.searchExistingIssue(fingerprint).then(async (existing) => {
|
|
269
|
+
if (!github) return;
|
|
270
|
+
if (existing?.state === "open") {
|
|
271
|
+
await github.addReaction(existing.number);
|
|
272
|
+
} else if (existing?.state === "closed") {
|
|
273
|
+
if (config?.reopenClosed) {
|
|
274
|
+
await github.reopenIssue(existing.number, recurrenceComment());
|
|
275
|
+
await github.addReaction(existing.number);
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
await github.createIssue(title, body, labels);
|
|
279
|
+
}
|
|
280
|
+
}).catch((err) => {
|
|
281
|
+
config?.onError?.(err);
|
|
282
|
+
});
|
|
283
|
+
pending.push(promise);
|
|
284
|
+
}
|
|
285
|
+
async function flush() {
|
|
286
|
+
await Promise.allSettled(pending);
|
|
287
|
+
pending.length = 0;
|
|
288
|
+
}
|
|
289
|
+
async function processError(error, fingerprint, context) {
|
|
290
|
+
if (!github) return;
|
|
291
|
+
const existing = await github.searchExistingIssue(fingerprint);
|
|
292
|
+
if (existing?.state === "open") {
|
|
293
|
+
await github.addReaction(existing.number);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (existing?.state === "closed") {
|
|
297
|
+
if (config?.reopenClosed) {
|
|
298
|
+
await github.reopenIssue(existing.number, recurrenceComment());
|
|
299
|
+
await github.addReaction(existing.number);
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const title = `[Error] ${error.name || "Error"}: ${error.message.slice(0, 80)}`;
|
|
304
|
+
const body = formatBody(error.message, error.stack, fingerprint, context);
|
|
305
|
+
const labels = buildLabels(fingerprint);
|
|
306
|
+
await github.createIssue(title, body, labels);
|
|
307
|
+
}
|
|
308
|
+
function buildLabels(fingerprint) {
|
|
309
|
+
return [
|
|
310
|
+
"error-report",
|
|
311
|
+
`fingerprint:${fingerprint}`,
|
|
312
|
+
...config?.labels ?? []
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
function formatBody(message, stack, fingerprint, context) {
|
|
316
|
+
const env = config?.environment ?? "unknown";
|
|
317
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
318
|
+
const sections = [
|
|
319
|
+
`## Error Report (Automated)`,
|
|
320
|
+
`**Environment:** ${env} | **Fingerprint:** \`${fingerprint}\` | **Time:** ${timestamp}`,
|
|
321
|
+
"",
|
|
322
|
+
`### Message`,
|
|
323
|
+
message
|
|
324
|
+
];
|
|
325
|
+
if (stack) {
|
|
326
|
+
sections.push("", "### Stack Trace", "```", stack, "```");
|
|
327
|
+
}
|
|
328
|
+
if (context?.tags && Object.keys(context.tags).length > 0) {
|
|
329
|
+
const tagLines = Object.entries(context.tags).map(([k, v]) => `- **${k}:** ${v}`).join("\n");
|
|
330
|
+
sections.push("", "### Tags", tagLines);
|
|
331
|
+
}
|
|
332
|
+
if (context?.requestUrl) {
|
|
333
|
+
sections.push("", `**Request URL:** ${context.requestUrl}`);
|
|
334
|
+
}
|
|
335
|
+
if (context?.user) {
|
|
336
|
+
sections.push(`**User:** ${context.user.id}${context.user.email ? ` (${context.user.email})` : ""}`);
|
|
337
|
+
}
|
|
338
|
+
if (context?.extras && Object.keys(context.extras).length > 0) {
|
|
339
|
+
sections.push(
|
|
340
|
+
"",
|
|
341
|
+
"<details>",
|
|
342
|
+
"<summary>Additional metadata</summary>",
|
|
343
|
+
"",
|
|
344
|
+
"```json",
|
|
345
|
+
JSON.stringify(context.extras, null, 2),
|
|
346
|
+
"```",
|
|
347
|
+
"</details>"
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return sections.join("\n");
|
|
351
|
+
}
|
|
352
|
+
function recurrenceComment() {
|
|
353
|
+
const env = config?.environment ?? "unknown";
|
|
354
|
+
return `**Recurrence detected** at ${(/* @__PURE__ */ new Date()).toISOString()} in \`${env}\` environment.`;
|
|
355
|
+
}
|
|
356
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
357
|
+
0 && (module.exports = {
|
|
358
|
+
captureException,
|
|
359
|
+
captureMessage,
|
|
360
|
+
flush,
|
|
361
|
+
init
|
|
362
|
+
});
|
|
363
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/fingerprint.ts","../src/normalizer.ts","../src/rate-limiter.ts","../src/github.ts","../src/client.ts"],"sourcesContent":["export { init, captureException, captureMessage, flush } from './client'\nexport type { ErrorTrackerConfig, ErrorContext } from './types'\n","/**\n * Error fingerprinting.\n *\n * Produces a deterministic hash from an error's name, message (truncated),\n * and top stack frames. Two occurrences of the \"same\" error — even across\n * different deploys with different line numbers — should produce the same\n * fingerprint.\n */\n\nimport { createHash } from 'node:crypto'\nimport { extractFrames } from './normalizer'\n\nconst MESSAGE_TRUNCATE_LENGTH = 100\n\n/**\n * Generate a 12-char hex fingerprint from an Error or plain string.\n */\nexport function generateFingerprint(input: Error | string): string {\n let name: string\n let message: string\n let stack: string | undefined\n\n if (typeof input === 'string') {\n name = 'Error'\n message = input\n stack = undefined\n } else {\n name = input.name || 'Error'\n message = input.message || ''\n stack = input.stack\n }\n\n const truncatedMessage = message.slice(0, MESSAGE_TRUNCATE_LENGTH)\n const frames = extractFrames(stack)\n const payload = `${name}\\n${truncatedMessage}\\n${frames.join('\\n')}`\n\n const hash = createHash('sha256').update(payload).digest('hex')\n return hash.slice(0, 12)\n}\n","/**\n * Stack trace normalizer.\n *\n * Strips volatile parts of stack traces (line/column numbers, webpack hashes,\n * query strings) so that the same logical error produces the same fingerprint\n * across deploys.\n */\n\n/** Strip line:col numbers like `:42:13` at the end of file paths */\nconst LINE_COL_RE = /:\\d+:\\d+/g\n\n/** Strip webpack-internal:/// prefix */\nconst WEBPACK_PREFIX_RE = /webpack-internal:\\/\\/\\//g\n\n/** Strip query strings from file paths like `?abc123` or `?v=hash` */\nconst QUERY_STRING_RE = /\\?[^\\s)]+/g\n\n/** Match stack frame lines (starts with \"at \" after whitespace) */\nconst FRAME_RE = /^\\s+at\\s+/\n\n/** Skip frames from node_modules */\nconst NODE_MODULES_RE = /node_modules/\n\n/**\n * Normalize a stack trace by removing volatile parts.\n * Returns a stable string suitable for hashing.\n */\nexport function normalizeStack(stack: string | undefined): string {\n if (!stack) return ''\n\n return stack\n .replace(WEBPACK_PREFIX_RE, '')\n .replace(QUERY_STRING_RE, '')\n .replace(LINE_COL_RE, '')\n}\n\n/**\n * Extract the first N meaningful (non-node_modules) frames from a stack trace.\n * Used for fingerprint generation — only top frames matter for identity.\n */\nexport function extractFrames(stack: string | undefined, maxFrames = 3): string[] {\n if (!stack) return []\n\n const lines = stack.split('\\n')\n const frames: string[] = []\n\n for (const line of lines) {\n if (!FRAME_RE.test(line)) continue\n if (NODE_MODULES_RE.test(line)) continue\n\n // Normalize the frame before collecting\n const normalized = line\n .replace(WEBPACK_PREFIX_RE, '')\n .replace(QUERY_STRING_RE, '')\n .replace(LINE_COL_RE, '')\n .trim()\n\n frames.push(normalized)\n if (frames.length >= maxFrames) break\n }\n\n return frames\n}\n","/**\n * Rate limiter with deduplication.\n *\n * Two layers of protection:\n * 1. Sliding window: max N issues created per minute\n * 2. Dedup window: suppress the same fingerprint within a configurable period\n */\n\nexport interface RateLimiterConfig {\n /** Max new issues per minute. */\n maxPerMinute: number\n /** Suppress duplicate fingerprints within this window (ms). */\n dedupeWindowMs: number\n}\n\nconst ONE_MINUTE = 60_000\nconst CLEANUP_INTERVAL = 5 * 60_000 // 5 minutes\n\nexport class RateLimiter {\n private readonly config: RateLimiterConfig\n private readonly timestamps: number[] = []\n private readonly dedup = new Map<string, number>()\n private cleanupTimer: ReturnType<typeof setInterval> | null = null\n\n constructor(config: RateLimiterConfig) {\n this.config = config\n this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL)\n // Unref so the timer doesn't keep the process alive\n if (this.cleanupTimer && typeof this.cleanupTimer === 'object' && 'unref' in this.cleanupTimer) {\n this.cleanupTimer.unref()\n }\n }\n\n /**\n * Check whether a fingerprint can be processed right now.\n * Returns false if rate-limited or deduped.\n */\n canProcess(fingerprint: string): boolean {\n const now = Date.now()\n\n // Check dedup first\n const lastSeen = this.dedup.get(fingerprint)\n if (lastSeen !== undefined && now - lastSeen < this.config.dedupeWindowMs) {\n return false\n }\n\n // Check rate limit\n this.pruneOldTimestamps(now)\n return this.timestamps.length < this.config.maxPerMinute\n }\n\n /**\n * Record that a fingerprint was processed.\n * Call this after successfully processing (or starting to process) an error.\n */\n recordProcessed(fingerprint: string): void {\n const now = Date.now()\n this.timestamps.push(now)\n this.dedup.set(fingerprint, now)\n }\n\n /** Stop the cleanup timer. Call on shutdown. */\n destroy(): void {\n if (this.cleanupTimer) {\n clearInterval(this.cleanupTimer)\n this.cleanupTimer = null\n }\n }\n\n private pruneOldTimestamps(now: number): void {\n const cutoff = now - ONE_MINUTE\n while (this.timestamps.length > 0 && this.timestamps[0]! < cutoff) {\n this.timestamps.shift()\n }\n }\n\n private cleanup(): void {\n const now = Date.now()\n this.pruneOldTimestamps(now)\n\n for (const [fp, ts] of this.dedup.entries()) {\n if (now - ts >= this.config.dedupeWindowMs) {\n this.dedup.delete(fp)\n }\n }\n }\n}\n","/**\n * GitHub API layer for error tracking.\n *\n * All methods catch errors internally and delegate to `onError` —\n * they never throw. This ensures the error tracker itself never\n * crashes the host application.\n */\n\nimport { Octokit } from 'octokit'\nimport type { GitHubClient, ExistingIssue } from './types'\n\nexport interface GitHubClientConfig {\n token: string\n repo: string // \"owner/repo\"\n onError: (err: unknown) => void\n}\n\nfunction parseRepo(repo: string): { owner: string; repo: string } {\n const [owner, name] = repo.split('/')\n if (!owner || !name) {\n throw new Error(`Invalid repo format \"${repo}\". Expected \"owner/repo\".`)\n }\n return { owner, repo: name }\n}\n\nexport function createGitHubClient(config: GitHubClientConfig): GitHubClient {\n const octokit = new Octokit({ auth: config.token })\n const { owner, repo } = parseRepo(config.repo)\n\n return {\n async searchExistingIssue(fingerprint: string): Promise<ExistingIssue | null> {\n try {\n const { data } = await octokit.rest.issues.listForRepo({\n owner,\n repo,\n labels: `fingerprint:${fingerprint}`,\n state: 'all',\n per_page: 1,\n })\n\n const issue = data[0]\n if (!issue) return null\n\n return {\n number: issue.number,\n state: issue.state as 'open' | 'closed',\n title: issue.title,\n }\n } catch (err) {\n config.onError(err)\n return null\n }\n },\n\n async createIssue(title: string, body: string, labels: string[]): Promise<number | null> {\n try {\n const { data } = await octokit.rest.issues.create({\n owner,\n repo,\n title,\n body,\n labels,\n })\n return data.number\n } catch (err) {\n config.onError(err)\n return null\n }\n },\n\n async addReaction(issueNumber: number): Promise<void> {\n try {\n await octokit.rest.reactions.createForIssue({\n owner,\n repo,\n issue_number: issueNumber,\n content: '+1',\n })\n } catch (err) {\n config.onError(err)\n }\n },\n\n async reopenIssue(issueNumber: number, comment: string): Promise<void> {\n try {\n await octokit.rest.issues.update({\n owner,\n repo,\n issue_number: issueNumber,\n state: 'open',\n })\n await octokit.rest.issues.createComment({\n owner,\n repo,\n issue_number: issueNumber,\n body: comment,\n })\n } catch (err) {\n config.onError(err)\n }\n },\n }\n}\n","/**\n * Error tracker client — the main orchestrator.\n *\n * Singleton pattern: call `init()` once at startup, then use\n * `captureException()` / `captureMessage()` anywhere in your app.\n * All GitHub API calls are fire-and-forget with `flush()` for\n * serverless environments that need to wait before responding.\n */\n\nimport type { ErrorTrackerConfig, ErrorContext, GitHubClient } from './types'\nimport { generateFingerprint } from './fingerprint'\nimport { RateLimiter } from './rate-limiter'\nimport { createGitHubClient } from './github'\n\n// ---------------------------------------------------------------------------\n// Module state (singleton)\n// ---------------------------------------------------------------------------\n\nlet config: ErrorTrackerConfig | null = null\nlet github: GitHubClient | null = null\nlet limiter: RateLimiter | null = null\nconst pending: Promise<void>[] = []\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Initialize the error tracker. Call once at app startup.\n */\nexport function init(cfg: ErrorTrackerConfig): void {\n config = {\n environment: 'development',\n enabled: true,\n reopenClosed: true,\n rateLimitPerMinute: 10,\n dedupeWindowMs: 60_000,\n labels: [],\n onError: console.error,\n ...cfg,\n }\n\n if (!config.enabled) return\n\n github = createGitHubClient({\n token: config.githubToken,\n repo: config.githubRepo,\n onError: config.onError!,\n })\n\n limiter = new RateLimiter({\n maxPerMinute: config.rateLimitPerMinute!,\n dedupeWindowMs: config.dedupeWindowMs!,\n })\n}\n\n/**\n * Capture an exception. Fire-and-forget — use `flush()` if you\n * need to wait for the GitHub API call to complete.\n */\nexport function captureException(error: Error, context?: ErrorContext): void {\n if (!config?.enabled || !github || !limiter) return\n\n const fingerprint = generateFingerprint(error)\n\n if (!limiter.canProcess(fingerprint)) {\n console.error(`[error-tracker] Rate limited or deduped: ${fingerprint}`)\n return\n }\n\n limiter.recordProcessed(fingerprint)\n\n const promise = processError(error, fingerprint, context).catch((err) => {\n config?.onError?.(err)\n })\n\n pending.push(promise)\n}\n\n/**\n * Capture a plain message as an error event.\n */\nexport function captureMessage(\n message: string,\n level: 'error' | 'warning' = 'error',\n context?: ErrorContext,\n): void {\n if (!config?.enabled || !github || !limiter) return\n\n const fingerprint = generateFingerprint(message)\n\n if (!limiter.canProcess(fingerprint)) return\n\n limiter.recordProcessed(fingerprint)\n\n const title = `[${level === 'warning' ? 'Warning' : 'Error'}] ${message.slice(0, 80)}`\n const body = formatBody(message, undefined, fingerprint, context)\n const labels = buildLabels(fingerprint)\n\n const promise = github.searchExistingIssue(fingerprint).then(async (existing) => {\n if (!github) return\n\n if (existing?.state === 'open') {\n await github.addReaction(existing.number)\n } else if (existing?.state === 'closed') {\n if (config?.reopenClosed) {\n await github.reopenIssue(existing.number, recurrenceComment())\n await github.addReaction(existing.number)\n }\n } else {\n await github.createIssue(title, body, labels)\n }\n }).catch((err) => {\n config?.onError?.(err)\n })\n\n pending.push(promise)\n}\n\n/**\n * Wait for all pending error reports to complete.\n * Call before serverless function returns.\n */\nexport async function flush(): Promise<void> {\n await Promise.allSettled(pending)\n pending.length = 0\n}\n\n/**\n * Reset internal state. For testing only.\n * @internal\n */\nexport function _reset(): void {\n limiter?.destroy()\n config = null\n github = null\n limiter = null\n pending.length = 0\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nasync function processError(\n error: Error,\n fingerprint: string,\n context?: ErrorContext,\n): Promise<void> {\n if (!github) return\n\n const existing = await github.searchExistingIssue(fingerprint)\n\n if (existing?.state === 'open') {\n await github.addReaction(existing.number)\n return\n }\n\n if (existing?.state === 'closed') {\n if (config?.reopenClosed) {\n await github.reopenIssue(existing.number, recurrenceComment())\n await github.addReaction(existing.number)\n }\n return\n }\n\n // No existing issue — create one\n const title = `[Error] ${(error.name || 'Error')}: ${error.message.slice(0, 80)}`\n const body = formatBody(error.message, error.stack, fingerprint, context)\n const labels = buildLabels(fingerprint)\n\n await github.createIssue(title, body, labels)\n}\n\nfunction buildLabels(fingerprint: string): string[] {\n return [\n 'error-report',\n `fingerprint:${fingerprint}`,\n ...(config?.labels ?? []),\n ]\n}\n\nfunction formatBody(\n message: string,\n stack: string | undefined,\n fingerprint: string,\n context?: ErrorContext,\n): string {\n const env = config?.environment ?? 'unknown'\n const timestamp = new Date().toISOString()\n\n const sections = [\n `## Error Report (Automated)`,\n `**Environment:** ${env} | **Fingerprint:** \\`${fingerprint}\\` | **Time:** ${timestamp}`,\n '',\n `### Message`,\n message,\n ]\n\n if (stack) {\n sections.push('', '### Stack Trace', '```', stack, '```')\n }\n\n if (context?.tags && Object.keys(context.tags).length > 0) {\n const tagLines = Object.entries(context.tags)\n .map(([k, v]) => `- **${k}:** ${v}`)\n .join('\\n')\n sections.push('', '### Tags', tagLines)\n }\n\n if (context?.requestUrl) {\n sections.push('', `**Request URL:** ${context.requestUrl}`)\n }\n\n if (context?.user) {\n sections.push(`**User:** ${context.user.id}${context.user.email ? ` (${context.user.email})` : ''}`)\n }\n\n if (context?.extras && Object.keys(context.extras).length > 0) {\n sections.push(\n '',\n '<details>',\n '<summary>Additional metadata</summary>',\n '',\n '```json',\n JSON.stringify(context.extras, null, 2),\n '```',\n '</details>',\n )\n }\n\n return sections.join('\\n')\n}\n\nfunction recurrenceComment(): string {\n const env = config?.environment ?? 'unknown'\n return `**Recurrence detected** at ${new Date().toISOString()} in \\`${env}\\` environment.`\n}\n"],"mappings":"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","names":["config","config"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for the error tracker.
|
|
3
|
+
* Pass to `init()` at app startup.
|
|
4
|
+
*/
|
|
5
|
+
interface ErrorTrackerConfig {
|
|
6
|
+
/** GitHub Personal Access Token with `repo` scope */
|
|
7
|
+
githubToken: string;
|
|
8
|
+
/** Repository in "owner/repo" format */
|
|
9
|
+
githubRepo: string;
|
|
10
|
+
/** Environment name included in issue body. Default: "development" */
|
|
11
|
+
environment?: string;
|
|
12
|
+
/** Additional labels applied to created issues (beyond "error-report"). */
|
|
13
|
+
labels?: string[];
|
|
14
|
+
/** Kill switch. Default: true */
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
/** Called when GitHub API fails. Default: console.error */
|
|
17
|
+
onError?: (err: unknown) => void;
|
|
18
|
+
/** Max new issues created per minute. Default: 10 */
|
|
19
|
+
rateLimitPerMinute?: number;
|
|
20
|
+
/** Suppress duplicate fingerprints within this window (ms). Default: 60_000 */
|
|
21
|
+
dedupeWindowMs?: number;
|
|
22
|
+
/** Reopen closed issues on recurrence instead of ignoring. Default: true */
|
|
23
|
+
reopenClosed?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Additional context attached to a captured error.
|
|
27
|
+
*/
|
|
28
|
+
interface ErrorContext {
|
|
29
|
+
tags?: Record<string, string>;
|
|
30
|
+
extras?: Record<string, unknown>;
|
|
31
|
+
user?: {
|
|
32
|
+
id: string;
|
|
33
|
+
email?: string;
|
|
34
|
+
};
|
|
35
|
+
requestUrl?: string;
|
|
36
|
+
serverName?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Error tracker client — the main orchestrator.
|
|
41
|
+
*
|
|
42
|
+
* Singleton pattern: call `init()` once at startup, then use
|
|
43
|
+
* `captureException()` / `captureMessage()` anywhere in your app.
|
|
44
|
+
* All GitHub API calls are fire-and-forget with `flush()` for
|
|
45
|
+
* serverless environments that need to wait before responding.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Initialize the error tracker. Call once at app startup.
|
|
50
|
+
*/
|
|
51
|
+
declare function init(cfg: ErrorTrackerConfig): void;
|
|
52
|
+
/**
|
|
53
|
+
* Capture an exception. Fire-and-forget — use `flush()` if you
|
|
54
|
+
* need to wait for the GitHub API call to complete.
|
|
55
|
+
*/
|
|
56
|
+
declare function captureException(error: Error, context?: ErrorContext): void;
|
|
57
|
+
/**
|
|
58
|
+
* Capture a plain message as an error event.
|
|
59
|
+
*/
|
|
60
|
+
declare function captureMessage(message: string, level?: 'error' | 'warning', context?: ErrorContext): void;
|
|
61
|
+
/**
|
|
62
|
+
* Wait for all pending error reports to complete.
|
|
63
|
+
* Call before serverless function returns.
|
|
64
|
+
*/
|
|
65
|
+
declare function flush(): Promise<void>;
|
|
66
|
+
|
|
67
|
+
export { type ErrorContext, type ErrorTrackerConfig, captureException, captureMessage, flush, init };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for the error tracker.
|
|
3
|
+
* Pass to `init()` at app startup.
|
|
4
|
+
*/
|
|
5
|
+
interface ErrorTrackerConfig {
|
|
6
|
+
/** GitHub Personal Access Token with `repo` scope */
|
|
7
|
+
githubToken: string;
|
|
8
|
+
/** Repository in "owner/repo" format */
|
|
9
|
+
githubRepo: string;
|
|
10
|
+
/** Environment name included in issue body. Default: "development" */
|
|
11
|
+
environment?: string;
|
|
12
|
+
/** Additional labels applied to created issues (beyond "error-report"). */
|
|
13
|
+
labels?: string[];
|
|
14
|
+
/** Kill switch. Default: true */
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
/** Called when GitHub API fails. Default: console.error */
|
|
17
|
+
onError?: (err: unknown) => void;
|
|
18
|
+
/** Max new issues created per minute. Default: 10 */
|
|
19
|
+
rateLimitPerMinute?: number;
|
|
20
|
+
/** Suppress duplicate fingerprints within this window (ms). Default: 60_000 */
|
|
21
|
+
dedupeWindowMs?: number;
|
|
22
|
+
/** Reopen closed issues on recurrence instead of ignoring. Default: true */
|
|
23
|
+
reopenClosed?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Additional context attached to a captured error.
|
|
27
|
+
*/
|
|
28
|
+
interface ErrorContext {
|
|
29
|
+
tags?: Record<string, string>;
|
|
30
|
+
extras?: Record<string, unknown>;
|
|
31
|
+
user?: {
|
|
32
|
+
id: string;
|
|
33
|
+
email?: string;
|
|
34
|
+
};
|
|
35
|
+
requestUrl?: string;
|
|
36
|
+
serverName?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Error tracker client — the main orchestrator.
|
|
41
|
+
*
|
|
42
|
+
* Singleton pattern: call `init()` once at startup, then use
|
|
43
|
+
* `captureException()` / `captureMessage()` anywhere in your app.
|
|
44
|
+
* All GitHub API calls are fire-and-forget with `flush()` for
|
|
45
|
+
* serverless environments that need to wait before responding.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Initialize the error tracker. Call once at app startup.
|
|
50
|
+
*/
|
|
51
|
+
declare function init(cfg: ErrorTrackerConfig): void;
|
|
52
|
+
/**
|
|
53
|
+
* Capture an exception. Fire-and-forget — use `flush()` if you
|
|
54
|
+
* need to wait for the GitHub API call to complete.
|
|
55
|
+
*/
|
|
56
|
+
declare function captureException(error: Error, context?: ErrorContext): void;
|
|
57
|
+
/**
|
|
58
|
+
* Capture a plain message as an error event.
|
|
59
|
+
*/
|
|
60
|
+
declare function captureMessage(message: string, level?: 'error' | 'warning', context?: ErrorContext): void;
|
|
61
|
+
/**
|
|
62
|
+
* Wait for all pending error reports to complete.
|
|
63
|
+
* Call before serverless function returns.
|
|
64
|
+
*/
|
|
65
|
+
declare function flush(): Promise<void>;
|
|
66
|
+
|
|
67
|
+
export { type ErrorContext, type ErrorTrackerConfig, captureException, captureMessage, flush, init };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// src/fingerprint.ts
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
|
|
4
|
+
// src/normalizer.ts
|
|
5
|
+
var LINE_COL_RE = /:\d+:\d+/g;
|
|
6
|
+
var WEBPACK_PREFIX_RE = /webpack-internal:\/\/\//g;
|
|
7
|
+
var QUERY_STRING_RE = /\?[^\s)]+/g;
|
|
8
|
+
var FRAME_RE = /^\s+at\s+/;
|
|
9
|
+
var NODE_MODULES_RE = /node_modules/;
|
|
10
|
+
function extractFrames(stack, maxFrames = 3) {
|
|
11
|
+
if (!stack) return [];
|
|
12
|
+
const lines = stack.split("\n");
|
|
13
|
+
const frames = [];
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
if (!FRAME_RE.test(line)) continue;
|
|
16
|
+
if (NODE_MODULES_RE.test(line)) continue;
|
|
17
|
+
const normalized = line.replace(WEBPACK_PREFIX_RE, "").replace(QUERY_STRING_RE, "").replace(LINE_COL_RE, "").trim();
|
|
18
|
+
frames.push(normalized);
|
|
19
|
+
if (frames.length >= maxFrames) break;
|
|
20
|
+
}
|
|
21
|
+
return frames;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/fingerprint.ts
|
|
25
|
+
var MESSAGE_TRUNCATE_LENGTH = 100;
|
|
26
|
+
function generateFingerprint(input) {
|
|
27
|
+
let name;
|
|
28
|
+
let message;
|
|
29
|
+
let stack;
|
|
30
|
+
if (typeof input === "string") {
|
|
31
|
+
name = "Error";
|
|
32
|
+
message = input;
|
|
33
|
+
stack = void 0;
|
|
34
|
+
} else {
|
|
35
|
+
name = input.name || "Error";
|
|
36
|
+
message = input.message || "";
|
|
37
|
+
stack = input.stack;
|
|
38
|
+
}
|
|
39
|
+
const truncatedMessage = message.slice(0, MESSAGE_TRUNCATE_LENGTH);
|
|
40
|
+
const frames = extractFrames(stack);
|
|
41
|
+
const payload = `${name}
|
|
42
|
+
${truncatedMessage}
|
|
43
|
+
${frames.join("\n")}`;
|
|
44
|
+
const hash = createHash("sha256").update(payload).digest("hex");
|
|
45
|
+
return hash.slice(0, 12);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/rate-limiter.ts
|
|
49
|
+
var ONE_MINUTE = 6e4;
|
|
50
|
+
var CLEANUP_INTERVAL = 5 * 6e4;
|
|
51
|
+
var RateLimiter = class {
|
|
52
|
+
config;
|
|
53
|
+
timestamps = [];
|
|
54
|
+
dedup = /* @__PURE__ */ new Map();
|
|
55
|
+
cleanupTimer = null;
|
|
56
|
+
constructor(config2) {
|
|
57
|
+
this.config = config2;
|
|
58
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
|
|
59
|
+
if (this.cleanupTimer && typeof this.cleanupTimer === "object" && "unref" in this.cleanupTimer) {
|
|
60
|
+
this.cleanupTimer.unref();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Check whether a fingerprint can be processed right now.
|
|
65
|
+
* Returns false if rate-limited or deduped.
|
|
66
|
+
*/
|
|
67
|
+
canProcess(fingerprint) {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
const lastSeen = this.dedup.get(fingerprint);
|
|
70
|
+
if (lastSeen !== void 0 && now - lastSeen < this.config.dedupeWindowMs) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
this.pruneOldTimestamps(now);
|
|
74
|
+
return this.timestamps.length < this.config.maxPerMinute;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Record that a fingerprint was processed.
|
|
78
|
+
* Call this after successfully processing (or starting to process) an error.
|
|
79
|
+
*/
|
|
80
|
+
recordProcessed(fingerprint) {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
this.timestamps.push(now);
|
|
83
|
+
this.dedup.set(fingerprint, now);
|
|
84
|
+
}
|
|
85
|
+
/** Stop the cleanup timer. Call on shutdown. */
|
|
86
|
+
destroy() {
|
|
87
|
+
if (this.cleanupTimer) {
|
|
88
|
+
clearInterval(this.cleanupTimer);
|
|
89
|
+
this.cleanupTimer = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
pruneOldTimestamps(now) {
|
|
93
|
+
const cutoff = now - ONE_MINUTE;
|
|
94
|
+
while (this.timestamps.length > 0 && this.timestamps[0] < cutoff) {
|
|
95
|
+
this.timestamps.shift();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
cleanup() {
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
this.pruneOldTimestamps(now);
|
|
101
|
+
for (const [fp, ts] of this.dedup.entries()) {
|
|
102
|
+
if (now - ts >= this.config.dedupeWindowMs) {
|
|
103
|
+
this.dedup.delete(fp);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// src/github.ts
|
|
110
|
+
import { Octokit } from "octokit";
|
|
111
|
+
function parseRepo(repo) {
|
|
112
|
+
const [owner, name] = repo.split("/");
|
|
113
|
+
if (!owner || !name) {
|
|
114
|
+
throw new Error(`Invalid repo format "${repo}". Expected "owner/repo".`);
|
|
115
|
+
}
|
|
116
|
+
return { owner, repo: name };
|
|
117
|
+
}
|
|
118
|
+
function createGitHubClient(config2) {
|
|
119
|
+
const octokit = new Octokit({ auth: config2.token });
|
|
120
|
+
const { owner, repo } = parseRepo(config2.repo);
|
|
121
|
+
return {
|
|
122
|
+
async searchExistingIssue(fingerprint) {
|
|
123
|
+
try {
|
|
124
|
+
const { data } = await octokit.rest.issues.listForRepo({
|
|
125
|
+
owner,
|
|
126
|
+
repo,
|
|
127
|
+
labels: `fingerprint:${fingerprint}`,
|
|
128
|
+
state: "all",
|
|
129
|
+
per_page: 1
|
|
130
|
+
});
|
|
131
|
+
const issue = data[0];
|
|
132
|
+
if (!issue) return null;
|
|
133
|
+
return {
|
|
134
|
+
number: issue.number,
|
|
135
|
+
state: issue.state,
|
|
136
|
+
title: issue.title
|
|
137
|
+
};
|
|
138
|
+
} catch (err) {
|
|
139
|
+
config2.onError(err);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
async createIssue(title, body, labels) {
|
|
144
|
+
try {
|
|
145
|
+
const { data } = await octokit.rest.issues.create({
|
|
146
|
+
owner,
|
|
147
|
+
repo,
|
|
148
|
+
title,
|
|
149
|
+
body,
|
|
150
|
+
labels
|
|
151
|
+
});
|
|
152
|
+
return data.number;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
config2.onError(err);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
async addReaction(issueNumber) {
|
|
159
|
+
try {
|
|
160
|
+
await octokit.rest.reactions.createForIssue({
|
|
161
|
+
owner,
|
|
162
|
+
repo,
|
|
163
|
+
issue_number: issueNumber,
|
|
164
|
+
content: "+1"
|
|
165
|
+
});
|
|
166
|
+
} catch (err) {
|
|
167
|
+
config2.onError(err);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
async reopenIssue(issueNumber, comment) {
|
|
171
|
+
try {
|
|
172
|
+
await octokit.rest.issues.update({
|
|
173
|
+
owner,
|
|
174
|
+
repo,
|
|
175
|
+
issue_number: issueNumber,
|
|
176
|
+
state: "open"
|
|
177
|
+
});
|
|
178
|
+
await octokit.rest.issues.createComment({
|
|
179
|
+
owner,
|
|
180
|
+
repo,
|
|
181
|
+
issue_number: issueNumber,
|
|
182
|
+
body: comment
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
config2.onError(err);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/client.ts
|
|
192
|
+
var config = null;
|
|
193
|
+
var github = null;
|
|
194
|
+
var limiter = null;
|
|
195
|
+
var pending = [];
|
|
196
|
+
function init(cfg) {
|
|
197
|
+
config = {
|
|
198
|
+
environment: "development",
|
|
199
|
+
enabled: true,
|
|
200
|
+
reopenClosed: true,
|
|
201
|
+
rateLimitPerMinute: 10,
|
|
202
|
+
dedupeWindowMs: 6e4,
|
|
203
|
+
labels: [],
|
|
204
|
+
onError: console.error,
|
|
205
|
+
...cfg
|
|
206
|
+
};
|
|
207
|
+
if (!config.enabled) return;
|
|
208
|
+
github = createGitHubClient({
|
|
209
|
+
token: config.githubToken,
|
|
210
|
+
repo: config.githubRepo,
|
|
211
|
+
onError: config.onError
|
|
212
|
+
});
|
|
213
|
+
limiter = new RateLimiter({
|
|
214
|
+
maxPerMinute: config.rateLimitPerMinute,
|
|
215
|
+
dedupeWindowMs: config.dedupeWindowMs
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
function captureException(error, context) {
|
|
219
|
+
if (!config?.enabled || !github || !limiter) return;
|
|
220
|
+
const fingerprint = generateFingerprint(error);
|
|
221
|
+
if (!limiter.canProcess(fingerprint)) {
|
|
222
|
+
console.error(`[error-tracker] Rate limited or deduped: ${fingerprint}`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
limiter.recordProcessed(fingerprint);
|
|
226
|
+
const promise = processError(error, fingerprint, context).catch((err) => {
|
|
227
|
+
config?.onError?.(err);
|
|
228
|
+
});
|
|
229
|
+
pending.push(promise);
|
|
230
|
+
}
|
|
231
|
+
function captureMessage(message, level = "error", context) {
|
|
232
|
+
if (!config?.enabled || !github || !limiter) return;
|
|
233
|
+
const fingerprint = generateFingerprint(message);
|
|
234
|
+
if (!limiter.canProcess(fingerprint)) return;
|
|
235
|
+
limiter.recordProcessed(fingerprint);
|
|
236
|
+
const title = `[${level === "warning" ? "Warning" : "Error"}] ${message.slice(0, 80)}`;
|
|
237
|
+
const body = formatBody(message, void 0, fingerprint, context);
|
|
238
|
+
const labels = buildLabels(fingerprint);
|
|
239
|
+
const promise = github.searchExistingIssue(fingerprint).then(async (existing) => {
|
|
240
|
+
if (!github) return;
|
|
241
|
+
if (existing?.state === "open") {
|
|
242
|
+
await github.addReaction(existing.number);
|
|
243
|
+
} else if (existing?.state === "closed") {
|
|
244
|
+
if (config?.reopenClosed) {
|
|
245
|
+
await github.reopenIssue(existing.number, recurrenceComment());
|
|
246
|
+
await github.addReaction(existing.number);
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
await github.createIssue(title, body, labels);
|
|
250
|
+
}
|
|
251
|
+
}).catch((err) => {
|
|
252
|
+
config?.onError?.(err);
|
|
253
|
+
});
|
|
254
|
+
pending.push(promise);
|
|
255
|
+
}
|
|
256
|
+
async function flush() {
|
|
257
|
+
await Promise.allSettled(pending);
|
|
258
|
+
pending.length = 0;
|
|
259
|
+
}
|
|
260
|
+
async function processError(error, fingerprint, context) {
|
|
261
|
+
if (!github) return;
|
|
262
|
+
const existing = await github.searchExistingIssue(fingerprint);
|
|
263
|
+
if (existing?.state === "open") {
|
|
264
|
+
await github.addReaction(existing.number);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (existing?.state === "closed") {
|
|
268
|
+
if (config?.reopenClosed) {
|
|
269
|
+
await github.reopenIssue(existing.number, recurrenceComment());
|
|
270
|
+
await github.addReaction(existing.number);
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const title = `[Error] ${error.name || "Error"}: ${error.message.slice(0, 80)}`;
|
|
275
|
+
const body = formatBody(error.message, error.stack, fingerprint, context);
|
|
276
|
+
const labels = buildLabels(fingerprint);
|
|
277
|
+
await github.createIssue(title, body, labels);
|
|
278
|
+
}
|
|
279
|
+
function buildLabels(fingerprint) {
|
|
280
|
+
return [
|
|
281
|
+
"error-report",
|
|
282
|
+
`fingerprint:${fingerprint}`,
|
|
283
|
+
...config?.labels ?? []
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
function formatBody(message, stack, fingerprint, context) {
|
|
287
|
+
const env = config?.environment ?? "unknown";
|
|
288
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
289
|
+
const sections = [
|
|
290
|
+
`## Error Report (Automated)`,
|
|
291
|
+
`**Environment:** ${env} | **Fingerprint:** \`${fingerprint}\` | **Time:** ${timestamp}`,
|
|
292
|
+
"",
|
|
293
|
+
`### Message`,
|
|
294
|
+
message
|
|
295
|
+
];
|
|
296
|
+
if (stack) {
|
|
297
|
+
sections.push("", "### Stack Trace", "```", stack, "```");
|
|
298
|
+
}
|
|
299
|
+
if (context?.tags && Object.keys(context.tags).length > 0) {
|
|
300
|
+
const tagLines = Object.entries(context.tags).map(([k, v]) => `- **${k}:** ${v}`).join("\n");
|
|
301
|
+
sections.push("", "### Tags", tagLines);
|
|
302
|
+
}
|
|
303
|
+
if (context?.requestUrl) {
|
|
304
|
+
sections.push("", `**Request URL:** ${context.requestUrl}`);
|
|
305
|
+
}
|
|
306
|
+
if (context?.user) {
|
|
307
|
+
sections.push(`**User:** ${context.user.id}${context.user.email ? ` (${context.user.email})` : ""}`);
|
|
308
|
+
}
|
|
309
|
+
if (context?.extras && Object.keys(context.extras).length > 0) {
|
|
310
|
+
sections.push(
|
|
311
|
+
"",
|
|
312
|
+
"<details>",
|
|
313
|
+
"<summary>Additional metadata</summary>",
|
|
314
|
+
"",
|
|
315
|
+
"```json",
|
|
316
|
+
JSON.stringify(context.extras, null, 2),
|
|
317
|
+
"```",
|
|
318
|
+
"</details>"
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return sections.join("\n");
|
|
322
|
+
}
|
|
323
|
+
function recurrenceComment() {
|
|
324
|
+
const env = config?.environment ?? "unknown";
|
|
325
|
+
return `**Recurrence detected** at ${(/* @__PURE__ */ new Date()).toISOString()} in \`${env}\` environment.`;
|
|
326
|
+
}
|
|
327
|
+
export {
|
|
328
|
+
captureException,
|
|
329
|
+
captureMessage,
|
|
330
|
+
flush,
|
|
331
|
+
init
|
|
332
|
+
};
|
|
333
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/fingerprint.ts","../src/normalizer.ts","../src/rate-limiter.ts","../src/github.ts","../src/client.ts"],"sourcesContent":["/**\n * Error fingerprinting.\n *\n * Produces a deterministic hash from an error's name, message (truncated),\n * and top stack frames. Two occurrences of the \"same\" error — even across\n * different deploys with different line numbers — should produce the same\n * fingerprint.\n */\n\nimport { createHash } from 'node:crypto'\nimport { extractFrames } from './normalizer'\n\nconst MESSAGE_TRUNCATE_LENGTH = 100\n\n/**\n * Generate a 12-char hex fingerprint from an Error or plain string.\n */\nexport function generateFingerprint(input: Error | string): string {\n let name: string\n let message: string\n let stack: string | undefined\n\n if (typeof input === 'string') {\n name = 'Error'\n message = input\n stack = undefined\n } else {\n name = input.name || 'Error'\n message = input.message || ''\n stack = input.stack\n }\n\n const truncatedMessage = message.slice(0, MESSAGE_TRUNCATE_LENGTH)\n const frames = extractFrames(stack)\n const payload = `${name}\\n${truncatedMessage}\\n${frames.join('\\n')}`\n\n const hash = createHash('sha256').update(payload).digest('hex')\n return hash.slice(0, 12)\n}\n","/**\n * Stack trace normalizer.\n *\n * Strips volatile parts of stack traces (line/column numbers, webpack hashes,\n * query strings) so that the same logical error produces the same fingerprint\n * across deploys.\n */\n\n/** Strip line:col numbers like `:42:13` at the end of file paths */\nconst LINE_COL_RE = /:\\d+:\\d+/g\n\n/** Strip webpack-internal:/// prefix */\nconst WEBPACK_PREFIX_RE = /webpack-internal:\\/\\/\\//g\n\n/** Strip query strings from file paths like `?abc123` or `?v=hash` */\nconst QUERY_STRING_RE = /\\?[^\\s)]+/g\n\n/** Match stack frame lines (starts with \"at \" after whitespace) */\nconst FRAME_RE = /^\\s+at\\s+/\n\n/** Skip frames from node_modules */\nconst NODE_MODULES_RE = /node_modules/\n\n/**\n * Normalize a stack trace by removing volatile parts.\n * Returns a stable string suitable for hashing.\n */\nexport function normalizeStack(stack: string | undefined): string {\n if (!stack) return ''\n\n return stack\n .replace(WEBPACK_PREFIX_RE, '')\n .replace(QUERY_STRING_RE, '')\n .replace(LINE_COL_RE, '')\n}\n\n/**\n * Extract the first N meaningful (non-node_modules) frames from a stack trace.\n * Used for fingerprint generation — only top frames matter for identity.\n */\nexport function extractFrames(stack: string | undefined, maxFrames = 3): string[] {\n if (!stack) return []\n\n const lines = stack.split('\\n')\n const frames: string[] = []\n\n for (const line of lines) {\n if (!FRAME_RE.test(line)) continue\n if (NODE_MODULES_RE.test(line)) continue\n\n // Normalize the frame before collecting\n const normalized = line\n .replace(WEBPACK_PREFIX_RE, '')\n .replace(QUERY_STRING_RE, '')\n .replace(LINE_COL_RE, '')\n .trim()\n\n frames.push(normalized)\n if (frames.length >= maxFrames) break\n }\n\n return frames\n}\n","/**\n * Rate limiter with deduplication.\n *\n * Two layers of protection:\n * 1. Sliding window: max N issues created per minute\n * 2. Dedup window: suppress the same fingerprint within a configurable period\n */\n\nexport interface RateLimiterConfig {\n /** Max new issues per minute. */\n maxPerMinute: number\n /** Suppress duplicate fingerprints within this window (ms). */\n dedupeWindowMs: number\n}\n\nconst ONE_MINUTE = 60_000\nconst CLEANUP_INTERVAL = 5 * 60_000 // 5 minutes\n\nexport class RateLimiter {\n private readonly config: RateLimiterConfig\n private readonly timestamps: number[] = []\n private readonly dedup = new Map<string, number>()\n private cleanupTimer: ReturnType<typeof setInterval> | null = null\n\n constructor(config: RateLimiterConfig) {\n this.config = config\n this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL)\n // Unref so the timer doesn't keep the process alive\n if (this.cleanupTimer && typeof this.cleanupTimer === 'object' && 'unref' in this.cleanupTimer) {\n this.cleanupTimer.unref()\n }\n }\n\n /**\n * Check whether a fingerprint can be processed right now.\n * Returns false if rate-limited or deduped.\n */\n canProcess(fingerprint: string): boolean {\n const now = Date.now()\n\n // Check dedup first\n const lastSeen = this.dedup.get(fingerprint)\n if (lastSeen !== undefined && now - lastSeen < this.config.dedupeWindowMs) {\n return false\n }\n\n // Check rate limit\n this.pruneOldTimestamps(now)\n return this.timestamps.length < this.config.maxPerMinute\n }\n\n /**\n * Record that a fingerprint was processed.\n * Call this after successfully processing (or starting to process) an error.\n */\n recordProcessed(fingerprint: string): void {\n const now = Date.now()\n this.timestamps.push(now)\n this.dedup.set(fingerprint, now)\n }\n\n /** Stop the cleanup timer. Call on shutdown. */\n destroy(): void {\n if (this.cleanupTimer) {\n clearInterval(this.cleanupTimer)\n this.cleanupTimer = null\n }\n }\n\n private pruneOldTimestamps(now: number): void {\n const cutoff = now - ONE_MINUTE\n while (this.timestamps.length > 0 && this.timestamps[0]! < cutoff) {\n this.timestamps.shift()\n }\n }\n\n private cleanup(): void {\n const now = Date.now()\n this.pruneOldTimestamps(now)\n\n for (const [fp, ts] of this.dedup.entries()) {\n if (now - ts >= this.config.dedupeWindowMs) {\n this.dedup.delete(fp)\n }\n }\n }\n}\n","/**\n * GitHub API layer for error tracking.\n *\n * All methods catch errors internally and delegate to `onError` —\n * they never throw. This ensures the error tracker itself never\n * crashes the host application.\n */\n\nimport { Octokit } from 'octokit'\nimport type { GitHubClient, ExistingIssue } from './types'\n\nexport interface GitHubClientConfig {\n token: string\n repo: string // \"owner/repo\"\n onError: (err: unknown) => void\n}\n\nfunction parseRepo(repo: string): { owner: string; repo: string } {\n const [owner, name] = repo.split('/')\n if (!owner || !name) {\n throw new Error(`Invalid repo format \"${repo}\". Expected \"owner/repo\".`)\n }\n return { owner, repo: name }\n}\n\nexport function createGitHubClient(config: GitHubClientConfig): GitHubClient {\n const octokit = new Octokit({ auth: config.token })\n const { owner, repo } = parseRepo(config.repo)\n\n return {\n async searchExistingIssue(fingerprint: string): Promise<ExistingIssue | null> {\n try {\n const { data } = await octokit.rest.issues.listForRepo({\n owner,\n repo,\n labels: `fingerprint:${fingerprint}`,\n state: 'all',\n per_page: 1,\n })\n\n const issue = data[0]\n if (!issue) return null\n\n return {\n number: issue.number,\n state: issue.state as 'open' | 'closed',\n title: issue.title,\n }\n } catch (err) {\n config.onError(err)\n return null\n }\n },\n\n async createIssue(title: string, body: string, labels: string[]): Promise<number | null> {\n try {\n const { data } = await octokit.rest.issues.create({\n owner,\n repo,\n title,\n body,\n labels,\n })\n return data.number\n } catch (err) {\n config.onError(err)\n return null\n }\n },\n\n async addReaction(issueNumber: number): Promise<void> {\n try {\n await octokit.rest.reactions.createForIssue({\n owner,\n repo,\n issue_number: issueNumber,\n content: '+1',\n })\n } catch (err) {\n config.onError(err)\n }\n },\n\n async reopenIssue(issueNumber: number, comment: string): Promise<void> {\n try {\n await octokit.rest.issues.update({\n owner,\n repo,\n issue_number: issueNumber,\n state: 'open',\n })\n await octokit.rest.issues.createComment({\n owner,\n repo,\n issue_number: issueNumber,\n body: comment,\n })\n } catch (err) {\n config.onError(err)\n }\n },\n }\n}\n","/**\n * Error tracker client — the main orchestrator.\n *\n * Singleton pattern: call `init()` once at startup, then use\n * `captureException()` / `captureMessage()` anywhere in your app.\n * All GitHub API calls are fire-and-forget with `flush()` for\n * serverless environments that need to wait before responding.\n */\n\nimport type { ErrorTrackerConfig, ErrorContext, GitHubClient } from './types'\nimport { generateFingerprint } from './fingerprint'\nimport { RateLimiter } from './rate-limiter'\nimport { createGitHubClient } from './github'\n\n// ---------------------------------------------------------------------------\n// Module state (singleton)\n// ---------------------------------------------------------------------------\n\nlet config: ErrorTrackerConfig | null = null\nlet github: GitHubClient | null = null\nlet limiter: RateLimiter | null = null\nconst pending: Promise<void>[] = []\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Initialize the error tracker. Call once at app startup.\n */\nexport function init(cfg: ErrorTrackerConfig): void {\n config = {\n environment: 'development',\n enabled: true,\n reopenClosed: true,\n rateLimitPerMinute: 10,\n dedupeWindowMs: 60_000,\n labels: [],\n onError: console.error,\n ...cfg,\n }\n\n if (!config.enabled) return\n\n github = createGitHubClient({\n token: config.githubToken,\n repo: config.githubRepo,\n onError: config.onError!,\n })\n\n limiter = new RateLimiter({\n maxPerMinute: config.rateLimitPerMinute!,\n dedupeWindowMs: config.dedupeWindowMs!,\n })\n}\n\n/**\n * Capture an exception. Fire-and-forget — use `flush()` if you\n * need to wait for the GitHub API call to complete.\n */\nexport function captureException(error: Error, context?: ErrorContext): void {\n if (!config?.enabled || !github || !limiter) return\n\n const fingerprint = generateFingerprint(error)\n\n if (!limiter.canProcess(fingerprint)) {\n console.error(`[error-tracker] Rate limited or deduped: ${fingerprint}`)\n return\n }\n\n limiter.recordProcessed(fingerprint)\n\n const promise = processError(error, fingerprint, context).catch((err) => {\n config?.onError?.(err)\n })\n\n pending.push(promise)\n}\n\n/**\n * Capture a plain message as an error event.\n */\nexport function captureMessage(\n message: string,\n level: 'error' | 'warning' = 'error',\n context?: ErrorContext,\n): void {\n if (!config?.enabled || !github || !limiter) return\n\n const fingerprint = generateFingerprint(message)\n\n if (!limiter.canProcess(fingerprint)) return\n\n limiter.recordProcessed(fingerprint)\n\n const title = `[${level === 'warning' ? 'Warning' : 'Error'}] ${message.slice(0, 80)}`\n const body = formatBody(message, undefined, fingerprint, context)\n const labels = buildLabels(fingerprint)\n\n const promise = github.searchExistingIssue(fingerprint).then(async (existing) => {\n if (!github) return\n\n if (existing?.state === 'open') {\n await github.addReaction(existing.number)\n } else if (existing?.state === 'closed') {\n if (config?.reopenClosed) {\n await github.reopenIssue(existing.number, recurrenceComment())\n await github.addReaction(existing.number)\n }\n } else {\n await github.createIssue(title, body, labels)\n }\n }).catch((err) => {\n config?.onError?.(err)\n })\n\n pending.push(promise)\n}\n\n/**\n * Wait for all pending error reports to complete.\n * Call before serverless function returns.\n */\nexport async function flush(): Promise<void> {\n await Promise.allSettled(pending)\n pending.length = 0\n}\n\n/**\n * Reset internal state. For testing only.\n * @internal\n */\nexport function _reset(): void {\n limiter?.destroy()\n config = null\n github = null\n limiter = null\n pending.length = 0\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nasync function processError(\n error: Error,\n fingerprint: string,\n context?: ErrorContext,\n): Promise<void> {\n if (!github) return\n\n const existing = await github.searchExistingIssue(fingerprint)\n\n if (existing?.state === 'open') {\n await github.addReaction(existing.number)\n return\n }\n\n if (existing?.state === 'closed') {\n if (config?.reopenClosed) {\n await github.reopenIssue(existing.number, recurrenceComment())\n await github.addReaction(existing.number)\n }\n return\n }\n\n // No existing issue — create one\n const title = `[Error] ${(error.name || 'Error')}: ${error.message.slice(0, 80)}`\n const body = formatBody(error.message, error.stack, fingerprint, context)\n const labels = buildLabels(fingerprint)\n\n await github.createIssue(title, body, labels)\n}\n\nfunction buildLabels(fingerprint: string): string[] {\n return [\n 'error-report',\n `fingerprint:${fingerprint}`,\n ...(config?.labels ?? []),\n ]\n}\n\nfunction formatBody(\n message: string,\n stack: string | undefined,\n fingerprint: string,\n context?: ErrorContext,\n): string {\n const env = config?.environment ?? 'unknown'\n const timestamp = new Date().toISOString()\n\n const sections = [\n `## Error Report (Automated)`,\n `**Environment:** ${env} | **Fingerprint:** \\`${fingerprint}\\` | **Time:** ${timestamp}`,\n '',\n `### Message`,\n message,\n ]\n\n if (stack) {\n sections.push('', '### Stack Trace', '```', stack, '```')\n }\n\n if (context?.tags && Object.keys(context.tags).length > 0) {\n const tagLines = Object.entries(context.tags)\n .map(([k, v]) => `- **${k}:** ${v}`)\n .join('\\n')\n sections.push('', '### Tags', tagLines)\n }\n\n if (context?.requestUrl) {\n sections.push('', `**Request URL:** ${context.requestUrl}`)\n }\n\n if (context?.user) {\n sections.push(`**User:** ${context.user.id}${context.user.email ? ` (${context.user.email})` : ''}`)\n }\n\n if (context?.extras && Object.keys(context.extras).length > 0) {\n sections.push(\n '',\n '<details>',\n '<summary>Additional metadata</summary>',\n '',\n '```json',\n JSON.stringify(context.extras, null, 2),\n '```',\n '</details>',\n )\n }\n\n return sections.join('\\n')\n}\n\nfunction recurrenceComment(): string {\n const env = config?.environment ?? 'unknown'\n return `**Recurrence detected** at ${new Date().toISOString()} in \\`${env}\\` environment.`\n}\n"],"mappings":"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","names":["config","config"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gh-issue-tracker",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight error tracking that creates GitHub Issues instead of sending to SaaS. Deduplication, fingerprinting, and rate limiting built-in.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsup",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"type-check": "tsc --noEmit",
|
|
26
|
+
"prepublishOnly": "pnpm build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"error-tracking",
|
|
30
|
+
"github-issues",
|
|
31
|
+
"error-reporter",
|
|
32
|
+
"sentry-alternative",
|
|
33
|
+
"fingerprinting",
|
|
34
|
+
"deduplication"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"octokit": "^4.1.2"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22.9.0",
|
|
45
|
+
"tsup": "^8.0.0",
|
|
46
|
+
"typescript": "^5.7.2",
|
|
47
|
+
"vitest": "^4.0.16"
|
|
48
|
+
}
|
|
49
|
+
}
|