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