diff --git a/services/platform-service/src/modules/telemetry/cross-product.test.ts b/services/platform-service/src/modules/telemetry/cross-product.test.ts new file mode 100644 index 00000000..3d48315a --- /dev/null +++ b/services/platform-service/src/modules/telemetry/cross-product.test.ts @@ -0,0 +1,191 @@ +/** + * Cross-product telemetry tests — multi-product query endpoints. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { + queryCrossProductSummary, + queryCrossProductDaily, + queryCrossProductClusters, +} from './repository.js'; +import { getCollection } from '../../lib/datastore.js'; +import type { TelemetryEventDoc, TelemetryErrorCluster } from './types.js'; + +function eventsCollection() { + return getCollection('telemetry_events', '/pk'); +} + +function clustersCollection() { + return getCollection('telemetry_error_clusters', '/pk'); +} + +function makeEvent( + productId: string, + overrides: Partial = {} +): TelemetryEventDoc { + const id = `evt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + return { + id, + productId, + anonymousInstallId: '660e8400-e29b-41d4-a716-446655440001', + sessionId: 'sess_test', + platform: 'ios', + channel: 'mobile_app', + osFamily: 'ios', + appVersion: '1.0.0', + buildNumber: '1', + releaseChannel: 'prod', + eventType: 'info', + module: 'core', + eventName: 'app_started', + occurredAt: '2026-03-19T10:00:00.000Z', + pk: `${productId}:202603:ios`, + receivedAt: new Date().toISOString(), + ttl: 2592000, + ...overrides, + }; +} + +function makeCluster( + productId: string, + overrides: Partial = {} +): TelemetryErrorCluster { + const fp = Math.random().toString(36).slice(2, 10); + return { + id: `${fp}:202603`, + pk: `${productId}:ios:core`, + productId, + fingerprint: fp, + platform: 'ios', + channel: 'mobile_app', + module: 'core', + eventName: 'crash', + affectedVersions: [ + { appVersion: '1.0.0', buildNumber: '1', count: 1, lastSeenAt: new Date().toISOString() }, + ], + firstSeenAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + totalCount: 5, + affectedUserIds: [], + affectedInstallIds: ['inst1'], + affectedOsFamilies: ['ios'], + severity: 'error', + status: 'open', + ttl: 7776000, + ...overrides, + }; +} + +describe('cross-product telemetry', () => { + beforeAll(async () => { + // Seed events for two products + const events = [ + makeEvent('product-a', { + eventType: 'info', + module: 'auth', + occurredAt: '2026-03-18T10:00:00.000Z', + }), + makeEvent('product-a', { + eventType: 'error', + module: 'auth', + occurredAt: '2026-03-18T11:00:00.000Z', + }), + makeEvent('product-a', { + eventType: 'info', + module: 'sync', + occurredAt: '2026-03-19T10:00:00.000Z', + }), + makeEvent('product-b', { + eventType: 'info', + module: 'core', + occurredAt: '2026-03-19T10:00:00.000Z', + platform: 'web' as const, + }), + makeEvent('product-b', { + eventType: 'fatal', + module: 'core', + occurredAt: '2026-03-19T11:00:00.000Z', + platform: 'web' as const, + }), + ]; + for (const e of events) { + await eventsCollection().upsert(e); + } + + // Seed clusters + await clustersCollection().upsert(makeCluster('product-a', { totalCount: 10 })); + await clustersCollection().upsert(makeCluster('product-b', { totalCount: 3 })); + }); + + it('queryCrossProductSummary returns per-product stats', async () => { + const results = await queryCrossProductSummary(['product-a', 'product-b']); + + expect(results).toHaveLength(2); + + const a = results.find(r => r.productId === 'product-a')!; + expect(a.totalEvents).toBeGreaterThanOrEqual(3); + expect(a.errorEvents).toBeGreaterThanOrEqual(1); + expect(a.errorRate).toBeGreaterThan(0); + expect(a.topModules.length).toBeGreaterThan(0); + + const b = results.find(r => r.productId === 'product-b')!; + expect(b.totalEvents).toBeGreaterThanOrEqual(2); + expect(b.errorEvents).toBeGreaterThanOrEqual(1); + }); + + it('queryCrossProductDaily returns daily breakdown', async () => { + const results = await queryCrossProductDaily(['product-a', 'product-b']); + + expect(results.length).toBeGreaterThan(0); + // Should have entries for both products + const productIds = new Set(results.map(r => r.productId)); + expect(productIds.has('product-a')).toBe(true); + expect(productIds.has('product-b')).toBe(true); + + // Verify date format + for (const r of results) { + expect(r.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(r.count).toBeGreaterThan(0); + } + }); + + it('queryCrossProductClusters returns merged cluster list', async () => { + const clusters = await queryCrossProductClusters(['product-a', 'product-b'], 10); + + expect(clusters.length).toBeGreaterThanOrEqual(2); + // Should be sorted by totalCount descending + for (let i = 1; i < clusters.length; i++) { + expect(clusters[i - 1].totalCount).toBeGreaterThanOrEqual(clusters[i].totalCount); + } + }); + + it('queryCrossProductSummary with date filter narrows results', async () => { + const results = await queryCrossProductSummary( + ['product-a'], + '2026-03-19T00:00:00.000Z', + '2026-03-19T23:59:59.000Z' + ); + + expect(results).toHaveLength(1); + const a = results[0]; + // Only the March 19th events should be included + expect(a.totalEvents).toBeGreaterThanOrEqual(1); + }); + + it('queryCrossProductSummary for unknown product returns zero counts', async () => { + const results = await queryCrossProductSummary(['nonexistent-product']); + + expect(results).toHaveLength(1); + expect(results[0].totalEvents).toBe(0); + expect(results[0].errorEvents).toBe(0); + expect(results[0].errorRate).toBe(0); + }); + + it('queryCrossProductClusters respects limit', async () => { + const clusters = await queryCrossProductClusters(['product-a', 'product-b'], 1); + + expect(clusters).toHaveLength(1); + // Should be the highest count cluster + expect(clusters[0].totalCount).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/services/platform-service/src/modules/telemetry/repository.ts b/services/platform-service/src/modules/telemetry/repository.ts index e3344b76..a22d1d9b 100644 --- a/services/platform-service/src/modules/telemetry/repository.ts +++ b/services/platform-service/src/modules/telemetry/repository.ts @@ -214,3 +214,116 @@ export async function updateCluster( return null; } } + +// ─── Cross-Product Queries ────────────────────────────────────────── + +export interface CrossProductSummary { + productId: string; + totalEvents: number; + errorEvents: number; + errorRate: number; + topModules: Array<{ module: string; count: number }>; + platforms: Array<{ platform: string; count: number }>; +} + +export interface DailyEventCount { + date: string; + productId: string; + count: number; +} + +export async function queryCrossProductSummary( + productIds: string[], + from?: string, + to?: string +): Promise { + const results: CrossProductSummary[] = []; + + for (const productId of productIds) { + const filter: FilterMap = { productId }; + if (from) filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $gte: from }; + if (to) filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $lte: to }; + + const events = await eventsCollection().findMany({ filter, limit: 1000 }); + + const errorEvents = events.filter(e => ['warn', 'error', 'fatal'].includes(e.eventType)); + + // Aggregate modules + const moduleCounts: Record = {}; + for (const e of events) { + moduleCounts[e.module] = (moduleCounts[e.module] ?? 0) + 1; + } + const topModules = Object.entries(moduleCounts) + .map(([module, count]) => ({ module, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + // Aggregate platforms + const platformCounts: Record = {}; + for (const e of events) { + platformCounts[e.platform] = (platformCounts[e.platform] ?? 0) + 1; + } + const platforms = Object.entries(platformCounts) + .map(([platform, count]) => ({ platform, count })) + .sort((a, b) => b.count - a.count); + + results.push({ + productId, + totalEvents: events.length, + errorEvents: errorEvents.length, + errorRate: events.length > 0 ? errorEvents.length / events.length : 0, + topModules, + platforms, + }); + } + + return results; +} + +export async function queryCrossProductDaily( + productIds: string[], + from?: string, + to?: string +): Promise { + const results: DailyEventCount[] = []; + + for (const productId of productIds) { + const filter: FilterMap = { productId }; + if (from) filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $gte: from }; + if (to) filter.occurredAt = { ...((filter.occurredAt as object) ?? {}), $lte: to }; + + const events = await eventsCollection().findMany({ filter, limit: 1000 }); + + // Aggregate by date + const dayCounts: Record = {}; + for (const e of events) { + const date = e.occurredAt.substring(0, 10); // YYYY-MM-DD + dayCounts[date] = (dayCounts[date] ?? 0) + 1; + } + + for (const [date, count] of Object.entries(dayCounts)) { + results.push({ date, productId, count }); + } + } + + return results.sort((a, b) => a.date.localeCompare(b.date)); +} + +export async function queryCrossProductClusters( + productIds: string[], + limit = 20 +): Promise { + const allClusters: TelemetryErrorCluster[] = []; + + for (const productId of productIds) { + const clusters = await clustersCollection().findMany({ + filter: { productId }, + sort: { totalCount: -1 }, + limit, + }); + allClusters.push(...clusters); + } + + // Sort by totalCount descending and take top N + return allClusters.sort((a, b) => b.totalCount - a.totalCount).slice(0, limit); +} diff --git a/services/platform-service/src/modules/telemetry/routes.ts b/services/platform-service/src/modules/telemetry/routes.ts index 7ad75621..34cbb15b 100644 --- a/services/platform-service/src/modules/telemetry/routes.ts +++ b/services/platform-service/src/modules/telemetry/routes.ts @@ -906,6 +906,37 @@ export async function telemetryRoutes(app: FastifyInstance) { return metrics; }); + // ── Admin: cross-product telemetry ────────────────────── + app.get('/telemetry/cross-product', async req => { + requireAdmin(req); + const { products, from, to, limit } = req.query as { + products?: string; + from?: string; + to?: string; + limit?: string; + }; + + if (!products) { + throw new BadRequestError('products query parameter required (comma-separated productIds)'); + } + + const productIds = products + .split(',') + .map(p => p.trim()) + .filter(Boolean); + if (productIds.length === 0 || productIds.length > 20) { + throw new BadRequestError('Provide 1-20 product IDs'); + } + + const [summary, daily, clusters] = await Promise.all([ + repo.queryCrossProductSummary(productIds, from, to), + repo.queryCrossProductDaily(productIds, from, to), + repo.queryCrossProductClusters(productIds, limit ? parseInt(limit, 10) : 20), + ]); + + return { summary, daily, clusters }; + }); + // ── Prometheus OpenMetrics export ────────────────────── app.get('/telemetry/metrics/prometheus', async (req, reply) => { requireAdmin(req);