feat(observability): structured logs, W3C trace IDs, Sentry hooks (#18)

- TS: pino + pino-http; every HTTP request log includes traceId from
  W3C traceparent header (generated if absent); forwarded to ml/serving
  on all /score, /generate, /reward, and /api/ml proxy calls
- Python: structlog JSON; FastAPI middleware binds trace_id via
  contextvars so every log line within a request carries it
- Sentry: optional SENTRY_DSN init in both runtimes (no-op if unset)
- Replace all console.* calls across services/api with pino logger
- Update tests to spy on logger instead of console

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 03:37:28 +00:00
parent 7281af83a4
commit c4960d0601
18 changed files with 1041 additions and 64 deletions

View File

@@ -1,7 +1,10 @@
import 'dotenv/config';
import { logger } from './logger.js';
import express from 'express';
import { pinoHttp } from 'pino-http';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import { tracingMiddleware } from './middleware/tracing.js';
import { config } from './config.js';
import { db, runMigrations } from './db/index.js';
import { tipScores, tipFeedback } from './db/schema.js';
@@ -26,13 +29,11 @@ import { registerProfileSubscriptions } from './profile/subscriber.js';
await mkdir(dirname(config.DATABASE_PATH), { recursive: true });
runMigrations();
// Keep the API alive on stray async faults (e.g. a single bad admin route)
// rather than dropping the whole process.
process.on('unhandledRejection', (reason) => {
console.error('[api] unhandledRejection', reason);
logger.error({ err: reason }, 'unhandledRejection');
});
process.on('uncaughtException', (err) => {
console.error('[api] uncaughtException', err);
logger.fatal({ err }, 'uncaughtException');
});
const app = express();
@@ -43,6 +44,15 @@ app.use(
credentials: true,
}),
);
app.use(tracingMiddleware);
app.use(
pinoHttp({
logger,
genReqId: (req) => req.traceId,
customProps: (req) => ({ traceId: req.traceId }),
autoLogging: { ignore: (req) => req.url === '/health' },
}),
);
app.use(express.json());
app.use(cookieParser());
app.use(sessionMiddleware);
@@ -56,16 +66,13 @@ app.use('/api/user', userRouter);
app.use('/api/push', pushRouter);
app.use('/api/admin', adminRouter);
// Proxy ml/serving endpoints through the API (admin-only).
// Allows admin UI to call /api/ml/stats/:userId, /api/ml/features/:userId
// without needing direct access to the ml/serving port.
app.use('/api/ml', requireAuth as any, requireAdmin as any, async (req: Request, res: Response) => {
const mlUrl = config.ML_SERVING_URL;
const target = `${mlUrl}${req.path}`;
try {
const upstream = await fetch(target, {
method: req.method,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', traceparent: req.traceparent },
body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined,
signal: AbortSignal.timeout(5000),
});
@@ -82,7 +89,7 @@ async function purgeExpiredData() {
await db.delete(tipScores).where(lt(tipScores.servedAt, cutoff));
await db.delete(tipFeedback).where(lt(tipFeedback.createdAt, cutoff));
} catch (err: any) {
console.error(`[purge] retention cleanup failed: ${err.message}`);
logger.error({ err }, 'retention cleanup failed');
}
}
@@ -90,7 +97,7 @@ purgeExpiredData();
setInterval(purgeExpiredData, 24 * 60 * 60 * 1000);
app.listen(config.PORT, () => {
console.log(`oO API listening on http://localhost:${config.PORT}`);
logger.info({ port: config.PORT }, 'oO API listening');
});
if (config.NATS_URL) {