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;
|
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;
|
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 ──────────────────────
|
// ── Prometheus OpenMetrics export ──────────────────────
|
||||||
app.get('/telemetry/metrics/prometheus', async (req, reply) => {
|
app.get('/telemetry/metrics/prometheus', async (req, reply) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user