- Rate limiting and request throttling
- Comprehensive input validation and data sanitization
- Proper CORS origin validation
- Request size limits
NOT PRODUCTION-READY. Use only for development and testing.
A Cloudflare Worker that serves as a reference implementation for receiving error reports from Foundry VTT modules and forwarding them to Sentry. This implementation demonstrates the standard error reporting API that module authors can implement with any backend.
- Architecture Overview
- Standard API Specification
- Deployment Guide
- Configuration
- Testing
- Alternative Implementations
- Security Considerations
Foundry VTT Module → Sentry Relay Worker → Sentry Project
↓
Standard Response Format
Key Features:
- Author-based routing: Different module authors can have separate Sentry projects
- Standard response format: Consistent JSON responses with event IDs
- CORS support: Works from any Foundry VTT domain
- Error transformation: Converts Foundry error format to Sentry events
- Health monitoring: Built-in health check endpoint
This implementation follows a reference API specification for Foundry VTT error reporting.
Receives error reports from Foundry VTT modules.
Request Format:
{
"error": {
"message": "Error description",
"stack": "Error stack trace",
"type": "Error",
"source": "module-name"
},
"attribution": {
"moduleId": "module-name",
"confidence": "high|medium|low",
"method": "automatic|manual",
"source": "stack-trace|user-report"
},
"foundry": {
"version": "12.331",
"system": {
"id": "dnd5e",
"version": "3.0.0"
},
"modules": [
{"id": "module-id", "version": "1.0.0"}
],
"scene": "Scene Name"
},
"meta": {
"timestamp": "2025-06-03T03:47:00.000Z",
"privacyLevel": "minimal|standard|detailed",
"reporterVersion": "1.0.0"
},
"client": {
"sessionId": "anonymous-session-id",
"browser": "Chrome 91.0"
}
}
Response Format:
{
"success": true,
"eventId": "fc6d8c0c43fc4630ad850ee518f1b9d0",
"message": "Error report received and processed successfully",
"timestamp": "2025-06-03T03:47:10.010Z",
"endpoint": "sentry-relay"
}
Tests connectivity to the error reporting backend.
Request Format:
{
"test": true,
"timestamp": "2025-06-03T03:47:00.000Z",
"source": "endpoint-test"
}
Response Format:
{
"success": true,
"eventId": "test-event-id",
"message": "Connectivity test successful for author 'author-name'",
"timestamp": "2025-06-03T03:47:10.010Z",
"endpoint": "sentry-relay"
}
Returns the health status of the service.
Response Format:
{
"status": "healthy",
"timestamp": "2025-06-03T03:47:10.010Z",
"service": "sentry-relay"
}
All endpoints return errors in this standard format:
{
"success": false,
"message": "Detailed error description",
"timestamp": "2025-06-03T03:47:10.010Z",
"endpoint": "sentry-relay",
"retryAfter": 300
}
- Cloudflare account with Workers enabled
- Sentry account with project(s) created
wrangler
CLI tool installed
# Clone or create your project
mkdir my-error-relay
cd my-error-relay
# Copy the reference implementation
cp -r /path/to/sentry-relay/* .
# Install dependencies
npm install
Edit wrangler.toml
:
name = "your-error-relay"
main = "src/index.ts"
compatibility_date = "2024-12-18"
compatibility_flags = ["nodejs_compat"]
[env.production]
name = "your-error-relay"
routes = [
{ pattern = "errors.yourdomain.com/*", zone_name = "yourdomain.com" }
]
[env.production.vars]
ALLOWED_ORIGINS = "https://yourdomain.com,http://localhost:30000"
For each author you want to support:
# Set Sentry DSN for your modules
echo "https://your-key@your-org.ingest.sentry.io/your-project-id" | \
npx wrangler secret put SENTRY_DSN_YOURNAME --env production
# Add more authors as needed
echo "https://other-key@org.ingest.sentry.io/other-project" | \
npx wrangler secret put SENTRY_DSN_OTHERNAME --env production
# Deploy to production
npx wrangler deploy --env production
In your Cloudflare dashboard, ensure your domain routes to the worker:
- Route:
errors.yourdomain.com/*
- Worker:
your-error-relay
The worker routes errors based on the {author}
path parameter:
/report/yourname
→ UsesSENTRY_DSN_YOURNAME
secret/report/othername
→ UsesSENTRY_DSN_OTHERNAME
secret
Adding New Authors:
- Create a Sentry project for the author
- Set the secret:
SENTRY_DSN_{UPPERCASE_AUTHOR_NAME}
- Deploy the updated worker
Variable | Description | Example |
---|---|---|
SENTRY_DSN_{AUTHOR} |
Sentry DSN for author's modules | https://key@org.ingest.sentry.io/project |
ALLOWED_ORIGINS |
CORS allowed origins | https://domain.com,http://localhost:30000 |
The worker automatically handles CORS for all origins by default (ALLOWED_ORIGINS = "*"
). This allows error reports from any Foundry VTT instance worldwide.
For additional security, you can restrict to specific origins:
- Set
ALLOWED_ORIGINS
to specific domains:"https://your-domain.com,http://localhost:30000"
- Use
"*"
(default) to allow all origins for maximum compatibility
curl https://errors.yourdomain.com/health
Expected response:
{
"status": "healthy",
"timestamp": "2025-06-03T03:47:10.010Z",
"service": "sentry-relay"
}
curl -X POST -H "Content-Type: application/json" \
-d '{"test": true, "timestamp": "2025-06-03T03:47:00.000Z", "source": "manual-test"}' \
https://errors.yourdomain.com/test/yourname
curl -X POST -H "Content-Type: application/json" \
-d '{
"error": {
"message": "Test error from manual testing",
"stack": "Error: Test\n at test.js:1:1",
"type": "Error",
"source": "manual-test"
},
"attribution": {
"moduleId": "test-module",
"confidence": "high",
"method": "manual",
"source": "user-report"
},
"foundry": {
"version": "12.331"
},
"meta": {
"timestamp": "2025-06-03T03:47:00.000Z",
"privacyLevel": "full",
"reporterVersion": "1.0.0"
}
}' \
https://errors.yourdomain.com/report/yourname
While this reference implementation uses Sentry, the same API can be implemented with other backends:
// Example: Forward errors to Discord
export default {
async fetch(request, env) {
const webhookUrl = env.DISCORD_WEBHOOK_URL;
if (url.pathname.startsWith('/report/')) {
const errorReport = await request.json();
// Transform to Discord message
const discordMessage = {
embeds: [{
title: `🚨 Error in ${errorReport.attribution.moduleId}`,
description: errorReport.error.message,
color: 0xff0000,
timestamp: errorReport.meta.timestamp
}]
};
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(discordMessage)
});
return new Response(JSON.stringify({
success: true,
eventId: crypto.randomUUID(),
message: "Error forwarded to Discord",
timestamp: new Date().toISOString(),
endpoint: "discord-webhook"
}));
}
}
}
// Example: Store errors in a database
export default {
async fetch(request, env) {
if (url.pathname.startsWith('/report/')) {
const errorReport = await request.json();
const eventId = crypto.randomUUID();
// Store in database (Cloudflare D1, PostgreSQL, etc.)
await env.DB.prepare(`
INSERT INTO error_reports (id, module_id, message, stack, timestamp)
VALUES (?, ?, ?, ?, ?)
`).bind(
eventId,
errorReport.attribution.moduleId,
errorReport.error.message,
errorReport.error.stack,
errorReport.meta.timestamp
).run();
return new Response(JSON.stringify({
success: true,
eventId,
message: "Error stored in database",
timestamp: new Date().toISOString(),
endpoint: "database-storage"
}));
}
}
}
// Example: Send errors via email
export default {
async fetch(request, env) {
if (url.pathname.startsWith('/report/')) {
const errorReport = await request.json();
// Send email (using service like SendGrid, Mailgun, etc.)
const emailBody = `
Error in module: ${errorReport.attribution.moduleId}
Message: ${errorReport.error.message}
Foundry Version: ${errorReport.foundry.version}
Time: ${errorReport.meta.timestamp}
Stack Trace:
${errorReport.error.stack}
`;
await sendEmail({
to: env.DEVELOPER_EMAIL,
subject: `🚨 Error in ${errorReport.attribution.moduleId}`,
body: emailBody
});
return new Response(JSON.stringify({
success: true,
eventId: `email-${Date.now()}`,
message: "Error emailed to developer",
timestamp: new Date().toISOString(),
endpoint: "email-notifications"
}));
}
}
}
Always validate incoming data:
function validateErrorReport(report) {
if (!report.error?.message) {
throw new Error('Missing error message');
}
if (!report.attribution?.moduleId) {
throw new Error('Missing module ID');
}
if (!report.meta?.timestamp) {
throw new Error('Missing timestamp');
}
// Add more validation as needed
}
Implement rate limiting to prevent abuse:
// Example: Simple rate limiting
const rateLimiter = new Map();
function checkRateLimit(clientIP, limit = 10, window = 60000) {
const now = Date.now();
const requests = rateLimiter.get(clientIP) || [];
// Remove old requests
const recentRequests = requests.filter(time => now - time < window);
if (recentRequests.length >= limit) {
return false; // Rate limited
}
recentRequests.push(now);
rateLimiter.set(clientIP, recentRequests);
return true;
}
Be specific with CORS origins:
function getCORSHeaders(env) {
const allowedOrigins = env.ALLOWED_ORIGINS?.split(',') || [];
return {
'Access-Control-Allow-Origin': allowedOrigins.join(','),
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Foundry-Version',
'Access-Control-Max-Age': '86400'
};
}
Never log or store sensitive information:
function sanitizeErrorReport(report) {
// Remove potential sensitive data
const sanitized = { ...report };
// Remove user-specific information
delete sanitized.client?.userId;
delete sanitized.client?.username;
// Sanitize stack traces of file paths
if (sanitized.error?.stack) {
sanitized.error.stack = sanitized.error.stack
.replace(/\/Users\/[^\/]+/g, '/Users/***')
.replace(/C:\\Users\\[^\\]+/g, 'C:\\Users\\***');
}
return sanitized;
}
This reference implementation is designed to be:
- Extensible: Easy to add new backends or modify behavior
- Reference compliant: Follows the reference error reporting API
- Well-documented: Clear examples for other developers
- Basic Security: Proof-of-concept with planned security features
When contributing:
- Maintain API compatibility
- Add comprehensive tests
- Update documentation
- Follow security best practices
This reference implementation is provided under the MIT License to encourage adoption and modification by the Foundry VTT community.
Need Help?
- Check the Errors and Echoes Documentation
- Review the module's API examples and integration guides
- Join the community discussion on Discord