Compétence Analyse des Charges de Travail
Générez des visualisations React interactives montrant comment la dépense client s'écoule des produits → charges de travail → équipes, avec décompositions SDK, identification des opportunités et évaluation des risques.
Workflow
Étape 1 : Collecter les Données du Compte
-
Trouver le compte dans Vitally :
vitally:find_account_by_name → get externalId (organization_id) vitally:get_account_full with filterUnnecessaryFields=false -
Interroger les données de facturation depuis PostHog :
SELECT date, report FROM postgres.prod.billing_usagereport WHERE organization_id = '[externalId]' ORDER BY date DESC LIMIT 3 -
Extraire les champs clés (voir references/data-mapping.md pour la liste complète des champs) :
- Vitally : MRR, forecasted_mrr, diff_dollars, champs de dépense produit
- Facturation : comptages d'événements SDK, événements IA, enregistrements, exceptions, demandes de feature flag
Étape 2 : Calculer les Proportions SDK
⚠️ CRITIQUE : Vérifier TOUS les SDK, pas seulement les courants. Flutter/React Native sont souvent le SDK dominant pour les entreprises mobile-first.
À partir des données de facturation, extraire la décomposition COMPLÈTE des SDK :
const sdkBreakdown = {
// Web
web: report.web_events_count_in_period || 0,
// Backend
node: report.node_events_count_in_period || 0,
python: report.python_events_count_in_period || 0,
go: report.go_events_count_in_period || 0,
ruby: report.ruby_events_count_in_period || 0,
php: report.php_events_count_in_period || 0,
java: report.java_events_count_in_period || 0,
// Mobile Native
ios: report.ios_events_count_in_period || 0,
android: report.android_events_count_in_period || 0,
// Mobile Cross-Platform (NE PAS OUBLIER CEUX-CI !)
flutter: report.flutter_events_count_in_period || 0,
react_native: report.react_native_events_count_in_period || 0,
};
const total = Object.values(sdkBreakdown).reduce((a, b) => a + b, 0);
// Vérifier : le total SDK devrait ≈ event_count_in_period
// Si grand écart, vous manquez un SDK !
Étape 3 : Définir les Charges de Travail
Créez des charges de travail basées sur les proportions SDK et les modèles d'utilisation des produits :
| Modèle | Type de Charge | Exemples |
|---|---|---|
| Web >80% | Application Web (primaire) | Tableau de bord SaaS |
| Node/Python/Go/Ruby/PHP/Java >80% | API Backend (primaire) | Produit API-first |
| iOS + Android >20% | Applications Mobile Native (primaire) | iOS/Android grand public |
| Flutter >20% | Application Mobile Flutter (primaire) | Mobile cross-platform |
| React Native >20% | Application Mobile React Native (primaire) | Mobile cross-platform |
| AI events >0 | Plateforme LLM/IA | Entreprise IA-first |
| Schémas externes >0 | Plateforme Data | Utilisation data warehouse |
Priorité Mobile : Si Flutter ou React Native domine, c'est la charge de travail mobile primaire (pas native iOS/Android).
Étape 4 : Allouer la Dépense aux Charges de Travail
Appliquer les règles d'allocation depuis references/spend-allocation.md :
| Produit | Règle d'Allocation |
|---|---|
| Analyse Produit | Par proportion d'événement SDK |
| Événements Identifiés | Par proportion d'événement SDK |
| Session Replay (Web) | 100% à la charge Web |
| Mobile Replay | Répartir par ratio événements iOS/Android |
| Feature Flags | 100% au SDK consommateur primaire |
| Error Tracking | Par source d'exception (web vs node) |
| LLM Analytics | 100% à la charge Plateforme LLM |
| Data Warehouse | 100% à la charge Plateforme Data |
| Group Analytics | 100% à la charge primaire |
| Teams/Scale | Plateforme globale (séparé) |
Étape 5 : Identifier les Opportunités et Risques
Appliquer les frameworks depuis references/opportunity-framework.md :
Opportunités (vérifier dans l'ordre) :
- Conversion mensuel → annuel (si pas annuel)
- Lacunes produit (inscrit mais $0 de dépense)
- Cross-sell (produits manquants pour leur profil)
- Expansion d'utilisation (approche des limites)
Risques (signaler si présents) :
- MRR en baisse (projeté < courant)
- Pas de contrat annuel (>$3K MRR mensuel)
- Score santé faible (<6)
- Dépendance produit unique (>50% de la dépense)
Étape 6 : Générer la Visualisation React
Utiliser le modèle depuis assets/workload-template.jsx. TOUS les composants ci-dessous sont REQUIS :
Composants OBLIGATOIRES (inclure TOUS) :
- SummaryCards - MRR, score santé, type de contrat, alertes
- SdkBreakdownBar - Barre visuelle de proportion SDK
- WorkloadCards - Cartes pour chaque charge avec produits
- OpportunitiesTable - Opportunités classées par confiance
- RisksTable - Si risques présents, classés par sévérité
- TreeView - REQUIS : Vue hiérarchique montrant Compte → Charges → Produits → Équipes avec lignes de connexion
- SankeyView - REQUIS : Diagramme flux revenu SVG montrant Produits (barres gauche) s'écoulant vers Charges (barres droite) avec chemins courbes
- MatrixView - Grille Produits × Charges avec cellules de dépense
Onglets Vue (DOIT inclure tous les 5) :
const tabs = [
{ id: 'overview', label: '📊 Overview' },
{ id: 'risks', label: '🚨 Risks' }, // Afficher si risks.length > 0
{ id: 'tree', label: '🌳 Tree View' }, // REQUIS
{ id: 'sankey', label: '💰 Revenue Flow' }, // REQUIS
{ id: 'matrix', label: '📋 Matrix' },
];
⚠️ NE PAS IGNORER TreeView ou SankeyView. Ce sont les visualisations les plus précieuses pour comprendre la structure du compte.
Composant Requis : TreeView
Copier ce composant exactement :
const TreeView = () => {
const activeWorkloads = workloadTotals.filter(w => w.type !== 'inactive');
return (
<div className="bg-white p-4 rounded-lg shadow border mb-4">
<div className="text-sm font-bold text-gray-700 mb-4">Hierarchical View: Account → Workloads → Products → Teams</div>
<div className="flex flex-col items-start">
<div className="bg-gray-900 text-white px-4 py-2 rounded font-bold mb-4">
{accountSummary.name} (${(accountSummary.arr/1000).toFixed(0)}k ARR)
<span className="text-gray-400 text-sm ml-2">| {accountSummary.employees} employees | {accountSummary.fundingStage}</span>
</div>
<div className="ml-8 border-l-2 border-gray-300">
{activeWorkloads.map((workload, wIdx) => (
<div key={wIdx} className="ml-4 mb-6">
<div className="flex items-center">
<div className="w-6 h-px bg-gray-300 mr-2"></div>
<div className="px-3 py-2 rounded text-sm font-medium"
style={{ backgroundColor: workload.type === 'primary' ? '#dbeafe' : '#f3e8ff',
color: workload.type === 'primary' ? '#1e40af' : '#6b21a8',
borderLeft: `4px solid ${workloadTypeColors[workload.type]}` }}>
<div className="font-bold">{workload.name}</div>
<div className="text-xs opacity-75">{workload.sdk} • {formatNumber(workload.dailyEvents)} events/day • {formatCurrency(workload.totalSpend)}/mo</div>
</div>
</div>
<div className="ml-10 mt-2 border-l border-gray-200">
{workload.products.map((product, pIdx) => (
<div key={pIdx} className="ml-4 mb-2 flex items-center">
<div className="w-4 h-px bg-gray-200 mr-2"></div>
<div className="w-3 h-3 rounded-full mr-2" style={{ backgroundColor: statusColors[product.status] }}></div>
<span className={`text-sm ${product.status === 'opportunity' ? 'text-gray-400' : 'text-gray-700'}`}>
<span className="font-medium">{product.name}</span>
{product.spend > 0 && <span className="text-green-600 ml-2">({formatCurrency(product.spend)}/mo)</span>}
</span>
</div>
))}
</div>
<div className="ml-10 mt-2 flex items-center gap-2">
<div className="w-4 h-px bg-gray-200"></div>
<span className="text-xs text-gray-500">Teams:</span>
{workload.teams.map((team, tIdx) => (
<span key={tIdx} className="bg-purple-100 text-purple-700 px-2 py-0.5 rounded text-xs">{team}</span>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
};
Composant Requis : SankeyView
Copier ce composant exactement :
const SankeyView = () => {
const productsWithSpend = [];
const productSpendMap = {};
workloadTotals.forEach(w => {
w.products.forEach(p => {
if (p.spend > 0) {
productSpendMap[p.name] = (productSpendMap[p.name] || 0) + p.spend;
}
});
});
Object.entries(productSpendMap).forEach(([name, amount]) => {
productsWithSpend.push({ name, amount });
});
productsWithSpend.sort((a, b) => b.amount - a.amount);
const totalSpend = productsWithSpend.reduce((s, p) => s + p.amount, 0) || 1;
const activeWorkloads = workloadTotals.filter(w => w.type !== 'inactive' && w.totalSpend > 0);
const productColors = ['#3b82f6', '#8b5cf6', '#22c55e', '#f59e0b', '#ec4899', '#06b6d4'];
productsWithSpend.forEach((p, i) => { p.color = productColors[i % productColors.length]; });
activeWorkloads.forEach((w, i) => { w.color = productColors[i % productColors.length]; });
const svgWidth = 800;
const svgHeight = Math.max(300, Math.max(productsWithSpend.length, activeWorkloads.length) * 60 + 60);
const leftX = 140, rightX = svgWidth - 200, barWidth = 30, gap = 8;
let leftY = 40;
const leftBars = productsWithSpend.map(p => {
const height = Math.max((p.amount / totalSpend) * (svgHeight - 80), 25);
const bar = { ...p, x: leftX, y: leftY, width: barWidth, height };
leftY += height + gap;
return bar;
});
let rightY = 40;
const rightBars = activeWorkloads.map(w => {
const height = Math.max((w.totalSpend / totalSpend) * (svgHeight - 80), 25);
const bar = { ...w, amount: w.totalSpend, x: rightX, y: rightY, width: barWidth, height };
rightY += height + gap;
return bar;
});
const flows = [];
const leftOffsets = {}, rightOffsets = {};
leftBars.forEach(b => { leftOffsets[b.name] = b.y; });
rightBars.forEach(b => { rightOffsets[b.name] = b.y; });
activeWorkloads.forEach(workload => {
workload.products.forEach(product => {
if (product.spend > 0) {
flows.push({ from: product.name, to: workload.name, amount: product.spend,
color: leftBars.find(b => b.name === product.name)?.color || '#888' });
}
});
});
return (
<div className="bg-white p-4 rounded-lg shadow border mb-4">
<div className="text-sm font-bold text-gray-700 mb-4">Revenue Flow: Products → Workloads</div>
<svg width={svgWidth} height={svgHeight} className="mx-auto">
{flows.map((flow, idx) => {
const fromBar = leftBars.find(b => b.name === flow.from);
const toBar = rightBars.find(b => b.name === flow.to);
if (!fromBar || !toBar) return null;
const flowHeight = Math.max((flow.amount / totalSpend) * (svgHeight - 80), 3);
const startY = leftOffsets[flow.from], endY = rightOffsets[flow.to];
leftOffsets[flow.from] += flowHeight;
rightOffsets[flow.to] += flowHeight;
const path = `M ${fromBar.x + barWidth} ${startY} C ${fromBar.x + barWidth + 100} ${startY}, ${toBar.x - 100} ${endY}, ${toBar.x} ${endY} L ${toBar.x} ${endY + flowHeight} C ${toBar.x - 100} ${endY + flowHeight}, ${fromBar.x + barWidth + 100} ${startY + flowHeight}, ${fromBar.x + barWidth} ${startY + flowHeight} Z`;
return <path key={idx} d={path} fill={flow.color} fillOpacity={0.3} stroke={flow.color} strokeWidth={0.5} />;
})}
{leftBars.map((bar, idx) => (
<g key={`left-${idx}`}>
<rect x={bar.x} y={bar.y} width={bar.width} height={bar.height} fill={bar.color} rx={4} />
<text x={bar.x - 10} y={bar.y + bar.height / 2} textAnchor="end" dominantBaseline="middle" fontSize={11} fill="#374151" fontWeight="500">{bar.name}</text>
<text x={bar.x - 10} y={bar.y + bar.height / 2 + 13} textAnchor="end" dominantBaseline="middle" fontSize={10} fill="#6b7280">{formatCurrency(bar.amount)}</text>
</g>
))}
{rightBars.map((bar, idx) => (
<g key={`right-${idx}`}>
<rect x={bar.x} y={bar.y} width={bar.width} height={bar.height} fill={bar.color} rx={4} />
<text x={bar.x + bar.width + 10} y={bar.y + bar.height / 2} textAnchor="start" dominantBaseline="middle" fontSize={11} fill="#374151" fontWeight="500">{bar.name}</text>
<text x={bar.x + bar.width + 10} y={bar.y + bar.height / 2 + 13} textAnchor="start" dominantBaseline="middle" fontSize={10} fill="#6b7280">{formatCurrency(bar.amount)} ({((bar.amount / totalSpend) * 100).toFixed(0)}%)</text>
</g>
))}
<text x={leftX + barWidth/2} y={20} textAnchor="middle" fontSize={11} fill="#6b7280" fontWeight="bold">PRODUCTS</text>
<text x={rightX + barWidth/2} y={20} textAnchor="middle" fontSize={11} fill="#6b7280" fontWeight="bold">WORKLOADS</text>
</svg>
<div className="text-xs text-gray-500 text-center mt-2">Flow width represents dollar allocation</div>
</div>
);
};
Format de Sortie
Générer un fichier composant React complet nommé {account-name}-workload-analysis.jsx qui :
- Contient toutes les données brutes dans des variables (à titre de référence)
- Calcule les proportions et allocations
- Définit les charges avec tableau de produits
- Inclut tous les composants de visualisation
- A des onglets vue pour différentes perspectives
Référence Rapide
Seuils de Statut
- Significatif : ≥$500/mo (vert)
- Adopté : $100-499/mo (bleu)
- Expérimentation : $1-99/mo (ambre)
- Opportunité : $0/mo (gris)
Champs SQL Clés
event_count_in_period, recording_count_in_period,
ai_event_count_in_period, exceptions_captured_in_period,
billable_feature_flag_requests_count_in_period,
enhanced_persons_event_count_in_period
Champs Dépense Vitally
product_analytics_forecasted_mrr, session_replay_forecasted_mrr,
feature_flags_forecasted_mrr, llm_analytics_forecasted_mrr,
enhanced_persons_forecasted_mrr, error_tracking_forecasted_mrr,
data_warehouse_forecasted_mrr, surveys_forecasted_mrr
Liste de Vérification Pré-Livraison
Avant de présenter l'artifact React, vérifier que TOUS les éléments suivants sont inclus :
- [ ] Composant SummaryCards avec MRR, santé, type de contrat
- [ ] SdkBreakdownBar montrant TOUS les SDK (y compris Flutter/React Native si présents)
- [ ] WorkloadCards pour chaque charge identifiée
- [ ] OpportunitiesTable avec opportunités classées
- [ ] RisksTable (si des risques identifiés)
- [ ] Composant TreeView - hiérarchie Compte → Charges → Produits → Équipes
- [ ] Composant SankeyView - SVG avec chemins flux courbes de Produits à Charges
- [ ] MatrixView avec grille Produits × Charges
- [ ] Onglets vue incluant : Overview, Risks, 🌳 Tree View, 💰 Revenue Flow, Matrix
- [ ] Section de référence données brutes (repliable)