import './telemetry'; import { otelLogger, SeverityNumber, meter } from './telemetry'; import Fastify, { FastifyInstance, FastifyError } from 'fastify'; declare module 'fastify' { interface FastifyRequest { errorHandled?: boolean; meta?: Record; } } function classifyErrorMessage(message: string): string { const msg = message.toLowerCase(); if (msg.includes('geocode') || msg.includes('nominatim')) return 'upstream_error:nominatim'; if (msg.includes('sponsors') || msg.includes('github')) return 'upstream_error:github'; if (msg.includes('openai') || msg.includes('screening')) return 'upstream_error:openai'; if (msg.includes('zammad') || msg.includes('ticket') || msg.includes('tag')) return 'upstream_error:zammad'; if (msg.includes('turnstile') || msg.includes('siteverify')) return 'upstream_error:turnstile'; return 'internal_error'; } function classifyError(error: FastifyError): string { if (error.code === 'FST_ERR_VALIDATION') return 'validation_error'; return classifyErrorMessage(error.message); } function classifyByStatus(statusCode: number): string { if (statusCode === 404) return 'not_found'; if (statusCode === 400) return 'client_error'; if (statusCode === 401 || statusCode === 403) return 'auth_error'; if (statusCode >= 400 && statusCode < 500) return 'client_error'; return 'internal_error'; } import cors from '@fastify/cors'; import { NominatimClient, NominatimResultSchema } from './services/NominatimClient'; import { classifyGeoQuery } from './services/GeoQueryClassifier'; import { GithubClient, SponsorsResponseSchema } from './services/GithubClient'; import { TurnstileClient } from './services/TurnstileClient'; import { ZammadClient, ContactMessageBodySchema, ContactMessageBody } from './services/ZammadClient'; import { AiScreeningClient } from './services/AiScreeningClient'; import { screenContactSubmission, planZammadActions } from './services/ContactScreeningService'; const start = async () => { const server: FastifyInstance = Fastify({ logger: { level: 'error', transport: { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss Z', ignore: 'pid,hostname', }, }, }, trustProxy: true, }); // Global error handler server.setErrorHandler((error: FastifyError, request, reply) => { const errorType = classifyError(error); const statusCode = error.statusCode ?? 500; otelLogger.emit({ severityNumber: SeverityNumber.ERROR, severityText: 'ERROR', body: error.message, attributes: { 'error.type': errorType, 'http.route': (request.routeOptions as { url?: string })?.url ?? '', 'http.request.method': request.method, 'http.response.status_code': statusCode, 'exception.message': error.message, 'exception.stacktrace': error.stack ?? '', }, }); request.errorHandled = true; if (statusCode !== 404) { server.log.error({ url: request.url, method: request.method, error: error.message, stack: error.stack, }, 'Request error'); } reply.status(statusCode).send({ error: 'Internal Server Error' }); }); // Coors Banquet Config await server.register(cors, { origin: (origin, cb) => { const allowedOrigins = [ 'http://localhost:5173', // DeFlock Legacy 'http://localhost:3000', // FlockHopper 'https://deflock.org', 'https://www.deflock.org', 'https://maps.deflock.org' ]; if (!origin || allowedOrigins.includes(origin) || /^https:\/\/.*\.deflock\.pages\.dev$/.test(origin)) { cb(null, true); } else { cb(null, false); } }, methods: ['GET', 'HEAD', 'POST'], }); server.addHook('onResponse', (request, reply, done) => { const statusCode = reply.statusCode; const route = (request.routeOptions as { url?: string })?.url ?? request.url.split('?')[0]; requestCounter.add(1, { 'http.route': route, 'http.request.method': request.method, 'http.response.status_code': statusCode, ...request.meta, }); if (!request.errorHandled && statusCode >= 400 && statusCode !== 404) { otelLogger.emit({ severityNumber: statusCode >= 500 ? SeverityNumber.ERROR : SeverityNumber.WARN, severityText: statusCode >= 500 ? 'ERROR' : 'WARN', body: `HTTP ${statusCode} ${request.method} ${route}`, attributes: { 'error.type': classifyByStatus(statusCode), 'http.route': route, 'http.request.method': request.method, 'http.response.status_code': statusCode, ...request.meta, }, }); } done(); }); const requestCounter = meter.createCounter('http.server.requests.total', { description: 'Total number of HTTP requests, by route, method, and status code', }); const backgroundErrorCounter = meter.createCounter('background_job.errors.total', { description: 'Total number of failures in fire-and-forget background jobs, by context', }); const aiScreeningCounter = meter.createCounter('ai_screening.completed.total', { description: 'Total number of contact submissions successfully screened by AI', }); function logBackgroundError(context: string, error: unknown, attributes: Record = {}) { const message = error instanceof Error ? error.message : String(error); // Always print locally, independent of whether the otelcol sidecar is up — background job // failures otherwise have no visibility at all when the OTel pipeline isn't reachable // (e.g. local dev without the collector running). server.log.error({ context, ...attributes, error: message, stack: error instanceof Error ? error.stack : undefined }, 'Background job error'); otelLogger.emit({ severityNumber: SeverityNumber.ERROR, severityText: 'ERROR', body: message, attributes: { 'error.type': classifyErrorMessage(message), 'background.context': context, ...attributes, }, }); backgroundErrorCounter.add(1, { 'background.context': context, 'error.type': classifyErrorMessage(message) }); } const nominatim = new NominatimClient(); const githubClient = new GithubClient(); const turnstileClient = new TurnstileClient(); const zammadClient = new ZammadClient(); const aiScreeningClient = new AiScreeningClient(); const shutdown = async () => { server.log.info("Shutting down"); await server.close(); process.exit(0); }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); server.get('/geocode', { schema: { querystring: { type: 'object', properties: { query: { type: 'string' }, }, required: ['query'], }, response: { 200: NominatimResultSchema, 404: { type: 'object', properties: { error: { type: 'string' } } }, 500: { type: 'object', properties: { error: { type: 'string' } } }, }, }, }, async (request, reply) => { const { query } = request.query as { query: string }; reply.header('Cache-Control', 'public, max-age=86400, s-maxage=86400'); const result = await nominatim.geocodeSingleResult(query); if (!result) { return reply.status(404).send({ error: 'No results found' }); } return result; }); server.get('/geocode/multi', { schema: { querystring: { type: 'object', properties: { query: { type: 'string' }, source: { type: 'string' }, }, required: ['query'], }, response: { 200: { type: 'array', items: NominatimResultSchema, }, 500: { type: 'object', properties: { error: { type: 'string' } } }, }, }, }, async (request, reply) => { const { query, source } = request.query as { query: string, source?: string }; const geoType = classifyGeoQuery(query); request.meta = { 'geocode.source': geoType === 'zip_code' ? 'local_zip' : 'nominatim', 'geocode.initiator': source ?? '', 'geocode.query_type': geoType, }; reply.header('Cache-Control', 'public, max-age=86400, s-maxage=86400'); const result = await nominatim.geocodePhrase(query, false); return result; }); server.get('/sponsors/github', { schema: { response: { 200: SponsorsResponseSchema, 500: { type: 'object', properties: { error: { type: 'string' } } }, }, }, }, async (request, reply) => { reply.header('Cache-Control', 'public, max-age=60, s-maxage=600'); const result = await githubClient.getSponsors(); return result; }); server.post('/contact/message', { schema: { body: ContactMessageBodySchema, response: { 201: { type: 'object', properties: {} }, 400: { type: 'object', properties: { error: { type: 'string' } } }, 500: { type: 'object', properties: { error: { type: 'string' } } }, }, }, }, async (request, reply) => { const { name, email, topic, subject, message, turnstileToken, aiScreeningOptOut } = request.body as ContactMessageBody; const remoteIp = request.ip; const valid = await turnstileClient.verify(turnstileToken, remoteIp); if (!valid) { return reply.status(400).send({ error: 'Invalid captcha' }); } const ticket = await zammadClient.createTicket({ name, email, topic, subject, message }); if (aiScreeningOptOut) { zammadClient.addTag(ticket.id, 'ai-opted-out') .catch(err => logBackgroundError('ai_screening.opt_out_tag', err, { 'ticket.id': String(ticket.id) })); } else { const senderEmailDomain = email.split('@')[1] ?? ''; screenContactSubmission( { ticketId: ticket.id, topic, subject, message, senderEmailDomain, replyTo: email }, { aiClient: aiScreeningClient, zammadClient }, ) .then(({ result }) => { aiScreeningCounter.add(1, { 'contact.topic': topic, 'ai_category': result.ai_category }); }) .catch(err => { logBackgroundError('ai_screening', err, { 'ticket.id': String(ticket.id) }); zammadClient.addTag(ticket.id, 'ai-screening-error') .catch(tagErr => logBackgroundError('ai_screening.error_tag', tagErr, { 'ticket.id': String(ticket.id) })); }); } return reply.status(201).send({}); }); server.post('/contact/message/dry-run', { schema: { body: { type: 'object', required: ['topic', 'subject', 'message'], properties: { topic: { type: 'string', enum: ['website-support', 'app-support', 'local-groups', 'media', 'questions-comments'], }, subject: { type: 'string', minLength: 1 }, message: { type: 'string', minLength: 1 }, email: { type: 'string' }, }, }, response: { 500: { type: 'object', properties: { error: { type: 'string' } } }, }, }, }, async (request, reply) => { const { topic, subject, message, email } = request.body as { topic: ContactMessageBody['topic']; subject: string; message: string; email?: string; }; const senderEmailDomain = email?.split('@')[1] ?? ''; const screening = await aiScreeningClient.screen({ topic, subject, message, senderEmailDomain }); const plannedActions = planZammadActions(screening); return reply.status(200).send({ screening, plannedActions }); }); server.head('/healthcheck', async (request, reply) => { reply.status(200).send(); }); try { const defaultPort = process.env.NODE_ENV === 'development' ? 3420 : 3000; const port = process.env.PORT ? parseInt(process.env.PORT, 10) : defaultPort; await server.listen({ host: '0.0.0.0', port }); console.log(`Server listening on port ${port}`); } catch (err) { console.error('Failed to start server:', err); server.log.error(err); process.exit(1); } }; start().catch(err => { console.error('Fatal error:', err); process.exit(1); });