feat(diagnostics): Phase 4 - automated triggers, crash sessions, session replay, profiling [4.1][4.2][4.3][4.4]
This commit is contained in:
parent
57168639f6
commit
05ad9dbedc
327
e2e/diagnostics.e2e.spec.ts
Normal file
327
e2e/diagnostics.e2e.spec.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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<string, number>(); // 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<void> {
|
||||||
|
// 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
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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<typeof ProfileTypeEnum>;
|
||||||
|
|
||||||
|
export const PlatformEnum = z.enum(['ios', 'android', 'web']);
|
||||||
|
export type ProfilePlatform = z.infer<typeof PlatformEnum>;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 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<string, number>; // 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<typeof IngestProfileSchema>;
|
||||||
|
export type QueryProfilesInput = z.infer<typeof QueryProfilesSchema>;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 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<string, unknown>;
|
||||||
|
}>;
|
||||||
|
summary: {
|
||||||
|
healthScore: number; // 0-100
|
||||||
|
topIssues: string[];
|
||||||
|
recommendations: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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<typeof ReplayEventTypeEnum>;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Base Replay Event
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ReplayEvent {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
productId: string;
|
||||||
|
timestamp: number; // milliseconds since session start
|
||||||
|
type: ReplayEventType;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 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<string, string>; // 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<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<typeof IngestReplayEventsSchema>;
|
||||||
|
export type QueryReplayInput = z.infer<typeof QueryReplaySchema>;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 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'];
|
||||||
|
}
|
||||||
@ -49,6 +49,7 @@ import { waitlistRoutes } from './modules/waitlist/routes.js';
|
|||||||
import { telemetryRoutes } from './modules/telemetry/routes.js';
|
import { telemetryRoutes } from './modules/telemetry/routes.js';
|
||||||
import { diagnosticsRoutes } from './modules/diagnostics/routes.js';
|
import { diagnosticsRoutes } from './modules/diagnostics/routes.js';
|
||||||
import { autoTriggerRoutes } from './modules/diagnostics/auto-trigger-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 { broadcastRoutes } from './modules/broadcasts/routes.js';
|
||||||
import { surveyRoutes } from './modules/surveys/routes.js';
|
import { surveyRoutes } from './modules/surveys/routes.js';
|
||||||
import { jobRoutes } from './modules/jobs/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' });
|
await app.register(diagnosticsRoutes, { prefix: '/api' });
|
||||||
// Auto-trigger routes for automated debug sessions (Phase 4)
|
// Auto-trigger routes for automated debug sessions (Phase 4)
|
||||||
await app.register(autoTriggerRoutes, { prefix: '/api' });
|
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
|
// Public routes — no auth, registered at top level
|
||||||
await app.register(publicRoutes, { prefix: '/api' });
|
await app.register(publicRoutes, { prefix: '/api' });
|
||||||
// Scheduled jobs module (admin: list, trigger, view runs)
|
// Scheduled jobs module (admin: list, trigger, view runs)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user