feat(ai-diagnostics): add telemetry linking and context enrichment [1.3]
This commit is contained in:
parent
a9e1486a09
commit
1ff02934fa
@ -7,6 +7,18 @@ description: Refresh the Windsurf chat history archive (re-scan all repos, updat
|
|||||||
Refreshes the centralized Windsurf chat history archive at `__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/`.
|
Refreshes the centralized Windsurf chat history archive at `__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/`.
|
||||||
Auto-discovers new repos, updates symlinks, and re-copies docs + workflows.
|
Auto-discovers new repos, updates symlinks, and re-copies docs + workflows.
|
||||||
|
|
||||||
|
## Covered Repos (All 7 workspaces)
|
||||||
|
|
||||||
|
| Repo | Product | Workflows | Docs |
|
||||||
|
|------|---------|-----------|------|
|
||||||
|
| `learning_voice_ai_agent` | LysnrAI | ✅ | ✅ |
|
||||||
|
| `learning_multimodal_memory_agents` | MindLyst | ✅ | ✅ |
|
||||||
|
| `learning_ai_clock` | ChronoMind | ✅ | — |
|
||||||
|
| `learning_ai_peakpulse` | PeakPulse | ✅ | — |
|
||||||
|
| `learning_ai_fastgap` | NomGap | ✅ | — |
|
||||||
|
| `learning_ai_jarvis_jr` | JarvisJr | ✅ | — |
|
||||||
|
| `learning_ai_common_plat` | Common Platform | ✅ | — |
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
// turbo
|
// turbo
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
Last refresh: 2026-03-03T07:00:03Z (2026-03-02 23:00:03 PST)
|
Last refresh: 2026-03-03T19:50:04Z (2026-03-03 11:50:04 PST)
|
||||||
Cascade conversations: 50 (348M)
|
Cascade conversations: 50 (297M)
|
||||||
Memories: 65
|
Memories: 65
|
||||||
Implicit context: 20
|
Implicit context: 20
|
||||||
Code tracker dirs: 149
|
Code tracker dirs: 188
|
||||||
File edit history: 2278 entries
|
File edit history: 2343 entries
|
||||||
Workspace storage: 28 workspaces
|
Workspace storage: 28 workspaces
|
||||||
Repo docs: 7 files across 2 repos
|
Repo docs: 7 files across 2 repos
|
||||||
Repo workflows: 35 files across 6 repos
|
Repo workflows: 35 files across 6 repos
|
||||||
|
|||||||
@ -7,6 +7,18 @@ description: Refresh the Windsurf chat history archive (re-scan all repos, updat
|
|||||||
Refreshes the centralized Windsurf chat history archive at `__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/`.
|
Refreshes the centralized Windsurf chat history archive at `__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/`.
|
||||||
Auto-discovers new repos, updates symlinks, and re-copies docs + workflows.
|
Auto-discovers new repos, updates symlinks, and re-copies docs + workflows.
|
||||||
|
|
||||||
|
## Covered Repos (All 7 workspaces)
|
||||||
|
|
||||||
|
| Repo | Product | Workflows | Docs |
|
||||||
|
|------|---------|-----------|------|
|
||||||
|
| `learning_voice_ai_agent` | LysnrAI | ✅ | ✅ |
|
||||||
|
| `learning_multimodal_memory_agents` | MindLyst | ✅ | ✅ |
|
||||||
|
| `learning_ai_clock` | ChronoMind | ✅ | — |
|
||||||
|
| `learning_ai_peakpulse` | PeakPulse | ✅ | — |
|
||||||
|
| `learning_ai_fastgap` | NomGap | ✅ | — |
|
||||||
|
| `learning_ai_jarvis_jr` | JarvisJr | ✅ | — |
|
||||||
|
| `learning_ai_common_plat` | Common Platform | ✅ | — |
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
// turbo
|
// turbo
|
||||||
|
|||||||
@ -18,7 +18,7 @@ Run `bash scripts/backup-main.sh` from any repository root
|
|||||||
// turbo
|
// turbo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
for repo in learning_ai_common_plat learning_voice_ai_agent learning_multimodal_memory_agents learning_ai_clock learning_ai_fastgap; do
|
for repo in learning_ai_common_plat learning_voice_ai_agent learning_multimodal_memory_agents learning_ai_clock learning_ai_fastgap learning_ai_jarvis_jr learning_ai_peakpulse; do
|
||||||
echo "━━━ Pushing $repo ━━━"
|
echo "━━━ Pushing $repo ━━━"
|
||||||
(cd ~/code/mygh/$repo && git push origin main 2>&1)
|
(cd ~/code/mygh/$repo && git push origin main 2>&1)
|
||||||
done
|
done
|
||||||
@ -29,7 +29,7 @@ echo "✨ All repos pushed!"
|
|||||||
## What it does:
|
## What it does:
|
||||||
|
|
||||||
1. **Backup** — creates timestamped backup branches, cleans up old ones (7 days), skips duplicates
|
1. **Backup** — creates timestamped backup branches, cleans up old ones (7 days), skips duplicates
|
||||||
2. **Push** — pushes `main` to `origin/main` for all 5 repos
|
2. **Push** — pushes `main` to `origin/main` for all 7 repos
|
||||||
|
|
||||||
## Repositories:
|
## Repositories:
|
||||||
|
|
||||||
@ -38,6 +38,8 @@ echo "✨ All repos pushed!"
|
|||||||
- learning_multimodal_memory_agents
|
- learning_multimodal_memory_agents
|
||||||
- learning_ai_clock
|
- learning_ai_clock
|
||||||
- learning_ai_fastgap
|
- learning_ai_fastgap
|
||||||
|
- learning_ai_jarvis_jr
|
||||||
|
- learning_ai_peakpulse
|
||||||
|
|
||||||
## When to use:
|
## When to use:
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,8 @@ Run `bash scripts/backup-main.sh` from any repository root
|
|||||||
- learning_multimodal_memory_agents
|
- learning_multimodal_memory_agents
|
||||||
- learning_ai_clock
|
- learning_ai_clock
|
||||||
- learning_ai_fastgap
|
- learning_ai_fastgap
|
||||||
|
- learning_ai_jarvis_jr
|
||||||
|
- learning_ai_peakpulse
|
||||||
|
|
||||||
## Features:
|
## Features:
|
||||||
|
|
||||||
|
|||||||
@ -12,11 +12,14 @@ Scans all repositories for pending changes and commits them in logical order wit
|
|||||||
|
|
||||||
## What it does:
|
## What it does:
|
||||||
|
|
||||||
1. **Scans** all 4 repos for changes:
|
1. **Scans** all 7 repos for changes:
|
||||||
- learning_ai_common_plat
|
- learning_ai_common_plat
|
||||||
- learning_voice_ai_agent
|
- learning_voice_ai_agent
|
||||||
- learning_multimodal_memory_agents
|
- learning_multimodal_memory_agents
|
||||||
- learning_ai_clock
|
- learning_ai_clock
|
||||||
|
- learning_ai_fastgap
|
||||||
|
- learning_ai_jarvis_jr
|
||||||
|
- learning_ai_peakpulse
|
||||||
|
|
||||||
2. **Analyzes** changed files to determine:
|
2. **Analyzes** changed files to determine:
|
||||||
- Commit scope (auth, ci, docs, feat, chore, etc.)
|
- Commit scope (auth, ci, docs, feat, chore, etc.)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: Push local main branch to origin for all 5 workspace repos
|
description: Push local main branch to origin for all 7 workspace repos
|
||||||
---
|
---
|
||||||
|
|
||||||
# Push Repos
|
# Push Repos
|
||||||
@ -9,7 +9,7 @@ Pushes local `main` to `origin/main` for all workspace repositories.
|
|||||||
// turbo
|
// turbo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
for repo in learning_ai_common_plat learning_voice_ai_agent learning_multimodal_memory_agents learning_ai_clock learning_ai_fastgap; do
|
for repo in learning_ai_common_plat learning_voice_ai_agent learning_multimodal_memory_agents learning_ai_clock learning_ai_fastgap learning_ai_jarvis_jr learning_ai_peakpulse; do
|
||||||
echo "━━━ $repo ━━━"
|
echo "━━━ $repo ━━━"
|
||||||
(cd ~/code/mygh/$repo && git push origin main)
|
(cd ~/code/mygh/$repo && git push origin main)
|
||||||
done
|
done
|
||||||
@ -17,7 +17,7 @@ done
|
|||||||
|
|
||||||
## What it does:
|
## What it does:
|
||||||
|
|
||||||
1. Iterates over all 5 workspace repos
|
1. Iterates over all 7 workspace repos
|
||||||
2. Runs `git push origin main` in each
|
2. Runs `git push origin main` in each
|
||||||
3. Fails fast if a repo has diverged from remote (resolve with rebase manually)
|
3. Fails fast if a repo has diverged from remote (resolve with rebase manually)
|
||||||
|
|
||||||
@ -28,6 +28,8 @@ done
|
|||||||
- learning_multimodal_memory_agents
|
- learning_multimodal_memory_agents
|
||||||
- learning_ai_clock
|
- learning_ai_clock
|
||||||
- learning_ai_fastgap
|
- learning_ai_fastgap
|
||||||
|
- learning_ai_jarvis_jr
|
||||||
|
- learning_ai_peakpulse
|
||||||
|
|
||||||
## When to use:
|
## When to use:
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: Pull latest from origin main across all 5 workspace repos
|
description: Pull latest from origin main across all 7 workspace repos
|
||||||
---
|
---
|
||||||
|
|
||||||
# Sync Repos
|
# Sync Repos
|
||||||
@ -9,7 +9,7 @@ Pulls the latest changes from `origin/main` for all workspace repositories.
|
|||||||
// turbo
|
// turbo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
for repo in learning_ai_common_plat learning_voice_ai_agent learning_multimodal_memory_agents learning_ai_clock learning_ai_fastgap; do
|
for repo in learning_ai_common_plat learning_voice_ai_agent learning_multimodal_memory_agents learning_ai_clock learning_ai_fastgap learning_ai_jarvis_jr learning_ai_peakpulse; do
|
||||||
echo "━━━ $repo ━━━"
|
echo "━━━ $repo ━━━"
|
||||||
(cd ~/code/mygh/$repo && git pull --ff-only origin main)
|
(cd ~/code/mygh/$repo && git pull --ff-only origin main)
|
||||||
done
|
done
|
||||||
@ -17,7 +17,7 @@ done
|
|||||||
|
|
||||||
## What it does:
|
## What it does:
|
||||||
|
|
||||||
1. Iterates over all 5 workspace repos
|
1. Iterates over all 7 workspace repos
|
||||||
2. Runs `git pull --ff-only origin main` in each
|
2. Runs `git pull --ff-only origin main` in each
|
||||||
3. Fails fast if there are local divergent commits (use `git pull --rebase` manually in that case)
|
3. Fails fast if there are local divergent commits (use `git pull --rebase` manually in that case)
|
||||||
|
|
||||||
@ -28,6 +28,8 @@ done
|
|||||||
- learning_multimodal_memory_agents
|
- learning_multimodal_memory_agents
|
||||||
- learning_ai_clock
|
- learning_ai_clock
|
||||||
- learning_ai_fastgap
|
- learning_ai_fastgap
|
||||||
|
- learning_ai_jarvis_jr
|
||||||
|
- learning_ai_peakpulse
|
||||||
|
|
||||||
## When to use:
|
## When to use:
|
||||||
|
|
||||||
|
|||||||
257
dashboards/admin-web/src/app/(dashboard)/experiments/page.tsx
Normal file
257
dashboards/admin-web/src/app/(dashboard)/experiments/page.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Experiments List Page — Admin Dashboard
|
||||||
|
* Shows all A/B experiments with status, metrics, and quick actions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
FlaskConical,
|
||||||
|
Plus,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Square,
|
||||||
|
BarChart3,
|
||||||
|
Users,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import type { ExperimentDoc } from '@/lib/experiments-types';
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { color: string; icon: typeof Play; label: string }> = {
|
||||||
|
draft: { color: 'bg-gray-500', icon: AlertCircle, label: 'Draft' },
|
||||||
|
running: { color: 'bg-green-500', icon: Play, label: 'Running' },
|
||||||
|
paused: { color: 'bg-yellow-500', icon: Pause, label: 'Paused' },
|
||||||
|
stopped: { color: 'bg-red-500', icon: Square, label: 'Stopped' },
|
||||||
|
completed: { color: 'bg-blue-500', icon: CheckCircle, label: 'Completed' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExperimentsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [experiments, setExperiments] = useState<ExperimentDoc[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchExperiments();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchExperiments() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/experiments');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch experiments');
|
||||||
|
const data = await response.json();
|
||||||
|
setExperiments(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningCount = experiments.filter(e => e.status === 'running').length;
|
||||||
|
const completedCount = experiments.filter(e => e.status === 'completed').length;
|
||||||
|
const totalParticipants = experiments.reduce((sum, e) => sum + (e.totalParticipants || 0), 0);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold flex items-center gap-3">
|
||||||
|
<FlaskConical className="h-8 w-8" />
|
||||||
|
A/B Experiments
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Intelligent experimentation with Bayesian statistics and auto-allocation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/experiments/suggestions">
|
||||||
|
<Sparkles className="h-4 w-4 mr-2" />
|
||||||
|
AI Suggestions
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => router.push('/experiments/new')}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
New Experiment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Experiments
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">{experiments.length}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Running
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-green-600">{runningCount}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Completed
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold text-blue-600">{completedCount}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Participants
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">{totalParticipants.toLocaleString()}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs defaultValue="all" className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">All ({experiments.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="running">Running ({runningCount})</TabsTrigger>
|
||||||
|
<TabsTrigger value="completed">Completed ({completedCount})</TabsTrigger>
|
||||||
|
<TabsTrigger value="draft">Drafts ({experiments.filter(e => e.status === 'draft').length})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="all" className="space-y-4">
|
||||||
|
{experiments.map(experiment => (
|
||||||
|
<ExperimentCard key={experiment.id} experiment={experiment} />
|
||||||
|
))}
|
||||||
|
{experiments.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<FlaskConical className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>No experiments yet. Create your first experiment to get started.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="running" className="space-y-4">
|
||||||
|
{experiments
|
||||||
|
.filter(e => e.status === 'running')
|
||||||
|
.map(experiment => (
|
||||||
|
<ExperimentCard key={experiment.id} experiment={experiment} />
|
||||||
|
))}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="completed" className="space-y-4">
|
||||||
|
{experiments
|
||||||
|
.filter(e => e.status === 'completed')
|
||||||
|
.map(experiment => (
|
||||||
|
<ExperimentCard key={experiment.id} experiment={experiment} />
|
||||||
|
))}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="draft" className="space-y-4">
|
||||||
|
{experiments
|
||||||
|
.filter(e => e.status === 'draft')
|
||||||
|
.map(experiment => (
|
||||||
|
<ExperimentCard key={experiment.id} experiment={experiment} />
|
||||||
|
))}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExperimentCard({ experiment }: { experiment: ExperimentDoc }) {
|
||||||
|
const status = statusConfig[experiment.status] || statusConfig.draft;
|
||||||
|
const StatusIcon = status.icon;
|
||||||
|
|
||||||
|
const daysRunning = experiment.startedAt
|
||||||
|
? Math.floor((Date.now() - new Date(experiment.startedAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
<Link href={`/experiments/${experiment.id}`} className="hover:underline">
|
||||||
|
{experiment.name}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<Badge className={`${status.color} text-white`}>
|
||||||
|
<StatusIcon className="h-3 w-3 mr-1" />
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
{experiment.aiGeneratedHypothesis && (
|
||||||
|
<Badge variant="outline">
|
||||||
|
<Sparkles className="h-3 w-3 mr-1" />
|
||||||
|
AI Generated
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm mb-4">{experiment.hypothesis}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
<span>Metric: {experiment.primaryMetric?.name || 'Not set'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>{experiment.totalParticipants?.toLocaleString() || 0} participants</span>
|
||||||
|
</div>
|
||||||
|
{experiment.status === 'running' && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>{daysRunning} days running</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href={`/experiments/${experiment.id}`}>
|
||||||
|
<BarChart3 className="h-4 w-4 mr-2" />
|
||||||
|
Results
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,445 @@
|
|||||||
|
/**
|
||||||
|
* Intelligent A/B Testing — AI Hypothesis Generation.
|
||||||
|
* Pattern detection from telemetry, LLM-powered hypothesis generation, auto-suggestions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExperimentSuggestion, GeneratedHypothesis, HypothesisInput, PrimaryMetric, ExperimentDoc } from './types.js';
|
||||||
|
import { createSuggestion } from './repository.js';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Pattern Detection
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface UsagePattern {
|
||||||
|
featureName: string;
|
||||||
|
totalUsage: number;
|
||||||
|
uniqueUsers: number;
|
||||||
|
adoptionRate: number; // 0–1
|
||||||
|
trendDirection: 'up' | 'down' | 'stable';
|
||||||
|
trendMagnitude: number; // % change
|
||||||
|
segmentPerformance: Record<string, number>; // platform/segment -> rate
|
||||||
|
temporalPatterns: {
|
||||||
|
dayOfWeek: Record<string, number>;
|
||||||
|
hourOfDay: Record<string, number>;
|
||||||
|
};
|
||||||
|
funnelDropOffs?: Array<{ stage: string; dropRate: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnomalyDetection {
|
||||||
|
featureName: string;
|
||||||
|
anomalyType: 'drop' | 'spike' | 'regression' | 'segment_mismatch';
|
||||||
|
severity: 'low' | 'medium' | 'high';
|
||||||
|
expectedValue: number;
|
||||||
|
actualValue: number;
|
||||||
|
deviation: number; // standard deviations from mean
|
||||||
|
affectedSegment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Opportunity {
|
||||||
|
featureName: string;
|
||||||
|
opportunityType: 'low_adoption' | 'high_dropoff' | 'competitor_gap' | 'unused_potential';
|
||||||
|
impactScore: number; // 0–100
|
||||||
|
currentMetric: number;
|
||||||
|
targetMetric: number;
|
||||||
|
estimatedLift: number;
|
||||||
|
supportingEvidence: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze telemetry data for usage patterns.
|
||||||
|
* Placeholder: In production, this queries telemetry_events container.
|
||||||
|
*/
|
||||||
|
export async function detectUsagePatterns(
|
||||||
|
productId: string,
|
||||||
|
featureNames: string[]
|
||||||
|
): Promise<UsagePattern[]> {
|
||||||
|
// Placeholder implementation
|
||||||
|
// In production: query telemetry_events with aggregation queries
|
||||||
|
return featureNames.map(name => ({
|
||||||
|
featureName: name,
|
||||||
|
totalUsage: Math.floor(Math.random() * 10000) + 1000,
|
||||||
|
uniqueUsers: Math.floor(Math.random() * 5000) + 500,
|
||||||
|
adoptionRate: Math.random() * 0.5 + 0.1,
|
||||||
|
trendDirection: Math.random() > 0.5 ? 'up' : 'down',
|
||||||
|
trendMagnitude: Math.random() * 0.3,
|
||||||
|
segmentPerformance: {
|
||||||
|
ios: Math.random() * 0.4 + 0.1,
|
||||||
|
android: Math.random() * 0.4 + 0.1,
|
||||||
|
web: Math.random() * 0.4 + 0.1,
|
||||||
|
},
|
||||||
|
temporalPatterns: {
|
||||||
|
dayOfWeek: { Mon: 0.2, Tue: 0.25, Wed: 0.25, Thu: 0.2, Fri: 0.1 },
|
||||||
|
hourOfDay: { '9': 0.15, '12': 0.2, '15': 0.25, '18': 0.2, '21': 0.1 },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect anomalies in feature usage.
|
||||||
|
*/
|
||||||
|
export async function detectAnomalies(
|
||||||
|
productId: string,
|
||||||
|
baselineDays = 30
|
||||||
|
): Promise<AnomalyDetection[]> {
|
||||||
|
// Placeholder: In production, compare current metrics against rolling averages
|
||||||
|
const anomalies: AnomalyDetection[] = [];
|
||||||
|
|
||||||
|
// Simulate anomaly detection
|
||||||
|
const randomFeatures = ['voice_dictation', 'keyboard_shortcuts', 'cloud_sync', 'analytics_view'];
|
||||||
|
for (const feature of randomFeatures) {
|
||||||
|
if (Math.random() > 0.7) {
|
||||||
|
anomalies.push({
|
||||||
|
featureName: feature,
|
||||||
|
anomalyType: Math.random() > 0.5 ? 'drop' : 'regression',
|
||||||
|
severity: Math.random() > 0.5 ? 'high' : 'medium',
|
||||||
|
expectedValue: 0.25,
|
||||||
|
actualValue: 0.15,
|
||||||
|
deviation: 2.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return anomalies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify experiment opportunities from usage patterns.
|
||||||
|
*/
|
||||||
|
export function identifyOpportunities(patterns: UsagePattern[]): Opportunity[] {
|
||||||
|
const opportunities: Opportunity[] = [];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
// Low adoption opportunity
|
||||||
|
if (pattern.adoptionRate < 0.2) {
|
||||||
|
opportunities.push({
|
||||||
|
featureName: pattern.featureName,
|
||||||
|
opportunityType: 'low_adoption',
|
||||||
|
impactScore: Math.floor((0.2 - pattern.adoptionRate) * 500),
|
||||||
|
currentMetric: pattern.adoptionRate,
|
||||||
|
targetMetric: 0.2,
|
||||||
|
estimatedLift: (0.2 - pattern.adoptionRate) / pattern.adoptionRate,
|
||||||
|
supportingEvidence: [
|
||||||
|
`Current adoption rate is ${(pattern.adoptionRate * 100).toFixed(1)}%`,
|
||||||
|
`Trend is ${pattern.trendDirection} by ${(pattern.trendMagnitude * 100).toFixed(1)}%`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment mismatch opportunity
|
||||||
|
const segmentRates = Object.values(pattern.segmentPerformance);
|
||||||
|
const maxSegment = Math.max(...segmentRates);
|
||||||
|
const minSegment = Math.min(...segmentRates);
|
||||||
|
if (maxSegment > minSegment * 2) {
|
||||||
|
opportunities.push({
|
||||||
|
featureName: pattern.featureName,
|
||||||
|
opportunityType: 'unused_potential',
|
||||||
|
impactScore: Math.floor((maxSegment - minSegment) * 100),
|
||||||
|
currentMetric: minSegment,
|
||||||
|
targetMetric: maxSegment,
|
||||||
|
estimatedLift: (maxSegment - minSegment) / minSegment,
|
||||||
|
supportingEvidence: [
|
||||||
|
`Best performing segment: ${(maxSegment * 100).toFixed(1)}%`,
|
||||||
|
`Worst performing segment: ${(minSegment * 100).toFixed(1)}%`,
|
||||||
|
`Potential to lift underperforming segments to best-in-class`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return opportunities.sort((a, b) => b.impactScore - a.impactScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// LLM Hypothesis Generation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate experiment hypothesis using LLM.
|
||||||
|
* Placeholder: In production, this calls Azure OpenAI.
|
||||||
|
*/
|
||||||
|
export async function generateHypothesis(
|
||||||
|
input: HypothesisInput
|
||||||
|
): Promise<GeneratedHypothesis> {
|
||||||
|
// Build prompt for LLM
|
||||||
|
const prompt = buildHypothesisPrompt(input);
|
||||||
|
|
||||||
|
// Placeholder: Simulate LLM response
|
||||||
|
// In production: const response = await callAzureOpenAI(prompt);
|
||||||
|
return simulateLLMHypothesis(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build structured prompt for hypothesis generation.
|
||||||
|
*/
|
||||||
|
function buildHypothesisPrompt(input: HypothesisInput): string {
|
||||||
|
return `
|
||||||
|
You are an expert product analyst and experimentation strategist.
|
||||||
|
|
||||||
|
Analyze this feature usage data and generate experiment hypotheses:
|
||||||
|
|
||||||
|
Feature: ${input.featureName}
|
||||||
|
Current Adoption Rate: ${(input.adoptionRate * 100).toFixed(1)}%
|
||||||
|
Baseline Rate: ${(input.baselineRate * 100).toFixed(1)}%
|
||||||
|
|
||||||
|
Segment Performance:
|
||||||
|
${Object.entries(input.segmentData)
|
||||||
|
.map(([segment, rate]) => `- ${segment}: ${(rate * 100).toFixed(1)}%`)
|
||||||
|
.join('\n')}
|
||||||
|
|
||||||
|
User Feedback Samples:
|
||||||
|
${input.feedbackSamples.map(f => `- "${f}"`).join('\n')}
|
||||||
|
|
||||||
|
Generate:
|
||||||
|
1. Primary hypothesis: "Changing X will improve Y because..."
|
||||||
|
2. 2-3 alternative hypotheses
|
||||||
|
3. Expected effect size (conservative)
|
||||||
|
4. Success metric recommendation
|
||||||
|
5. Risk assessment (low/medium/high)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate LLM hypothesis response (placeholder).
|
||||||
|
*/
|
||||||
|
function simulateLLMHypothesis(input: HypothesisInput): GeneratedHypothesis {
|
||||||
|
const effectSize = Math.abs(input.adoptionRate - input.baselineRate) * 0.5;
|
||||||
|
const riskLevel = effectSize > 0.1 ? 'medium' : 'low';
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary: `Redesigning the ${input.featureName} onboarding flow to highlight key benefits will increase adoption by ${(effectSize * 100).toFixed(0)}% because users currently don't understand the value proposition.`,
|
||||||
|
alternatives: [
|
||||||
|
`Adding tooltips and contextual help to ${input.featureName} will reduce confusion and increase usage by ${(effectSize * 0.7 * 100).toFixed(0)}%.`,
|
||||||
|
`Simplifying the ${input.featureName} UI to match competitor patterns will improve familiarity and adoption by ${(effectSize * 0.5 * 100).toFixed(0)}%.`,
|
||||||
|
],
|
||||||
|
expectedEffectSize: Math.max(effectSize, 0.05),
|
||||||
|
successMetric: 'adoption_rate',
|
||||||
|
riskAssessment: riskLevel,
|
||||||
|
impactScore: Math.floor((input.baselineRate - input.adoptionRate) * 200),
|
||||||
|
difficultyScore: Math.floor(Math.random() * 30) + 20,
|
||||||
|
powerPrediction: 85,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Hypothesis Ranking
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RankedHypothesis extends GeneratedHypothesis {
|
||||||
|
rankScore: number;
|
||||||
|
estimatedImpact: number;
|
||||||
|
estimatedEffort: number;
|
||||||
|
estimatedPower: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rank hypotheses by expected value.
|
||||||
|
*/
|
||||||
|
export function rankHypotheses(
|
||||||
|
hypotheses: GeneratedHypothesis[],
|
||||||
|
baseTraffic: number
|
||||||
|
): RankedHypothesis[] {
|
||||||
|
return hypotheses.map(h => {
|
||||||
|
// Expected value calculation
|
||||||
|
const impact = h.impactScore;
|
||||||
|
const effort = h.difficultyScore;
|
||||||
|
const power = h.powerPrediction;
|
||||||
|
|
||||||
|
// Risk-adjusted expected value
|
||||||
|
const riskMultiplier = h.riskAssessment === 'low' ? 1.0 : h.riskAssessment === 'medium' ? 0.8 : 0.6;
|
||||||
|
|
||||||
|
// Rank score: higher impact, lower effort, higher power = better
|
||||||
|
const rankScore = (impact * power * riskMultiplier) / (effort + 10);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...h,
|
||||||
|
rankScore,
|
||||||
|
estimatedImpact: impact,
|
||||||
|
estimatedEffort: effort,
|
||||||
|
estimatedPower: power,
|
||||||
|
};
|
||||||
|
}).sort((a, b) => b.rankScore - a.rankScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Auto-Experiment Suggestions
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SuggestedExperiment {
|
||||||
|
name: string;
|
||||||
|
hypothesis: string;
|
||||||
|
description: string;
|
||||||
|
variants: Array<{
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
flagConfig: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
primaryMetric: PrimaryMetric;
|
||||||
|
suggestedDuration: number;
|
||||||
|
suggestedSampleSize: number;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate complete experiment suggestion from opportunity.
|
||||||
|
*/
|
||||||
|
export function generateExperimentSuggestion(
|
||||||
|
opportunity: Opportunity,
|
||||||
|
hypothesis: GeneratedHypothesis,
|
||||||
|
productId: string
|
||||||
|
): Omit<ExperimentSuggestion, 'id' | 'createdAt'> {
|
||||||
|
const variantNames = ['Control', 'Treatment A', 'Treatment B'];
|
||||||
|
if (hypothesis.alternatives.length < 2) {
|
||||||
|
variantNames.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestedVariants = variantNames.map((name, i) => ({
|
||||||
|
name,
|
||||||
|
description: i === 0
|
||||||
|
? 'Current implementation (control)'
|
||||||
|
: hypothesis.alternatives[i - 1] || `Alternative approach ${i}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sampleSize = Math.ceil(
|
||||||
|
(hypothesis.expectedEffectSize > 0 ? 16 / (hypothesis.expectedEffectSize ** 2) : 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hypothesis,
|
||||||
|
suggestedVariants,
|
||||||
|
suggestedMetrics: [
|
||||||
|
{
|
||||||
|
name: opportunity.opportunityType === 'low_adoption' ? 'adoption_rate' : 'conversion_rate',
|
||||||
|
type: 'conversion',
|
||||||
|
eventName: `${opportunity.featureName}_used`,
|
||||||
|
aggregation: 'unique',
|
||||||
|
direction: 'increase',
|
||||||
|
minimumDetectableEffect: hypothesis.expectedEffectSize * 100,
|
||||||
|
} as PrimaryMetric,
|
||||||
|
],
|
||||||
|
suggestedDuration: Math.max(7, Math.ceil(sampleSize / 100)),
|
||||||
|
suggestedSampleSize: sampleSize,
|
||||||
|
priority: opportunity.impactScore,
|
||||||
|
aiGenerated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate weekly AI report with top experiment opportunities.
|
||||||
|
*/
|
||||||
|
export async function generateWeeklyReport(
|
||||||
|
productId: string
|
||||||
|
): Promise<{
|
||||||
|
topOpportunities: Opportunity[];
|
||||||
|
suggestedExperiments: Array<Omit<ExperimentSuggestion, 'id' | 'createdAt'>>;
|
||||||
|
anomalies: AnomalyDetection[];
|
||||||
|
generatedAt: string;
|
||||||
|
}> {
|
||||||
|
// Detect patterns and opportunities
|
||||||
|
const patterns = await detectUsagePatterns(productId, [
|
||||||
|
'voice_dictation',
|
||||||
|
'keyboard_shortcuts',
|
||||||
|
'cloud_sync',
|
||||||
|
'analytics_view',
|
||||||
|
'settings_customization',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const opportunities = identifyOpportunities(patterns);
|
||||||
|
const anomalies = await detectAnomalies(productId);
|
||||||
|
|
||||||
|
// Generate hypotheses for top opportunities
|
||||||
|
const topOpportunities = opportunities.slice(0, 5);
|
||||||
|
const suggestedExperiments: Array<Omit<ExperimentSuggestion, 'id' | 'createdAt'>> = [];
|
||||||
|
|
||||||
|
for (const opp of topOpportunities) {
|
||||||
|
const hypothesisInput: HypothesisInput = {
|
||||||
|
featureName: opp.featureName,
|
||||||
|
adoptionRate: opp.currentMetric,
|
||||||
|
baselineRate: opp.targetMetric,
|
||||||
|
segmentData: {},
|
||||||
|
feedbackSamples: opp.supportingEvidence,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hypothesis = await generateHypothesis(hypothesisInput);
|
||||||
|
const suggestion = generateExperimentSuggestion(opp, hypothesis, productId);
|
||||||
|
suggestedExperiments.push(suggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
topOpportunities,
|
||||||
|
suggestedExperiments,
|
||||||
|
anomalies,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// AI Insights for Results
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ExperimentInsights {
|
||||||
|
summary: string;
|
||||||
|
unexpectedFindings: string[];
|
||||||
|
segmentAnalysis: Record<string, string>;
|
||||||
|
followUpSuggestions: Array<{
|
||||||
|
experimentName: string;
|
||||||
|
hypothesis: string;
|
||||||
|
priority: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate AI insights for completed experiment.
|
||||||
|
* Placeholder: In production, calls Azure OpenAI with full experiment data.
|
||||||
|
*/
|
||||||
|
export function generateExperimentInsights(
|
||||||
|
experiment: ExperimentDoc,
|
||||||
|
result: { variantResults: Array<{ variantName: string; probabilityBeatsControl: number; expectedLiftPercent: number }> },
|
||||||
|
winnerVariantId?: string
|
||||||
|
): ExperimentInsights {
|
||||||
|
const winner = result.variantResults.find(v => v.probabilityBeatsControl > 0.95);
|
||||||
|
const loser = result.variantResults.find(v => v.probabilityBeatsControl < 0.05);
|
||||||
|
|
||||||
|
const insights: ExperimentInsights = {
|
||||||
|
summary: '',
|
||||||
|
unexpectedFindings: [],
|
||||||
|
segmentAnalysis: {},
|
||||||
|
followUpSuggestions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (winner) {
|
||||||
|
insights.summary = `The ${winner.variantName} variant significantly outperformed control with ${winner.probabilityBeatsControl.toFixed(1)}% probability of superiority and a ${winner.expectedLiftPercent.toFixed(1)}% lift. This validates the hypothesis that ${experiment.hypothesis}`;
|
||||||
|
} else if (loser) {
|
||||||
|
insights.summary = `The experiment did not produce a winning variant. The control performed better than expected, suggesting the current implementation is already optimized for this metric.`;
|
||||||
|
} else {
|
||||||
|
insights.summary = `Results were inconclusive. No variant demonstrated statistically significant improvement over control. Consider running the experiment longer or testing more distinct variations.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add unexpected findings based on result patterns
|
||||||
|
const allPositive = result.variantResults.every(v => v.expectedLiftPercent > 0);
|
||||||
|
const allNegative = result.variantResults.every(v => v.expectedLiftPercent < 0);
|
||||||
|
|
||||||
|
if (allNegative) {
|
||||||
|
insights.unexpectedFindings.push('All variants underperformed control, suggesting possible confounding factors or measurement issues.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(result.variantResults[0]?.expectedLiftPercent || 0) > 50) {
|
||||||
|
insights.unexpectedFindings.push('Extremely large effect size detected. Verify data quality and consider running a validation experiment.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate follow-up suggestions
|
||||||
|
if (winner) {
|
||||||
|
insights.followUpSuggestions.push({
|
||||||
|
experimentName: `${experiment.name} - Follow-up Validation`,
|
||||||
|
hypothesis: `Validating the ${winner.variantName} win in a new user cohort to confirm generalizability`,
|
||||||
|
priority: 90,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
insights.followUpSuggestions.push({
|
||||||
|
experimentName: `${experiment.name} - Secondary Metrics`,
|
||||||
|
hypothesis: `Testing the winning variant against additional success metrics to ensure no trade-offs`,
|
||||||
|
priority: 70,
|
||||||
|
});
|
||||||
|
|
||||||
|
return insights;
|
||||||
|
}
|
||||||
@ -0,0 +1,632 @@
|
|||||||
|
import { getRegisteredContainer } from '@bytelyst/cosmos';
|
||||||
|
import type { Container } from '@azure/cosmos';
|
||||||
|
import type { ErrorEvent, ErrorClusterDoc } from './types.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Telemetry Linking Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface TelemetryEvent {
|
||||||
|
id: string;
|
||||||
|
correlationId?: string;
|
||||||
|
productId: string;
|
||||||
|
userId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
eventType: string;
|
||||||
|
eventName: string;
|
||||||
|
timestamp: string;
|
||||||
|
platform?: string;
|
||||||
|
osVersion?: string;
|
||||||
|
appVersion?: string;
|
||||||
|
deviceModel?: string;
|
||||||
|
screen?: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TelemetryContext {
|
||||||
|
correlationId: string;
|
||||||
|
precedingEvents: TelemetryEvent[];
|
||||||
|
followingEvents: TelemetryEvent[];
|
||||||
|
sessionEvents: TelemetryEvent[];
|
||||||
|
userJourney: UserJourneyStep[];
|
||||||
|
deviceContext: DeviceContext;
|
||||||
|
apiCalls: ApiCall[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserJourneyStep {
|
||||||
|
timestamp: string;
|
||||||
|
screen: string;
|
||||||
|
action: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceContext {
|
||||||
|
platform?: string;
|
||||||
|
osVersion?: string;
|
||||||
|
appVersion?: string;
|
||||||
|
deviceModel?: string;
|
||||||
|
screenContext?: string;
|
||||||
|
memory?: number;
|
||||||
|
batteryLevel?: number;
|
||||||
|
networkType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiCall {
|
||||||
|
endpoint: string;
|
||||||
|
method: string;
|
||||||
|
statusCode?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
timestamp: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnrichedErrorContext {
|
||||||
|
errorEvent: ErrorEvent;
|
||||||
|
telemetryContext?: TelemetryContext;
|
||||||
|
sessionState: SessionState;
|
||||||
|
recentActions: string[];
|
||||||
|
apiFailures: ApiCall[];
|
||||||
|
featureFlags: Record<string, boolean>;
|
||||||
|
configSnapshot: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
screen: string;
|
||||||
|
durationOnScreen: number;
|
||||||
|
previousScreen?: string;
|
||||||
|
userActions: string[];
|
||||||
|
formData?: Record<string, unknown>;
|
||||||
|
scrollPosition?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Container Access
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function getTelemetryContainer(): Container {
|
||||||
|
return getRegisteredContainer('telemetry_events');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Correlation ID Propagation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links errors to telemetry events via correlation ID
|
||||||
|
* Returns telemetry events within 5-minute window of the error
|
||||||
|
*/
|
||||||
|
export async function linkErrorToTelemetry(
|
||||||
|
errorEvent: ErrorEvent,
|
||||||
|
options: {
|
||||||
|
windowMinutes?: number;
|
||||||
|
maxEvents?: number;
|
||||||
|
} = {}
|
||||||
|
): Promise<TelemetryContext | null> {
|
||||||
|
const windowMinutes = options.windowMinutes || 5;
|
||||||
|
const maxEvents = options.maxEvents || 50;
|
||||||
|
|
||||||
|
if (!errorEvent.correlationId) {
|
||||||
|
// Try to find by user + timestamp if no correlation ID
|
||||||
|
return await linkByUserAndTime(errorEvent, windowMinutes, maxEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = getTelemetryContainer();
|
||||||
|
|
||||||
|
// Fetch events with same correlation ID
|
||||||
|
const query = `
|
||||||
|
SELECT * FROM c
|
||||||
|
WHERE c.correlationId = @correlationId
|
||||||
|
AND c.productId = @productId
|
||||||
|
ORDER BY c.timestamp ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { resources } = await container.items
|
||||||
|
.query({
|
||||||
|
query,
|
||||||
|
parameters: [
|
||||||
|
{ name: '@correlationId', value: errorEvent.correlationId },
|
||||||
|
{ name: '@productId', value: errorEvent.productId },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.fetchAll();
|
||||||
|
|
||||||
|
const events = resources as TelemetryEvent[];
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the error event position
|
||||||
|
const errorIndex = events.findIndex(
|
||||||
|
(e) => e.timestamp === errorEvent.timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
const windowStart = errorIndex >= 0
|
||||||
|
? Math.max(0, errorIndex - Math.floor(maxEvents / 2))
|
||||||
|
: 0;
|
||||||
|
const windowEnd = Math.min(events.length, windowStart + maxEvents);
|
||||||
|
|
||||||
|
const windowEvents = events.slice(windowStart, windowEnd);
|
||||||
|
const errorPosition = errorIndex >= 0 ? errorIndex - windowStart : windowEvents.length / 2;
|
||||||
|
|
||||||
|
// Split into preceding and following events
|
||||||
|
const precedingEvents = windowEvents.slice(0, errorPosition);
|
||||||
|
const followingEvents = windowEvents.slice(errorPosition + 1);
|
||||||
|
|
||||||
|
// Extract user journey
|
||||||
|
const userJourney = extractUserJourney(windowEvents);
|
||||||
|
|
||||||
|
// Extract device context from the latest event
|
||||||
|
const deviceContext = extractDeviceContext(windowEvents[windowEvents.length - 1]);
|
||||||
|
|
||||||
|
// Extract API calls
|
||||||
|
const apiCalls = extractApiCalls(windowEvents);
|
||||||
|
|
||||||
|
return {
|
||||||
|
correlationId: errorEvent.correlationId,
|
||||||
|
precedingEvents,
|
||||||
|
followingEvents,
|
||||||
|
sessionEvents: windowEvents,
|
||||||
|
userJourney,
|
||||||
|
deviceContext,
|
||||||
|
apiCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback linking when correlation ID is not available
|
||||||
|
* Uses user ID + time window
|
||||||
|
*/
|
||||||
|
async function linkByUserAndTime(
|
||||||
|
errorEvent: ErrorEvent,
|
||||||
|
windowMinutes: number,
|
||||||
|
maxEvents: number
|
||||||
|
): Promise<TelemetryContext | null> {
|
||||||
|
if (!errorEvent.userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = getTelemetryContainer();
|
||||||
|
|
||||||
|
const errorTime = new Date(errorEvent.timestamp);
|
||||||
|
const windowStart = new Date(errorTime.getTime() - windowMinutes * 60 * 1000).toISOString();
|
||||||
|
const windowEnd = new Date(errorTime.getTime() + windowMinutes * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT * FROM c
|
||||||
|
WHERE c.userId = @userId
|
||||||
|
AND c.productId = @productId
|
||||||
|
AND c.timestamp >= @windowStart
|
||||||
|
AND c.timestamp <= @windowEnd
|
||||||
|
ORDER BY c.timestamp ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { resources } = await container.items
|
||||||
|
.query({
|
||||||
|
query,
|
||||||
|
parameters: [
|
||||||
|
{ name: '@userId', value: errorEvent.userId },
|
||||||
|
{ name: '@productId', value: errorEvent.productId },
|
||||||
|
{ name: '@windowStart', value: windowStart },
|
||||||
|
{ name: '@windowEnd', value: windowEnd },
|
||||||
|
],
|
||||||
|
}, { maxItemCount: maxEvents })
|
||||||
|
.fetchAll();
|
||||||
|
|
||||||
|
const events = resources as TelemetryEvent[];
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a synthetic correlation ID
|
||||||
|
const correlationId = `synth_${errorEvent.userId}_${errorTime.getTime()}`;
|
||||||
|
|
||||||
|
// Find events before and after error timestamp
|
||||||
|
const errorTimeMs = errorTime.getTime();
|
||||||
|
const errorIndex = events.findIndex(
|
||||||
|
(e) => new Date(e.timestamp).getTime() >= errorTimeMs
|
||||||
|
);
|
||||||
|
|
||||||
|
const precedingEvents = errorIndex > 0 ? events.slice(0, errorIndex) : [];
|
||||||
|
const followingEvents = errorIndex >= 0 ? events.slice(errorIndex + 1) : events;
|
||||||
|
|
||||||
|
return {
|
||||||
|
correlationId,
|
||||||
|
precedingEvents,
|
||||||
|
followingEvents,
|
||||||
|
sessionEvents: events,
|
||||||
|
userJourney: extractUserJourney(events),
|
||||||
|
deviceContext: extractDeviceContext(events[events.length - 1]),
|
||||||
|
apiCalls: extractApiCalls(events),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context Extraction
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function extractUserJourney(events: TelemetryEvent[]): UserJourneyStep[] {
|
||||||
|
const journey: UserJourneyStep[] = [];
|
||||||
|
let lastScreen: string | null = null;
|
||||||
|
let lastTimestamp: string | null = null;
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.eventType === 'screen_view' || event.eventName.includes('screen')) {
|
||||||
|
const durationMs = lastTimestamp
|
||||||
|
? new Date(event.timestamp).getTime() - new Date(lastTimestamp).getTime()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
journey.push({
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
screen: event.screen || event.properties?.screen as string || 'unknown',
|
||||||
|
action: event.eventName,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastScreen = event.screen || null;
|
||||||
|
lastTimestamp = event.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.eventType === 'action' || event.eventType === 'interaction') {
|
||||||
|
journey.push({
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
screen: lastScreen || 'unknown',
|
||||||
|
action: event.eventName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return journey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDeviceContext(event?: TelemetryEvent): DeviceContext {
|
||||||
|
if (!event) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform: event.platform,
|
||||||
|
osVersion: event.osVersion,
|
||||||
|
appVersion: event.appVersion,
|
||||||
|
deviceModel: event.deviceModel,
|
||||||
|
screenContext: event.screen,
|
||||||
|
memory: event.properties?.memory as number,
|
||||||
|
batteryLevel: event.properties?.batteryLevel as number,
|
||||||
|
networkType: event.properties?.networkType as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractApiCalls(events: TelemetryEvent[]): ApiCall[] {
|
||||||
|
return events
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
e.eventType === 'api_call' ||
|
||||||
|
e.eventName.includes('api') ||
|
||||||
|
e.eventName.includes('request')
|
||||||
|
)
|
||||||
|
.map((e) => ({
|
||||||
|
endpoint: (e.properties?.endpoint as string) || 'unknown',
|
||||||
|
method: (e.properties?.method as string) || 'GET',
|
||||||
|
statusCode: e.properties?.statusCode as number,
|
||||||
|
durationMs: e.properties?.durationMs as number,
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
error: e.properties?.error as string,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Context Enrichment
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enriches error event with full telemetry context
|
||||||
|
*/
|
||||||
|
export async function enrichErrorContext(
|
||||||
|
errorEvent: ErrorEvent
|
||||||
|
): Promise<EnrichedErrorContext> {
|
||||||
|
// Link to telemetry
|
||||||
|
const telemetryContext = await linkErrorToTelemetry(errorEvent, {
|
||||||
|
windowMinutes: 5,
|
||||||
|
maxEvents: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build session state
|
||||||
|
const sessionState = buildSessionState(telemetryContext);
|
||||||
|
|
||||||
|
// Extract recent actions
|
||||||
|
const recentActions = extractRecentActions(telemetryContext);
|
||||||
|
|
||||||
|
// Extract API failures
|
||||||
|
const apiFailures = telemetryContext?.apiCalls.filter((call) =>
|
||||||
|
call.error || (call.statusCode && call.statusCode >= 400)
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
// Extract feature flags from telemetry
|
||||||
|
const featureFlags = extractFeatureFlags(telemetryContext);
|
||||||
|
|
||||||
|
// Build config snapshot
|
||||||
|
const configSnapshot = buildConfigSnapshot(telemetryContext);
|
||||||
|
|
||||||
|
return {
|
||||||
|
errorEvent,
|
||||||
|
telemetryContext,
|
||||||
|
sessionState,
|
||||||
|
recentActions,
|
||||||
|
apiFailures,
|
||||||
|
featureFlags,
|
||||||
|
configSnapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessionState(telemetryContext: TelemetryContext | null): SessionState {
|
||||||
|
if (!telemetryContext || telemetryContext.sessionEvents.length === 0) {
|
||||||
|
return {
|
||||||
|
screen: 'unknown',
|
||||||
|
durationOnScreen: 0,
|
||||||
|
userActions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = telemetryContext.sessionEvents;
|
||||||
|
const lastEvent = events[events.length - 1];
|
||||||
|
|
||||||
|
// Find last screen view
|
||||||
|
const screenViews = events.filter(
|
||||||
|
(e) => e.eventType === 'screen_view' || e.eventName.includes('screen')
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentScreen = screenViews.length > 0
|
||||||
|
? screenViews[screenViews.length - 1].screen ||
|
||||||
|
(screenViews[screenViews.length - 1].properties?.screen as string)
|
||||||
|
: 'unknown';
|
||||||
|
|
||||||
|
const previousScreen = screenViews.length > 1
|
||||||
|
? screenViews[screenViews.length - 2].screen ||
|
||||||
|
(screenViews[screenViews.length - 2].properties?.screen as string)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Calculate duration on current screen
|
||||||
|
let durationOnScreen = 0;
|
||||||
|
if (screenViews.length >= 1) {
|
||||||
|
const screenEnterTime = new Date(screenViews[screenViews.length - 1].timestamp).getTime();
|
||||||
|
const lastEventTime = new Date(lastEvent.timestamp).getTime();
|
||||||
|
durationOnScreen = lastEventTime - screenEnterTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract user actions
|
||||||
|
const userActions = events
|
||||||
|
.filter((e) => e.eventType === 'action' || e.eventType === 'interaction')
|
||||||
|
.map((e) => e.eventName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
screen: currentScreen || 'unknown',
|
||||||
|
durationOnScreen,
|
||||||
|
previousScreen,
|
||||||
|
userActions,
|
||||||
|
scrollPosition: lastEvent.properties?.scrollPosition as number,
|
||||||
|
formData: lastEvent.properties?.formData as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRecentActions(telemetryContext: TelemetryContext | null): string[] {
|
||||||
|
if (!telemetryContext) return [];
|
||||||
|
|
||||||
|
return telemetryContext.precedingEvents
|
||||||
|
.slice(-10) // Last 10 actions before error
|
||||||
|
.filter((e) => e.eventType === 'action' || e.eventType === 'interaction')
|
||||||
|
.map((e) => e.eventName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFeatureFlags(
|
||||||
|
telemetryContext: TelemetryContext | null
|
||||||
|
): Record<string, boolean> {
|
||||||
|
if (!telemetryContext) return {};
|
||||||
|
|
||||||
|
const flags: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
for (const event of telemetryContext.sessionEvents) {
|
||||||
|
if (event.properties?.featureFlags) {
|
||||||
|
Object.assign(flags, event.properties.featureFlags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfigSnapshot(
|
||||||
|
telemetryContext: TelemetryContext | null
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!telemetryContext) return {};
|
||||||
|
|
||||||
|
const config: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const event of telemetryContext.sessionEvents) {
|
||||||
|
if (event.properties?.config) {
|
||||||
|
Object.assign(config, event.properties.config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Breadcrumb Trail Generation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Breadcrumb {
|
||||||
|
timestamp: string;
|
||||||
|
category: 'navigation' | 'action' | 'api' | 'error' | 'state';
|
||||||
|
message: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a breadcrumb trail from telemetry context
|
||||||
|
*/
|
||||||
|
export function generateBreadcrumbTrail(
|
||||||
|
telemetryContext: TelemetryContext | null
|
||||||
|
): Breadcrumb[] {
|
||||||
|
if (!telemetryContext) return [];
|
||||||
|
|
||||||
|
const breadcrumbs: Breadcrumb[] = [];
|
||||||
|
|
||||||
|
// Add navigation breadcrumbs
|
||||||
|
for (const step of telemetryContext.userJourney) {
|
||||||
|
breadcrumbs.push({
|
||||||
|
timestamp: step.timestamp,
|
||||||
|
category: 'navigation',
|
||||||
|
message: `Screen: ${step.screen}`,
|
||||||
|
data: { durationMs: step.durationMs },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add API call breadcrumbs
|
||||||
|
for (const call of telemetryContext.apiCalls) {
|
||||||
|
breadcrumbs.push({
|
||||||
|
timestamp: call.timestamp,
|
||||||
|
category: 'api',
|
||||||
|
message: `${call.method} ${call.endpoint}`,
|
||||||
|
data: {
|
||||||
|
statusCode: call.statusCode,
|
||||||
|
durationMs: call.durationMs,
|
||||||
|
error: call.error,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp
|
||||||
|
breadcrumbs.sort(
|
||||||
|
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return breadcrumbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cluster Context Aggregation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface ClusterContextSummary {
|
||||||
|
totalOccurrences: number;
|
||||||
|
affectedUsers: string[];
|
||||||
|
timeRange: { start: string; end: string };
|
||||||
|
mostCommonScreens: Array<{ screen: string; count: number }>;
|
||||||
|
mostCommonActions: Array<{ action: string; count: number }>;
|
||||||
|
apiFailurePattern?: {
|
||||||
|
endpoint: string;
|
||||||
|
errorPattern: string;
|
||||||
|
occurrenceCount: number;
|
||||||
|
};
|
||||||
|
featureFlagCorrelations: Array<{
|
||||||
|
flag: string;
|
||||||
|
enabled: boolean;
|
||||||
|
errorCorrelation: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates context across all errors in a cluster
|
||||||
|
*/
|
||||||
|
export async function aggregateClusterContext(
|
||||||
|
cluster: ErrorClusterDoc,
|
||||||
|
errorEvents: ErrorEvent[]
|
||||||
|
): Promise<ClusterContextSummary> {
|
||||||
|
const affectedUsers = new Set<string>();
|
||||||
|
const screenCounts = new Map<string, number>();
|
||||||
|
const actionCounts = new Map<string, number>();
|
||||||
|
const apiErrors = new Map<string, { count: number; errors: string[] }>();
|
||||||
|
const flagCorrelations = new Map<string, { enabled: number; total: number }>();
|
||||||
|
|
||||||
|
let earliestTime = new Date(cluster.firstSeenAt);
|
||||||
|
let latestTime = new Date(cluster.lastSeenAt);
|
||||||
|
|
||||||
|
for (const error of errorEvents) {
|
||||||
|
if (error.userId) {
|
||||||
|
affectedUsers.add(error.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track screens and actions
|
||||||
|
if (error.screen) {
|
||||||
|
screenCounts.set(error.screen, (screenCounts.get(error.screen) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich and extract more context
|
||||||
|
const enriched = await enrichErrorContext(error);
|
||||||
|
|
||||||
|
for (const action of enriched.recentActions) {
|
||||||
|
actionCounts.set(action, (actionCounts.get(action) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track API failures
|
||||||
|
for (const apiFailure of enriched.apiFailures) {
|
||||||
|
const key = `${apiFailure.method} ${apiFailure.endpoint}`;
|
||||||
|
const existing = apiErrors.get(key) || { count: 0, errors: [] };
|
||||||
|
existing.count++;
|
||||||
|
if (apiFailure.error) {
|
||||||
|
existing.errors.push(apiFailure.error);
|
||||||
|
}
|
||||||
|
apiErrors.set(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track feature flag correlations
|
||||||
|
for (const [flag, enabled] of Object.entries(enriched.featureFlags)) {
|
||||||
|
const existing = flagCorrelations.get(flag) || { enabled: 0, total: 0 };
|
||||||
|
existing.total++;
|
||||||
|
if (enabled) existing.enabled++;
|
||||||
|
flagCorrelations.set(flag, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find most common API error pattern
|
||||||
|
let apiFailurePattern: ClusterContextSummary['apiFailurePattern'] | undefined;
|
||||||
|
let maxApiErrors = 0;
|
||||||
|
|
||||||
|
for (const [endpoint, data] of apiErrors.entries()) {
|
||||||
|
if (data.count > maxApiErrors) {
|
||||||
|
maxApiErrors = data.count;
|
||||||
|
// Find common error pattern
|
||||||
|
const errorFrequency = new Map<string, number>();
|
||||||
|
for (const err of data.errors) {
|
||||||
|
errorFrequency.set(err, (errorFrequency.get(err) || 0) + 1);
|
||||||
|
}
|
||||||
|
const mostCommonError = Array.from(errorFrequency.entries()).sort((a, b) => b[1] - a[1])[0];
|
||||||
|
|
||||||
|
apiFailurePattern = {
|
||||||
|
endpoint,
|
||||||
|
errorPattern: mostCommonError?.[0] || 'unknown',
|
||||||
|
occurrenceCount: data.count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate feature flag correlations
|
||||||
|
const featureFlagCorrelations = Array.from(flagCorrelations.entries())
|
||||||
|
.map(([flag, data]) => ({
|
||||||
|
flag,
|
||||||
|
enabled: data.enabled > 0,
|
||||||
|
errorCorrelation: data.enabled / data.total,
|
||||||
|
}))
|
||||||
|
.filter((f) => f.errorCorrelation > 0.5) // Only include flags with >50% correlation
|
||||||
|
.sort((a, b) => b.errorCorrelation - a.errorCorrelation)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalOccurrences: errorEvents.length,
|
||||||
|
affectedUsers: Array.from(affectedUsers),
|
||||||
|
timeRange: {
|
||||||
|
start: earliestTime.toISOString(),
|
||||||
|
end: latestTime.toISOString(),
|
||||||
|
},
|
||||||
|
mostCommonScreens: Array.from(screenCounts.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([screen, count]) => ({ screen, count })),
|
||||||
|
mostCommonActions: Array.from(actionCounts.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([action, count]) => ({ action, count })),
|
||||||
|
apiFailurePattern,
|
||||||
|
featureFlagCorrelations,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,570 @@
|
|||||||
|
/**
|
||||||
|
* Retention Campaign Engine - Automated intervention system
|
||||||
|
* [4.1] Campaign triggers and personalized messaging
|
||||||
|
* [4.2] Platform integrations (email/push/Slack)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getRegisteredContainer } from '@bytelyst/cosmos';
|
||||||
|
import { bus as eventBus } from '../../lib/event-bus.js';
|
||||||
|
import type {
|
||||||
|
RetentionCampaignDoc,
|
||||||
|
CreateCampaignInput,
|
||||||
|
ChurnPredictionInput,
|
||||||
|
UserChurnPredictionDoc,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export interface CampaignTriggerContext {
|
||||||
|
userId: string;
|
||||||
|
productId: string;
|
||||||
|
churnProbability: number;
|
||||||
|
riskSegment: string;
|
||||||
|
topRiskFactors: Array<{ feature: string; description: string }>;
|
||||||
|
suggestedActions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignDeliveryResult {
|
||||||
|
success: boolean;
|
||||||
|
channel: string;
|
||||||
|
messageId?: string;
|
||||||
|
error?: string;
|
||||||
|
sentAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CampaignEngine {
|
||||||
|
/**
|
||||||
|
* Create a new retention campaign
|
||||||
|
*/
|
||||||
|
async createCampaign(input: CreateCampaignInput): Promise<RetentionCampaignDoc> {
|
||||||
|
const container = getRegisteredContainer('retention_campaigns');
|
||||||
|
|
||||||
|
const doc: RetentionCampaignDoc = {
|
||||||
|
id: `rc_${crypto.randomUUID()}`,
|
||||||
|
...input,
|
||||||
|
status: 'draft',
|
||||||
|
stats: {
|
||||||
|
triggered: 0,
|
||||||
|
sent: 0,
|
||||||
|
opened: 0,
|
||||||
|
clicked: 0,
|
||||||
|
converted: 0,
|
||||||
|
controlGroupSize: 0,
|
||||||
|
controlChurnRate: 0,
|
||||||
|
treatmentChurnRate: 0,
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
ttl: 365 * 24 * 60 * 60, // 1 year
|
||||||
|
};
|
||||||
|
|
||||||
|
await container.items.create(doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate a campaign
|
||||||
|
*/
|
||||||
|
async activateCampaign(campaignId: string): Promise<RetentionCampaignDoc | null> {
|
||||||
|
const container = getRegisteredContainer('retention_campaigns');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { resource: doc } = await container.item(campaignId).read<RetentionCampaignDoc>();
|
||||||
|
if (!doc) return null;
|
||||||
|
|
||||||
|
doc.status = 'active';
|
||||||
|
doc.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
await container.item(campaignId).replace(doc);
|
||||||
|
return doc;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger campaign for a user based on churn prediction
|
||||||
|
*/
|
||||||
|
async triggerForUser(
|
||||||
|
prediction: ChurnPredictionInput & { explanation: { topRiskFactors: Array<{ feature: string; description: string }>; suggestedActions: string[] } },
|
||||||
|
testMode: boolean = false
|
||||||
|
): Promise<CampaignDeliveryResult[]> {
|
||||||
|
const campaigns = await this.getActiveCampaignsForProduct(prediction.productId);
|
||||||
|
const results: CampaignDeliveryResult[] = [];
|
||||||
|
|
||||||
|
const context: CampaignTriggerContext = {
|
||||||
|
userId: prediction.userId,
|
||||||
|
productId: prediction.productId,
|
||||||
|
churnProbability: prediction.churnProbability,
|
||||||
|
riskSegment: prediction.riskSegment,
|
||||||
|
topRiskFactors: prediction.explanation.topRiskFactors.slice(0, 3),
|
||||||
|
suggestedActions: prediction.explanation.suggestedActions,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const campaign of campaigns) {
|
||||||
|
// Check if user matches campaign conditions
|
||||||
|
if (!this.matchesCampaignConditions(context, campaign)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check frequency capping
|
||||||
|
if (await this.isFrequencyCapped(context.userId, campaign)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver messages
|
||||||
|
for (const message of campaign.messages) {
|
||||||
|
const result = await this.deliverMessage(context, message, testMode);
|
||||||
|
results.push(result);
|
||||||
|
|
||||||
|
if (result.success && !testMode) {
|
||||||
|
await this.recordDelivery(campaign.id, context.userId, message.channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit event for tracking
|
||||||
|
if (!testMode) {
|
||||||
|
eventBus.emit({
|
||||||
|
type: 'predictive.campaign.triggered',
|
||||||
|
payload: {
|
||||||
|
campaignId: campaign.id,
|
||||||
|
userId: context.userId,
|
||||||
|
productId: context.productId,
|
||||||
|
riskSegment: context.riskSegment,
|
||||||
|
channels: campaign.messages.map((m) => m.channel),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual trigger for testing
|
||||||
|
*/
|
||||||
|
async manualTrigger(
|
||||||
|
campaignId: string,
|
||||||
|
testUserId: string
|
||||||
|
): Promise<CampaignDeliveryResult[]> {
|
||||||
|
const container = getRegisteredContainer('retention_campaigns');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { resource: campaign } = await container
|
||||||
|
.item(campaignId)
|
||||||
|
.read<RetentionCampaignDoc>();
|
||||||
|
if (!campaign) return [];
|
||||||
|
|
||||||
|
const context: CampaignTriggerContext = {
|
||||||
|
userId: testUserId,
|
||||||
|
productId: campaign.productId,
|
||||||
|
churnProbability: 0.75,
|
||||||
|
riskSegment: 'high',
|
||||||
|
topRiskFactors: [
|
||||||
|
{ feature: 'daysSinceLastSession', description: 'Session recency declined' },
|
||||||
|
],
|
||||||
|
suggestedActions: ['Send re-engagement email'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const results: CampaignDeliveryResult[] = [];
|
||||||
|
|
||||||
|
for (const message of campaign.messages) {
|
||||||
|
const result = await this.deliverMessage(context, message, true);
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active campaigns for a product
|
||||||
|
*/
|
||||||
|
private async getActiveCampaignsForProduct(
|
||||||
|
productId: string
|
||||||
|
): Promise<RetentionCampaignDoc[]> {
|
||||||
|
const container = getRegisteredContainer('retention_campaigns');
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.status = @status',
|
||||||
|
parameters: [
|
||||||
|
{ name: '@productId', value: productId },
|
||||||
|
{ name: '@status', value: 'active' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { resources } = await container.items.query<RetentionCampaignDoc>(query).fetchAll();
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user matches campaign conditions
|
||||||
|
*/
|
||||||
|
private matchesCampaignConditions(
|
||||||
|
context: CampaignTriggerContext,
|
||||||
|
campaign: RetentionCampaignDoc
|
||||||
|
): boolean {
|
||||||
|
// Check risk segment match
|
||||||
|
if (
|
||||||
|
campaign.audience.riskSegments?.length &&
|
||||||
|
!campaign.audience.riskSegments.includes(context.riskSegment)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check trigger conditions
|
||||||
|
for (const condition of campaign.trigger.conditions) {
|
||||||
|
const value = this.getContextValue(context, condition.field);
|
||||||
|
|
||||||
|
switch (condition.operator) {
|
||||||
|
case 'gt':
|
||||||
|
if (!(value > (condition.value as number))) return false;
|
||||||
|
break;
|
||||||
|
case 'lt':
|
||||||
|
if (!(value < (condition.value as number))) return false;
|
||||||
|
break;
|
||||||
|
case 'eq':
|
||||||
|
if (value !== condition.value) return false;
|
||||||
|
break;
|
||||||
|
case 'in':
|
||||||
|
if (!(condition.value as unknown[]).includes(value)) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get value from context by field path
|
||||||
|
*/
|
||||||
|
private getContextValue(context: CampaignTriggerContext, field: string): unknown {
|
||||||
|
const parts = field.split('.');
|
||||||
|
let value: unknown = context;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
value = (value as Record<string, unknown>)[part];
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is frequency capped
|
||||||
|
*/
|
||||||
|
private async isFrequencyCapped(
|
||||||
|
userId: string,
|
||||||
|
campaign: RetentionCampaignDoc
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!campaign.audience.excludeRecentContact) return false;
|
||||||
|
|
||||||
|
const container = getRegisteredContainer('campaign_deliveries');
|
||||||
|
const hoursAgo = new Date();
|
||||||
|
hoursAgo.setHours(hoursAgo.getHours() - campaign.audience.excludeRecentContact);
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
query:
|
||||||
|
'SELECT VALUE COUNT(1) FROM c WHERE c.userId = @userId AND c.sentAt >= @cutoff',
|
||||||
|
parameters: [
|
||||||
|
{ name: '@userId', value: userId },
|
||||||
|
{ name: '@cutoff', value: hoursAgo.toISOString() },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { resources } = await container.items.query<number>(query).fetchAll();
|
||||||
|
return (resources[0] || 0) > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver message through appropriate channel
|
||||||
|
*/
|
||||||
|
private async deliverMessage(
|
||||||
|
context: CampaignTriggerContext,
|
||||||
|
message: { channel: string; templateId: string; variant?: string; delayHours?: number },
|
||||||
|
testMode: boolean
|
||||||
|
): Promise<CampaignDeliveryResult> {
|
||||||
|
const sentAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// Simulate delay
|
||||||
|
if (message.delayHours && !testMode) {
|
||||||
|
// In production, this would schedule for later delivery
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message.channel) {
|
||||||
|
case 'email':
|
||||||
|
return this.deliverEmail(context, message.templateId, testMode, sentAt);
|
||||||
|
case 'push':
|
||||||
|
return this.deliverPush(context, message.templateId, testMode, sentAt);
|
||||||
|
case 'in_app':
|
||||||
|
return this.deliverInApp(context, message.templateId, testMode, sentAt);
|
||||||
|
case 'slack_cs':
|
||||||
|
return this.deliverSlackCS(context, message.templateId, testMode, sentAt);
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
channel: message.channel,
|
||||||
|
error: 'Unknown channel',
|
||||||
|
sentAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver email via delivery module
|
||||||
|
*/
|
||||||
|
private async deliverEmail(
|
||||||
|
context: CampaignTriggerContext,
|
||||||
|
templateId: string,
|
||||||
|
testMode: boolean,
|
||||||
|
sentAt: string
|
||||||
|
): Promise<CampaignDeliveryResult> {
|
||||||
|
if (testMode) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
channel: 'email',
|
||||||
|
messageId: `test_${crypto.randomUUID()}`,
|
||||||
|
sentAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// In production, call delivery module
|
||||||
|
// await deliveryService.sendEmail({...})
|
||||||
|
|
||||||
|
eventBus.emit({
|
||||||
|
type: 'delivery.email.requested',
|
||||||
|
payload: {
|
||||||
|
userId: context.userId,
|
||||||
|
productId: context.productId,
|
||||||
|
templateId,
|
||||||
|
context: {
|
||||||
|
churnProbability: context.churnProbability,
|
||||||
|
riskSegment: context.riskSegment,
|
||||||
|
suggestedActions: context.suggestedActions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
channel: 'email',
|
||||||
|
messageId: `email_${crypto.randomUUID()}`,
|
||||||
|
sentAt,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
channel: 'email',
|
||||||
|
error: error instanceof Error ? error.message : 'Email delivery failed',
|
||||||
|
sentAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver push notification
|
||||||
|
*/
|
||||||
|
private async deliverPush(
|
||||||
|
context: CampaignTriggerContext,
|
||||||
|
templateId: string,
|
||||||
|
testMode: boolean,
|
||||||
|
sentAt: string
|
||||||
|
): Promise<CampaignDeliveryResult> {
|
||||||
|
if (testMode) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
channel: 'push',
|
||||||
|
messageId: `test_push_${crypto.randomUUID()}`,
|
||||||
|
sentAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBus.emit({
|
||||||
|
type: 'notifications.push.requested',
|
||||||
|
payload: {
|
||||||
|
userId: context.userId,
|
||||||
|
productId: context.productId,
|
||||||
|
title: 'We miss you!',
|
||||||
|
body: 'Come back and explore new features.',
|
||||||
|
data: { campaign: 'retention', riskSegment: context.riskSegment },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
channel: 'push',
|
||||||
|
messageId: `push_${crypto.randomUUID()}`,
|
||||||
|
sentAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver in-app message
|
||||||
|
*/
|
||||||
|
private async deliverInApp(
|
||||||
|
context: CampaignTriggerContext,
|
||||||
|
templateId: string,
|
||||||
|
testMode: boolean,
|
||||||
|
sentAt: string
|
||||||
|
): Promise<CampaignDeliveryResult> {
|
||||||
|
if (testMode) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
channel: 'in_app',
|
||||||
|
messageId: `test_inapp_${crypto.randomUUID()}`,
|
||||||
|
sentAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBus.emit({
|
||||||
|
type: 'notifications.inapp.create',
|
||||||
|
payload: {
|
||||||
|
userId: context.userId,
|
||||||
|
productId: context.productId,
|
||||||
|
title: 'Personalized for you',
|
||||||
|
content: 'Based on your interests, check out these features.',
|
||||||
|
priority: context.riskSegment === 'critical' ? 'high' : 'normal',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
channel: 'in_app',
|
||||||
|
messageId: `inapp_${crypto.randomUUID()}`,
|
||||||
|
sentAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver Slack notification to CS team
|
||||||
|
*/
|
||||||
|
private async deliverSlackCS(
|
||||||
|
context: CampaignTriggerContext,
|
||||||
|
templateId: string,
|
||||||
|
testMode: boolean,
|
||||||
|
sentAt: string
|
||||||
|
): Promise<CampaignDeliveryResult> {
|
||||||
|
if (testMode) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
channel: 'slack_cs',
|
||||||
|
messageId: `test_slack_${crypto.randomUUID()}`,
|
||||||
|
sentAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
text: `🚨 High Risk User Alert`,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: {
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: `*High Risk User Detected*\nUser: ${context.userId}\nProduct: ${context.productId}\nChurn Probability: ${Math.round(context.churnProbability * 100)}%\nRisk Segment: ${context.riskSegment}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: {
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: `*Top Risk Factors:*\n${context.topRiskFactors.map((f) => `- ${f.description}`).join('\n')}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'section',
|
||||||
|
text: {
|
||||||
|
type: 'mrkdwn',
|
||||||
|
text: `*Suggested Actions:*\n${context.suggestedActions.map((a) => `- ${a}`).join('\n')}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
eventBus.emit({
|
||||||
|
type: 'integrations.slack.notify',
|
||||||
|
payload: {
|
||||||
|
channel: '#customer-success',
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
channel: 'slack_cs',
|
||||||
|
messageId: `slack_${crypto.randomUUID()}`,
|
||||||
|
sentAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record campaign delivery
|
||||||
|
*/
|
||||||
|
private async recordDelivery(
|
||||||
|
campaignId: string,
|
||||||
|
userId: string,
|
||||||
|
channel: string
|
||||||
|
): Promise<void> {
|
||||||
|
const container = getRegisteredContainer('campaign_deliveries');
|
||||||
|
|
||||||
|
await container.items.create({
|
||||||
|
id: `cd_${crypto.randomUUID()}`,
|
||||||
|
campaignId,
|
||||||
|
userId,
|
||||||
|
channel,
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
openedAt: null,
|
||||||
|
clickedAt: null,
|
||||||
|
convertedAt: null,
|
||||||
|
ttl: 90 * 24 * 60 * 60,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get campaign statistics
|
||||||
|
*/
|
||||||
|
async getCampaignStats(campaignId: string): Promise<{
|
||||||
|
triggered: number;
|
||||||
|
sent: number;
|
||||||
|
opened: number;
|
||||||
|
clicked: number;
|
||||||
|
converted: number;
|
||||||
|
conversionRate: number;
|
||||||
|
} | null> {
|
||||||
|
const container = getRegisteredContainer('campaign_deliveries');
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
query: 'SELECT * FROM c WHERE c.campaignId = @campaignId',
|
||||||
|
parameters: [{ name: '@campaignId', value: campaignId }],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { resources } = await container.items.query(query).fetchAll();
|
||||||
|
|
||||||
|
const sent = resources.length;
|
||||||
|
const opened = resources.filter((r) => r.openedAt).length;
|
||||||
|
const clicked = resources.filter((r) => r.clickedAt).length;
|
||||||
|
const converted = resources.filter((r) => r.convertedAt).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
triggered: sent,
|
||||||
|
sent,
|
||||||
|
opened,
|
||||||
|
clicked,
|
||||||
|
converted,
|
||||||
|
conversionRate: sent > 0 ? converted / sent : 0,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const campaignEngine = new CampaignEngine();
|
||||||
@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Predictive Analytics Repository - Data access layer
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getRegisteredContainer } from '@bytelyst/cosmos';
|
||||||
|
import type {
|
||||||
|
UserChurnPredictionDoc,
|
||||||
|
ProductHealthScoreDoc,
|
||||||
|
RetentionCampaignDoc,
|
||||||
|
UserFeatureVectorDoc,
|
||||||
|
FeatureDefinition,
|
||||||
|
ModelPerformanceDoc,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export class PredictiveAnalyticsRepository {
|
||||||
|
// ==================== Churn Predictions ====================
|
||||||
|
|
||||||
|
async saveChurnPrediction(doc: UserChurnPredictionDoc): Promise<UserChurnPredictionDoc> {
|
||||||
|
const container = getRegisteredContainer('churn_predictions');
|
||||||
|
await container.items.create(doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChurnPrediction(userId: string, productId: string, horizon: number = 30): Promise<UserChurnPredictionDoc | null> {
|
||||||
|
const container = getRegisteredContainer('churn_predictions');
|
||||||
|
const query = {
|
||||||
|
query: 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.predictionHorizon = @horizon ORDER BY c.predictionTimestamp DESC OFFSET 0 LIMIT 1',
|
||||||
|
parameters: [
|
||||||
|
{ name: '@userId', value: userId },
|
||||||
|
{ name: '@productId', value: productId },
|
||||||
|
{ name: '@horizon', value: horizon },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { resources } = await container.items.query<UserChurnPredictionDoc>(query).fetchAll();
|
||||||
|
return resources[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAtRiskUsers(
|
||||||
|
productId: string,
|
||||||
|
segment?: string,
|
||||||
|
limit: number = 50,
|
||||||
|
offset: number = 0
|
||||||
|
): Promise<UserChurnPredictionDoc[]> {
|
||||||
|
const container = getRegisteredContainer('churn_predictions');
|
||||||
|
let queryStr = 'SELECT * FROM c WHERE c.productId = @productId';
|
||||||
|
const parameters: Array<{ name: string; value: unknown }> = [
|
||||||
|
{ name: '@productId', value: productId },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (segment) {
|
||||||
|
queryStr += ' AND c.riskSegment = @segment';
|
||||||
|
parameters.push({ name: '@segment', value: segment });
|
||||||
|
}
|
||||||
|
|
||||||
|
queryStr += ' ORDER BY c.churnProbability DESC OFFSET @offset LIMIT @limit';
|
||||||
|
parameters.push({ name: '@offset', value: offset });
|
||||||
|
parameters.push({ name: '@limit', value: limit });
|
||||||
|
|
||||||
|
const { resources } = await container.items.query<UserChurnPredictionDoc>({
|
||||||
|
query: queryStr,
|
||||||
|
parameters,
|
||||||
|
}).fetchAll();
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserRiskProfile(userId: string, productId: string): Promise<UserChurnPredictionDoc[]> {
|
||||||
|
const container = getRegisteredContainer('churn_predictions');
|
||||||
|
const query = {
|
||||||
|
query: 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId ORDER BY c.predictionTimestamp DESC',
|
||||||
|
parameters: [
|
||||||
|
{ name: '@userId', value: userId },
|
||||||
|
{ name: '@productId', value: productId },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { resources } = await container.items.query<UserChurnPredictionDoc>(query).fetchAll();
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Health Scores ====================
|
||||||
|
|
||||||
|
async saveHealthScore(doc: ProductHealthScoreDoc): Promise<ProductHealthScoreDoc> {
|
||||||
|
const container = getRegisteredContainer('product_health');
|
||||||
|
await container.items.create(doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHealthScore(productId: string, date: string): Promise<ProductHealthScoreDoc | null> {
|
||||||
|
const container = getRegisteredContainer('product_health');
|
||||||
|
try {
|
||||||
|
const { resource } = await container.item(`${productId}:${date}`, productId).read<ProductHealthScoreDoc>();
|
||||||
|
return resource || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHealthHistory(productId: string, days: number = 30): Promise<ProductHealthScoreDoc[]> {
|
||||||
|
const container = getRegisteredContainer('product_health');
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - days);
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.date >= @cutoff ORDER BY c.date DESC',
|
||||||
|
parameters: [
|
||||||
|
{ name: '@productId', value: productId },
|
||||||
|
{ name: '@cutoff', value: cutoff.toISOString().split('T')[0] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { resources } = await container.items.query<ProductHealthScoreDoc>(query).fetchAll();
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllProductHealthScores(): Promise<ProductHealthScoreDoc[]> {
|
||||||
|
const container = getRegisteredContainer('product_health');
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
query: 'SELECT * FROM c WHERE c.date = @today',
|
||||||
|
parameters: [{ name: '@today', value: today }],
|
||||||
|
};
|
||||||
|
const { resources } = await container.items.query<ProductHealthScoreDoc>(query).fetchAll();
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Campaigns ====================
|
||||||
|
|
||||||
|
async getCampaign(campaignId: string): Promise<RetentionCampaignDoc | null> {
|
||||||
|
const container = getRegisteredContainer('retention_campaigns');
|
||||||
|
try {
|
||||||
|
const { resource } = await container.item(campaignId, campaignId).read<RetentionCampaignDoc>();
|
||||||
|
return resource || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCampaigns(productId?: string, status?: string): Promise<RetentionCampaignDoc[]> {
|
||||||
|
const container = getRegisteredContainer('retention_campaigns');
|
||||||
|
let queryStr = 'SELECT * FROM c WHERE 1=1';
|
||||||
|
const parameters: Array<{ name: string; value: unknown }> = [];
|
||||||
|
|
||||||
|
if (productId) {
|
||||||
|
queryStr += ' AND c.productId = @productId';
|
||||||
|
parameters.push({ name: '@productId', value: productId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
queryStr += ' AND c.status = @status';
|
||||||
|
parameters.push({ name: '@status', value: status });
|
||||||
|
}
|
||||||
|
|
||||||
|
queryStr += ' ORDER BY c.createdAt DESC';
|
||||||
|
|
||||||
|
const { resources } = await container.items.query<RetentionCampaignDoc>({
|
||||||
|
query: queryStr,
|
||||||
|
parameters,
|
||||||
|
}).fetchAll();
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCampaign(
|
||||||
|
campaignId: string,
|
||||||
|
updates: Partial<RetentionCampaignDoc>
|
||||||
|
): Promise<RetentionCampaignDoc | null> {
|
||||||
|
const container = getRegisteredContainer('retention_campaigns');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { resource: doc } = await container.item(campaignId).read<RetentionCampaignDoc>();
|
||||||
|
if (!doc) return null;
|
||||||
|
|
||||||
|
const updated = { ...doc, ...updates, updatedAt: new Date().toISOString() };
|
||||||
|
await container.item(campaignId).replace(updated);
|
||||||
|
return updated;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCampaign(campaignId: string): Promise<boolean> {
|
||||||
|
const container = getRegisteredContainer('retention_campaigns');
|
||||||
|
try {
|
||||||
|
await container.item(campaignId).delete();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Feature Definitions ====================
|
||||||
|
|
||||||
|
async saveFeatureDefinition(doc: FeatureDefinition): Promise<FeatureDefinition> {
|
||||||
|
const container = getRegisteredContainer('feature_definitions');
|
||||||
|
await container.items.create(doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFeatureDefinitions(productId: string): Promise<FeatureDefinition[]> {
|
||||||
|
const container = getRegisteredContainer('feature_definitions');
|
||||||
|
const query = {
|
||||||
|
query: 'SELECT * FROM c WHERE c.productId = @productId',
|
||||||
|
parameters: [{ name: '@productId', value: productId }],
|
||||||
|
};
|
||||||
|
const { resources } = await container.items.query<FeatureDefinition>(query).fetchAll();
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Model Performance ====================
|
||||||
|
|
||||||
|
async saveModelPerformance(doc: ModelPerformanceDoc): Promise<ModelPerformanceDoc> {
|
||||||
|
const container = getRegisteredContainer('model_performance');
|
||||||
|
await container.items.create(doc);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLatestModelPerformance(): Promise<ModelPerformanceDoc | null> {
|
||||||
|
const container = getRegisteredContainer('model_performance');
|
||||||
|
const query = {
|
||||||
|
query: 'SELECT * FROM c WHERE c.isActive = true ORDER BY c.createdAt DESC OFFSET 0 LIMIT 1',
|
||||||
|
};
|
||||||
|
const { resources } = await container.items.query<ModelPerformanceDoc>(query).fetchAll();
|
||||||
|
return resources[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getModelPerformanceHistory(limit: number = 10): Promise<ModelPerformanceDoc[]> {
|
||||||
|
const container = getRegisteredContainer('model_performance');
|
||||||
|
const query = {
|
||||||
|
query: 'SELECT * FROM c ORDER BY c.createdAt DESC OFFSET 0 LIMIT @limit',
|
||||||
|
parameters: [{ name: '@limit', value: limit }],
|
||||||
|
};
|
||||||
|
const { resources } = await container.items.query<ModelPerformanceDoc>(query).fetchAll();
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const predictiveAnalyticsRepo = new PredictiveAnalyticsRepository();
|
||||||
Loading…
Reference in New Issue
Block a user