diff --git a/src/parser.ts b/src/parser.ts index 0f6bcc0..7d8a01b 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -219,6 +219,64 @@ function inferLogLevel(eventName: string): LogLevel { return 'info'; } +/** + * Parse a JSON object directly into a LogEvent + * + * Used for HTTP-ingested events that are already parsed as JSON objects. + * + * @param obj - Parsed JSON object + * @returns Parsed LogEvent or null if invalid + */ +export function parseEventObject(obj: unknown): LogEvent | null { + if (typeof obj !== 'object' || obj === null) { + return null; + } + + // Check for NEEDLE format + if (isNeedleFormat(obj)) { + return parseNeedleFormat(obj); + } + + // Try as legacy format - validate required fields + const parsed = obj as Record; + if (typeof parsed.ts !== 'number') { + return null; + } + if (typeof parsed.worker !== 'string') { + return null; + } + if (!isValidLogLevel(parsed.level)) { + return null; + } + if (typeof parsed.msg !== 'string') { + return null; + } + + // Construct LogEvent with validated fields + const event: LogEvent = { + ts: parsed.ts, + worker: parsed.worker, + level: parsed.level, + msg: parsed.msg, + }; + + // Copy optional fields if present + if (typeof parsed.tool === 'string') event.tool = parsed.tool; + if (typeof parsed.path === 'string') event.path = parsed.path; + if (typeof parsed.bead === 'string') event.bead = parsed.bead; + if (typeof parsed.duration_ms === 'number') event.duration_ms = parsed.duration_ms; + if (typeof parsed.error === 'string') event.error = parsed.error; + + // Copy any additional fields + for (const key of Object.keys(parsed)) { + if (!isStandardField(key) && !(key in event)) { + event[key] = parsed[key]; + } + } + + return event; +} + /** * Parse multiple log lines * diff --git a/src/web/server.ts b/src/web/server.ts index 4ce7344..e678fe2 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -13,6 +13,10 @@ import { WebSocketServer, WebSocket } from 'ws'; import { LogEvent, EventFilter, CrossReferenceEntityType, CrossReferenceRelationship, DagOptions, BeadStatus } from '../types.js'; import { InMemoryEventStore } from '../store.js'; import { refreshDependencyGraph, getDagStats } from '../tui/dagUtils.js'; +import { parseEventObject } from '../parser.js'; + +/** Maximum payload size for POST requests (64KB) */ +const MAX_PAYLOAD_SIZE = 64 * 1024; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -50,6 +54,9 @@ export function createWebServer(options: WebServerOptions): WebServer { httpServer = createServer(app); wsServer = new WebSocketServer({ server: httpServer }); + // JSON body parser for POST requests + app.use(express.json({ limit: MAX_PAYLOAD_SIZE })); + // WebSocket connection handling wsServer.on('connection', (ws: WebSocket) => { clients.add(ws); @@ -101,6 +108,48 @@ export function createWebServer(options: WebServerOptions): WebServer { res.json(events); }); + // POST endpoint to ingest NEEDLE telemetry events + app.post('/api/events', (req: Request, res: Response) => { + try { + const eventObj = req.body; + + // Validate request body exists + if (!eventObj || typeof eventObj !== 'object') { + res.status(400).json({ error: 'Invalid request body', message: 'Expected JSON object' }); + return; + } + + // Validate required fields for NEEDLE format + if (!eventObj.ts) { + res.status(400).json({ error: 'Missing required field', message: 'Field "ts" is required' }); + return; + } + if (!eventObj.event) { + res.status(400).json({ error: 'Missing required field', message: 'Field "event" is required' }); + return; + } + + // Parse the event object + const logEvent = parseEventObject(eventObj); + if (!logEvent) { + res.status(400).json({ error: 'Invalid event format', message: 'Failed to parse event object' }); + return; + } + + // Store the event + store.add(logEvent); + + // Broadcast to all connected WebSocket clients + broadcast(logEvent); + + // Return success + res.status(201).json({ success: true, event: logEvent }); + } catch (err) { + console.error('Error processing POST /api/events:', err); + res.status(500).json({ error: 'Internal server error', message: err instanceof Error ? err.message : 'Unknown error' }); + } + }); + // Get worker details app.get('/api/workers/:id', (req: Request, res: Response) => { const workerId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;