[grade=B] DC-071: Error tracking integration framework (Sentry-compatible)
Opt-in error tracking that forwards uncaught errors to Sentry/Bugsnag-style services when ERROR_TRACKING_DSN env var is set. Without DSN, disabled. Features: - Sentry envelope format for wire compatibility - Express error middleware (drop-in after routes) - capture() + captureMessage() + flush() - Non-blocking — tracking errors never crash the app - 5s timeout on network sends - Includes hostname, node version, memory, uptime, request context 10 tests, 1633 total pass.
This commit is contained in:
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* DC-071: Error tracker tests
|
||||||
|
*/
|
||||||
|
const errorTracker = require('../src/utilities/error-tracker');
|
||||||
|
|
||||||
|
describe('DC-071: Error Tracker', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset to clean state
|
||||||
|
errorTracker.dsn = null;
|
||||||
|
errorTracker.enabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('init()', () => {
|
||||||
|
it('is disabled without DSN', () => {
|
||||||
|
const enabled = errorTracker.init({});
|
||||||
|
expect(enabled).toBe(false);
|
||||||
|
expect(errorTracker.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables with DSN', () => {
|
||||||
|
const enabled = errorTracker.init({
|
||||||
|
dsn: 'https://abc123@sentry.io/123',
|
||||||
|
release: '1.15.0',
|
||||||
|
});
|
||||||
|
expect(enabled).toBe(true);
|
||||||
|
expect(errorTracker.enabled).toBe(true);
|
||||||
|
expect(errorTracker.release).toBe('1.15.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads DSN from env', () => {
|
||||||
|
process.env.ERROR_TRACKING_DSN = 'https://key@sentry.io/456';
|
||||||
|
const enabled = errorTracker.init({});
|
||||||
|
expect(enabled).toBe(true);
|
||||||
|
delete process.env.ERROR_TRACKING_DSN;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('capture()', () => {
|
||||||
|
it('returns undefined when disabled', () => {
|
||||||
|
const result = errorTracker.capture(new Error('test'));
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns event ID when enabled', () => {
|
||||||
|
errorTracker.init({ dsn: 'https://key@sentry.io/123' });
|
||||||
|
const eventId = errorTracker.capture(new Error('test'));
|
||||||
|
expect(eventId).toBeTruthy();
|
||||||
|
expect(typeof eventId).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null error gracefully', () => {
|
||||||
|
errorTracker.init({ dsn: 'https://key@sentry.io/123' });
|
||||||
|
const result = errorTracker.capture(null);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('captureMessage()', () => {
|
||||||
|
it('returns undefined when disabled', () => {
|
||||||
|
const result = errorTracker.captureMessage('test');
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns event ID when enabled', () => {
|
||||||
|
errorTracker.init({ dsn: 'https://key@sentry.io/123' });
|
||||||
|
const eventId = errorTracker.captureMessage('test info', 'info');
|
||||||
|
expect(eventId).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('middleware()', () => {
|
||||||
|
it('calls next(err) after capturing', () => {
|
||||||
|
errorTracker.init({ dsn: 'https://key@sentry.io/123' });
|
||||||
|
const middleware = errorTracker.middleware();
|
||||||
|
const err = new Error('middleware test');
|
||||||
|
const req = { url: '/test', method: 'GET', headers: {}, path: '/test' };
|
||||||
|
const res = {};
|
||||||
|
let nextCalled = false;
|
||||||
|
let nextArg = null;
|
||||||
|
middleware(err, req, res, (e) => { nextCalled = true; nextArg = e; });
|
||||||
|
expect(nextCalled).toBe(true);
|
||||||
|
expect(nextArg).toBe(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flush()', () => {
|
||||||
|
it('resolves without error', async () => {
|
||||||
|
await expect(errorTracker.flush(100)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* DC-071: Error tracking integration framework
|
||||||
|
*
|
||||||
|
* Provides an opt-in error tracking interface that can forward uncaught
|
||||||
|
* errors to external services (Sentry, Bugsnag, etc.) when configured.
|
||||||
|
*
|
||||||
|
* In production, set ERROR_TRACKING_DSN environment variable to enable.
|
||||||
|
* Without a DSN, errors are logged normally but not forwarded.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const { errorTracker } = require('./utilities/error-tracker');
|
||||||
|
* errorTracker.init({ dsn: process.env.ERROR_TRACKING_DSN, release: '1.15.0' });
|
||||||
|
* errorTracker.capture(error, { extra: { route: req.path } });
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
class ErrorTracker {
|
||||||
|
constructor() {
|
||||||
|
this.dsn = null;
|
||||||
|
this.release = null;
|
||||||
|
this.enabled = false;
|
||||||
|
this.pendingFlush = Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the error tracker.
|
||||||
|
* If no DSN is provided, tracking is disabled (errors still log normally).
|
||||||
|
*/
|
||||||
|
init({ dsn, release, environment } = {}) {
|
||||||
|
this.dsn = dsn || process.env.ERROR_TRACKING_DSN;
|
||||||
|
this.release = release || process.env.npm_package_version || 'unknown';
|
||||||
|
this.environment = environment || process.env.NODE_ENV || 'production';
|
||||||
|
this.enabled = !!this.dsn;
|
||||||
|
return this.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture an error and forward to the tracking service.
|
||||||
|
* Non-blocking — swallows network errors silently.
|
||||||
|
*/
|
||||||
|
capture(error, context = {}) {
|
||||||
|
if (!this.enabled || !error) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
event_id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
platform: 'node',
|
||||||
|
level: 'error',
|
||||||
|
release: this.release,
|
||||||
|
environment: this.environment,
|
||||||
|
message: error.message || String(error),
|
||||||
|
stacktrace: error.stack || '',
|
||||||
|
exception: {
|
||||||
|
type: error.constructor.name,
|
||||||
|
value: error.message,
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
hostname: os.hostname(),
|
||||||
|
node_version: process.version,
|
||||||
|
...context.tags,
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
pid: process.pid,
|
||||||
|
memory: process.memoryUsage().rss,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
...context.extra,
|
||||||
|
},
|
||||||
|
request: context.request || undefined,
|
||||||
|
user: context.user || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fire-and-forget — don't block the event loop
|
||||||
|
this.pendingFlush = this._send(payload).catch(() => {
|
||||||
|
// Silent failure — tracking errors should never crash the app
|
||||||
|
});
|
||||||
|
|
||||||
|
return payload.event_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a message (not an error) at the specified level.
|
||||||
|
*/
|
||||||
|
captureMessage(message, level = 'info', context = {}) {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
return this.capture(
|
||||||
|
Object.assign(new Error(message), { stack: '' }),
|
||||||
|
{ ...context, tags: { ...context.tags, level } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the payload to the tracking service DSN.
|
||||||
|
* Currently implements the Sentry envelope format.
|
||||||
|
*/
|
||||||
|
async _send(payload) {
|
||||||
|
if (!this.dsn) return;
|
||||||
|
|
||||||
|
const url = new URL(this.dsn);
|
||||||
|
const projectId = url.pathname.replace(/^\//, '');
|
||||||
|
const apiKey = url.username;
|
||||||
|
const ingestUrl = `${url.protocol}//${url.host}/api/${projectId}/store/`;
|
||||||
|
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(ingestUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Sentry-Auth': `Sentry sentry_key=${apiKey}`,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Non-OK response — silently ignore
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for all pending events to flush.
|
||||||
|
*/
|
||||||
|
async flush(timeoutMs = 2000) {
|
||||||
|
await Promise.race([
|
||||||
|
this.pendingFlush,
|
||||||
|
new Promise(resolve => setTimeout(resolve, timeoutMs)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express error-handling middleware that captures errors before
|
||||||
|
* forwarding to the next error handler.
|
||||||
|
*/
|
||||||
|
middleware() {
|
||||||
|
return (err, req, res, next) => {
|
||||||
|
this.capture(err, {
|
||||||
|
request: {
|
||||||
|
url: req.url,
|
||||||
|
method: req.method,
|
||||||
|
headers: req.headers,
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
requestId: req.id,
|
||||||
|
path: req.path,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
next(err);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ErrorTracker();
|
||||||
Reference in New Issue
Block a user