feat(ai-diagnostics): add admin UI insights page [4.1]

This commit is contained in:
saravanakumardb1 2026-03-03 12:15:09 -08:00
parent 458d835e5a
commit 460ed6d0c0
2 changed files with 1151 additions and 0 deletions

View File

@ -0,0 +1,709 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Brain,
Search,
AlertTriangle,
CheckCircle,
Clock,
TrendingUp,
MessageSquare,
Sparkles,
ChevronRight,
BarChart3,
Activity,
} from 'lucide-react';
// Types
interface ErrorCluster {
id: string;
errorType: string;
message: string;
status: 'active' | 'investigating' | 'resolved' | 'ignored';
count: number;
firstSeen: string;
lastSeen: string;
platform: string;
productId: string;
latestInsight?: DiagnosticInsight;
}
interface DiagnosticInsight {
id: string;
rootCause: string;
confidence: number;
impact: string;
nextSteps: string[];
suggestedCodeFix?: string;
patternSummary?: string;
}
interface QueryResult {
query: string;
aiResponse: string;
confidence: number;
supportingData: Array<{
type: string;
id: string;
title: string;
relevanceScore: number;
}>;
suggestedActions: string[];
}
interface ProactiveAlert {
id: string;
alertType: string;
severity: 'critical' | 'high' | 'medium' | 'low';
title: string;
description: string;
clusterId?: string;
acknowledged: boolean;
createdAt: string;
}
export default function AIDiagnosticsPage() {
// State
const [clusters, setClusters] = useState<ErrorCluster[]>([]);
const [alerts, setAlerts] = useState<ProactiveAlert[]>([]);
const [query, setQuery] = useState('');
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedProduct, setSelectedProduct] = useState<string>('all');
const [selectedCluster, setSelectedCluster] = useState<ErrorCluster | null>(null);
const [activeTab, setActiveTab] = useState<'overview' | 'clusters' | 'alerts' | 'query'>('overview');
// Fetch clusters on mount
useEffect(() => {
fetchClusters();
fetchAlerts();
}, [selectedProduct]);
const fetchClusters = async () => {
try {
const params = new URLSearchParams();
if (selectedProduct !== 'all') params.set('productId', selectedProduct);
params.set('limit', '50');
params.set('includeInsights', 'true');
const res = await fetch(`/api/ai-diagnostics/clusters?${params}`);
if (!res.ok) throw new Error('Failed to fetch clusters');
const data = await res.json();
setClusters(data.clusters || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch clusters');
}
};
const fetchAlerts = async () => {
try {
const params = new URLSearchParams();
if (selectedProduct !== 'all') params.set('productId', selectedProduct);
const res = await fetch(`/api/ai-diagnostics/alerts?${params}`);
if (!res.ok) throw new Error('Failed to fetch alerts');
const data = await res.json();
setAlerts(data.alerts || []);
} catch (err) {
console.error('Failed to fetch alerts:', err);
}
};
const handleQuery = async () => {
if (!query.trim()) return;
setLoading(true);
setError(null);
try {
const res = await fetch('/api/ai-diagnostics/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
productId: selectedProduct === 'all' ? undefined : selectedProduct,
}),
});
if (!res.ok) throw new Error('Query failed');
const data = await res.json();
setQueryResult(data.result);
setActiveTab('query');
} catch (err) {
setError(err instanceof Error ? err.message : 'Query failed');
} finally {
setLoading(false);
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
active: 'destructive',
investigating: 'default',
resolved: 'secondary',
ignored: 'outline',
};
return <Badge variant={variants[status] || 'default'}>{status}</Badge>;
};
const getSeverityBadge = (severity: string) => {
const colors: Record<string, string> = {
critical: 'bg-red-500',
high: 'bg-orange-500',
medium: 'bg-yellow-500',
low: 'bg-blue-500',
};
return (
<Badge className={colors[severity] || 'bg-gray-500'}>
{severity}
</Badge>
);
};
// Overview stats
const activeClusters = clusters.filter(c => c.status === 'active').length;
const investigatingClusters = clusters.filter(c => c.status === 'investigating').length;
const resolvedClusters = clusters.filter(c => c.status === 'resolved').length;
const unacknowledgedAlerts = alerts.filter(a => !a.acknowledged).length;
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Brain className="h-6 w-6 text-purple-500" />
AI Diagnostic Assistant
</h1>
<p className="text-muted-foreground mt-1">
AI-powered root cause analysis and error investigation
</p>
</div>
<div className="flex items-center gap-2">
<Select value={selectedProduct} onValueChange={setSelectedProduct}>
<SelectTrigger className="w-40">
<SelectValue placeholder="All Products" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Products</SelectItem>
<SelectItem value="lysnrai">LysnrAI</SelectItem>
<SelectItem value="mindlyst">MindLyst</SelectItem>
<SelectItem value="chronomind">ChronoMind</SelectItem>
<SelectItem value="nomgap">NomGap</SelectItem>
<SelectItem value="jarvisjr">JarvisJr</SelectItem>
<SelectItem value="peakpulse">PeakPulse</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Error Alert */}
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Natural Language Query */}
<Card className="border-purple-200 bg-purple-50/50">
<CardContent className="pt-6">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Ask AI about errors (e.g., 'Why did iOS keyboard crash yesterday?')"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleQuery()}
className="pl-9"
/>
</div>
<Button
onClick={handleQuery}
disabled={loading || !query.trim()}
className="bg-purple-600 hover:bg-purple-700"
>
{loading ? (
<Sparkles className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
Ask AI
</Button>
</div>
<div className="flex gap-2 mt-3 text-sm text-muted-foreground">
<span>Try:</span>
{[
'What are the top errors this week?',
'Compare iOS vs Android crash rates',
'Show new error types today',
'Why did auth failures spike?',
].map((suggestion) => (
<button
key={suggestion}
onClick={() => setQuery(suggestion)}
className="text-purple-600 hover:underline"
>
{suggestion}
</button>
))}
</div>
</CardContent>
</Card>
{/* Tabs */}
<div className="flex gap-2 border-b">
{[
{ id: 'overview', label: 'Overview', icon: BarChart3 },
{ id: 'clusters', label: `Error Clusters (${clusters.length})`, icon: AlertTriangle },
{ id: 'alerts', label: `Alerts (${unacknowledgedAlerts})`, icon: Activity },
...(queryResult ? [{ id: 'query', label: 'Query Result', icon: MessageSquare }] : []),
].map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setActiveTab(id as typeof activeTab)}
className={`flex items-center gap-2 px-4 py-2 border-b-2 transition-colors ${
activeTab === id
? 'border-purple-500 text-purple-600'
: 'border-transparent hover:border-gray-300'
}`}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Stats Grid */}
<div className="grid grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Active Clusters
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-red-600">{activeClusters}</div>
<p className="text-sm text-muted-foreground mt-1">
Errors requiring attention
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Investigating
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-yellow-600">{investigatingClusters}</div>
<p className="text-sm text-muted-foreground mt-1">
Under investigation
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Resolved
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-green-600">{resolvedClusters}</div>
<p className="text-sm text-muted-foreground mt-1">
Issues resolved this week
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
AI Insights
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-purple-600">
{clusters.filter(c => c.latestInsight).length}
</div>
<p className="text-sm text-muted-foreground mt-1">
With AI analysis
</p>
</CardContent>
</Card>
</div>
{/* Recent Active Clusters */}
<Card>
<CardHeader>
<CardTitle>Most Critical Active Clusters</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{clusters
.filter(c => c.status === 'active')
.slice(0, 5)
.map((cluster) => (
<div
key={cluster.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 cursor-pointer"
onClick={() => {
setSelectedCluster(cluster);
setActiveTab('clusters');
}}
>
<div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-red-500" />
<div>
<div className="font-medium">{cluster.errorType}</div>
<div className="text-sm text-muted-foreground line-clamp-1">
{cluster.message}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<Badge variant="secondary">{cluster.count} occurrences</Badge>
{cluster.latestInsight && (
<Badge className="bg-purple-100 text-purple-800">
<Sparkles className="h-3 w-3 mr-1" />
AI Analysis
</Badge>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</div>
))}
{activeClusters === 0 && (
<div className="text-center py-8 text-muted-foreground">
<CheckCircle className="h-12 w-12 mx-auto mb-2 text-green-500" />
<p>No active error clusters</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Recent Alerts */}
{unacknowledgedAlerts > 0 && (
<Card>
<CardHeader>
<CardTitle>Unacknowledged Alerts</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{alerts
.filter(a => !a.acknowledged)
.slice(0, 3)
.map((alert) => (
<div
key={alert.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
>
<div className="flex items-center gap-3">
{getSeverityBadge(alert.severity)}
<div>
<div className="font-medium">{alert.title}</div>
<div className="text-sm text-muted-foreground">
{alert.description}
</div>
</div>
</div>
<Button variant="outline" size="sm">
Acknowledge
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)}
{/* Clusters Tab */}
{activeTab === 'clusters' && (
<Card>
<CardHeader>
<CardTitle>Error Clusters</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{clusters.map((cluster) => (
<Dialog key={cluster.id}>
<DialogTrigger asChild>
<div className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
<div className="flex items-center gap-3">
{getStatusBadge(cluster.status)}
<div>
<div className="font-medium">{cluster.errorType}</div>
<div className="text-sm text-muted-foreground line-clamp-1">
{cluster.message}
</div>
<div className="flex gap-2 mt-1 text-xs text-muted-foreground">
<span>{cluster.platform}</span>
<span></span>
<span>{cluster.productId}</span>
<span></span>
<span>Last: {new Date(cluster.lastSeen).toLocaleDateString()}</span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<Badge variant="secondary">{cluster.count} occurrences</Badge>
{cluster.latestInsight && (
<Badge className="bg-purple-100 text-purple-800">
<Sparkles className="h-3 w-3 mr-1" />
AI
</Badge>
)}
</div>
</div>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{cluster.errorType}
{getStatusBadge(cluster.status)}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<h4 className="font-medium mb-1">Error Message</h4>
<p className="text-sm text-muted-foreground">{cluster.message}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="font-medium mb-1">Platform</h4>
<p className="text-sm text-muted-foreground">{cluster.platform}</p>
</div>
<div>
<h4 className="font-medium mb-1">Product</h4>
<p className="text-sm text-muted-foreground">{cluster.productId}</p>
</div>
<div>
<h4 className="font-medium mb-1">First Seen</h4>
<p className="text-sm text-muted-foreground">
{new Date(cluster.firstSeen).toLocaleString()}
</p>
</div>
<div>
<h4 className="font-medium mb-1">Last Seen</h4>
<p className="text-sm text-muted-foreground">
{new Date(cluster.lastSeen).toLocaleString()}
</p>
</div>
</div>
<div>
<h4 className="font-medium mb-1">Occurrences</h4>
<p className="text-sm text-muted-foreground">{cluster.count}</p>
</div>
{cluster.latestInsight && (
<div className="bg-purple-50 p-4 rounded-lg">
<h4 className="font-medium mb-2 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-purple-600" />
AI Analysis
</h4>
<div className="space-y-2 text-sm">
<div>
<span className="font-medium">Root Cause: </span>
{cluster.latestInsight.rootCause}
</div>
<div>
<span className="font-medium">Confidence: </span>
{(cluster.latestInsight.confidence * 100).toFixed(0)}%
</div>
<div>
<span className="font-medium">Impact: </span>
{cluster.latestInsight.impact}
</div>
{cluster.latestInsight.nextSteps.length > 0 && (
<div>
<span className="font-medium">Next Steps: </span>
<ul className="list-disc list-inside mt-1">
{cluster.latestInsight.nextSteps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
<div className="flex gap-2">
<Button
onClick={() => {
// Trigger re-analysis
fetch(`/api/ai-diagnostics/clusters/${cluster.id}/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: cluster.productId }),
}).then(() => fetchClusters());
}}
>
<Sparkles className="h-4 w-4 mr-2" />
Re-Analyze with AI
</Button>
</div>
</div>
</DialogContent>
</Dialog>
))}
{clusters.length === 0 && !loading && (
<div className="text-center py-8 text-muted-foreground">
<CheckCircle className="h-12 w-12 mx-auto mb-2 text-green-500" />
<p>No error clusters found</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Alerts Tab */}
{activeTab === 'alerts' && (
<Card>
<CardHeader>
<CardTitle>Proactive Alerts</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{alerts.map((alert) => (
<div
key={alert.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
>
<div className="flex items-center gap-3">
{getSeverityBadge(alert.severity)}
<div>
<div className="font-medium">{alert.title}</div>
<div className="text-sm text-muted-foreground">
{alert.description}
</div>
<div className="flex gap-2 mt-1 text-xs text-muted-foreground">
<span>{alert.alertType}</span>
<span></span>
<Clock className="h-3 w-3" />
<span>{new Date(alert.createdAt).toLocaleString()}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{!alert.acknowledged && (
<Button variant="outline" size="sm">
Acknowledge
</Button>
)}
{alert.acknowledged && (
<Badge variant="outline">
<CheckCircle className="h-3 w-3 mr-1" />
Acknowledged
</Badge>
)}
</div>
</div>
))}
{alerts.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<CheckCircle className="h-12 w-12 mx-auto mb-2 text-green-500" />
<p>No alerts at this time</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Query Result Tab */}
{activeTab === 'query' && queryResult && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Query Result
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* AI Response */}
<div className="bg-purple-50 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="h-4 w-4 text-purple-600" />
<span className="font-medium">AI Response</span>
<Badge variant="outline">
Confidence: {(queryResult.confidence * 100).toFixed(0)}%
</Badge>
</div>
<p className="text-sm whitespace-pre-wrap">{queryResult.aiResponse}</p>
</div>
{/* Supporting Data */}
{queryResult.supportingData.length > 0 && (
<div>
<h4 className="font-medium mb-2">Supporting Data</h4>
<div className="space-y-2">
{queryResult.supportingData.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex items-center gap-2">
{item.type === 'cluster' && <AlertTriangle className="h-4 w-4 text-red-500" />}
{item.type === 'insight' && <Sparkles className="h-4 w-4 text-purple-500" />}
{item.type === 'trend' && <TrendingUp className="h-4 w-4 text-blue-500" />}
<span className="font-medium">{item.title}</span>
</div>
<Badge variant="outline">
Relevance: {(item.relevanceScore * 100).toFixed(0)}%
</Badge>
</div>
))}
</div>
</div>
)}
{/* Suggested Actions */}
{queryResult.suggestedActions.length > 0 && (
<div>
<h4 className="font-medium mb-2">Suggested Actions</h4>
<ul className="space-y-1">
{queryResult.suggestedActions.map((action, i) => (
<li key={i} className="flex items-center gap-2 text-sm">
<ChevronRight className="h-4 w-4 text-purple-500" />
{action}
</li>
))}
</ul>
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,442 @@
/**
* Automated Triggers for Remote Diagnostics
* Error-threshold triggers and crash-triggered auto sessions.
*/
import { z } from 'zod';
import type { DebugSessionDoc } from './types.js';
import { createSession, updateSessionStats } from './repository.js';
import { getRegisteredContainer } from '@bytelyst/cosmos';
// ─────────────────────────────────────────────────────────────────────────────
// Trigger Configuration Types
// ─────────────────────────────────────────────────────────────────────────────
export const TriggerConfigSchema = z.object({
id: z.string(),
productId: z.string(),
name: z.string(),
enabled: z.boolean().default(true),
// Trigger conditions
condition: z.discriminatedUnion('type', [
z.object({
type: z.literal('error_rate'),
threshold: z.number().min(0).max(1), // 0.0 - 1.0 (0% to 100%)
windowMinutes: z.number().min(1).max(60),
minEvents: z.number().min(10), // Minimum events before triggering
}),
z.object({
type: z.literal('crash_count'),
threshold: z.number().min(1),
windowMinutes: z.number().min(1).max(60),
}),
z.object({
type: z.literal('fatal_log'),
count: z.number().min(1),
windowMinutes: z.number().min(1).max(60),
}),
]),
// Session to create when triggered
sessionConfig: z.object({
collectionLevel: z.enum(['standard', 'debug', 'trace']).default('debug'),
captureLogs: z.boolean().default(true),
captureNetwork: z.boolean().default(true),
captureScreenshots: z.boolean().default(true),
screenshotOnError: z.boolean().default(true),
maxDurationMinutes: z.number().min(5).max(1440).default(60),
}),
// Notifications
notifications: z.object({
slackWebhook?: z.string().optional(),
teamsWebhook?: z.string().optional(),
emailAdmins: z.boolean().default(true),
pagerDutyKey?: z.string().optional(),
}),
// Cooldown to prevent spam
cooldownMinutes: z.number().min(5).max(1440).default(60),
lastTriggeredAt: z.string().datetime().optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export type TriggerConfig = z.infer<typeof TriggerConfigSchema>;
export const CreateTriggerConfigSchema = TriggerConfigSchema.omit({
id: true,
lastTriggeredAt: true,
createdAt: true,
updatedAt: true,
});
export type CreateTriggerConfigInput = z.infer<typeof CreateTriggerConfigSchema>;
// ─────────────────────────────────────────────────────────────────────────────
// Repository Functions
// ─────────────────────────────────────────────────────────────────────────────
const TRIGGER_CONTAINER = 'diagnostic_triggers';
export async function getTriggerContainer() {
return getRegisteredContainer(TRIGGER_CONTAINER);
}
export async function createTriggerConfig(
input: CreateTriggerConfigInput
): Promise<TriggerConfig> {
const container = await getTriggerContainer();
const now = new Date().toISOString();
const config: TriggerConfig = {
id: `trig_${crypto.randomUUID().replace(/-/g, '')}`,
...input,
lastTriggeredAt: undefined,
createdAt: now,
updatedAt: now,
};
await container.items.create(config);
return config;
}
export async function getTriggerConfig(id: string): Promise<TriggerConfig | null> {
const container = await getTriggerContainer();
try {
const { resource } = await container.item(id, id).read<TriggerConfig>();
return resource || null;
} catch {
return null;
}
}
export async function listTriggerConfigs(productId: string): Promise<TriggerConfig[]> {
const container = await getTriggerContainer();
const query = {
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.createdAt DESC',
parameters: [{ name: '@productId', value: productId }],
};
const { resources } = await container.items.query<TriggerConfig>(query).fetchAll();
return resources;
}
export async function updateTriggerConfig(
id: string,
updates: Partial<Omit<TriggerConfig, 'id' | 'productId' | 'createdAt'>>
): Promise<TriggerConfig | null> {
const container = await getTriggerContainer();
const existing = await getTriggerConfig(id);
if (!existing) return null;
const updated: TriggerConfig = {
...existing,
...updates,
updatedAt: new Date().toISOString(),
};
await container.item(id, id).replace(updated);
return updated;
}
export async function deleteTriggerConfig(id: string): Promise<boolean> {
const container = await getTriggerContainer();
try {
await container.item(id, id).delete();
return true;
} catch {
return false;
}
}
export async function recordTriggerExecution(
id: string,
sessionId: string
): Promise<void> {
const container = await getTriggerContainer();
const now = new Date().toISOString();
await container.item(id, id).patch({
operations: [
{ op: 'set', path: '/lastTriggeredAt', value: now },
{ op: 'incr', path: '/triggerCount', value: 1 },
{ op: 'set', path: '/lastSessionId', value: sessionId },
],
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Trigger Evaluation Logic
// ─────────────────────────────────────────────────────────────────────────────
interface ErrorStats {
totalEvents: number;
errorCount: number;
fatalCount: number;
crashCount: number;
errorRate: number;
}
/**
* Query error statistics from telemetry events.
*/
export async function queryErrorStats(
productId: string,
windowMinutes: number
): Promise<ErrorStats> {
const telemetryContainer = await getRegisteredContainer('telemetry_events');
const since = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
// Query for error events
const errorQuery = {
query: `
SELECT VALUE COUNT(1)
FROM c
WHERE c.productId = @productId
AND c.timestamp >= @since
AND c.eventType = 'error'
`,
parameters: [
{ name: '@productId', value: productId },
{ name: '@since', value: since },
],
};
// Query for total events
const totalQuery = {
query: `
SELECT VALUE COUNT(1)
FROM c
WHERE c.productId = @productId
AND c.timestamp >= @since
`,
parameters: [
{ name: '@productId', value: productId },
{ name: '@since', value: since },
],
};
// Query for fatal logs from diagnostics
const fatalQuery = {
query: `
SELECT VALUE COUNT(1)
FROM c
WHERE c.productId = @productId
AND c.timestamp >= @since
AND c.level = 'fatal'
`,
parameters: [
{ name: '@productId', value: productId },
{ name: '@since', value: since },
],
};
const [{ resources: errorResult }, { resources: totalResult }, { resources: fatalResult }] =
await Promise.all([
telemetryContainer.items.query(errorQuery).fetchNext(),
telemetryContainer.items.query(totalQuery).fetchNext(),
telemetryContainer.items.query(fatalQuery).fetchNext(),
]);
const errorCount = errorResult[0] || 0;
const totalEvents = totalResult[0] || 0;
const fatalCount = fatalResult[0] || 0;
return {
totalEvents,
errorCount,
fatalCount,
crashCount: fatalCount, // Treat fatal as crashes for now
errorRate: totalEvents > 0 ? errorCount / totalEvents : 0,
};
}
/**
* Evaluate a single trigger configuration.
*/
export async function evaluateTrigger(
trigger: TriggerConfig,
adminUserId: string
): Promise<{ triggered: boolean; reason?: string; session?: DebugSessionDoc }> {
// Check cooldown
if (trigger.lastTriggeredAt) {
const lastTrigger = new Date(trigger.lastTriggeredAt).getTime();
const cooldownMs = trigger.cooldownMinutes * 60 * 1000;
if (Date.now() - lastTrigger < cooldownMs) {
return { triggered: false, reason: 'Cooldown active' };
}
}
// Check if trigger is enabled
if (!trigger.enabled) {
return { triggered: false, reason: 'Trigger disabled' };
}
const stats = await queryErrorStats(trigger.productId, trigger.condition.windowMinutes);
switch (trigger.condition.type) {
case 'error_rate': {
if (stats.totalEvents < trigger.condition.minEvents) {
return { triggered: false, reason: 'Insufficient events' };
}
if (stats.errorRate >= trigger.condition.threshold) {
// Trigger auto-session
const session = await createAutoSession(trigger, adminUserId);
await recordTriggerExecution(trigger.id, session.id);
await sendTriggerNotifications(trigger, session, stats);
return { triggered: true, reason: `Error rate ${(stats.errorRate * 100).toFixed(1)}% exceeded threshold ${(trigger.condition.threshold * 100).toFixed(1)}%`, session };
}
break;
}
case 'crash_count': {
if (stats.crashCount >= trigger.condition.threshold) {
const session = await createAutoSession(trigger, adminUserId);
await recordTriggerExecution(trigger.id, session.id);
await sendTriggerNotifications(trigger, session, stats);
return { triggered: true, reason: `${stats.crashCount} crashes in ${trigger.condition.windowMinutes} minutes`, session };
}
break;
}
case 'fatal_log': {
if (stats.fatalCount >= trigger.condition.count) {
const session = await createAutoSession(trigger, adminUserId);
await recordTriggerExecution(trigger.id, session.id);
await sendTriggerNotifications(trigger, session, stats);
return { triggered: true, reason: `${stats.fatalCount} fatal logs in ${trigger.condition.windowMinutes} minutes`, session };
}
break;
}
}
return { triggered: false, reason: 'Threshold not met' };
}
/**
* Create an auto-triggered debug session.
*/
async function createAutoSession(
trigger: TriggerConfig,
adminUserId: string
): Promise<DebugSessionDoc> {
const session = await createSession({
productId: trigger.productId,
targetUserId: undefined, // Auto-sessions target all users
targetAnonymousId: undefined,
targetDeviceId: undefined,
targetSessionId: undefined,
status: 'active',
collectionLevel: trigger.sessionConfig.collectionLevel,
captureLogs: trigger.sessionConfig.captureLogs,
captureNetwork: trigger.sessionConfig.captureNetwork,
captureScreenshots: trigger.sessionConfig.captureScreenshots,
screenshotOnError: trigger.sessionConfig.screenshotOnError,
maxDurationMinutes: trigger.sessionConfig.maxDurationMinutes,
createdBy: adminUserId, // System/admin who created the trigger
autoTriggered: true,
triggerId: trigger.id,
triggerName: trigger.name,
});
return session;
}
/**
* Send notifications when trigger fires.
*/
async function sendTriggerNotifications(
trigger: TriggerConfig,
session: DebugSessionDoc,
stats: ErrorStats
): Promise<void> {
const { notifications } = trigger;
// Slack webhook
if (notifications.slackWebhook) {
try {
await fetch(notifications.slackWebhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `🚨 Auto-trigger fired: ${trigger.name}`,
attachments: [{
color: 'danger',
fields: [
{ title: 'Product', value: trigger.productId, short: true },
{ title: 'Session', value: session.id, short: true },
{ title: 'Error Rate', value: `${(stats.errorRate * 100).toFixed(1)}%`, short: true },
{ title: 'Crashes', value: stats.crashCount.toString(), short: true },
],
}],
}),
});
} catch (err) {
// Log but don't fail
console.error('Failed to send Slack notification:', err);
}
}
// Teams webhook
if (notifications.teamsWebhook) {
try {
await fetch(notifications.teamsWebhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
'@type': 'MessageCard',
'@context': 'https://schema.org/extensions',
themeColor: 'FF0000',
title: `🚨 Auto-trigger fired: ${trigger.name}`,
text: `Error rate ${(stats.errorRate * 100).toFixed(1)}% exceeded threshold`,
sections: [{
facts: [
{ name: 'Product:', value: trigger.productId },
{ name: 'Session:', value: session.id },
{ name: 'Crashes:', value: stats.crashCount.toString() },
],
}],
}),
});
} catch (err) {
console.error('Failed to send Teams notification:', err);
}
}
// PagerDuty (future)
if (notifications.pagerDutyKey) {
// Integration with PagerDuty Events API v2
// https://developer.pagerduty.com/docs/events-api-v2-overview/
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Background Job Runner
// ─────────────────────────────────────────────────────────────────────────────
/**
* Run all triggers for a product.
* Called by the scheduled job every 5 minutes.
*/
export async function runAllTriggers(
productId: string,
adminUserId: string
): Promise<Array<{ triggerId: string; triggered: boolean; reason?: string }>> {
const triggers = await listTriggerConfigs(productId);
const results: Array<{ triggerId: string; triggered: boolean; reason?: string }> = [];
for (const trigger of triggers) {
const result = await evaluateTrigger(trigger, adminUserId);
results.push({
triggerId: trigger.id,
triggered: result.triggered,
reason: result.reason,
});
}
return results;
}