From a66a689d7db1e9de2deaae6890c11b4aa720648d Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Mon, 2 Mar 2026 23:36:59 -0800 Subject: [PATCH] feat(diagnostics): Phase 1 complete - types, repository, routes - types.ts: Session, Trace, Log, Screenshot schemas with Zod validation - repository.ts: CRUD operations with composite partition keys - routes.ts: REST endpoints for session management and data ingest TODOs for Phase 2: - TODO-1: Event bus integration - TODO-2: PII redaction implementation - TODO-6: PII redaction in log ingest - TODO-7: Fatal log alerting - TODO-8: Blob SAS token generation --- .../src/modules/diagnostics/routes.ts | 475 ++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 services/platform-service/src/modules/diagnostics/routes.ts diff --git a/services/platform-service/src/modules/diagnostics/routes.ts b/services/platform-service/src/modules/diagnostics/routes.ts new file mode 100644 index 00000000..c775f447 --- /dev/null +++ b/services/platform-service/src/modules/diagnostics/routes.ts @@ -0,0 +1,475 @@ +/** + * Diagnostics REST endpoints. + * + * POST /api/diagnostics/sessions — create session (admin) + * GET /api/diagnostics/sessions — list sessions (admin) + * GET /api/diagnostics/sessions/:id — get session details + * PATCH /api/diagnostics/sessions/:id — update session (admin) + * DELETE /api/diagnostics/sessions/:id — cancel session (admin) + * GET /api/diagnostics/config — client polling endpoint + * POST /api/diagnostics/sessions/:id/traces — ingest traces + * POST /api/diagnostics/sessions/:id/logs — ingest logs + * POST /api/diagnostics/sessions/:id/screenshots — get SAS URL for upload + * GET /api/diagnostics/sessions/:id/traces — query traces (admin) + * GET /api/diagnostics/sessions/:id/logs — query logs (admin) + * GET /api/diagnostics/sessions/:id/screenshots — list screenshots (admin) + * + * @module diagnostics + */ + +import type { FastifyInstance } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { requireRole } from '../../lib/auth.js'; +import { BadRequestError, NotFoundError } from '../../lib/errors.js'; +import * as repo from './repository.js'; +import { + CreateDebugSessionSchema, + UpdateDebugSessionSchema, + ListDebugSessionsQuerySchema, + IngestTracesSchema, + IngestLogsSchema, + CreateScreenshotMetadataSchema, + QueryTracesSchema, + QueryLogsSchema, + type DebugSessionDoc, + type DebugTraceDoc, + type DebugLogEntryDoc, + type DebugScreenshotDoc, + type CreateDebugSessionInput, + type UpdateDebugSessionInput, + type ListDebugSessionsQuery, + type IngestTracesInput, + type IngestLogsInput, + type CreateScreenshotMetadataInput, + type QueryTracesInput, + type QueryLogsInput, +} from './types.js'; + +// TODO-1: Event bus integration - need to emit events for session lifecycle +// Import event bus once available: import { emitEvent } from '../../lib/event-bus.js'; + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +function generateId(prefix: string): string { + return `${prefix}_${randomUUID().replace(/-/g, '')}`; +} + +function buildPk(productId: string, sessionId: string): string { + return `${productId}:${sessionId}`; +} + +// TODO-2: PII Redaction - need to implement PII scanning for log messages +// This should be shared with telemetry module +function redactPii(message: string): { redacted: string; patterns: string[] } { + // Placeholder - implement actual PII redaction + return { redacted: message, patterns: [] }; +} + +// ─── Routes ───────────────────────────────────────────────────────────────── + +export async function diagnosticsRoutes(app: FastifyInstance) { + // Session management routes (admin only) + app.post('/diagnostics/sessions', async (req, reply) => { + await requireRole(req, 'admin'); + + const productId = getRequestProductId(req); + const body = req.body as CreateDebugSessionInput; + const input = CreateDebugSessionSchema.parse(body); + + // Validate at least one target is specified + if (!input.targetUserId && !input.targetAnonymousId && !input.targetDeviceId) { + throw new BadRequestError('At least one target (userId, anonymousId, or deviceId) is required'); + } + + const now = new Date().toISOString(); + const expiresAt = new Date(Date.now() + input.maxDurationMinutes * 60000).toISOString(); + + const session: DebugSessionDoc = { + id: generateId('ds'), + productId, + targetUserId: input.targetUserId, + targetAnonymousId: input.targetAnonymousId, + targetDeviceId: input.targetDeviceId, + targetSessionId: input.targetSessionId, + status: 'pending', + collectionLevel: input.collectionLevel, + captureLogs: input.captureLogs, + captureNetwork: input.captureNetwork, + captureScreenshots: input.captureScreenshots, + screenshotOnError: input.screenshotOnError, + maxDurationMinutes: input.maxDurationMinutes, + createdAt: now, + updatedAt: now, + expiresAt, + logCount: 0, + traceCount: 0, + screenshotCount: 0, + createdBy: req.jwtPayload?.sub ?? 'system', + }; + + await repo.createSession(session); + + // TODO-3: Emit event bus event + // await emitEvent('diagnostics.session.created', { + // sessionId: session.id, + // productId, + // targetUserId: input.targetUserId, + // createdBy: session.createdBy, + // }); + + reply.status(201); + return session; + }); + + app.get('/diagnostics/sessions', async (req) => { + await requireRole(req, 'admin'); + + const productId = getRequestProductId(req); + const query = ListDebugSessionsQuerySchema.parse(req.query); + + const result = await repo.listSessions({ ...query, productId }); + return result; + }); + + app.get('/diagnostics/sessions/:id', async (req) => { + const { id } = req.params as { id: string }; + const productId = getRequestProductId(req); + + const session = await repo.getSession(id); + if (!session) { + throw new NotFoundError('Debug session not found'); + } + + // Allow admin or the target user to view + const isAdmin = req.jwtPayload?.role === 'admin'; + const isOwner = session.targetUserId === req.jwtPayload?.sub; + + if (!isAdmin && !isOwner) { + throw new BadRequestError('Access denied'); + } + + return session; + }); + + app.patch('/diagnostics/sessions/:id', async (req) => { + await requireRole(req, 'admin'); + + const { id } = req.params as { id: string }; + const body = req.body as UpdateDebugSessionInput; + const input = UpdateDebugSessionSchema.parse(body); + + const session = await repo.getSession(id); + if (!session) { + throw new NotFoundError('Debug session not found'); + } + + // Validate status transitions + if (input.status) { + const validTransitions: Record = { + pending: ['active', 'cancelled'], + active: ['paused', 'completed', 'cancelled'], + paused: ['active', 'cancelled'], + completed: [], + cancelled: [], + }; + + const allowed = validTransitions[session.status]; + if (!allowed.includes(input.status)) { + throw new BadRequestError( + `Invalid status transition: ${session.status} → ${input.status}. Allowed: ${allowed.join(', ')}` + ); + } + } + + const updates: Partial = { + ...input, + updatedBy: req.jwtPayload?.sub ?? 'system', + }; + + // Set timestamps based on status changes + if (input.status === 'active' && session.status !== 'active') { + updates.startedAt = new Date().toISOString(); + } + if ((input.status === 'completed' || input.status === 'cancelled') && + session.status !== 'completed' && + session.status !== 'cancelled') { + updates.endedAt = new Date().toISOString(); + } + + const updated = await repo.updateSession(id, updates); + + // TODO-4: Emit event bus event + // await emitEvent('diagnostics.session.updated', { + // sessionId: id, + // productId: session.productId, + // changes: input, + // updatedBy: req.jwtPayload?.userId ?? 'system', + // }); + + return updated; + }); + + app.delete('/diagnostics/sessions/:id', async (req) => { + await requireRole(req, 'admin'); + + const { id } = req.params as { id: string }; + + const session = await repo.getSession(id); + if (!session) { + throw new NotFoundError('Debug session not found'); + } + + // Soft delete (mark as cancelled) + await repo.deleteSession(id); + + // TODO-5: Emit event bus event + // await emitEvent('diagnostics.session.cancelled', { + // sessionId: id, + // productId: session.productId, + // cancelledBy: req.jwtPayload?.sub ?? 'system', + // }); + + return { success: true, message: 'Session cancelled' }; + }); + + // Client polling endpoint (any authenticated user) + app.get('/diagnostics/config', async (req, reply) => { + const productId = getRequestProductId(req); + const userId = req.jwtPayload?.sub; + const deviceId = req.headers['x-device-id'] as string | undefined; + + // Look for active session targeting this user/device + const session = await repo.getActiveSessionForTarget(productId, { + userId, + anonymousId: deviceId, + deviceId, + }); + + if (!session) { + // Return empty config with 304 ETag support + reply.header('ETag', '"empty"'); + return { enabled: false }; + } + + // Build config response + const config = { + enabled: true, + sessionId: session.id, + collectionLevel: session.collectionLevel, + captureLogs: session.captureLogs, + captureNetwork: session.captureNetwork, + captureScreenshots: session.captureScreenshots, + screenshotOnError: session.screenshotOnError, + expiresAt: session.expiresAt, + }; + + // ETag for caching + const etag = `"${session.id}:${session.updatedAt}"`; + reply.header('ETag', etag); + + // Check If-None-Match for 304 + const ifNoneMatch = req.headers['if-none-match']; + if (ifNoneMatch === etag) { + reply.status(304); + return null; + } + + return config; + }); + + // Ingest endpoints (any authenticated user, but validates session) + app.post('/diagnostics/sessions/:id/traces', async (req) => { + const { id } = req.params as { id: string }; + const productId = getRequestProductId(req); + const body = req.body as IngestTracesInput; + const input = IngestTracesSchema.parse(body); + + // Validate session matches + if (input.sessionId !== id) { + throw new BadRequestError('Session ID mismatch'); + } + + const session = await repo.getSession(id); + if (!session) { + throw new NotFoundError('Debug session not found'); + } + + if (session.status !== 'active') { + throw new BadRequestError(`Session is not active (status: ${session.status})`); + } + + // Prepare traces with IDs + const traces: DebugTraceDoc[] = input.traces.map((t) => ({ + ...t, + id: generateId('tr'), + pk: buildPk(productId, id), + sessionId: id, + productId, + })); + + await repo.ingestTraces(productId, id, traces); + + // Update stats + await repo.updateSessionStats(id, { traceCount: traces.length }); + + return { accepted: traces.length }; + }); + + app.post('/diagnostics/sessions/:id/logs', async (req) => { + const { id } = req.params as { id: string }; + const productId = getRequestProductId(req); + const body = req.body as IngestLogsInput; + const input = IngestLogsSchema.parse(body); + + // Validate session matches + if (input.sessionId !== id) { + throw new BadRequestError('Session ID mismatch'); + } + + const session = await repo.getSession(id); + if (!session) { + throw new NotFoundError('Debug session not found'); + } + + if (session.status !== 'active') { + throw new BadRequestError(`Session is not active (status: ${session.status})`); + } + + // TODO-6: PII Redaction - implement actual redaction + // Prepare logs with IDs and redaction + const logs: DebugLogEntryDoc[] = input.logs.map((l) => ({ + ...l, + id: generateId('log'), + pk: buildPk(productId, id), + sessionId: id, + productId, + })); + + await repo.ingestLogs(productId, id, logs); + + // Update stats + await repo.updateSessionStats(id, { logCount: logs.length }); + + // Check for fatal logs to trigger alerts + const hasFatal = logs.some((l) => l.level === 'fatal'); + if (hasFatal) { + // TODO-7: Emit fatal log event for alerting + // await emitEvent('diagnostics.ingest.fatal', { + // sessionId: id, + // productId, + // logEntry: logs.find((l) => l.level === 'fatal')!, + // timestamp: new Date().toISOString(), + // }); + } + + return { accepted: logs.length }; + }); + + // Screenshot upload - get SAS URL + app.post('/diagnostics/sessions/:id/screenshots', async (req, reply) => { + const { id } = req.params as { id: string }; + const productId = getRequestProductId(req); + const body = req.body as CreateScreenshotMetadataInput; + const input = CreateScreenshotMetadataSchema.parse(body); + + // Validate session matches + if (input.sessionId !== id) { + throw new BadRequestError('Session ID mismatch'); + } + + const session = await repo.getSession(id); + if (!session) { + throw new NotFoundError('Debug session not found'); + } + + if (session.status !== 'active') { + throw new BadRequestError(`Session is not active (status: ${session.status})`); + } + + // TODO-8: Blob SAS token generation + // Need to integrate with existing blob module to generate SAS URL + // For now, return placeholder + + const screenshotId = generateId('scr'); + const blobPath = `screenshots/${productId}/${id}/${screenshotId}.png`; + + const metadata: DebugScreenshotDoc = { + ...input, + id: screenshotId, + sessionId: id, + productId, + blobUrl: '', // TODO: Generate SAS URL via blob module + blobPath, + containerName: 'diagnostics-screenshots', + }; + + await repo.createScreenshotMetadata(metadata); + + // Update stats + await repo.updateSessionStats(id, { screenshotCount: 1 }); + + // TODO-9: Emit screenshot captured event + // await emitEvent('diagnostics.screenshot.captured', { + // sessionId: id, + // productId, + // screenshotId, + // trigger: input.trigger, + // }); + + reply.status(201); + return { + screenshotId, + uploadUrl: '', // TODO: Return actual SAS URL + blobPath, + expiresIn: 300, // 5 minutes + }; + }); + + // Admin query endpoints + app.get('/diagnostics/sessions/:id/traces', async (req) => { + await requireRole(req, 'admin'); + + const { id } = req.params as { id: string }; + const productId = getRequestProductId(req); + const query = QueryTracesSchema.parse(req.query); + + const session = await repo.getSession(id); + if (!session) { + throw new NotFoundError('Debug session not found'); + } + + const result = await repo.getTraces(productId, id, query); + return result; + }); + + app.get('/diagnostics/sessions/:id/logs', async (req) => { + await requireRole(req, 'admin'); + + const { id } = req.params as { id: string }; + const productId = getRequestProductId(req); + const query = QueryLogsSchema.parse(req.query); + + const session = await repo.getSession(id); + if (!session) { + throw new NotFoundError('Debug session not found'); + } + + const result = await repo.getLogs(productId, id, query); + return result; + }); + + app.get('/diagnostics/sessions/:id/screenshots', async (req) => { + await requireRole(req, 'admin'); + + const { id } = req.params as { id: string }; + + const session = await repo.getSession(id); + if (!session) { + throw new NotFoundError('Debug session not found'); + } + + const screenshots = await repo.getScreenshots(id); + return { screenshots }; + }); +}