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
This commit is contained in:
saravanakumardb1 2026-03-02 23:36:59 -08:00
parent f272a44bbe
commit a66a689d7d

View File

@ -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<string, string[]> = {
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<DebugSessionDoc> = {
...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 };
});
}