From f272a44bbef0a3ad72ac3609af9d22778b84fe1a Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Mon, 2 Mar 2026 23:34:35 -0800 Subject: [PATCH] feat(diagnostics): add repository.ts with session, trace, log, screenshot CRUD --- .../src/modules/diagnostics/repository.ts | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 services/platform-service/src/modules/diagnostics/repository.ts diff --git a/services/platform-service/src/modules/diagnostics/repository.ts b/services/platform-service/src/modules/diagnostics/repository.ts new file mode 100644 index 00000000..8ea4e065 --- /dev/null +++ b/services/platform-service/src/modules/diagnostics/repository.ts @@ -0,0 +1,307 @@ +/** + * Diagnostics repository — debug session management and data ingestion. + * + * Uses @bytelyst/datastore for cloud-agnostic storage. + * + * @module diagnostics + */ + +import type { FilterMap } from '@bytelyst/datastore'; +import { getCollection } from '../../lib/datastore.js'; +import type { + DebugSessionDoc, + DebugTraceDoc, + DebugLogEntryDoc, + DebugScreenshotDoc, + ListDebugSessionsQuery, + QueryTracesInput, + QueryLogsInput, +} from './types.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Collection accessors +// ───────────────────────────────────────────────────────────────────────────── + +function sessionsCollection() { + return getCollection('debug_sessions', '/id'); +} + +function tracesCollection() { + return getCollection('debug_traces', '/pk'); +} + +function logsCollection() { + return getCollection('debug_logs', '/pk'); +} + +function screenshotsCollection() { + return getCollection('debug_screenshots', '/sessionId'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helper: Build composite partition key +// ───────────────────────────────────────────────────────────────────────────── + +function buildPk(productId: string, sessionId: string): string { + return `${productId}:${sessionId}`; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sessions +// ───────────────────────────────────────────────────────────────────────────── + +export async function createSession(doc: DebugSessionDoc): Promise { + await sessionsCollection().upsert(doc); + return doc; +} + +export async function getSession(sessionId: string): Promise { + const result = await sessionsCollection().findMany({ + filter: { id: sessionId }, + limit: 1, + }); + return result[0] ?? null; +} + +export async function updateSession( + sessionId: string, + updates: Partial +): Promise { + const existing = await getSession(sessionId); + if (!existing) return null; + + const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() }; + await sessionsCollection().upsert(updated); + return updated; +} + +export async function deleteSession(sessionId: string): Promise { + // Datastore doesn't have delete - use soft delete + const existing = await getSession(sessionId); + if (!existing) return false; + + await sessionsCollection().upsert({ + ...existing, + status: 'cancelled', + updatedAt: new Date().toISOString(), + }); + return true; +} + +export async function listSessions( + query: ListDebugSessionsQuery +): Promise<{ sessions: DebugSessionDoc[]; total: number }> { + const filter: FilterMap = {}; + + if (query.productId) filter.productId = query.productId; + if (query.status) filter.status = query.status; + if (query.targetUserId) filter.targetUserId = query.targetUserId; + + // Date range filtering on createdAt + if (query.from || query.to) { + filter.createdAt = {}; + if (query.from) (filter.createdAt as Record).gte = query.from; + if (query.to) (filter.createdAt as Record).lte = query.to; + } + + const result = await sessionsCollection().findMany({ + filter, + sort: { createdAt: -1 }, + limit: query.limit, + offset: query.offset, + }); + + // Get total count (simplified - in production use COUNT query) + const allResult = await sessionsCollection().findMany({ filter }); + const total = allResult.length; + + return { sessions: result, total }; +} + +export async function getActiveSessionForTarget( + productId: string, + target: { userId?: string; anonymousId?: string; deviceId?: string } +): Promise { + const filter: FilterMap = { productId, status: 'active' }; + + if (target.userId) filter.targetUserId = target.userId; + if (target.anonymousId) filter.targetAnonymousId = target.anonymousId; + if (target.deviceId) filter.targetDeviceId = target.deviceId; + + const result = await sessionsCollection().findMany({ + filter, + sort: { createdAt: -1 }, + limit: 1, + }); + + // Check if session hasn't expired + const session = result[0]; + if (!session) return null; + + const now = new Date().toISOString(); + if (session.expiresAt < now) return null; + + return session; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Traces +// ───────────────────────────────────────────────────────────────────────────── + +export async function ingestTraces( + productId: string, + sessionId: string, + traces: DebugTraceDoc[] +): Promise { + const pk = buildPk(productId, sessionId); + + // Prepare docs with composite pk + const docs = traces.map(trace => ({ + ...trace, + pk, + sessionId, + productId, + })); + + // Batch upsert for idempotency + await Promise.all(docs.map(doc => tracesCollection().upsert(doc))); +} + +export async function getTraces( + productId: string, + sessionId: string, + query: QueryTracesInput +): Promise<{ traces: DebugTraceDoc[]; continuationToken?: string }> { + const pk = buildPk(productId, sessionId); + + const result = await tracesCollection().findMany({ + filter: { pk }, + sort: { startTime: 1 }, + limit: query.limit, + }); + + return { + traces: result, + continuationToken: undefined, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Logs +// ───────────────────────────────────────────────────────────────────────────── + +export async function ingestLogs( + productId: string, + sessionId: string, + logs: DebugLogEntryDoc[] +): Promise { + const pk = buildPk(productId, sessionId); + + // Prepare docs with composite pk and receivedAt + const now = new Date().toISOString(); + const docs = logs.map(log => ({ + ...log, + pk, + sessionId, + productId, + receivedAt: now, + })); + + // Batch upsert + await Promise.all(docs.map(doc => logsCollection().upsert(doc))); +} + +export async function getLogs( + productId: string, + sessionId: string, + query: QueryLogsInput +): Promise<{ logs: DebugLogEntryDoc[]; continuationToken?: string }> { + const pk = buildPk(productId, sessionId); + + const filter: FilterMap = { pk }; + + if (query.level) filter.level = query.level; + + // Date range + if (query.from || query.to) { + filter.timestamp = {}; + if (query.from) (filter.timestamp as Record).gte = query.from; + if (query.to) (filter.timestamp as Record).lte = query.to; + } + + // Search in message (if supported by datastore, otherwise filter in memory) + // Note: Cosmos supports CONTAINS in queries + + const result = await logsCollection().findMany({ + filter, + sort: { timestamp: -1 }, + limit: query.limit, + }); + + // In-memory search filtering if needed + let logs = result; + if (query.search) { + const searchLower = query.search.toLowerCase(); + logs = logs.filter( + (log: DebugLogEntryDoc) => + log.message.toLowerCase().includes(searchLower) || + log.module.toLowerCase().includes(searchLower) + ); + } + + return { + logs, + continuationToken: undefined, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Screenshots +// ───────────────────────────────────────────────────────────────────────────── + +export async function createScreenshotMetadata( + doc: DebugScreenshotDoc +): Promise { + await screenshotsCollection().upsert(doc); + return doc; +} + +export async function getScreenshots(sessionId: string): Promise { + const result = await screenshotsCollection().findMany({ + filter: { sessionId }, + sort: { capturedAt: -1 }, + }); + return result; +} + +export async function getScreenshot(screenshotId: string): Promise { + const result = await screenshotsCollection().findMany({ + filter: { id: screenshotId }, + limit: 1, + }); + return result[0] ?? null; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Stats update (denormalized counters) +// ───────────────────────────────────────────────────────────────────────────── + +export async function updateSessionStats( + sessionId: string, + stats: { logCount?: number; traceCount?: number; screenshotCount?: number } +): Promise { + const existing = await getSession(sessionId); + if (!existing) return; + + const updated: DebugSessionDoc = { + ...existing, + ...(stats.logCount !== undefined && { logCount: existing.logCount + stats.logCount }), + ...(stats.traceCount !== undefined && { traceCount: existing.traceCount + stats.traceCount }), + ...(stats.screenshotCount !== undefined && { + screenshotCount: existing.screenshotCount + stats.screenshotCount, + }), + updatedAt: new Date().toISOString(), + }; + + await sessionsCollection().upsert(updated); +}