diff --git a/services/platform-service/src/modules/ab-testing/repository.ts b/services/platform-service/src/modules/ab-testing/repository.ts index 5d2a0f6d..72633f72 100644 --- a/services/platform-service/src/modules/ab-testing/repository.ts +++ b/services/platform-service/src/modules/ab-testing/repository.ts @@ -14,9 +14,10 @@ import type { ExperimentSuggestion, CreateExperimentInput, UpdateExperimentInput, + TargetingConfig, } from './types.js'; import { assignVariant, assignByStrategy, isInExperimentBucket, type StrategyContext } from './bucketing.js'; -import type { TargetingContext, TargetingConfig } from './targeting.js'; +import type { TargetingContext } from './targeting.js'; import { matchesTargeting } from './targeting.js'; // ───────────────────────────────────────────────────────────────────────────── diff --git a/services/platform-service/src/modules/diagnostics/performance-profile-repository.ts b/services/platform-service/src/modules/diagnostics/performance-profile-repository.ts new file mode 100644 index 00000000..f0bd816f --- /dev/null +++ b/services/platform-service/src/modules/diagnostics/performance-profile-repository.ts @@ -0,0 +1,127 @@ +/** + * Performance Profile Repository — Remote Diagnostics + * Ingest and query performance profiling data. + */ + +import { getCollection } from '../../lib/datastore.js'; +import type { + PerformanceProfileDoc, + ProfileData, + QueryProfilesInput, +} from './performance-profile-types.js'; + +const PROFILES_CONTAINER = 'performance_profiles'; + +function profilesCollection() { + return getCollection(PROFILES_CONTAINER, '/pk'); +} + +/** + * Ingest a performance profile. + */ +export async function ingestProfile( + doc: PerformanceProfileDoc +): Promise<{ id: string }> { + const collection = profilesCollection(); + await collection.upsert(doc); + return { id: doc.id }; +} + +/** + * Get a performance profile by ID. + */ +export async function getProfile( + productId: string, + sessionId: string, + profileId: string +): Promise { + const collection = profilesCollection(); + const pk = `${productId}:${sessionId}`; + + const results = await collection.findMany({ + filter: { pk, profileId }, + limit: 1, + }); + + return results[0] ?? null; +} + +/** + * Query performance profiles for a session. + */ +export async function queryProfiles( + productId: string, + sessionId: string, + query: QueryProfilesInput +): Promise<{ + profiles: PerformanceProfileDoc[]; + total: number; +}> { + const collection = profilesCollection(); + const pk = `${productId}:${sessionId}`; + + // Build filter + const filter: Record = { pk }; + if (query.profileType) { + filter.profileType = query.profileType; + } + if (query.platform) { + filter.platform = query.platform; + } + + const results = await collection.findMany({ + filter, + limit: query.limit, + }); + + return { + profiles: results, + total: results.length, + }; +} + +/** + * List all profiles for a product (admin query). + */ +export async function listProfilesForProduct( + productId: string, + limit: number = 50 +): Promise { + const collection = profilesCollection(); + + // Query by productId field (not pk prefix) + const results = await collection.findMany({ + filter: { productId }, + limit, + }); + + return results; +} + +/** + * Delete a performance profile. + */ +export async function deleteProfile( + productId: string, + sessionId: string, + profileId: string +): Promise { + const collection = profilesCollection(); + const pk = `${productId}:${sessionId}`; + + const results = await collection.findMany({ + filter: { pk, profileId }, + limit: 1, + }); + + if (results.length === 0) return false; + + // Soft delete + await collection.upsert({ + ...results[0], + profile: { type: results[0].profile.type, platform: results[0].platform } as ProfileData, + updatedAt: new Date().toISOString(), + }); + + return true; +} diff --git a/services/platform-service/src/modules/diagnostics/performance-profile-routes.ts b/services/platform-service/src/modules/diagnostics/performance-profile-routes.ts new file mode 100644 index 00000000..61301d2d --- /dev/null +++ b/services/platform-service/src/modules/diagnostics/performance-profile-routes.ts @@ -0,0 +1,150 @@ +/** + * Performance Profile Routes — Remote Diagnostics + * Ingest and query performance profiling data. + */ + +import type { FastifyInstance } from 'fastify'; +import { randomUUID } from 'node:crypto'; +import { requireRole } from '../../lib/auth.js'; +import { IngestProfileSchema, QueryProfilesSchema } from './performance-profile-types.js'; +import { + ingestProfile, + getProfile, + queryProfiles, + listProfilesForProduct, +} from './performance-profile-repository.js'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { NotFoundError, BadRequestError } from '../../lib/errors.js'; +import type { PerformanceProfileDoc } from './performance-profile-types.js'; + +export async function performanceProfileRoutes(app: FastifyInstance): Promise { + // Ingest performance profile (client endpoint) + app.post('/diagnostics/sessions/:id/profiles', async (req, reply) => { + const { id: sessionId } = req.params as { id: string }; + const productId = getRequestProductId(req); + + // Require authentication + if (!req.jwtPayload?.sub) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + // Validate request body + const result = IngestProfileSchema.safeParse(req.body); + if (!result.success) { + return reply.status(400).send({ + error: 'Invalid profile data', + details: result.error.issues, + }); + } + + const { profileType, platform, deviceInfo, profile } = result.data; + + // Validate session ID matches + if (result.data.sessionId !== sessionId) { + throw new BadRequestError('Session ID mismatch'); + } + + // Create profile document + const now = new Date().toISOString(); + const profileId = `prof_${randomUUID().replace(/-/g, '')}`; + + const doc: PerformanceProfileDoc = { + id: `pp_${randomUUID().replace(/-/g, '')}`, + pk: `${productId}:${sessionId}`, + sessionId, + productId, + profileId, + profileType, + platform, + deviceInfo, + profile: profile as unknown as PerformanceProfileDoc['profile'], + startedAt: now, + endedAt: now, + createdAt: now, + ttl: 7 * 86400, // 7 days + }; + + const { id } = await ingestProfile(doc); + + app.log.info(`Ingested ${profileType} profile ${profileId} for session ${sessionId}`); + + reply.status(201); + return { + id, + profileId, + sessionId, + profileType, + }; + }); + + // Get specific profile (admin only) + app.get('/diagnostics/sessions/:id/profiles/:profileId', async (req, reply) => { + await requireRole(req, 'admin'); + + const { id: sessionId, profileId } = req.params as { id: string; profileId: string }; + const productId = getRequestProductId(req); + + const profile = await getProfile(productId, sessionId, profileId); + if (!profile) { + throw new NotFoundError('Performance profile not found'); + } + + return { + id: profile.id, + profileId: profile.profileId, + profileType: profile.profileType, + platform: profile.platform, + deviceInfo: profile.deviceInfo, + profile: profile.profile, + startedAt: profile.startedAt, + endedAt: profile.endedAt, + }; + }); + + // Query profiles for a session (admin only) + app.get('/diagnostics/sessions/:id/profiles', async (req, reply) => { + await requireRole(req, 'admin'); + + const { id: sessionId } = req.params as { id: string }; + const productId = getRequestProductId(req); + const query = QueryProfilesSchema.parse(req.query); + + const result = await queryProfiles(productId, sessionId, query); + + return { + profiles: result.profiles.map(p => ({ + id: p.id, + profileId: p.profileId, + profileType: p.profileType, + platform: p.platform, + deviceInfo: p.deviceInfo, + startedAt: p.startedAt, + endedAt: p.endedAt, + })), + total: result.total, + }; + }); + + // List all profiles for product (admin only) + app.get('/diagnostics/profiles', async (req, reply) => { + await requireRole(req, 'admin'); + + const productId = getRequestProductId(req); + const query = req.query as { limit?: string }; + const limit = Math.min(parseInt(query?.limit || '50', 10), 100); + + const profiles = await listProfilesForProduct(productId, limit); + + return { + profiles: profiles.map(p => ({ + id: p.id, + profileId: p.profileId, + sessionId: p.sessionId, + profileType: p.profileType, + platform: p.platform, + deviceInfo: p.deviceInfo, + startedAt: p.startedAt, + })), + }; + }); +} diff --git a/services/platform-service/src/modules/diagnostics/performance-profile-types.ts b/services/platform-service/src/modules/diagnostics/performance-profile-types.ts index c5da6a77..ff187303 100644 --- a/services/platform-service/src/modules/diagnostics/performance-profile-types.ts +++ b/services/platform-service/src/modules/diagnostics/performance-profile-types.ts @@ -173,6 +173,7 @@ export interface PerformanceProfileDoc { startedAt: string; endedAt: string; createdAt: string; + updatedAt?: string; // TTL ttl: number; diff --git a/services/platform-service/src/modules/diagnostics/session-replay-repository.ts b/services/platform-service/src/modules/diagnostics/session-replay-repository.ts index a007c1fa..831e9a86 100644 --- a/services/platform-service/src/modules/diagnostics/session-replay-repository.ts +++ b/services/platform-service/src/modules/diagnostics/session-replay-repository.ts @@ -65,6 +65,7 @@ export async function ingestReplayEvents( durationMs: lastEvent?.timestamp || 0, privacyConfig, createdAt: now, + updatedAt: now, ttl: 7 * 86400, // 7 days }; diff --git a/services/platform-service/src/modules/diagnostics/session-replay-routes.ts b/services/platform-service/src/modules/diagnostics/session-replay-routes.ts new file mode 100644 index 00000000..522f15e2 --- /dev/null +++ b/services/platform-service/src/modules/diagnostics/session-replay-routes.ts @@ -0,0 +1,98 @@ +/** + * Session Replay Routes — Remote Diagnostics + * Ingest and query session replay events. + */ + +import type { FastifyInstance } from 'fastify'; +import { requireRole } from '../../lib/auth.js'; +import { IngestReplayEventsSchema, QueryReplaySchema } from './session-replay-types.js'; +import { + ingestReplayEvents, + getSessionReplay, + queryReplayEvents, +} from './session-replay-repository.js'; +import { getRequestProductId } from '../../lib/request-context.js'; +import { NotFoundError, BadRequestError } from '../../lib/errors.js'; + +export async function sessionReplayRoutes(app: FastifyInstance): Promise { + // Ingest replay events (client endpoint) + app.post('/diagnostics/sessions/:id/replay', async (req, reply) => { + const { id: sessionId } = req.params as { id: string }; + const productId = getRequestProductId(req); + const deviceId = req.headers['x-device-id'] as string | undefined; + + // Require authentication + if (!req.jwtPayload?.sub) { + return reply.status(401).send({ error: 'Authentication required' }); + } + + // Validate request body + const result = IngestReplayEventsSchema.safeParse(req.body); + if (!result.success) { + return reply.status(400).send({ + error: 'Invalid replay events', + details: result.error.issues, + }); + } + + const { events, privacyConfig } = result.data; + + // Validate session ID matches + if (result.data.sessionId !== sessionId) { + throw new BadRequestError('Session ID mismatch'); + } + + // Ingest events + const ingestResult = await ingestReplayEvents( + productId, + sessionId, + events, + privacyConfig + ); + + app.log.info(`Ingested ${ingestResult.accepted} replay events for session ${sessionId}`); + + return { + accepted: ingestResult.accepted, + sessionId, + }; + }); + + // Get full session replay (admin only) + app.get('/diagnostics/sessions/:id/replay', async (req, reply) => { + await requireRole(req, 'admin'); + + const { id: sessionId } = req.params as { id: string }; + const productId = getRequestProductId(req); + + const replay = await getSessionReplay(productId, sessionId); + if (!replay) { + throw new NotFoundError('Session replay not found'); + } + + return { + sessionId: replay.sessionId, + eventCount: replay.eventCount, + durationMs: replay.durationMs, + events: replay.events, + privacyConfig: replay.privacyConfig, + }; + }); + + // Query replay events with pagination (admin only) + app.get('/diagnostics/sessions/:id/replay/events', async (req, reply) => { + await requireRole(req, 'admin'); + + const { id: sessionId } = req.params as { id: string }; + const productId = getRequestProductId(req); + const query = QueryReplaySchema.parse(req.query); + + const result = await queryReplayEvents(productId, sessionId, query); + + return { + events: result.events, + totalEvents: result.totalEvents, + continuationToken: result.continuationToken, + }; + }); +} diff --git a/services/platform-service/src/modules/diagnostics/session-replay-types.ts b/services/platform-service/src/modules/diagnostics/session-replay-types.ts index ecc87462..c97f8597 100644 --- a/services/platform-service/src/modules/diagnostics/session-replay-types.ts +++ b/services/platform-service/src/modules/diagnostics/session-replay-types.ts @@ -30,7 +30,6 @@ export type ReplayEventType = z.infer; export interface ReplayEvent { id: string; sessionId: string; - productId: string; timestamp: number; // milliseconds since session start type: ReplayEventType; data: Record; @@ -133,6 +132,7 @@ export interface SessionReplayDoc { // Storage createdAt: string; + updatedAt: string; ttl: number; // Auto-expiry (default 7 days) } diff --git a/services/platform-service/src/modules/diagnostics/trigger-job.ts b/services/platform-service/src/modules/diagnostics/trigger-job.ts new file mode 100644 index 00000000..2036c529 --- /dev/null +++ b/services/platform-service/src/modules/diagnostics/trigger-job.ts @@ -0,0 +1,148 @@ +/** + * Diagnostic Trigger Job — Background job for auto-trigger evaluation + * Runs every 5 minutes to check trigger conditions and auto-start sessions. + */ + +import { setInterval } from 'node:timers'; +import { runAllTriggers } from './auto-triggers.js'; +import { getRegisteredContainer } from '@bytelyst/cosmos'; +import type { TriggerConfig } from './auto-triggers.js'; + +const TRIGGER_CONTAINER = 'diagnostic_triggers'; +const EVALUATION_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + +let jobInterval: ReturnType | null = null; + +/** + * Get all unique product IDs that have triggers configured. + */ +async function getProductsWithTriggers(): Promise { + const container = await getRegisteredContainer(TRIGGER_CONTAINER); + + // Query all triggers and extract unique product IDs + const query = { + query: 'SELECT DISTINCT c.productId FROM c WHERE c.enabled = true', + }; + + const { resources } = await container.items.query<{ productId: string }>(query).fetchAll(); + return resources.map(r => r.productId); +} + +/** + * Evaluate a single trigger by ID. + * Used for testing or manual re-evaluation. + */ +export async function evaluateSingleTrigger( + triggerId: string, + adminUserId: string +): Promise<{ triggered: boolean; reason?: string; sessionId?: string }> { + const container = await getRegisteredContainer(TRIGGER_CONTAINER); + + try { + const { resource } = await container.item(triggerId, triggerId).read(); + if (!resource) { + return { triggered: false, reason: 'Trigger not found' }; + } + + if (!resource.enabled) { + return { triggered: false, reason: 'Trigger disabled' }; + } + + const { evaluateTrigger } = await import('./auto-triggers.js'); + const result = await evaluateTrigger(resource, adminUserId); + + return { + triggered: result.triggered, + reason: result.reason, + sessionId: result.session?.id, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { triggered: false, reason: `Error: ${msg}` }; + } +} + +/** + * Run trigger evaluation for all products. + * Called by the scheduled job. + */ +async function runTriggerEvaluation(log?: { info: (msg: string) => void }): Promise { + const logger = log || console; + + try { + logger.info('[diagnostic-trigger-job] Starting trigger evaluation'); + + const productIds = await getProductsWithTriggers(); + logger.info(`[diagnostic-trigger-job] Found ${productIds.length} products with enabled triggers`); + + let totalEvaluated = 0; + let totalTriggered = 0; + + for (const productId of productIds) { + try { + // Use system user for auto-triggered evaluations + const results = await runAllTriggers(productId, 'system'); + totalEvaluated += results.length; + totalTriggered += results.filter(r => r.triggered).length; + + const triggeredCount = results.filter(r => r.triggered).length; + if (triggeredCount > 0) { + logger.info(`[diagnostic-trigger-job] Product ${productId}: ${triggeredCount}/${results.length} triggers fired`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.info(`[diagnostic-trigger-job] Error evaluating triggers for ${productId}: ${msg}`); + } + } + + logger.info(`[diagnostic-trigger-job] Evaluation complete: ${totalTriggered}/${totalEvaluated} triggers fired`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.info(`[diagnostic-trigger-job] Fatal error: ${msg}`); + } +} + +/** + * Start the background trigger evaluation job. + * Called once at server startup. + */ +export function startTriggerEvaluationJob(log?: { info: (msg: string) => void }): void { + if (jobInterval) { + return; // Already running + } + + const logger = log || console; + logger.info('[diagnostic-trigger-job] Starting evaluation job (5 minute interval)'); + + // Run immediately on startup + runTriggerEvaluation(log).catch(() => {}); + + // Schedule recurring runs + jobInterval = setInterval(() => { + runTriggerEvaluation(log).catch(() => {}); + }, EVALUATION_INTERVAL_MS); +} + +/** + * Stop the background trigger evaluation job. + * Used for graceful shutdown. + */ +export function stopTriggerEvaluationJob(): void { + if (jobInterval) { + clearInterval(jobInterval); + jobInterval = null; + } +} + +/** + * Get current job status. + */ +export function getTriggerJobStatus(): { + running: boolean; + intervalMinutes: number; +} { + return { + running: jobInterval !== null, + intervalMinutes: EVALUATION_INTERVAL_MS / 60000, + }; +} diff --git a/services/platform-service/src/modules/predictive-analytics/health-scoring.ts b/services/platform-service/src/modules/predictive-analytics/health-scoring.ts index 5ce346be..fb5597a5 100644 --- a/services/platform-service/src/modules/predictive-analytics/health-scoring.ts +++ b/services/platform-service/src/modules/predictive-analytics/health-scoring.ts @@ -224,7 +224,7 @@ export class HealthScoringEngine { const metrics = { avgSessionLength: input.avgSessionLength, sessionsPerUser: input.sessionsPerUser, - featureAdoption: input.featureAdoption, + featureAdoptionAvg: featureAdoptionAvg, }; const sessionLengthScore = this.normalizeLinear(input.avgSessionLength, 600) * 100; @@ -409,10 +409,10 @@ export class HealthScoringEngine { const currentScore = this.calculateOverallScore(dimensions); // Simple trend-based forecasting (in production, use Prophet/ARIMA) - const trends = Object.values(dimensions).map((d) => + const trends: number[] = Object.values(dimensions).map((d) => d.trend === 'improving' ? 1 : d.trend === 'declining' ? -1 : 0 ); - const avgTrend = trends.reduce((a, b) => a + b, 0) / trends.length; + const avgTrend = trends.reduce((a: number, b: number) => a + b, 0) / trends.length; // 7-day forecast const trend7Days = avgTrend * 2; // Small movement diff --git a/services/platform-service/src/modules/predictive-analytics/repository.ts b/services/platform-service/src/modules/predictive-analytics/repository.ts index 0795842f..1b973ca3 100644 --- a/services/platform-service/src/modules/predictive-analytics/repository.ts +++ b/services/platform-service/src/modules/predictive-analytics/repository.ts @@ -43,7 +43,7 @@ export class PredictiveAnalyticsRepository { ): Promise { const container = getRegisteredContainer('churn_predictions'); let queryStr = 'SELECT * FROM c WHERE c.productId = @productId'; - const parameters: Array<{ name: string; value: unknown }> = [ + const parameters: Array<{ name: string; value: string | number }> = [ { name: '@productId', value: productId }, ]; @@ -137,7 +137,7 @@ export class PredictiveAnalyticsRepository { async listCampaigns(productId?: string, status?: string): Promise { const container = getRegisteredContainer('retention_campaigns'); let queryStr = 'SELECT * FROM c WHERE 1=1'; - const parameters: Array<{ name: string; value: unknown }> = []; + const parameters: Array<{ name: string; value: string }> = []; if (productId) { queryStr += ' AND c.productId = @productId'; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index d9693a3e..96f5e52e 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -50,6 +50,9 @@ 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 { sessionReplayRoutes } from './modules/diagnostics/session-replay-routes.js'; +import { performanceProfileRoutes } from './modules/diagnostics/performance-profile-routes.js'; +import { startTriggerEvaluationJob } from './modules/diagnostics/trigger-job.js'; import { broadcastRoutes } from './modules/broadcasts/routes.js'; import { surveyRoutes } from './modules/surveys/routes.js'; import { jobRoutes } from './modules/jobs/routes.js'; @@ -156,6 +159,10 @@ await app.register(diagnosticsRoutes, { prefix: '/api' }); await app.register(autoTriggerRoutes, { prefix: '/api' }); // Crash-trigger routes for crash-triggered auto-sessions (Phase 4) await app.register(crashTriggerRoutes, { prefix: '/api' }); +// Session replay routes (Phase 4) +await app.register(sessionReplayRoutes, { prefix: '/api' }); +// Performance profiling routes (Phase 4) +await app.register(performanceProfileRoutes, { prefix: '/api' }); // Public routes — no auth, registered at top level await app.register(publicRoutes, { prefix: '/api' }); // Scheduled jobs module (admin: list, trigger, view runs) @@ -192,4 +199,7 @@ await app.register(surveyRoutes, { prefix: '/api' }); // Register event bus subscribers registerDiagnosticsSubscribers(app.log); +// Start diagnostic trigger evaluation job (Phase 4) +startTriggerEvaluationJob(app.log); + await startService(app, { port: config.PORT, host: config.HOST });