关于 workload-analysis
workload-analysis 是一项面向客户账户分析的技能,根据 Vitally 账户数据和 PostHog 账单数据,将产品支出分配到工作负载、识别机会与风险,并生成交互式 React 可视化。
主要功能
- 01整合账户与账单数据
- 02分析完整 SDK 使用比例
- 03按规则分配产品支出
- 04识别扩展机会与账户风险
- 05生成多视图 React 分析组件
适用场景
- 01梳理账户支出在工作负载间的分布
- 02发现产品交叉销售和用量扩展机会
- 03评估收入下降或产品依赖风险
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ posthog/ skills --skill workload-analysis安装后在 Claude Code 中输入/workload-analysis确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.mdassets/workload-template.jsxreferences/data-mapping.mdopportunity-framework.mdspend-allocation.md详细介绍
工作负载分析技能
生成交互式 React 可视化图表,展示客户支出如何从产品流向工作负载,再流向团队,并提供 SDK 明细、机会识别和风险评估。
工作流
第 1 步:收集账户数据
-
在 Vitally 中查找账户:
vitally:find_account_by_name → get externalId (organization_id) vitally:get_account_full with filterUnnecessaryFields=false -
从 PostHog 查询计费数据:
SELECT date, report FROM postgres.prod.billing_usagereport WHERE organization_id = '[externalId]' ORDER BY date DESC LIMIT 3 -
提取关键字段(完整字段列表请参阅 references/data-mapping.md):
- Vitally:MRR、forecasted_mrr、diff_dollars、产品支出字段
- 计费数据:SDK 事件数、AI 事件、录制、异常、功能标志请求
第 2 步:计算 SDK 占比
⚠️ 重要:检查所有 SDK,而不只是常见 SDK。对于移动优先型公司,Flutter/React Native 往往是占主导地位的 SDK。
从计费数据中提取完整的 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 (DON'T MISS THESE!)
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);
// Verify: SDK total should ≈ event_count_in_period
// If big gap, you're missing an SDK!
第 3 步:定义工作负载
根据 SDK 占比和产品使用模式创建工作负载:
| 模式 | 工作负载类型 | 示例 |
|---|---|---|
| Web >80% | Web 应用(主要) | SaaS 仪表板 |
| Node/Python/Go/Ruby/PHP/Java >80% | 后端 API(主要) | API 优先型产品 |
| iOS + Android >20% | 原生移动应用(主要) | 面向消费者的 iOS/Android 应用 |
| Flutter >20% | Flutter 移动应用(主要) | 跨平台移动应用 |
| React Native >20% | React Native 应用(主要) | 跨平台移动应用 |
| AI 事件 >0 | LLM/AI 平台 | AI 优先型公司 |
| 外部 schema >0 | 数据平台 | 数据仓库使用情况 |
移动端优先级: 如果 Flutter 或 React Native 占主导地位,它就是主要的移动工作负载(而非 iOS/Android 原生应用)。
第 4 步:将支出分配给工作负载
应用 references/spend-allocation.md 中的分配规则:
| 产品 | 分配规则 |
|---|---|
| 产品分析 | 按 SDK 事件占比分配 |
| 已识别事件 | 按 SDK 事件占比分配 |
| 会话回放(Web) | 100% 分配给 Web 工作负载 |
| 移动端回放 | 按 iOS/Android 事件比例拆分 |
| 功能标志 | 100% 分配给主要 SDK 使用方 |
| 错误追踪 | 按异常来源(Web 与 Node)分配 |
| LLM 分析 | 100% 分配给 LLM 平台工作负载 |
| 数据仓库 | 100% 分配给数据平台工作负载 |
| 群组分析 | 100% 分配给主要工作负载 |
| 团队/规模 | 覆盖整个平台(单独列出) |
第 5 步:识别机会与风险
应用 references/opportunity-framework.md 中的框架:
机会(按顺序检查):
- 月付转年付(如果当前不是年付)
- 产品缺口(已启用但支出为 $0)
- 交叉销售(客户画像对应的产品尚未使用)
- 用量扩展(即将达到限制)
风险(如存在则标记):
- MRR 下降(预测值低于当前值)
- 没有年度合同(月付且 MRR >$3K)
- 健康评分低(<6)
- 依赖单一产品(占支出 >50%)
第 6 步:生成 React 可视化图表
使用 assets/workload-template.jsx 中的模板。以下所有组件均为必需:
必需组件(全部包含):
- SummaryCards - MRR、健康评分、合同类型、提醒
- SdkBreakdownBar - SDK 占比可视化条
- WorkloadCards - 显示各工作负载及其产品的卡片
- OpportunitiesTable - 按优先级排列并标注置信度的机会
- RisksTable - 如存在风险,则按严重程度排序
- TreeView - 必需:使用连接线展示账户 → 工作负载 → 产品 → 团队的层级视图
- SankeyView - 必需:SVG 收入流向图,展示产品(左侧条形)通过曲线路径流向工作负载(右侧条形)
- MatrixView - 产品 × 工作负载网格,单元格中显示支出
视图标签页(必须包含全部 5 个):
const tabs = [
{ id: 'overview', label: '📊 Overview' },
{ id: 'risks', label: '🚨 Risks' }, // Show if risks.length > 0
{ id: 'tree', label: '🌳 Tree View' }, // REQUIRED
{ id: 'sankey', label: '💰 Revenue Flow' }, // REQUIRED
{ id: 'matrix', label: '📋 Matrix' },
];
⚠️ 不要跳过 TreeView 或 SankeyView。它们是了解账户结构最有价值的可视化图表。
必需组件:TreeView
完全照抄此组件:
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>
);
};
必需组件:SankeyView
完全照抄此组件:
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>
);
};
输出格式
生成一个名为 {account-name}-workload-analysis.jsx 的完整 React 组件文件,其中:
- 以变量形式包含所有原始数据(供参考)
- 计算比例和分配情况
- 定义包含 products 数组的工作负载
- 包含所有可视化组件
- 提供不同视角的视图标签页
快速参考
状态阈值
- 显著:≥$500/mo(绿色)
- 已采用:$100-499/mo(蓝色)
- 试用中:$1-99/mo(琥珀色)
- 机会:$0/mo(灰色)
关键 SQL 字段
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
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
交付前检查清单
提交 React 产物前,确认以下各项均已包含:
- SummaryCards 组件,包含 MRR、健康状况、合同类型
- SdkBreakdownBar 显示所有 SDK(包括存在的 Flutter/React Native)
- 为每个已识别的工作负载提供 WorkloadCards
- OpportunitiesTable,列出按排名排序的机会
- RisksTable(若识别出任何风险)
- TreeView 组件——层级结构:Account → Workloads → Products → Teams
- SankeyView 组件——使用 SVG 绘制从 Products 到 Workloads 的弯曲流向路径
- MatrixView,展示 Products × Workloads 网格
- 视图标签页包括:Overview、Risks、🌳 Tree View、💰 Revenue Flow、Matrix
- 原始数据参考部分(可折叠)