Files
dashcaddy/dashcaddy-api/src/utilities/error-tracker.js
T
Krystie 503de258b8 [grade=pending] QA sprint: commit 103 at-risk files from multi-agent sprint work
Committed by Hermes autonomous QA sprint 2026-08-13.
These files were modified during the Aug 12 sprint but never committed.
2026-08-12 17:34:10 -07:00

161 lines
4.3 KiB
JavaScript

/**
* 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();