diff --git a/e2e/diagnostics.e2e.spec.ts b/e2e/diagnostics.e2e.spec.ts new file mode 100644 index 00000000..e90492c2 --- /dev/null +++ b/e2e/diagnostics.e2e.spec.ts @@ -0,0 +1,327 @@ +/** + * E2E Integration Tests — Remote Diagnostics Phase 4 + * Tests the complete flow: Admin creates session → Client captures → Admin views + */ + +import { test, expect } from '@playwright/test'; + +const API_BASE = process.env.TEST_API_URL || 'http://localhost:4003'; +const ADMIN_TOKEN = process.env.TEST_ADMIN_TOKEN || 'test-admin-token'; +const PRODUCT_ID = 'test-product'; + +test.describe('Remote Diagnostics E2E Flow', () => { + let sessionId: string; + + test('[E2E-1] Admin can create debug session', async ({ request }) => { + const response = await request.post(`${API_BASE}/api/diagnostics/sessions`, { + headers: { + 'Authorization': `Bearer ${ADMIN_TOKEN}`, + 'X-Product-Id': PRODUCT_ID, + }, + data: { + targetDeviceId: 'test-device-123', + collectionLevel: 'debug', + captureLogs: true, + captureNetwork: true, + captureScreenshots: true, + maxDurationMinutes: 30, + }, + }); + + expect(response.status()).toBe(201); + const session = await response.json(); + expect(session.id).toBeDefined(); + expect(session.status).toBe('pending'); + expect(session.productId).toBe(PRODUCT_ID); + + sessionId = session.id; + }); + + test('[E2E-2] Client can poll for active session config', async ({ request }) => { + // First, admin must activate the session + const activateResponse = await request.patch(`${API_BASE}/api/diagnostics/sessions/${sessionId}`, { + headers: { + 'Authorization': `Bearer ${ADMIN_TOKEN}`, + 'X-Product-Id': PRODUCT_ID, + }, + data: { + status: 'active', + }, + }); + expect(activateResponse.status()).toBe(200); + + // Now client polls for config + const configResponse = await request.get(`${API_BASE}/api/diagnostics/config`, { + headers: { + 'Authorization': `Bearer ${ADMIN_TOKEN}`, + 'X-Product-Id': PRODUCT_ID, + 'X-Device-Id': 'test-device-123', + }, + }); + + expect(configResponse.status()).toBe(200); + const config = await configResponse.json(); + expect(config.enabled).toBe(true); + expect(config.sessionId).toBe(sessionId); + expect(config.captureLogs).toBe(true); + expect(config.captureNetwork).toBe(true); + }); + + test('[E2E-3] Client can ingest trace spans', async ({ request }) => { + const response = await request.post(`${API_BASE}/api/diagnostics/sessions/${sessionId}/traces`, { + headers: { + 'Authorization': `Bearer ${ADMIN_TOKEN}`, + 'X-Product-Id': PRODUCT_ID, + 'X-Device-Id': 'test-device-123', + }, + data: { + sessionId, + traces: [ + { + traceId: 'trace-001', + spanId: 'span-001', + name: 'UserLogin', + startTime: new Date().toISOString(), + durationMs: 150, + attributes: { userId: 'user-123' }, + status: 'ok', + }, + { + traceId: 'trace-002', + spanId: 'span-002', + parentId: 'span-001', + name: 'DatabaseQuery', + startTime: new Date().toISOString(), + durationMs: 50, + attributes: { query: 'SELECT * FROM users' }, + status: 'ok', + }, + ], + }, + }); + + expect(response.status()).toBe(200); + const result = await response.json(); + expect(result.accepted).toBe(2); + }); + + test('[E2E-4] Client can ingest log entries', async ({ request }) => { + const response = await request.post(`${API_BASE}/api/diagnostics/sessions/${sessionId}/logs`, { + headers: { + 'Authorization': `Bearer ${ADMIN_TOKEN}`, + 'X-Product-Id': PRODUCT_ID, + 'X-Device-Id': 'test-device-123', + }, + data: { + sessionId, + logs: [ + { + level: 'info', + message: 'Application started successfully', + timestamp: new Date().toISOString(), + module: 'AppLifecycle', + context: { version: '1.0.0' }, + }, + { + level: 'debug', + message: 'User authenticated', + timestamp: new Date().toISOString(), + module: 'AuthManager', + context: { userId: 'user-123', method: 'token' }, + }, + { + level: 'error', + message: 'Failed to load configuration', + timestamp: new Date().toISOString(), + module: 'ConfigLoader', + context: { error: 'File not found' }, + }, + ], + }, + }); + + expect(response.status()).toBe(200); + const result = await response.json(); + expect(result.accepted).toBe(3); + }); + + test('[E2E-5] Admin can query traces for session', async ({ request }) => { + const response = await request.get(`${API_BASE}/api/diagnostics/sessions/${sessionId}/traces`, { + headers: { + 'Authorization': `Bearer ${ADMIN_TOKEN}`, + 'X-Product-Id': PRODUCT_ID, + }, + }); + + expect(response.status()).toBe(200); + const result = await response.json(); + expect(Array.isArray(result.traces)).toBe(true); + expect(result.traces.length).toBeGreaterThanOrEqual(2); + }); + + test('[E2E-6] Admin can query logs for session', async ({ request }) => { + const response = await request.get(`${API_BASE}/api/diagnostics/sessions/${sessionId}/logs`, { + headers: { + 'Authorization': `Bearer ${ADMIN_TOKEN}`, + 'X-Product-Id': PRODUCT_ID, + }, + }); + + expect(response.status()).toBe(200); + const result = await response.json(); + expect(Array.isArray(result.logs)).toBe(true); + expect(result.logs.length).toBeGreaterThanOrEqual(3); + + // Verify log levels are preserved + const errorLog = result.logs.find((l: { level: string }) => l.level === 'error'); + expect(errorLog).toBeDefined(); + expect(errorLog.message).toContain('Failed to load'); + }); + + test('[E2E-7] Admin can complete debug session', async ({ request }) => { + const response = await request.patch(`${API_BASE}/api/diagnostics/sessions/${sessionId}`, { + headers: { + 'Authorization': `Bearer ${ADMIN_TOKEN}`, + 'X-Product-Id': PRODUCT_ID, + }, + data: { + status: 'completed', + }, + }); + + expect(response.status()).toBe(200); + const session = await response.json(); + expect(session.status).toBe('completed'); + expect(session.endedAt).toBeDefined(); + expect(session.traceCount).toBeGreaterThanOrEqual(2); + expect(session.logCount).toBeGreaterThanOrEqual(3); + }); +}); + +test.describe('Auto-Trigger E2E Tests', () => { + test('[E2E-AUTO-1] Admin can create error-threshold trigger', async ({ request }) => { + const response = await request.post(`${API_BASE}/api/diagnostics/triggers`, { + headers: { + 'Authorization': `Bearer ${ADMIN_TOKEN}`, + 'X-Product-Id': PRODUCT_ID, + }, + data: { + productId: PRODUCT_ID, + name: 'High Error Rate Alert', + enabled: true, + condition: { + type: 'error_rate', + threshold: 0.1, // 10% + windowMinutes: 5, + minEvents: 50, + }, + sessionConfig: { + collectionLevel: 'debug', + captureLogs: true, + captureNetwork: true, + captureScreenshots: true, + maxDurationMinutes: 60, + }, + notifications: { + emailAdmins: true, + }, + cooldownMinutes: 30, + }, + }); + + expect(response.status()).toBe(201); + const result = await response.json(); + expect(result.trigger.id).toBeDefined(); + expect(result.trigger.condition.type).toBe('error_rate'); + }); + + test('[E2E-AUTO-2] Admin can manually run triggers', async ({ request }) => { + const response = await request.post(`${API_BASE}/api/diagnostics/triggers/run`, { + headers: { + 'Authorization': `Bearer ${ADMIN_TOKEN}`, + 'X-Product-Id': PRODUCT_ID, + }, + data: { + productId: PRODUCT_ID, + }, + }); + + expect(response.status()).toBe(200); + const result = await response.json(); + expect(Array.isArray(result.results)).toBe(true); + }); + + test('[E2E-CRASH-1] Client can report crash and trigger auto-session', async ({ request }) => { + const response = await request.post(`${API_BASE}/api/diagnostics/crash-report`, { + headers: { + 'Authorization': `Bearer ${ADMIN_TOKEN}`, + 'X-Product-Id': PRODUCT_ID, + }, + data: { + productId: PRODUCT_ID, + deviceId: 'test-crash-device', + sessionId: 'test-session-456', + timestamp: new Date().toISOString(), + errorType: 'SIGSEGV', + errorMessage: 'Segmentation fault at 0x00000000', + stackTrace: 'Thread 0: 0x001 0x002 0x003', + breadcrumbs: [ + { timestamp: new Date().toISOString(), category: 'navigation', message: 'Opened settings' }, + { timestamp: new Date().toISOString(), category: 'action', message: 'Clicked save' }, + ], + deviceState: { + memoryUsage: 1024 * 1024 * 100, // 100MB + batteryLevel: 85, + osVersion: 'iOS 17.2', + appVersion: '1.5.0', + }, + }, + }); + + // Should accept the crash report (session may or may not be created due to cooldown) + expect([201, 202]).toContain(response.status()); + const result = await response.json(); + expect(result.accepted).toBe(true); + }); +}); + +test.describe('Access Control E2E Tests', () => { + test('[E2E-AC-1] Non-admin cannot create session', async ({ request }) => { + const response = await request.post(`${API_BASE}/api/diagnostics/sessions`, { + headers: { + 'Authorization': 'Bearer invalid-token', + 'X-Product-Id': PRODUCT_ID, + }, + data: { + targetDeviceId: 'test-device', + }, + }); + + expect(response.status()).toBe(401); + }); + + test('[E2E-AC-2] User cannot access another user\'s session data', async ({ request }) => { + // Create a session for user A + const createResponse = await request.post(`${API_BASE}/api/diagnostics/sessions`, { + headers: { + 'Authorization': `Bearer ${ADMIN_TOKEN}`, + 'X-Product-Id': PRODUCT_ID, + }, + data: { + targetUserId: 'user-a', + collectionLevel: 'standard', + }, + }); + const session = await createResponse.json(); + + // User B tries to access + const getResponse = await request.get(`${API_BASE}/api/diagnostics/sessions/${session.id}`, { + headers: { + 'Authorization': 'Bearer user-b-token', + 'X-Product-Id': PRODUCT_ID, + }, + }); + + expect(getResponse.status()).toBe(403); + }); +}); diff --git a/services/platform-service/src/modules/diagnostics/crash-trigger.ts b/services/platform-service/src/modules/diagnostics/crash-trigger.ts new file mode 100644 index 00000000..326c5195 --- /dev/null +++ b/services/platform-service/src/modules/diagnostics/crash-trigger.ts @@ -0,0 +1,184 @@ +/** + * Crash-Triggered Auto Sessions — Remote Diagnostics Phase 4 + * Automatically start debug sessions when crashes are detected. + */ + +import type { FastifyInstance } from 'fastify'; +import type { DebugSessionDoc } from './types.js'; +import { createSession } from './repository.js'; +import { bus } from '../../lib/event-bus.js'; +import { randomUUID } from 'node:crypto'; + +export interface CrashReport { + productId: string; + userId?: string; + anonymousId?: string; + deviceId: string; + sessionId: string; + timestamp: string; + errorType: string; + errorMessage: string; + stackTrace?: string; + breadcrumbs: Array<{ + timestamp: string; + category: string; + message: string; + }>; + deviceState: { + memoryUsage: number; + batteryLevel?: number; + osVersion: string; + appVersion: string; + }; +} + +export interface AutoSessionConfig { + enabled: boolean; + preCrashContextSeconds: number; // How much history to capture (default: 60) + collectionLevel: 'standard' | 'debug' | 'trace'; + captureScreenshots: boolean; + maxDurationMinutes: number; + cooldownMinutes: number; // Prevent spam +} + +const CRASH_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between auto-sessions per device +const recentCrashes = new Map(); // deviceId -> lastCrashTimestamp + +/** + * Handle incoming crash report and auto-start debug session if configured. + */ +export async function handleCrashReport( + crash: CrashReport, + config: AutoSessionConfig, + adminUserId: string +): Promise<{ session?: DebugSessionDoc; created: boolean; reason?: string }> { + if (!config.enabled) { + return { created: false, reason: 'Auto-sessions disabled' }; + } + + // Check cooldown + const lastCrash = recentCrashes.get(crash.deviceId); + if (lastCrash && Date.now() - lastCrash < config.cooldownMinutes * 60 * 1000) { + return { created: false, reason: 'Cooldown active' }; + } + + // Record this crash + recentCrashes.set(crash.deviceId, Date.now()); + + // Clean up old entries (devices not seen in 1 hour) + const oneHourAgo = Date.now() - 60 * 60 * 1000; + for (const [deviceId, timestamp] of recentCrashes) { + if (timestamp < oneHourAgo) { + recentCrashes.delete(deviceId); + } + } + + // Create auto-session + const now = new Date().toISOString(); + const expiresAt = new Date(Date.now() + config.maxDurationMinutes * 60 * 1000).toISOString(); + + const session: DebugSessionDoc = { + id: `ds_${randomUUID().replace(/-/g, '')}`, + productId: crash.productId, + targetUserId: crash.userId, + targetAnonymousId: crash.anonymousId, + targetDeviceId: crash.deviceId, + targetSessionId: crash.sessionId, + status: 'active', + collectionLevel: config.collectionLevel, + captureLogs: true, + captureNetwork: true, + captureScreenshots: config.captureScreenshots, + screenshotOnError: true, + maxDurationMinutes: config.maxDurationMinutes, + createdAt: now, + updatedAt: now, + startedAt: now, + expiresAt, + logCount: 0, + traceCount: 0, + screenshotCount: 0, + createdBy: adminUserId, + }; + + await createSession(session); + + // Emit event for notifications + bus.emit('diagnostics.session.created', { + sessionId: session.id, + productId: crash.productId, + targetUserId: crash.userId, + targetAnonymousId: crash.anonymousId, + targetDeviceId: crash.deviceId, + createdBy: adminUserId, + }); + + return { session, created: true }; +} + +/** + * Fastify route handler for crash report ingestion. + */ +export async function crashTriggerRoutes(app: FastifyInstance): Promise { + // Client crash reporting endpoint + app.post('/diagnostics/crash-report', async (req, reply) => { + // Require authentication + if (!req.jwtPayload?.sub) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + const crash = req.body as CrashReport; + + // Validate required fields + if (!crash.productId || !crash.deviceId || !crash.errorType) { + return reply.status(400).send({ + error: 'Missing required fields: productId, deviceId, errorType', + }); + } + + // Default auto-session config + const config: AutoSessionConfig = { + enabled: true, + preCrashContextSeconds: 60, + collectionLevel: 'debug', + captureScreenshots: true, + maxDurationMinutes: 30, + cooldownMinutes: 5, + }; + + const result = await handleCrashReport(crash, config, req.jwtPayload.sub); + + if (result.created && result.session) { + reply.status(201); + return { + accepted: true, + sessionId: result.session.id, + message: 'Crash report accepted, debug session started', + }; + } else { + reply.status(202); + return { + accepted: true, + sessionId: null, + reason: result.reason, + message: 'Crash report accepted, but no session created', + }; + } + }); + + // Get crash-triggered sessions for a product (admin only) + app.get('/diagnostics/crash-sessions', async (req) => { + // Import requireRole inline to avoid circular dependency + const { requireRole } = await import('../../lib/auth.js'); + await requireRole(req, 'admin'); + + const { productId } = req.query as { productId?: string }; + if (!productId) { + return { sessions: [] }; + } + + // Query for sessions that were created from crash triggers + // These are identified by the event bus events + return { sessions: [] }; // Placeholder - would query by trigger metadata + }); +} diff --git a/services/platform-service/src/modules/diagnostics/performance-profile-types.ts b/services/platform-service/src/modules/diagnostics/performance-profile-types.ts new file mode 100644 index 00000000..c5da6a77 --- /dev/null +++ b/services/platform-service/src/modules/diagnostics/performance-profile-types.ts @@ -0,0 +1,271 @@ +/** + * Performance Profiling Types — Remote Diagnostics Phase 4 + * CPU/Memory profiling for iOS, Android, and Web. + */ + +import { z } from 'zod'; + +// ───────────────────────────────────────────────────────────────────────────── +// Profile Types +// ───────────────────────────────────────────────────────────────────────────── + +export const ProfileTypeEnum = z.enum(['cpu', 'memory', 'battery', 'network', 'render']); +export type ProfileType = z.infer; + +export const PlatformEnum = z.enum(['ios', 'android', 'web']); +export type ProfilePlatform = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// CPU Profile +// ───────────────────────────────────────────────────────────────────────────── + +export interface CPUProfileSample { + timestamp: number; // milliseconds since profile start + threadId: string; + threadName?: string; + cpuUsagePercent: number; // 0-100 + callStack?: string[]; // Function/method names in stack +} + +export interface CPUProfile { + type: 'cpu'; + platform: ProfilePlatform; + samples: CPUProfileSample[]; + durationMs: number; + samplingIntervalMs: number; + threads: Array<{ + id: string; + name: string; + avgCpuPercent: number; + maxCpuPercent: number; + }>; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Memory Profile +// ───────────────────────────────────────────────────────────────────────────── + +export interface MemoryProfileSample { + timestamp: number; + usedBytes: number; + totalBytes: number; + heapBytes?: number; + stackBytes?: number; + nativeBytes?: number; // For React Native / hybrid apps + objectCounts?: Record; // Class name -> instance count +} + +export interface MemoryProfile { + type: 'memory'; + platform: ProfilePlatform; + samples: MemoryProfileSample[]; + durationMs: number; + samplingIntervalMs: number; + peakUsageBytes: number; + leakSuspects?: Array<{ + className: string; + instanceCount: number; + growthRate: number; // instances per minute + }>; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Battery Profile +// ───────────────────────────────────────────────────────────────────────────── + +export interface BatteryProfileSample { + timestamp: number; + level: number; // 0-100 + state: 'charging' | 'discharging' | 'full' | 'unknown'; + powerSource?: 'battery' | 'ac' | 'usb' | 'wireless'; + estimatedTimeRemaining?: number; // minutes + temperature?: number; // celsius (Android only) +} + +export interface BatteryProfile { + type: 'battery'; + platform: ProfilePlatform; + samples: BatteryProfileSample[]; + durationMs: number; + samplingIntervalMs: number; + drainRatePerHour: number; // percentage per hour +} + +// ───────────────────────────────────────────────────────────────────────────── +// Network Profile +// ───────────────────────────────────────────────────────────────────────────── + +export interface NetworkProfileSample { + timestamp: number; + bytesSent: number; + bytesReceived: number; + requestsCount: number; + errorsCount: number; + latencyAvg: number; // ms + latencyMax: number; // ms +} + +export interface NetworkProfile { + type: 'network'; + platform: ProfilePlatform; + samples: NetworkProfileSample[]; + durationMs: number; + samplingIntervalMs: number; + totalBytesSent: number; + totalBytesReceived: number; + totalRequests: number; + totalErrors: number; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Render Profile (Frame times) +// ───────────────────────────────────────────────────────────────────────────── + +export interface RenderProfileSample { + timestamp: number; + frameTimeMs: number; + droppedFrames: number; + gpuTimeMs?: number; + layoutTimeMs?: number; + drawTimeMs?: number; +} + +export interface RenderProfile { + type: 'render'; + platform: ProfilePlatform; + samples: RenderProfileSample[]; + durationMs: number; + fps: number; + frameDrops: number; + avgFrameTimeMs: number; + p95FrameTimeMs: number; + p99FrameTimeMs: number; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Profile Document (stored in Cosmos) +// ───────────────────────────────────────────────────────────────────────────── + +export type ProfileData = CPUProfile | MemoryProfile | BatteryProfile | NetworkProfile | RenderProfile; + +export interface PerformanceProfileDoc { + id: string; + pk: string; // Composite: `${productId}:${sessionId}` + sessionId: string; + productId: string; + + // Profile metadata + profileId: string; + profileType: ProfileType; + platform: ProfilePlatform; + deviceInfo: { + deviceModel: string; + osVersion: string; + appVersion: string; + cpuCores?: number; + totalMemoryBytes?: number; + }; + + // Profile data + profile: ProfileData; + + // Timestamps + startedAt: string; + endedAt: string; + createdAt: string; + + // TTL + ttl: number; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Input Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const IngestProfileSchema = z.object({ + sessionId: z.string(), + profileType: ProfileTypeEnum, + platform: PlatformEnum, + deviceInfo: z.object({ + deviceModel: z.string(), + osVersion: z.string(), + appVersion: z.string(), + cpuCores: z.number().optional(), + totalMemoryBytes: z.number().optional(), + }), + profile: z.record(z.unknown()), // ProfileData validated at runtime +}); + +export const QueryProfilesSchema = z.object({ + profileType: ProfileTypeEnum.optional(), + platform: PlatformEnum.optional(), + limit: z.coerce.number().min(1).max(50).default(10), + continuationToken: z.string().optional(), +}); + +export type IngestProfileInput = z.infer; +export type QueryProfilesInput = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Profiling Configuration +// ───────────────────────────────────────────────────────────────────────────── + +export interface ProfilingConfig { + enabled: boolean; + autoStart: boolean; + + // Profile types to capture + cpu: { + enabled: boolean; + samplingIntervalMs: number; + durationMs: number; + }; + memory: { + enabled: boolean; + samplingIntervalMs: number; + durationMs: number; + trackObjectCounts: boolean; + }; + battery: { + enabled: boolean; + samplingIntervalMs: number; + }; + network: { + enabled: boolean; + samplingIntervalMs: number; + }; + render: { + enabled: boolean; + samplingIntervalMs: number; + }; + + // Trigger conditions + triggers: { + onHighCPU: boolean; // Auto-profile when CPU > threshold + cpuThreshold: number; + onMemoryPressure: boolean; // Auto-profile on memory warning + onLowBattery: boolean; // Auto-profile when battery < 20% + onFrameDrops: boolean; // Auto-profile when frames drop + frameDropThreshold: number; + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Profile Analysis Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface ProfileAnalysis { + profileId: string; + issues: Array<{ + severity: 'info' | 'warning' | 'critical'; + category: string; + description: string; + recommendation: string; + evidence: Record; + }>; + summary: { + healthScore: number; // 0-100 + topIssues: string[]; + recommendations: string[]; + }; +} diff --git a/services/platform-service/src/modules/diagnostics/session-replay-types.ts b/services/platform-service/src/modules/diagnostics/session-replay-types.ts new file mode 100644 index 00000000..ecc87462 --- /dev/null +++ b/services/platform-service/src/modules/diagnostics/session-replay-types.ts @@ -0,0 +1,220 @@ +/** + * Session Replay Types — Remote Diagnostics Phase 4 + * DOM/View state capture for video-style replay. + */ + +import { z } from 'zod'; + +// ───────────────────────────────────────────────────────────────────────────── +// Replay Event Types +// ───────────────────────────────────────────────────────────────────────────── + +export const ReplayEventTypeEnum = z.enum([ + 'initial_state', + 'dom_mutation', + 'view_change', + 'user_interaction', + 'network_request', + 'console_log', + 'error', + 'scroll', + 'resize', +]); + +export type ReplayEventType = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Base Replay Event +// ───────────────────────────────────────────────────────────────────────────── + +export interface ReplayEvent { + id: string; + sessionId: string; + productId: string; + timestamp: number; // milliseconds since session start + type: ReplayEventType; + data: Record; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Specific Event Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface InitialStateEvent extends ReplayEvent { + type: 'initial_state'; + data: { + url: string; + viewport: { width: number; height: number }; + domSnapshot?: string; // Serialized DOM (privacy-sanitized) + viewHierarchy?: string; // For native apps (iOS/Android) + styles?: Record; // CSS styles + }; +} + +export interface DOMMutationEvent extends ReplayEvent { + type: 'dom_mutation'; + data: { + target: string; // CSS selector or element path + mutationType: 'childList' | 'attributes' | 'characterData'; + addedNodes?: string[]; + removedNodes?: string[]; + attributeName?: string; + attributeValue?: string; + oldValue?: string; + }; +} + +export interface ViewChangeEvent extends ReplayEvent { + type: 'view_change'; + data: { + viewName: string; + viewId?: string; + viewController?: string; // iOS: UIViewController, Android: Activity/Fragment + params?: Record; + }; +} + +export interface UserInteractionEvent extends ReplayEvent { + type: 'user_interaction'; + data: { + interactionType: 'click' | 'tap' | 'input' | 'focus' | 'blur' | 'hover' | 'keypress'; + target: string; // CSS selector or element path + x?: number; // Coordinates for click/tap + y?: number; + value?: string; // For input fields (sanitized - no passwords) + key?: string; // For keypress events + }; +} + +export interface ScrollEvent extends ReplayEvent { + type: 'scroll'; + data: { + target: string; + scrollX: number; + scrollY: number; + }; +} + +export interface ResizeEvent extends ReplayEvent { + type: 'resize'; + data: { + width: number; + height: number; + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Session Replay Document (stored in Cosmos) +// ───────────────────────────────────────────────────────────────────────────── + +export interface SessionReplayDoc { + id: string; + pk: string; // Composite: `${productId}:${sessionId}` + sessionId: string; + productId: string; + + // Event data + events: ReplayEvent[]; + eventCount: number; + + // Metadata + startTimestamp: string; + endTimestamp?: string; + durationMs: number; + + // Privacy settings + privacyConfig: { + maskInputs: boolean; + maskAllInputs: boolean; // If true, all input values are masked + unmaskInputs: string[]; // CSS selectors for inputs to NOT mask + blockSelector: string; // CSS selector for elements to completely block (e.g., password fields) + ignoreSelector: string; // CSS selector for elements to ignore + }; + + // Storage + createdAt: string; + ttl: number; // Auto-expiry (default 7 days) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Replay Configuration +// ───────────────────────────────────────────────────────────────────────────── + +export interface ReplayConfig { + enabled: boolean; + sampleRate: number; // 0-1, percentage of sessions to capture + maxDurationMinutes: number; + maxEvents: number; + + // Privacy settings + privacy: { + maskInputs: boolean; + maskAllInputs: boolean; + unmaskInputs: string[]; + blockSelector: string; + ignoreSelector: string; + }; + + // Performance + throttleMouse: number; // Minimum ms between mouse move events + throttleScroll: number; // Minimum ms between scroll events + inlineStylesheet: boolean; // Inline stylesheets for accurate replay + recordCanvas: boolean; // Experimental: record canvas operations +} + +// ───────────────────────────────────────────────────────────────────────────── +// Input Schemas +// ───────────────────────────────────────────────────────────────────────────── + +export const ReplayEventSchema = z.object({ + id: z.string(), + sessionId: z.string(), + timestamp: z.number(), + type: ReplayEventTypeEnum, + data: z.record(z.unknown()), +}); + +export const IngestReplayEventsSchema = z.object({ + sessionId: z.string(), + events: z.array(ReplayEventSchema).max(100), + startTimestamp: z.string().datetime(), + privacyConfig: z.object({ + maskInputs: z.boolean(), + maskAllInputs: z.boolean(), + unmaskInputs: z.array(z.string()), + blockSelector: z.string(), + ignoreSelector: z.string(), + }), +}); + +export const QueryReplaySchema = z.object({ + limit: z.coerce.number().min(1).max(500).default(100), + continuationToken: z.string().optional(), +}); + +export type IngestReplayEventsInput = z.infer; +export type QueryReplayInput = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Client SDK Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface ReplayRecorder { + start(): void; + stop(): void; + pause(): void; + resume(): void; + isRecording(): boolean; + getEvents(): ReplayEvent[]; + clear(): void; +} + +// Configuration for client SDK +export interface ReplayRecorderConfig { + sessionId: string; + productId: string; + apiEndpoint: string; + flushIntervalMs: number; + maxBufferSize: number; + privacy: ReplayConfig['privacy']; +} diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 6928284d..d9693a3e 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -49,6 +49,7 @@ import { waitlistRoutes } from './modules/waitlist/routes.js'; import { telemetryRoutes } from './modules/telemetry/routes.js'; import { diagnosticsRoutes } from './modules/diagnostics/routes.js'; import { autoTriggerRoutes } from './modules/diagnostics/auto-trigger-routes.js'; +import { crashTriggerRoutes } from './modules/diagnostics/crash-trigger.js'; import { broadcastRoutes } from './modules/broadcasts/routes.js'; import { surveyRoutes } from './modules/surveys/routes.js'; import { jobRoutes } from './modules/jobs/routes.js'; @@ -153,6 +154,8 @@ await app.register(telemetryRoutes, { prefix: '/api' }); await app.register(diagnosticsRoutes, { prefix: '/api' }); // Auto-trigger routes for automated debug sessions (Phase 4) await app.register(autoTriggerRoutes, { prefix: '/api' }); +// Crash-trigger routes for crash-triggered auto-sessions (Phase 4) +await app.register(crashTriggerRoutes, { prefix: '/api' }); // Public routes — no auth, registered at top level await app.register(publicRoutes, { prefix: '/api' }); // Scheduled jobs module (admin: list, trigger, view runs)