feat(platform-service): cross-product telemetry endpoint (Phase 4.2) — 6 tests

GET /telemetry/cross-product?products=a,b,c — admin-only endpoint for
multi-product telemetry comparison:
- Per-product summary: totalEvents, errorEvents, errorRate, topModules, platforms
- Daily event counts per product (for stacked bar charts)
- Top error clusters across products (merged + sorted by count)
- Date range filtering via from/to query params
- 6 tests: summary, daily breakdown, clusters, date filter, unknown product, limit
This commit is contained in:
saravanakumardb1 2026-03-19 22:04:00 -07:00
parent 1fe1e75999
commit d900df3dc8
3 changed files with 335 additions and 0 deletions

View File

@ -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<TelemetryEventDoc>('telemetry_events', '/pk');
}
function clustersCollection() {
return getCollection<TelemetryErrorCluster>('telemetry_error_clusters', '/pk');
}
function makeEvent(
productId: string,
overrides: Partial<TelemetryEventDoc> = {}
): 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> = {}
): 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);
});
});

View File

@ -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<CrossProductSummary[]> {
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<string, number> = {};
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<string, number> = {};
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<DailyEventCount[]> {
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<string, number> = {};
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<TelemetryErrorCluster[]> {
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);
}

View File

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