feat(diagnostics): add repository.ts with session, trace, log, screenshot CRUD
This commit is contained in:
parent
dea1521dd5
commit
f272a44bbe
307
services/platform-service/src/modules/diagnostics/repository.ts
Normal file
307
services/platform-service/src/modules/diagnostics/repository.ts
Normal file
@ -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<DebugSessionDoc>('debug_sessions', '/id');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tracesCollection() {
|
||||||
|
return getCollection<DebugTraceDoc>('debug_traces', '/pk');
|
||||||
|
}
|
||||||
|
|
||||||
|
function logsCollection() {
|
||||||
|
return getCollection<DebugLogEntryDoc>('debug_logs', '/pk');
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenshotsCollection() {
|
||||||
|
return getCollection<DebugScreenshotDoc>('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<DebugSessionDoc> {
|
||||||
|
await sessionsCollection().upsert(doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(sessionId: string): Promise<DebugSessionDoc | null> {
|
||||||
|
const result = await sessionsCollection().findMany({
|
||||||
|
filter: { id: sessionId },
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
return result[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSession(
|
||||||
|
sessionId: string,
|
||||||
|
updates: Partial<DebugSessionDoc>
|
||||||
|
): Promise<DebugSessionDoc | null> {
|
||||||
|
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<boolean> {
|
||||||
|
// 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<string, string>).gte = query.from;
|
||||||
|
if (query.to) (filter.createdAt as Record<string, string>).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<DebugSessionDoc | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, string>).gte = query.from;
|
||||||
|
if (query.to) (filter.timestamp as Record<string, string>).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<DebugScreenshotDoc> {
|
||||||
|
await screenshotsCollection().upsert(doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getScreenshots(sessionId: string): Promise<DebugScreenshotDoc[]> {
|
||||||
|
const result = await screenshotsCollection().findMany({
|
||||||
|
filter: { sessionId },
|
||||||
|
sort: { capturedAt: -1 },
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getScreenshot(screenshotId: string): Promise<DebugScreenshotDoc | null> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user