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:
parent
f272a44bbe
commit
a66a689d7d
475
services/platform-service/src/modules/diagnostics/routes.ts
Normal file
475
services/platform-service/src/modules/diagnostics/routes.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user