fix(platform-service): resolve type errors in diagnostics and ab-testing modules
This commit is contained in:
parent
914e344a92
commit
03cda3a74f
@ -14,9 +14,10 @@ import type {
|
|||||||
ExperimentSuggestion,
|
ExperimentSuggestion,
|
||||||
CreateExperimentInput,
|
CreateExperimentInput,
|
||||||
UpdateExperimentInput,
|
UpdateExperimentInput,
|
||||||
|
TargetingConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { assignVariant, assignByStrategy, isInExperimentBucket, type StrategyContext } from './bucketing.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';
|
import { matchesTargeting } from './targeting.js';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -173,6 +173,7 @@ export interface PerformanceProfileDoc {
|
|||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string;
|
endedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
|
||||||
// TTL
|
// TTL
|
||||||
ttl: number;
|
ttl: number;
|
||||||
|
|||||||
@ -65,6 +65,7 @@ export async function ingestReplayEvents(
|
|||||||
durationMs: lastEvent?.timestamp || 0,
|
durationMs: lastEvent?.timestamp || 0,
|
||||||
privacyConfig,
|
privacyConfig,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
ttl: 7 * 86400, // 7 days
|
ttl: 7 * 86400, // 7 days
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -30,7 +30,6 @@ export type ReplayEventType = z.infer<typeof ReplayEventTypeEnum>;
|
|||||||
export interface ReplayEvent {
|
export interface ReplayEvent {
|
||||||
id: string;
|
id: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
productId: string;
|
|
||||||
timestamp: number; // milliseconds since session start
|
timestamp: number; // milliseconds since session start
|
||||||
type: ReplayEventType;
|
type: ReplayEventType;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
@ -133,6 +132,7 @@ export interface SessionReplayDoc {
|
|||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
ttl: number; // Auto-expiry (default 7 days)
|
ttl: number; // Auto-expiry (default 7 days)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
148
services/platform-service/src/modules/diagnostics/trigger-job.ts
Normal file
148
services/platform-service/src/modules/diagnostics/trigger-job.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -224,7 +224,7 @@ export class HealthScoringEngine {
|
|||||||
const metrics = {
|
const metrics = {
|
||||||
avgSessionLength: input.avgSessionLength,
|
avgSessionLength: input.avgSessionLength,
|
||||||
sessionsPerUser: input.sessionsPerUser,
|
sessionsPerUser: input.sessionsPerUser,
|
||||||
featureAdoption: input.featureAdoption,
|
featureAdoptionAvg: featureAdoptionAvg,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessionLengthScore = this.normalizeLinear(input.avgSessionLength, 600) * 100;
|
const sessionLengthScore = this.normalizeLinear(input.avgSessionLength, 600) * 100;
|
||||||
@ -409,10 +409,10 @@ export class HealthScoringEngine {
|
|||||||
const currentScore = this.calculateOverallScore(dimensions);
|
const currentScore = this.calculateOverallScore(dimensions);
|
||||||
|
|
||||||
// Simple trend-based forecasting (in production, use Prophet/ARIMA)
|
// 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
|
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
|
// 7-day forecast
|
||||||
const trend7Days = avgTrend * 2; // Small movement
|
const trend7Days = avgTrend * 2; // Small movement
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export class PredictiveAnalyticsRepository {
|
|||||||
): Promise<UserChurnPredictionDoc[]> {
|
): Promise<UserChurnPredictionDoc[]> {
|
||||||
const container = getRegisteredContainer('churn_predictions');
|
const container = getRegisteredContainer('churn_predictions');
|
||||||
let queryStr = 'SELECT * FROM c WHERE c.productId = @productId';
|
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 },
|
{ name: '@productId', value: productId },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -137,7 +137,7 @@ export class PredictiveAnalyticsRepository {
|
|||||||
async listCampaigns(productId?: string, status?: string): Promise<RetentionCampaignDoc[]> {
|
async listCampaigns(productId?: string, status?: string): Promise<RetentionCampaignDoc[]> {
|
||||||
const container = getRegisteredContainer('retention_campaigns');
|
const container = getRegisteredContainer('retention_campaigns');
|
||||||
let queryStr = 'SELECT * FROM c WHERE 1=1';
|
let queryStr = 'SELECT * FROM c WHERE 1=1';
|
||||||
const parameters: Array<{ name: string; value: unknown }> = [];
|
const parameters: Array<{ name: string; value: string }> = [];
|
||||||
|
|
||||||
if (productId) {
|
if (productId) {
|
||||||
queryStr += ' AND c.productId = @productId';
|
queryStr += ' AND c.productId = @productId';
|
||||||
|
|||||||
@ -50,6 +50,9 @@ 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 { 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 { 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';
|
||||||
@ -156,6 +159,10 @@ await app.register(diagnosticsRoutes, { prefix: '/api' });
|
|||||||
await app.register(autoTriggerRoutes, { prefix: '/api' });
|
await app.register(autoTriggerRoutes, { prefix: '/api' });
|
||||||
// Crash-trigger routes for crash-triggered auto-sessions (Phase 4)
|
// Crash-trigger routes for crash-triggered auto-sessions (Phase 4)
|
||||||
await app.register(crashTriggerRoutes, { prefix: '/api' });
|
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
|
// 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)
|
||||||
@ -192,4 +199,7 @@ await app.register(surveyRoutes, { prefix: '/api' });
|
|||||||
// Register event bus subscribers
|
// Register event bus subscribers
|
||||||
registerDiagnosticsSubscribers(app.log);
|
registerDiagnosticsSubscribers(app.log);
|
||||||
|
|
||||||
|
// Start diagnostic trigger evaluation job (Phase 4)
|
||||||
|
startTriggerEvaluationJob(app.log);
|
||||||
|
|
||||||
await startService(app, { port: config.PORT, host: config.HOST });
|
await startService(app, { port: config.PORT, host: config.HOST });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user