fix(platform-service): resolve type errors in diagnostics and ab-testing modules

This commit is contained in:
saravanakumardb1 2026-03-03 12:22:35 -08:00
parent 914e344a92
commit 03cda3a74f
11 changed files with 543 additions and 7 deletions

View File

@ -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';
// ─────────────────────────────────────────────────────────────────────────────

View File

@ -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<PerformanceProfileDoc>(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<PerformanceProfileDoc | null> {
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<string, unknown> = { 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<PerformanceProfileDoc[]> {
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<boolean> {
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;
}

View File

@ -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<void> {
// 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,
})),
};
});
}

View File

@ -173,6 +173,7 @@ export interface PerformanceProfileDoc {
startedAt: string;
endedAt: string;
createdAt: string;
updatedAt?: string;
// TTL
ttl: number;

View File

@ -65,6 +65,7 @@ export async function ingestReplayEvents(
durationMs: lastEvent?.timestamp || 0,
privacyConfig,
createdAt: now,
updatedAt: now,
ttl: 7 * 86400, // 7 days
};

View File

@ -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<void> {
// 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,
};
});
}

View File

@ -30,7 +30,6 @@ export type ReplayEventType = z.infer<typeof ReplayEventTypeEnum>;
export interface ReplayEvent {
id: string;
sessionId: string;
productId: string;
timestamp: number; // milliseconds since session start
type: ReplayEventType;
data: Record<string, unknown>;
@ -133,6 +132,7 @@ export interface SessionReplayDoc {
// Storage
createdAt: string;
updatedAt: string;
ttl: number; // Auto-expiry (default 7 days)
}

View File

@ -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<typeof setInterval> | null = null;
/**
* Get all unique product IDs that have triggers configured.
*/
async function getProductsWithTriggers(): Promise<string[]> {
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<TriggerConfig>();
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<void> {
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,
};
}

View File

@ -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

View File

@ -43,7 +43,7 @@ export class PredictiveAnalyticsRepository {
): Promise<UserChurnPredictionDoc[]> {
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<RetentionCampaignDoc[]> {
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';

View File

@ -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 });