rootly-runtime 1.2.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/CHANGELOG.md +119 -0
- package/README.md +297 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +29 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +118 -0
- package/dist/runtime.d.ts +5 -0
- package/dist/runtime.js +113 -0
- package/dist/transport.d.ts +2 -0
- package/dist/transport.js +83 -0
- package/package.json +30 -0
- package/rootly-runtime-1.0.0.tgz +0 -0
- package/rootly-runtime-1.2.0.tgz +0 -0
- package/src/context.ts +29 -0
- package/src/index.ts +118 -0
- package/src/runtime.ts +125 -0
- package/src/transport.ts +48 -0
- package/tsconfig.json +24 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to @rootly/runtime will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## [1.2.0] - 2026-02-09
|
|
11
|
+
|
|
12
|
+
### š Production Hardening Release
|
|
13
|
+
|
|
14
|
+
This release hardens the SDK for production use with critical bug fixes, performance improvements, and a cleaner public API.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
#### Production-Grade Features
|
|
19
|
+
- **Environment Normalization** - Automatic normalization to `production` or `preview`
|
|
20
|
+
- `production`/`prod` ā `production`
|
|
21
|
+
- All other values (development, staging, test) ā `preview`
|
|
22
|
+
- Falls back to `process.env.NODE_ENV` if not specified
|
|
23
|
+
- **Severity Support** - Capture errors with severity levels
|
|
24
|
+
- `error` (default), `warning`, `info`
|
|
25
|
+
- Example: `capture(error, {}, 'warning')`
|
|
26
|
+
- **Debug Mode** - Optional debug logging to stderr
|
|
27
|
+
- Enable with `init({ debug: true })`
|
|
28
|
+
- Logs deduplication, rate limiting, and send events
|
|
29
|
+
- **Recursive Capture Protection** - Symbol flag prevents infinite loops
|
|
30
|
+
- Marks errors on first capture
|
|
31
|
+
- Silently drops if same error object captured again
|
|
32
|
+
- **Stable Fingerprinting** - Improved error deduplication
|
|
33
|
+
- Normalizes whitespace in stack traces
|
|
34
|
+
- Uses first non-empty stack frame
|
|
35
|
+
- More consistent across stack format variations
|
|
36
|
+
- **Hard Memory Cap** - Prevents unbounded growth
|
|
37
|
+
- Max 500 fingerprints in memory
|
|
38
|
+
- Auto-deletes oldest 50% when exceeded
|
|
39
|
+
- **Real Graceful Shutdown** - Tracks pending HTTP requests
|
|
40
|
+
- 200ms delay if requests in-flight
|
|
41
|
+
- Handles `SIGTERM` and `beforeExit` events
|
|
42
|
+
|
|
43
|
+
#### API Improvements
|
|
44
|
+
- **Clean Public API** - Removed `apiUrl` from `InitOptions`
|
|
45
|
+
- Normal users no longer see backend URL configuration
|
|
46
|
+
- Advanced users can use `ROOTLY_API_URL` env variable
|
|
47
|
+
- Makes SDK feel like a professional SaaS product
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
#### Performance Optimizations
|
|
52
|
+
- **Optimized Rate Limiter** - O(n) instead of O(n²)
|
|
53
|
+
- Single `splice()` instead of repeated `shift()`
|
|
54
|
+
- More efficient for high-error scenarios
|
|
55
|
+
- **Debug Logging** - Uses `process.stderr.write` instead of `console.log`
|
|
56
|
+
- Cleaner for production logging agents
|
|
57
|
+
- More professional output
|
|
58
|
+
|
|
59
|
+
#### API Changes
|
|
60
|
+
- **InitOptions Interface** - Removed `apiUrl` parameter
|
|
61
|
+
- Before: `init({ apiKey, environment, apiUrl, debug })`
|
|
62
|
+
- After: `init({ apiKey, environment, debug })`
|
|
63
|
+
- Use `ROOTLY_API_URL` env variable for custom backends
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
#### Critical Bug Fixes
|
|
68
|
+
- **Environment Fallback** - Now uses `NODE_ENV` when environment not specified
|
|
69
|
+
- Before: `undefined` ā `'production'` (incorrect for dev)
|
|
70
|
+
- After: `undefined` ā uses `NODE_ENV` ā normalized
|
|
71
|
+
- Prevents dev errors being marked as production incidents
|
|
72
|
+
- **Listener Guard Bug** - SDK now always registers error handlers
|
|
73
|
+
- Before: Silently disabled if app had existing listeners
|
|
74
|
+
- After: Always registers using `prependListener`
|
|
75
|
+
- SDK now works in all production apps
|
|
76
|
+
- **Transport Decrement Bug** - Fixed `pendingRequests` counter
|
|
77
|
+
- Before: Could go negative if error before increment
|
|
78
|
+
- After: Only decrements in handlers after increment
|
|
79
|
+
- Graceful shutdown logic no longer broken
|
|
80
|
+
- **Severity Default** - Uses nullish coalescing (`??`) instead of OR (`||`)
|
|
81
|
+
- Before: Empty string `''` ā `'error'`
|
|
82
|
+
- After: Empty string `''` ā preserved
|
|
83
|
+
- Safer edge case handling
|
|
84
|
+
|
|
85
|
+
### Technical Details
|
|
86
|
+
|
|
87
|
+
- **Line Count**: 283 lines (17 under 300 target)
|
|
88
|
+
- **Dependencies**: Zero (only native Node.js modules)
|
|
89
|
+
- **Backward Compatibility**: Fully backward compatible
|
|
90
|
+
- Existing code works unchanged
|
|
91
|
+
- New features are opt-in
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## [1.0.0] - 2026-02-08
|
|
96
|
+
|
|
97
|
+
### š Initial Release
|
|
98
|
+
|
|
99
|
+
First production release of @rootly/runtime SDK.
|
|
100
|
+
|
|
101
|
+
### Added
|
|
102
|
+
|
|
103
|
+
- **Automatic Error Capture** - Global handlers for `uncaughtException` and `unhandledRejection`
|
|
104
|
+
- **Manual Error Capture** - `capture(error, context)` for handled errors
|
|
105
|
+
- **Function Wrapping** - `wrap(fn)` for automatic error capture
|
|
106
|
+
- **Express Middleware** - `expressErrorHandler()` for 5xx errors
|
|
107
|
+
- **Error Deduplication** - Same error within 10s sent only once
|
|
108
|
+
- **Rate Limiting** - Max 20 errors per 60 seconds
|
|
109
|
+
- **Commit SHA Detection** - Auto-detects from Vercel, Render, GitHub Actions
|
|
110
|
+
- **Custom Context** - Add user data, metadata to errors
|
|
111
|
+
- **Production Safety** - Fail-silent design, never crashes app
|
|
112
|
+
- **Zero Dependencies** - Uses native Node.js `https` module
|
|
113
|
+
- **TypeScript Support** - Full type definitions included
|
|
114
|
+
|
|
115
|
+
### Technical Details
|
|
116
|
+
|
|
117
|
+
- **Line Count**: 274 lines
|
|
118
|
+
- **Node.js**: >= 18.0.0
|
|
119
|
+
- **License**: MIT
|
package/README.md
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# @rootly/runtime
|
|
2
|
+
|
|
3
|
+
Production-grade runtime error tracking for Node.js applications.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @rootly/runtime
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { init } from '@rootly/runtime';
|
|
15
|
+
|
|
16
|
+
// Initialize at app startup
|
|
17
|
+
init({
|
|
18
|
+
apiKey: process.env.ROOTLY_API_KEY!,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// That's it! All unhandled errors are now captured
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### Basic Setup (Required)
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import { init } from '@rootly/runtime';
|
|
30
|
+
|
|
31
|
+
init({
|
|
32
|
+
apiKey: process.env.ROOTLY_API_KEY!, // Required: Get from Rootly dashboard
|
|
33
|
+
environment: 'production', // Optional: 'production' or 'preview' (default: NODE_ENV)
|
|
34
|
+
debug: true // Optional: Enable debug logging (default: false)
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**What happens automatically:**
|
|
39
|
+
- ā
Captures all `uncaughtException` errors
|
|
40
|
+
- ā
Captures all `unhandledRejection` errors
|
|
41
|
+
- ā
Deduplicates identical errors (10s window)
|
|
42
|
+
- ā
Rate limits to 20 errors/60s
|
|
43
|
+
- ā
Auto-detects commit SHA from environment
|
|
44
|
+
- ā
Graceful shutdown handling
|
|
45
|
+
|
|
46
|
+
### Manual Error Capture
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import { capture } from '@rootly/runtime';
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Your code...
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// Capture with custom context
|
|
55
|
+
capture(error, {
|
|
56
|
+
user_id: '12345',
|
|
57
|
+
action: 'checkout',
|
|
58
|
+
amount: 99.99
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Handle error gracefully
|
|
62
|
+
res.status(500).json({ error: 'Something went wrong' });
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Severity Levels (New in v1.2.0)
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { capture } from '@rootly/runtime';
|
|
70
|
+
|
|
71
|
+
// Error (default)
|
|
72
|
+
capture(error, { user_id: '123' }, 'error');
|
|
73
|
+
|
|
74
|
+
// Warning
|
|
75
|
+
capture(error, { deprecation: 'old_api' }, 'warning');
|
|
76
|
+
|
|
77
|
+
// Info
|
|
78
|
+
capture(error, { event: 'migration_complete' }, 'info');
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Wrap Functions (Auto-Capture)
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import { wrap } from '@rootly/runtime';
|
|
85
|
+
|
|
86
|
+
// Wrap sync functions
|
|
87
|
+
const processPayment = wrap((amount: number) => {
|
|
88
|
+
if (amount < 0) throw new Error('Invalid amount');
|
|
89
|
+
// Process payment...
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Wrap async functions
|
|
93
|
+
const fetchUser = wrap(async (userId: string) => {
|
|
94
|
+
const response = await fetch(`/api/users/${userId}`);
|
|
95
|
+
if (!response.ok) throw new Error('User not found');
|
|
96
|
+
return response.json();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Errors are captured AND re-thrown
|
|
100
|
+
try {
|
|
101
|
+
await fetchUser('123');
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// Error was sent to Rootly, now handle it
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Express Middleware (5xx Error Capture)
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import express from 'express';
|
|
111
|
+
import { init, expressErrorHandler } from '@rootly/runtime';
|
|
112
|
+
|
|
113
|
+
init({ apiKey: process.env.ROOTLY_API_KEY! });
|
|
114
|
+
|
|
115
|
+
const app = express();
|
|
116
|
+
|
|
117
|
+
// Your routes
|
|
118
|
+
app.get('/api/users', async (req, res) => {
|
|
119
|
+
// Your code...
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Add Rootly error handler BEFORE your final error handler
|
|
123
|
+
app.use(expressErrorHandler());
|
|
124
|
+
|
|
125
|
+
// Your final error handler
|
|
126
|
+
app.use((err, req, res, next) => {
|
|
127
|
+
res.status(500).json({ error: err.message });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
app.listen(3000);
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Behavior**:
|
|
134
|
+
- ā
Captures errors when `res.statusCode >= 500`
|
|
135
|
+
- ā Ignores 4xx errors (validation, auth, etc.)
|
|
136
|
+
- Adds Express context: `method`, `path`, `status_code`, `source: 'express'`
|
|
137
|
+
- Always calls `next(err)` to continue error chain
|
|
138
|
+
|
|
139
|
+
## Configuration
|
|
140
|
+
|
|
141
|
+
### Required
|
|
142
|
+
|
|
143
|
+
- `apiKey` - Your Rootly API key from the dashboard
|
|
144
|
+
|
|
145
|
+
### Optional
|
|
146
|
+
|
|
147
|
+
- `environment` - `'production'` or `'preview'` (default: `process.env.NODE_ENV` or `'production'`)
|
|
148
|
+
- `'production'` or `'prod'` ā normalized to `'production'`
|
|
149
|
+
- All other values ā normalized to `'preview'`
|
|
150
|
+
- `debug` - Enable debug logging to stderr (default: `false`)
|
|
151
|
+
|
|
152
|
+
### Advanced: Custom Backend URL
|
|
153
|
+
|
|
154
|
+
For development, staging, or self-hosted deployments, set the `ROOTLY_API_URL` environment variable:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
# Development
|
|
158
|
+
export ROOTLY_API_URL=http://localhost:5000
|
|
159
|
+
|
|
160
|
+
# Staging
|
|
161
|
+
export ROOTLY_API_URL=https://staging.rootly.io
|
|
162
|
+
|
|
163
|
+
# Self-hosted
|
|
164
|
+
export ROOTLY_API_URL=https://rootly.your-company.com
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Note**: This is an advanced feature. Normal users should not configure this.
|
|
168
|
+
|
|
169
|
+
## Features
|
|
170
|
+
|
|
171
|
+
### Production-Grade Hardening (v1.2.0)
|
|
172
|
+
|
|
173
|
+
- ā
**Environment Normalization** - Automatic production/preview normalization
|
|
174
|
+
- ā
**Recursive Capture Protection** - Prevents infinite loops if SDK throws
|
|
175
|
+
- ā
**Stable Fingerprinting** - Consistent error deduplication
|
|
176
|
+
- ā
**Severity Support** - error/warning/info levels
|
|
177
|
+
- ā
**Hard Memory Cap** - Max 500 fingerprints (auto-cleanup)
|
|
178
|
+
- ā
**Optimized Rate Limiter** - O(n) performance
|
|
179
|
+
- ā
**Debug Mode** - Optional stderr logging
|
|
180
|
+
- ā
**Real Graceful Shutdown** - Tracks pending requests
|
|
181
|
+
|
|
182
|
+
### Core Features
|
|
183
|
+
|
|
184
|
+
- ā
Zero dependencies (uses native Node.js `https` module)
|
|
185
|
+
- ā
Captures `uncaughtException` and `unhandledRejection`
|
|
186
|
+
- ā
Express middleware for 5xx server errors
|
|
187
|
+
- ā
Manual error capture with custom context
|
|
188
|
+
- ā
Function wrapping for auto-capture
|
|
189
|
+
- ā
Auto-detects commit SHA from multiple platforms
|
|
190
|
+
- ā
Production-safe (never crashes your app)
|
|
191
|
+
- ā
Minimal overhead (283 lines total)
|
|
192
|
+
|
|
193
|
+
### Production Safety
|
|
194
|
+
|
|
195
|
+
- ā
**Error Deduplication** - Same error within 10s sent only once
|
|
196
|
+
- ā
**Rate Limiting** - Max 20 errors per 60 seconds
|
|
197
|
+
- ā
**Graceful Shutdown** - Handles SIGTERM and beforeExit
|
|
198
|
+
- ā
**Fail-Silent** - Never throws errors internally
|
|
199
|
+
- ā
**No Retries** - Keeps it simple
|
|
200
|
+
- ā
**No Queueing** - Immediate send
|
|
201
|
+
|
|
202
|
+
## Commit SHA Detection
|
|
203
|
+
|
|
204
|
+
The SDK automatically detects commit SHA from environment variables (in priority order):
|
|
205
|
+
|
|
206
|
+
1. `VERCEL_GIT_COMMIT_SHA` (Vercel)
|
|
207
|
+
2. `RENDER_GIT_COMMIT` (Render)
|
|
208
|
+
3. `GITHUB_SHA` (GitHub Actions)
|
|
209
|
+
4. `COMMIT_SHA` (Custom)
|
|
210
|
+
|
|
211
|
+
### Manual Setup
|
|
212
|
+
|
|
213
|
+
For platforms without auto-detection:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
# Docker
|
|
217
|
+
docker run -e COMMIT_SHA=$(git rev-parse HEAD) your-image
|
|
218
|
+
|
|
219
|
+
# Other platforms
|
|
220
|
+
export COMMIT_SHA=$(git rev-parse HEAD)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## API Reference
|
|
224
|
+
|
|
225
|
+
### `init(options: InitOptions): void`
|
|
226
|
+
|
|
227
|
+
Initialize the SDK. Must be called before other functions.
|
|
228
|
+
|
|
229
|
+
**Options**:
|
|
230
|
+
- `apiKey: string` - Required. Your Rootly API key
|
|
231
|
+
- `environment?: 'production' | 'preview'` - Optional. Defaults to `process.env.NODE_ENV` or `'production'`
|
|
232
|
+
- `debug?: boolean` - Optional. Enable debug logging. Defaults to `false`
|
|
233
|
+
|
|
234
|
+
### `capture(error: Error, extraContext?: object, severity?: 'error' | 'warning' | 'info'): void`
|
|
235
|
+
|
|
236
|
+
Manually capture an error with optional custom context and severity.
|
|
237
|
+
|
|
238
|
+
**Example**:
|
|
239
|
+
```typescript
|
|
240
|
+
capture(new Error('Payment failed'), {
|
|
241
|
+
user_id: '123',
|
|
242
|
+
amount: 99.99
|
|
243
|
+
}, 'error');
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### `wrap<T>(fn: T): T`
|
|
247
|
+
|
|
248
|
+
Wrap a function to automatically capture errors. Works with both sync and async functions.
|
|
249
|
+
|
|
250
|
+
**Example**:
|
|
251
|
+
```typescript
|
|
252
|
+
const safeFunction = wrap(() => {
|
|
253
|
+
// Your code that might throw
|
|
254
|
+
});
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### `expressErrorHandler(): ExpressErrorHandler`
|
|
258
|
+
|
|
259
|
+
Express middleware for capturing 5xx errors. Place before your final error handler.
|
|
260
|
+
|
|
261
|
+
**Example**:
|
|
262
|
+
```typescript
|
|
263
|
+
app.use(expressErrorHandler());
|
|
264
|
+
app.use((err, req, res, next) => {
|
|
265
|
+
res.status(500).json({ error: err.message });
|
|
266
|
+
});
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Changelog
|
|
270
|
+
|
|
271
|
+
### v1.2.0 (2026-02-09)
|
|
272
|
+
|
|
273
|
+
**Production Hardening Release**
|
|
274
|
+
|
|
275
|
+
- ā
Environment normalization with NODE_ENV fallback
|
|
276
|
+
- ā
Removed `apiUrl` from public API (use `ROOTLY_API_URL` env var)
|
|
277
|
+
- ā
Recursive capture protection using Symbol flag
|
|
278
|
+
- ā
Stable fingerprinting algorithm
|
|
279
|
+
- ā
Severity support (error/warning/info)
|
|
280
|
+
- ā
Hard memory cap (500 max fingerprints)
|
|
281
|
+
- ā
Optimized rate limiter (O(n) performance)
|
|
282
|
+
- ā
Debug mode with stderr logging
|
|
283
|
+
- ā
Real graceful shutdown tracking
|
|
284
|
+
- ā
Fixed listener guard bug (SDK now always registers)
|
|
285
|
+
- ā
Fixed transport decrement bug
|
|
286
|
+
- ā
Nullish coalescing for severity
|
|
287
|
+
|
|
288
|
+
### v1.0.0 (2026-02-08)
|
|
289
|
+
|
|
290
|
+
- Initial release
|
|
291
|
+
- Basic error capture and reporting
|
|
292
|
+
- Express middleware
|
|
293
|
+
- Function wrapping
|
|
294
|
+
|
|
295
|
+
## License
|
|
296
|
+
|
|
297
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context building and commit SHA detection
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Detect commit SHA from environment variables
|
|
6
|
+
* Priority: VERCEL_GIT_COMMIT_SHA > RENDER_GIT_COMMIT > GITHUB_SHA > COMMIT_SHA
|
|
7
|
+
*/
|
|
8
|
+
export declare function getCommitSha(): string;
|
|
9
|
+
/**
|
|
10
|
+
* Build context object for error payload
|
|
11
|
+
*/
|
|
12
|
+
export declare function buildContext(environment: string, extraContext?: any): any;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Context building and commit SHA detection
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getCommitSha = getCommitSha;
|
|
7
|
+
exports.buildContext = buildContext;
|
|
8
|
+
/**
|
|
9
|
+
* Detect commit SHA from environment variables
|
|
10
|
+
* Priority: VERCEL_GIT_COMMIT_SHA > RENDER_GIT_COMMIT > GITHUB_SHA > COMMIT_SHA
|
|
11
|
+
*/
|
|
12
|
+
function getCommitSha() {
|
|
13
|
+
return (process.env.VERCEL_GIT_COMMIT_SHA ||
|
|
14
|
+
process.env.RENDER_GIT_COMMIT ||
|
|
15
|
+
process.env.GITHUB_SHA ||
|
|
16
|
+
process.env.COMMIT_SHA ||
|
|
17
|
+
'');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build context object for error payload
|
|
21
|
+
*/
|
|
22
|
+
function buildContext(environment, extraContext) {
|
|
23
|
+
return {
|
|
24
|
+
commit_sha: getCommitSha(),
|
|
25
|
+
environment,
|
|
26
|
+
occurred_at: new Date().toISOString(),
|
|
27
|
+
...extraContext,
|
|
28
|
+
};
|
|
29
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rootly/runtime - Production-grade runtime error tracking for Node.js
|
|
3
|
+
*/
|
|
4
|
+
interface InitOptions {
|
|
5
|
+
apiKey: string;
|
|
6
|
+
environment?: string;
|
|
7
|
+
debug?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function init(options: InitOptions): void;
|
|
10
|
+
export declare function capture(error: Error, extraContext?: any, severity?: 'error' | 'warning' | 'info'): void;
|
|
11
|
+
export declare function wrap<T extends (...args: any[]) => any>(fn: T): T;
|
|
12
|
+
export declare function expressErrorHandler(): (err: any, req: any, res: any, next: any) => void;
|
|
13
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @rootly/runtime - Production-grade runtime error tracking for Node.js
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.init = init;
|
|
7
|
+
exports.capture = capture;
|
|
8
|
+
exports.wrap = wrap;
|
|
9
|
+
exports.expressErrorHandler = expressErrorHandler;
|
|
10
|
+
const runtime_1 = require("./runtime");
|
|
11
|
+
const transport_1 = require("./transport");
|
|
12
|
+
const DEFAULT_API_URL = 'https://3.111.33.111.nip.io';
|
|
13
|
+
let isInitialized = false;
|
|
14
|
+
let apiKey;
|
|
15
|
+
let environment;
|
|
16
|
+
let apiUrl;
|
|
17
|
+
function normalizeEnvironment(env) {
|
|
18
|
+
if (!env)
|
|
19
|
+
return 'production';
|
|
20
|
+
const normalized = env.toLowerCase().trim();
|
|
21
|
+
return (normalized === 'production' || normalized === 'prod') ? 'production' : 'preview';
|
|
22
|
+
}
|
|
23
|
+
function init(options) {
|
|
24
|
+
try {
|
|
25
|
+
if (!options.apiKey || typeof options.apiKey !== 'string')
|
|
26
|
+
return;
|
|
27
|
+
if (isInitialized)
|
|
28
|
+
return;
|
|
29
|
+
apiKey = options.apiKey;
|
|
30
|
+
environment = normalizeEnvironment(options.environment || process.env.NODE_ENV);
|
|
31
|
+
apiUrl = process.env.ROOTLY_API_URL?.trim() || DEFAULT_API_URL;
|
|
32
|
+
isInitialized = true;
|
|
33
|
+
if (options.debug)
|
|
34
|
+
(0, runtime_1.setDebugMode)(true);
|
|
35
|
+
process.prependListener('uncaughtException', handleError);
|
|
36
|
+
process.prependListener('unhandledRejection', handleRejection);
|
|
37
|
+
process.on('beforeExit', () => {
|
|
38
|
+
if ((0, transport_1.getPendingRequests)() > 0)
|
|
39
|
+
setTimeout(() => { }, 200);
|
|
40
|
+
});
|
|
41
|
+
process.on('SIGTERM', () => {
|
|
42
|
+
if ((0, transport_1.getPendingRequests)() > 0)
|
|
43
|
+
setTimeout(() => { }, 200);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
// Fail silently
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function capture(error, extraContext, severity) {
|
|
51
|
+
try {
|
|
52
|
+
if (!apiKey)
|
|
53
|
+
return;
|
|
54
|
+
(0, runtime_1.captureError)(error, apiKey, environment, apiUrl, extraContext, severity);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
// Fail silently
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function wrap(fn) {
|
|
61
|
+
return ((...args) => {
|
|
62
|
+
try {
|
|
63
|
+
const result = fn(...args);
|
|
64
|
+
if (result && typeof result.then === 'function') {
|
|
65
|
+
return result.catch((error) => {
|
|
66
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
67
|
+
capture(err);
|
|
68
|
+
throw error;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
75
|
+
capture(err);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function expressErrorHandler() {
|
|
81
|
+
return (err, req, res, next) => {
|
|
82
|
+
try {
|
|
83
|
+
if (!apiKey)
|
|
84
|
+
return next(err);
|
|
85
|
+
if (res.statusCode >= 500) {
|
|
86
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
87
|
+
const extraContext = {
|
|
88
|
+
source: 'express',
|
|
89
|
+
method: req.method,
|
|
90
|
+
path: req.path || req.url,
|
|
91
|
+
status_code: res.statusCode,
|
|
92
|
+
};
|
|
93
|
+
(0, runtime_1.captureError)(error, apiKey, environment, apiUrl, extraContext);
|
|
94
|
+
}
|
|
95
|
+
next(err);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
next(err);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function handleError(error) {
|
|
103
|
+
try {
|
|
104
|
+
(0, runtime_1.captureError)(error, apiKey, environment, apiUrl);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
// Fail silently
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function handleRejection(reason) {
|
|
111
|
+
try {
|
|
112
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
113
|
+
(0, runtime_1.captureError)(error, apiKey, environment, apiUrl);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
// Fail silently
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core error capture logic with deduplication and rate limiting
|
|
3
|
+
*/
|
|
4
|
+
export declare function setDebugMode(enabled: boolean): void;
|
|
5
|
+
export declare function captureError(error: Error, apiKey: string, environment: string, apiUrl: string, extraContext?: any, severity?: 'error' | 'warning' | 'info'): void;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Core error capture logic with deduplication and rate limiting
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.setDebugMode = setDebugMode;
|
|
7
|
+
exports.captureError = captureError;
|
|
8
|
+
const context_1 = require("./context");
|
|
9
|
+
const transport_1 = require("./transport");
|
|
10
|
+
const ROOTLY_CAPTURED = Symbol('rootly_captured');
|
|
11
|
+
const errorFingerprints = new Map();
|
|
12
|
+
const DEDUP_WINDOW_MS = 10000;
|
|
13
|
+
const MAX_FINGERPRINTS = 500;
|
|
14
|
+
const errorTimestamps = [];
|
|
15
|
+
const RATE_LIMIT_MAX = 20;
|
|
16
|
+
const RATE_LIMIT_WINDOW_MS = 60000;
|
|
17
|
+
let debugMode = false;
|
|
18
|
+
function setDebugMode(enabled) {
|
|
19
|
+
debugMode = enabled;
|
|
20
|
+
}
|
|
21
|
+
function debugLog(message) {
|
|
22
|
+
if (debugMode)
|
|
23
|
+
process.stderr.write(`[Rootly SDK] ${message}\n`);
|
|
24
|
+
}
|
|
25
|
+
function getStableStackFrame(stack) {
|
|
26
|
+
try {
|
|
27
|
+
const lines = stack.split('\n');
|
|
28
|
+
// Skip first line (error message), find first non-empty stack frame
|
|
29
|
+
for (let i = 1; i < lines.length; i++) {
|
|
30
|
+
const trimmed = lines[i].trim();
|
|
31
|
+
if (trimmed)
|
|
32
|
+
return trimmed.replace(/\s+/g, ' ');
|
|
33
|
+
}
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function computeFingerprint(error) {
|
|
41
|
+
try {
|
|
42
|
+
const message = error.message || 'Unknown';
|
|
43
|
+
const stableFrame = getStableStackFrame(error.stack || '');
|
|
44
|
+
return `${message}:${stableFrame}`;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
return 'unknown';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function shouldDeduplicate(fingerprint) {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const lastSent = errorFingerprints.get(fingerprint);
|
|
53
|
+
if (lastSent && (now - lastSent) < DEDUP_WINDOW_MS) {
|
|
54
|
+
debugLog(`Deduplicated: ${fingerprint.substring(0, 50)}...`);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
errorFingerprints.set(fingerprint, now);
|
|
58
|
+
// Hard memory cap: delete oldest 50% if exceeded
|
|
59
|
+
if (errorFingerprints.size > MAX_FINGERPRINTS) {
|
|
60
|
+
const entries = Array.from(errorFingerprints.entries());
|
|
61
|
+
entries.sort((a, b) => a[1] - b[1]); // Sort by timestamp
|
|
62
|
+
const toDelete = Math.floor(MAX_FINGERPRINTS / 2);
|
|
63
|
+
for (let i = 0; i < toDelete; i++) {
|
|
64
|
+
errorFingerprints.delete(entries[i][0]);
|
|
65
|
+
}
|
|
66
|
+
debugLog(`Memory cap: deleted ${toDelete} old fingerprints`);
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
function isRateLimited() {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
let validIndex = 0;
|
|
73
|
+
while (validIndex < errorTimestamps.length && now - errorTimestamps[validIndex] > RATE_LIMIT_WINDOW_MS) {
|
|
74
|
+
validIndex++;
|
|
75
|
+
}
|
|
76
|
+
if (validIndex > 0)
|
|
77
|
+
errorTimestamps.splice(0, validIndex);
|
|
78
|
+
if (errorTimestamps.length >= RATE_LIMIT_MAX) {
|
|
79
|
+
debugLog('Rate limited: 20/60s exceeded');
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
errorTimestamps.push(now);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
function captureError(error, apiKey, environment, apiUrl, extraContext, severity) {
|
|
86
|
+
try {
|
|
87
|
+
// Recursive capture protection
|
|
88
|
+
if (error[ROOTLY_CAPTURED]) {
|
|
89
|
+
debugLog('Recursive capture prevented');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
error[ROOTLY_CAPTURED] = true;
|
|
93
|
+
const fingerprint = computeFingerprint(error);
|
|
94
|
+
if (shouldDeduplicate(fingerprint))
|
|
95
|
+
return;
|
|
96
|
+
if (isRateLimited())
|
|
97
|
+
return;
|
|
98
|
+
const payload = {
|
|
99
|
+
error: {
|
|
100
|
+
message: error.message || 'Unknown error',
|
|
101
|
+
type: error.name || 'Error',
|
|
102
|
+
stack: error.stack || 'No stack trace available',
|
|
103
|
+
severity: severity ?? 'error',
|
|
104
|
+
},
|
|
105
|
+
context: (0, context_1.buildContext)(environment, extraContext),
|
|
106
|
+
};
|
|
107
|
+
debugLog(`Sending: ${error.message} (${severity ?? 'error'})`);
|
|
108
|
+
(0, transport_1.sendPayload)(payload, apiKey, apiUrl);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
// Fail silently
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.getPendingRequests = getPendingRequests;
|
|
37
|
+
exports.sendPayload = sendPayload;
|
|
38
|
+
const https = __importStar(require("https"));
|
|
39
|
+
const http = __importStar(require("http"));
|
|
40
|
+
let pendingRequests = 0;
|
|
41
|
+
function getPendingRequests() {
|
|
42
|
+
return pendingRequests;
|
|
43
|
+
}
|
|
44
|
+
function sendPayload(payload, apiKey, apiUrl) {
|
|
45
|
+
try {
|
|
46
|
+
const data = JSON.stringify(payload);
|
|
47
|
+
// Parse URL
|
|
48
|
+
const url = new URL(apiUrl + '/api/ingest');
|
|
49
|
+
const isHttps = url.protocol === 'https:';
|
|
50
|
+
const options = {
|
|
51
|
+
hostname: url.hostname,
|
|
52
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
53
|
+
path: url.pathname,
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: {
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
'Content-Length': Buffer.byteLength(data),
|
|
58
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
59
|
+
},
|
|
60
|
+
timeout: 5000,
|
|
61
|
+
};
|
|
62
|
+
pendingRequests++;
|
|
63
|
+
const client = isHttps ? https : http;
|
|
64
|
+
const req = client.request(options, (res) => {
|
|
65
|
+
res.on('data', () => { });
|
|
66
|
+
res.on('end', () => {
|
|
67
|
+
pendingRequests--;
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
req.on('error', () => {
|
|
71
|
+
pendingRequests--;
|
|
72
|
+
});
|
|
73
|
+
req.on('timeout', () => {
|
|
74
|
+
req.destroy();
|
|
75
|
+
pendingRequests--;
|
|
76
|
+
});
|
|
77
|
+
req.write(data);
|
|
78
|
+
req.end();
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
// Do not decrement here - only decrement in handlers after increment
|
|
82
|
+
}
|
|
83
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rootly-runtime",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Minimal runtime error tracking for Node.js production apps",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"prepublishOnly": "npm run build"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"error-tracking",
|
|
13
|
+
"production",
|
|
14
|
+
"monitoring",
|
|
15
|
+
"rootly"
|
|
16
|
+
],
|
|
17
|
+
"author": "Rootly",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^20.0.0",
|
|
21
|
+
"typescript": "^5.3.0"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18.0.0"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/Lancerhawk/Project-Rootly"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
Binary file
|
|
Binary file
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context building and commit SHA detection
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detect commit SHA from environment variables
|
|
7
|
+
* Priority: VERCEL_GIT_COMMIT_SHA > RENDER_GIT_COMMIT > GITHUB_SHA > COMMIT_SHA
|
|
8
|
+
*/
|
|
9
|
+
export function getCommitSha(): string {
|
|
10
|
+
return (
|
|
11
|
+
process.env.VERCEL_GIT_COMMIT_SHA ||
|
|
12
|
+
process.env.RENDER_GIT_COMMIT ||
|
|
13
|
+
process.env.GITHUB_SHA ||
|
|
14
|
+
process.env.COMMIT_SHA ||
|
|
15
|
+
''
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build context object for error payload
|
|
21
|
+
*/
|
|
22
|
+
export function buildContext(environment: string, extraContext?: any): any {
|
|
23
|
+
return {
|
|
24
|
+
commit_sha: getCommitSha(),
|
|
25
|
+
environment,
|
|
26
|
+
occurred_at: new Date().toISOString(),
|
|
27
|
+
...extraContext,
|
|
28
|
+
};
|
|
29
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rootly/runtime - Production-grade runtime error tracking for Node.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { captureError, setDebugMode } from './runtime';
|
|
6
|
+
import { getPendingRequests } from './transport';
|
|
7
|
+
|
|
8
|
+
interface InitOptions {
|
|
9
|
+
apiKey: string;
|
|
10
|
+
environment?: string;
|
|
11
|
+
debug?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_API_URL = 'https://3.111.33.111.nip.io';
|
|
15
|
+
|
|
16
|
+
let isInitialized = false;
|
|
17
|
+
let apiKey: string;
|
|
18
|
+
let environment: 'production' | 'preview';
|
|
19
|
+
let apiUrl: string;
|
|
20
|
+
|
|
21
|
+
function normalizeEnvironment(env?: string): 'production' | 'preview' {
|
|
22
|
+
if (!env) return 'production';
|
|
23
|
+
const normalized = env.toLowerCase().trim();
|
|
24
|
+
return (normalized === 'production' || normalized === 'prod') ? 'production' : 'preview';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function init(options: InitOptions): void {
|
|
28
|
+
try {
|
|
29
|
+
if (!options.apiKey || typeof options.apiKey !== 'string') return;
|
|
30
|
+
if (isInitialized) return;
|
|
31
|
+
|
|
32
|
+
apiKey = options.apiKey;
|
|
33
|
+
environment = normalizeEnvironment(options.environment || process.env.NODE_ENV);
|
|
34
|
+
apiUrl = process.env.ROOTLY_API_URL?.trim() || DEFAULT_API_URL;
|
|
35
|
+
isInitialized = true;
|
|
36
|
+
|
|
37
|
+
if (options.debug) setDebugMode(true);
|
|
38
|
+
|
|
39
|
+
process.prependListener('uncaughtException', handleError);
|
|
40
|
+
process.prependListener('unhandledRejection', handleRejection);
|
|
41
|
+
|
|
42
|
+
process.on('beforeExit', () => {
|
|
43
|
+
if (getPendingRequests() > 0) setTimeout(() => { }, 200);
|
|
44
|
+
});
|
|
45
|
+
process.on('SIGTERM', () => {
|
|
46
|
+
if (getPendingRequests() > 0) setTimeout(() => { }, 200);
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
// Fail silently
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function capture(error: Error, extraContext?: any, severity?: 'error' | 'warning' | 'info'): void {
|
|
54
|
+
try {
|
|
55
|
+
if (!apiKey) return;
|
|
56
|
+
captureError(error, apiKey, environment, apiUrl, extraContext, severity);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// Fail silently
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function wrap<T extends (...args: any[]) => any>(fn: T): T {
|
|
63
|
+
return ((...args: any[]) => {
|
|
64
|
+
try {
|
|
65
|
+
const result = fn(...args);
|
|
66
|
+
if (result && typeof result.then === 'function') {
|
|
67
|
+
return result.catch((error: any) => {
|
|
68
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
69
|
+
capture(err);
|
|
70
|
+
throw error;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
76
|
+
capture(err);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}) as T;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function expressErrorHandler() {
|
|
83
|
+
return (err: any, req: any, res: any, next: any): void => {
|
|
84
|
+
try {
|
|
85
|
+
if (!apiKey) return next(err);
|
|
86
|
+
if (res.statusCode >= 500) {
|
|
87
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
88
|
+
const extraContext = {
|
|
89
|
+
source: 'express',
|
|
90
|
+
method: req.method,
|
|
91
|
+
path: req.path || req.url,
|
|
92
|
+
status_code: res.statusCode,
|
|
93
|
+
};
|
|
94
|
+
captureError(error, apiKey, environment, apiUrl, extraContext);
|
|
95
|
+
}
|
|
96
|
+
next(err);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
next(err);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function handleError(error: Error): void {
|
|
104
|
+
try {
|
|
105
|
+
captureError(error, apiKey, environment, apiUrl);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// Fail silently
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function handleRejection(reason: any): void {
|
|
112
|
+
try {
|
|
113
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
114
|
+
captureError(error, apiKey, environment, apiUrl);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
// Fail silently
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core error capture logic with deduplication and rate limiting
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { buildContext } from './context';
|
|
6
|
+
import { sendPayload } from './transport';
|
|
7
|
+
|
|
8
|
+
const ROOTLY_CAPTURED = Symbol('rootly_captured');
|
|
9
|
+
const errorFingerprints = new Map<string, number>();
|
|
10
|
+
const DEDUP_WINDOW_MS = 10000;
|
|
11
|
+
const MAX_FINGERPRINTS = 500;
|
|
12
|
+
const errorTimestamps: number[] = [];
|
|
13
|
+
const RATE_LIMIT_MAX = 20;
|
|
14
|
+
const RATE_LIMIT_WINDOW_MS = 60000;
|
|
15
|
+
|
|
16
|
+
let debugMode = false;
|
|
17
|
+
|
|
18
|
+
export function setDebugMode(enabled: boolean): void {
|
|
19
|
+
debugMode = enabled;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function debugLog(message: string): void {
|
|
23
|
+
if (debugMode) process.stderr.write(`[Rootly SDK] ${message}\n`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getStableStackFrame(stack: string): string {
|
|
27
|
+
try {
|
|
28
|
+
const lines = stack.split('\n');
|
|
29
|
+
// Skip first line (error message), find first non-empty stack frame
|
|
30
|
+
for (let i = 1; i < lines.length; i++) {
|
|
31
|
+
const trimmed = lines[i].trim();
|
|
32
|
+
if (trimmed) return trimmed.replace(/\s+/g, ' ');
|
|
33
|
+
}
|
|
34
|
+
return '';
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function computeFingerprint(error: Error): string {
|
|
41
|
+
try {
|
|
42
|
+
const message = error.message || 'Unknown';
|
|
43
|
+
const stableFrame = getStableStackFrame(error.stack || '');
|
|
44
|
+
return `${message}:${stableFrame}`;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return 'unknown';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function shouldDeduplicate(fingerprint: string): boolean {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const lastSent = errorFingerprints.get(fingerprint);
|
|
53
|
+
|
|
54
|
+
if (lastSent && (now - lastSent) < DEDUP_WINDOW_MS) {
|
|
55
|
+
debugLog(`Deduplicated: ${fingerprint.substring(0, 50)}...`);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
errorFingerprints.set(fingerprint, now);
|
|
60
|
+
|
|
61
|
+
// Hard memory cap: delete oldest 50% if exceeded
|
|
62
|
+
if (errorFingerprints.size > MAX_FINGERPRINTS) {
|
|
63
|
+
const entries = Array.from(errorFingerprints.entries());
|
|
64
|
+
entries.sort((a, b) => a[1] - b[1]); // Sort by timestamp
|
|
65
|
+
const toDelete = Math.floor(MAX_FINGERPRINTS / 2);
|
|
66
|
+
for (let i = 0; i < toDelete; i++) {
|
|
67
|
+
errorFingerprints.delete(entries[i][0]);
|
|
68
|
+
}
|
|
69
|
+
debugLog(`Memory cap: deleted ${toDelete} old fingerprints`);
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isRateLimited(): boolean {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
|
|
77
|
+
let validIndex = 0;
|
|
78
|
+
while (validIndex < errorTimestamps.length && now - errorTimestamps[validIndex] > RATE_LIMIT_WINDOW_MS) {
|
|
79
|
+
validIndex++;
|
|
80
|
+
}
|
|
81
|
+
if (validIndex > 0) errorTimestamps.splice(0, validIndex);
|
|
82
|
+
|
|
83
|
+
if (errorTimestamps.length >= RATE_LIMIT_MAX) {
|
|
84
|
+
debugLog('Rate limited: 20/60s exceeded');
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
errorTimestamps.push(now);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function captureError(
|
|
92
|
+
error: Error,
|
|
93
|
+
apiKey: string,
|
|
94
|
+
environment: string,
|
|
95
|
+
apiUrl: string,
|
|
96
|
+
extraContext?: any,
|
|
97
|
+
severity?: 'error' | 'warning' | 'info'
|
|
98
|
+
): void {
|
|
99
|
+
try {
|
|
100
|
+
// Recursive capture protection
|
|
101
|
+
if ((error as any)[ROOTLY_CAPTURED]) {
|
|
102
|
+
debugLog('Recursive capture prevented');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
(error as any)[ROOTLY_CAPTURED] = true;
|
|
106
|
+
|
|
107
|
+
const fingerprint = computeFingerprint(error);
|
|
108
|
+
if (shouldDeduplicate(fingerprint)) return;
|
|
109
|
+
if (isRateLimited()) return;
|
|
110
|
+
|
|
111
|
+
const payload = {
|
|
112
|
+
error: {
|
|
113
|
+
message: error.message || 'Unknown error',
|
|
114
|
+
type: error.name || 'Error',
|
|
115
|
+
stack: error.stack || 'No stack trace available',
|
|
116
|
+
severity: severity ?? 'error',
|
|
117
|
+
},
|
|
118
|
+
context: buildContext(environment, extraContext),
|
|
119
|
+
};
|
|
120
|
+
debugLog(`Sending: ${error.message} (${severity ?? 'error'})`);
|
|
121
|
+
sendPayload(payload, apiKey, apiUrl);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Fail silently
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as https from 'https';
|
|
2
|
+
import * as http from 'http';
|
|
3
|
+
|
|
4
|
+
let pendingRequests = 0;
|
|
5
|
+
|
|
6
|
+
export function getPendingRequests(): number {
|
|
7
|
+
return pendingRequests;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function sendPayload(payload: any, apiKey: string, apiUrl: string): void {
|
|
11
|
+
try {
|
|
12
|
+
const data = JSON.stringify(payload);
|
|
13
|
+
// Parse URL
|
|
14
|
+
const url = new URL(apiUrl + '/api/ingest');
|
|
15
|
+
const isHttps = url.protocol === 'https:';
|
|
16
|
+
const options = {
|
|
17
|
+
hostname: url.hostname,
|
|
18
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
19
|
+
path: url.pathname,
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: {
|
|
22
|
+
'Content-Type': 'application/json',
|
|
23
|
+
'Content-Length': Buffer.byteLength(data),
|
|
24
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
25
|
+
},
|
|
26
|
+
timeout: 5000,
|
|
27
|
+
};
|
|
28
|
+
pendingRequests++;
|
|
29
|
+
const client = isHttps ? https : http;
|
|
30
|
+
const req = client.request(options, (res) => {
|
|
31
|
+
res.on('data', () => { });
|
|
32
|
+
res.on('end', () => {
|
|
33
|
+
pendingRequests--;
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
req.on('error', () => {
|
|
37
|
+
pendingRequests--;
|
|
38
|
+
});
|
|
39
|
+
req.on('timeout', () => {
|
|
40
|
+
req.destroy();
|
|
41
|
+
pendingRequests--;
|
|
42
|
+
});
|
|
43
|
+
req.write(data);
|
|
44
|
+
req.end();
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// Do not decrement here - only decrement in handlers after increment
|
|
47
|
+
}
|
|
48
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": [
|
|
6
|
+
"ES2020"
|
|
7
|
+
],
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"outDir": "./dist",
|
|
10
|
+
"rootDir": "./src",
|
|
11
|
+
"strict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"moduleResolution": "node"
|
|
16
|
+
},
|
|
17
|
+
"include": [
|
|
18
|
+
"src/**/*"
|
|
19
|
+
],
|
|
20
|
+
"exclude": [
|
|
21
|
+
"node_modules",
|
|
22
|
+
"dist"
|
|
23
|
+
]
|
|
24
|
+
}
|