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:
parent
1fe1e75999
commit
d900df3dc8
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user