@growthbook/mcp 1.3.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/server/index.js +11 -4
- package/server/prompts/experiment-prompts.js +30 -0
- package/server/tools/defaults.js +12 -12
- package/server/tools/environments.js +3 -3
- package/server/tools/experiments/experiment-summary.js +494 -0
- package/server/tools/{experiments.js → experiments/experiments.js} +95 -29
- package/server/tools/features.js +12 -12
- package/server/tools/metrics.js +7 -9
- package/server/tools/projects.js +3 -3
- package/server/tools/sdk-connections.js +7 -7
- package/server/tools/search.js +46 -8
- package/server/types/types.js +1 -0
- package/server/utils.js +153 -5
- package/server/prompts/experiment-analysis.js +0 -13
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { fetchWithRateLimit, handleResNotOk } from "../../utils.js";
|
|
2
|
+
// Helper functions
|
|
3
|
+
function median(arr) {
|
|
4
|
+
if (arr.length === 0)
|
|
5
|
+
return null;
|
|
6
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
7
|
+
const mid = Math.floor(sorted.length / 2);
|
|
8
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
9
|
+
}
|
|
10
|
+
function round(n, decimals = 4) {
|
|
11
|
+
if (n === null || n === undefined || isNaN(n))
|
|
12
|
+
return null;
|
|
13
|
+
return Math.round(n * 10 ** decimals) / 10 ** decimals;
|
|
14
|
+
}
|
|
15
|
+
function formatLift(lift) {
|
|
16
|
+
if (lift === null)
|
|
17
|
+
return "N/A";
|
|
18
|
+
const sign = lift >= 0 ? "+" : "";
|
|
19
|
+
return `${sign}${(lift * 100).toFixed(1)}%`;
|
|
20
|
+
}
|
|
21
|
+
function getYearMonth(dateStr) {
|
|
22
|
+
if (!dateStr)
|
|
23
|
+
return null;
|
|
24
|
+
const d = new Date(dateStr);
|
|
25
|
+
if (isNaN(d.getTime()))
|
|
26
|
+
return null;
|
|
27
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
28
|
+
}
|
|
29
|
+
function computeVerdict(exp, metricLookup) {
|
|
30
|
+
const resultData = exp.result?.results?.[0];
|
|
31
|
+
const srmPValue = resultData?.checks?.srm ?? null;
|
|
32
|
+
const totalUsers = resultData?.totalUsers || 0;
|
|
33
|
+
const srmPassing = srmPValue !== null ? srmPValue > 0.001 : true;
|
|
34
|
+
// Get goal and guardrail metric IDs
|
|
35
|
+
const goalIds = exp.settings?.goals?.map((g) => g.metricId) || [];
|
|
36
|
+
const guardrailIds = new Set(exp.settings?.guardrails?.map((g) => g.metricId) || []);
|
|
37
|
+
// Check guardrail regression
|
|
38
|
+
const guardrailsRegressed = resultData
|
|
39
|
+
? (resultData.metrics || [])
|
|
40
|
+
.filter((m) => guardrailIds.has(m.metricId))
|
|
41
|
+
.some((m) => {
|
|
42
|
+
const metricInfo = metricLookup.get(m.metricId);
|
|
43
|
+
const isInverse = metricInfo?.inverse ?? false;
|
|
44
|
+
return m.variations.slice(1).some((v) => {
|
|
45
|
+
const analysis = v.analyses?.[0];
|
|
46
|
+
if (!analysis)
|
|
47
|
+
return false;
|
|
48
|
+
if (analysis.chanceToBeatControl !== undefined) {
|
|
49
|
+
return isInverse
|
|
50
|
+
? analysis.chanceToBeatControl > 0.95
|
|
51
|
+
: analysis.chanceToBeatControl < 0.05;
|
|
52
|
+
}
|
|
53
|
+
return isInverse
|
|
54
|
+
? (analysis.ciLow ?? 0) > 0
|
|
55
|
+
: (analysis.ciHigh ?? 0) < 0;
|
|
56
|
+
});
|
|
57
|
+
})
|
|
58
|
+
: false;
|
|
59
|
+
// Verdict: Match GrowthBook's ExperimentWinRate.tsx exactly
|
|
60
|
+
// exp.results maps to resultSummary.status in the API
|
|
61
|
+
const userResult = exp.resultSummary?.status?.toLowerCase() || "";
|
|
62
|
+
let verdict;
|
|
63
|
+
if (userResult === "won") {
|
|
64
|
+
verdict = "won";
|
|
65
|
+
}
|
|
66
|
+
else if (userResult === "lost") {
|
|
67
|
+
verdict = "lost";
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Everything else is "inconclusive": dnf, inconclusive, undefined, null, ""
|
|
71
|
+
verdict = "inconclusive";
|
|
72
|
+
}
|
|
73
|
+
// Compute primary metric result for display
|
|
74
|
+
const primaryMetricResult = resultData
|
|
75
|
+
? computePrimaryMetricResult(resultData, metricLookup, goalIds)
|
|
76
|
+
: null;
|
|
77
|
+
return {
|
|
78
|
+
verdict,
|
|
79
|
+
primaryMetricResult,
|
|
80
|
+
guardrailsRegressed,
|
|
81
|
+
srmPassing,
|
|
82
|
+
srmPValue,
|
|
83
|
+
totalUsers,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function computePrimaryMetricResult(resultData, metricLookup, goalIds) {
|
|
87
|
+
const primaryMetricId = goalIds[0];
|
|
88
|
+
if (!primaryMetricId)
|
|
89
|
+
return null;
|
|
90
|
+
const primaryMetricData = resultData.metrics?.find((m) => m.metricId === primaryMetricId);
|
|
91
|
+
if (!primaryMetricData || primaryMetricData.variations.length <= 1) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const metricInfo = metricLookup.get(primaryMetricId);
|
|
95
|
+
const isInverse = metricInfo?.inverse ?? false;
|
|
96
|
+
// Find best performing variation (excluding control at index 0)
|
|
97
|
+
let bestVariation = primaryMetricData.variations[1];
|
|
98
|
+
let bestLift = bestVariation?.analyses?.[0]?.percentChange ?? 0;
|
|
99
|
+
for (let i = 2; i < primaryMetricData.variations.length; i++) {
|
|
100
|
+
const v = primaryMetricData.variations[i];
|
|
101
|
+
const lift = v.analyses?.[0]?.percentChange ?? 0;
|
|
102
|
+
const isBetter = isInverse ? lift < bestLift : lift > bestLift;
|
|
103
|
+
if (isBetter) {
|
|
104
|
+
bestVariation = v;
|
|
105
|
+
bestLift = lift;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const analysis = bestVariation?.analyses?.[0];
|
|
109
|
+
if (!analysis)
|
|
110
|
+
return null;
|
|
111
|
+
const lift = analysis.percentChange;
|
|
112
|
+
const chanceToBeatControl = analysis.chanceToBeatControl;
|
|
113
|
+
let significant = false;
|
|
114
|
+
if (chanceToBeatControl !== undefined) {
|
|
115
|
+
significant = chanceToBeatControl > 0.95 || chanceToBeatControl < 0.05;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
significant =
|
|
119
|
+
analysis.ciLow !== undefined &&
|
|
120
|
+
analysis.ciHigh !== undefined &&
|
|
121
|
+
(analysis.ciLow > 0 || analysis.ciHigh < 0);
|
|
122
|
+
}
|
|
123
|
+
let direction = "flat";
|
|
124
|
+
if (significant) {
|
|
125
|
+
const rawPositive = lift > 0;
|
|
126
|
+
const isWinning = isInverse ? !rawPositive : rawPositive;
|
|
127
|
+
direction = isWinning ? "winning" : "losing";
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
id: primaryMetricId,
|
|
131
|
+
name: metricInfo?.name || primaryMetricId,
|
|
132
|
+
lift: round(lift),
|
|
133
|
+
significant,
|
|
134
|
+
direction,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// Metric Lookup with caching
|
|
138
|
+
const metricCache = new Map();
|
|
139
|
+
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
140
|
+
const MAX_CONCURRENT_FETCHES = 10; // Limit concurrent API calls
|
|
141
|
+
// Helper to process array in batches with concurrency limit
|
|
142
|
+
async function processBatch(items, concurrency, processor) {
|
|
143
|
+
const results = [];
|
|
144
|
+
for (let i = 0; i < items.length; i += concurrency) {
|
|
145
|
+
const batch = items.slice(i, i + concurrency);
|
|
146
|
+
const batchResults = await Promise.all(batch.map(processor));
|
|
147
|
+
results.push(...batchResults);
|
|
148
|
+
}
|
|
149
|
+
return results;
|
|
150
|
+
}
|
|
151
|
+
async function getMetricLookup(baseApiUrl, apiKey, metricIds) {
|
|
152
|
+
const metricLookup = new Map();
|
|
153
|
+
if (metricIds.size === 0) {
|
|
154
|
+
return metricLookup;
|
|
155
|
+
}
|
|
156
|
+
// Check cache first
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
const uncachedMetricIds = [];
|
|
159
|
+
const factMetricIds = [];
|
|
160
|
+
const regularMetricIds = [];
|
|
161
|
+
for (const metricId of metricIds) {
|
|
162
|
+
const cached = metricCache.get(metricId);
|
|
163
|
+
if (cached && now - cached.timestamp < CACHE_TTL_MS) {
|
|
164
|
+
metricLookup.set(metricId, cached.info);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
uncachedMetricIds.push(metricId);
|
|
168
|
+
if (metricId.startsWith("fact__")) {
|
|
169
|
+
factMetricIds.push(metricId);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
regularMetricIds.push(metricId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// If all metrics are cached, return early
|
|
177
|
+
if (uncachedMetricIds.length === 0) {
|
|
178
|
+
return metricLookup;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
// Fetch regular metrics in batches with concurrency limit
|
|
182
|
+
const regularResults = await processBatch(regularMetricIds, MAX_CONCURRENT_FETCHES, async (metricId) => {
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/metrics/${metricId}`, {
|
|
185
|
+
headers: {
|
|
186
|
+
Authorization: `Bearer ${apiKey}`,
|
|
187
|
+
"Content-Type": "application/json",
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
await handleResNotOk(res);
|
|
191
|
+
const data = await res.json();
|
|
192
|
+
const info = {
|
|
193
|
+
id: metricId,
|
|
194
|
+
name: data.name || metricId,
|
|
195
|
+
inverse: data.inverse || false,
|
|
196
|
+
type: data.type || "binomial",
|
|
197
|
+
};
|
|
198
|
+
// Cache the result
|
|
199
|
+
metricCache.set(metricId, { info, timestamp: now });
|
|
200
|
+
return { id: metricId, info };
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
console.error(`Error fetching metric ${metricId}:`, error);
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
// Fetch fact metrics in batches with concurrency limit
|
|
208
|
+
const factResults = await processBatch(factMetricIds, MAX_CONCURRENT_FETCHES, async (metricId) => {
|
|
209
|
+
try {
|
|
210
|
+
const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/fact-metrics/${metricId}`, {
|
|
211
|
+
headers: {
|
|
212
|
+
Authorization: `Bearer ${apiKey}`,
|
|
213
|
+
"Content-Type": "application/json",
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
await handleResNotOk(res);
|
|
217
|
+
const data = await res.json();
|
|
218
|
+
const info = {
|
|
219
|
+
id: metricId,
|
|
220
|
+
name: data.name || metricId,
|
|
221
|
+
inverse: false,
|
|
222
|
+
type: "count", // Fact metrics are typically count type
|
|
223
|
+
};
|
|
224
|
+
// Cache the result
|
|
225
|
+
metricCache.set(metricId, { info, timestamp: now });
|
|
226
|
+
return { id: metricId, info };
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
console.error(`Error fetching fact metric ${metricId}:`, error);
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
// Add fetched metrics to lookup
|
|
234
|
+
for (const result of regularResults) {
|
|
235
|
+
if (result) {
|
|
236
|
+
metricLookup.set(result.id, result.info);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const result of factResults) {
|
|
240
|
+
if (result) {
|
|
241
|
+
metricLookup.set(result.id, result.info);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
console.error("Error fetching metrics for lookup:", error);
|
|
247
|
+
// Return partial map if some fetches fail
|
|
248
|
+
}
|
|
249
|
+
return metricLookup;
|
|
250
|
+
}
|
|
251
|
+
async function buildExperimentStats(experiments, baseApiUrl, apiKey, reportProgress) {
|
|
252
|
+
await reportProgress(3, "Figuring out metrics...");
|
|
253
|
+
// Extract all unique metric IDs
|
|
254
|
+
const metricIds = new Set();
|
|
255
|
+
for (const exp of experiments) {
|
|
256
|
+
if (exp.settings?.goals) {
|
|
257
|
+
for (const goal of exp.settings.goals) {
|
|
258
|
+
if (goal.metricId)
|
|
259
|
+
metricIds.add(goal.metricId);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (exp.settings?.guardrails) {
|
|
263
|
+
for (const guardrail of exp.settings.guardrails) {
|
|
264
|
+
if (guardrail.metricId)
|
|
265
|
+
metricIds.add(guardrail.metricId);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const metricLookup = await getMetricLookup(baseApiUrl, apiKey, metricIds);
|
|
270
|
+
const cards = [];
|
|
271
|
+
const byVerdict = { won: 0, lost: 0, inconclusive: 0 };
|
|
272
|
+
const byProject = {};
|
|
273
|
+
const byTag = {};
|
|
274
|
+
const byMonth = {};
|
|
275
|
+
const byType = {
|
|
276
|
+
standard: 0,
|
|
277
|
+
bandit: 0,
|
|
278
|
+
};
|
|
279
|
+
const srmIssues = [];
|
|
280
|
+
const durations = [];
|
|
281
|
+
const winnerLifts = [];
|
|
282
|
+
const loserLifts = [];
|
|
283
|
+
let totalUsers = 0;
|
|
284
|
+
let srmFailures = 0;
|
|
285
|
+
let guardrailRegressions = 0;
|
|
286
|
+
let experimentsWithResults = 0;
|
|
287
|
+
await reportProgress(4, "Computing experiment stats...");
|
|
288
|
+
for (const exp of experiments) {
|
|
289
|
+
const verdictResult = computeVerdict(exp, metricLookup);
|
|
290
|
+
const { verdict, primaryMetricResult, guardrailsRegressed, srmPassing, srmPValue, } = verdictResult;
|
|
291
|
+
const expType = exp.type || "standard";
|
|
292
|
+
// Parse dates
|
|
293
|
+
const dateStarted = exp.result?.dateStart || null;
|
|
294
|
+
const dateEnded = exp.result?.dateEnd || null;
|
|
295
|
+
let durationDays = null;
|
|
296
|
+
if (dateStarted && dateEnded) {
|
|
297
|
+
const start = new Date(dateStarted);
|
|
298
|
+
const end = new Date(dateEnded);
|
|
299
|
+
if (!isNaN(start.getTime()) && !isNaN(end.getTime())) {
|
|
300
|
+
durationDays = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
|
301
|
+
if (durationDays >= 0)
|
|
302
|
+
durations.push(durationDays);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Build card
|
|
306
|
+
const card = {
|
|
307
|
+
id: exp.id,
|
|
308
|
+
name: exp.name,
|
|
309
|
+
trackingKey: exp.trackingKey,
|
|
310
|
+
hypothesis: exp.hypothesis || "",
|
|
311
|
+
verdict,
|
|
312
|
+
project: exp.project || "",
|
|
313
|
+
tags: exp.tags || [],
|
|
314
|
+
owner: exp.owner || "",
|
|
315
|
+
type: expType,
|
|
316
|
+
primaryMetric: primaryMetricResult
|
|
317
|
+
? {
|
|
318
|
+
...primaryMetricResult,
|
|
319
|
+
liftFormatted: formatLift(primaryMetricResult.lift),
|
|
320
|
+
}
|
|
321
|
+
: null,
|
|
322
|
+
totalUsers: verdictResult.totalUsers,
|
|
323
|
+
durationDays,
|
|
324
|
+
dateStarted,
|
|
325
|
+
dateEnded,
|
|
326
|
+
srmPassing,
|
|
327
|
+
srmPValue,
|
|
328
|
+
guardrailsRegressed,
|
|
329
|
+
};
|
|
330
|
+
cards.push(card);
|
|
331
|
+
// Accumulate stats
|
|
332
|
+
byVerdict[verdict]++;
|
|
333
|
+
totalUsers += verdictResult.totalUsers;
|
|
334
|
+
if (verdictResult.totalUsers > 0) {
|
|
335
|
+
experimentsWithResults++;
|
|
336
|
+
if (!srmPassing) {
|
|
337
|
+
srmFailures++;
|
|
338
|
+
srmIssues.push({
|
|
339
|
+
id: exp.id,
|
|
340
|
+
name: exp.name,
|
|
341
|
+
srmPValue: srmPValue,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
if (guardrailsRegressed)
|
|
345
|
+
guardrailRegressions++;
|
|
346
|
+
}
|
|
347
|
+
if (verdict === "won" && primaryMetricResult?.lift != null) {
|
|
348
|
+
winnerLifts.push(Math.abs(primaryMetricResult.lift));
|
|
349
|
+
}
|
|
350
|
+
if (verdict === "lost" && primaryMetricResult?.lift != null) {
|
|
351
|
+
loserLifts.push(primaryMetricResult.lift);
|
|
352
|
+
}
|
|
353
|
+
// By project
|
|
354
|
+
const project = exp.project || "(none)";
|
|
355
|
+
if (!byProject[project]) {
|
|
356
|
+
byProject[project] = {
|
|
357
|
+
count: 0,
|
|
358
|
+
won: 0,
|
|
359
|
+
lost: 0,
|
|
360
|
+
inconclusive: 0,
|
|
361
|
+
winRate: null,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
byProject[project].count++;
|
|
365
|
+
if (verdict === "won")
|
|
366
|
+
byProject[project].won++;
|
|
367
|
+
if (verdict === "lost")
|
|
368
|
+
byProject[project].lost++;
|
|
369
|
+
if (verdict === "inconclusive")
|
|
370
|
+
byProject[project].inconclusive++;
|
|
371
|
+
// By tags
|
|
372
|
+
for (const tag of exp.tags || []) {
|
|
373
|
+
if (!byTag[tag]) {
|
|
374
|
+
byTag[tag] = {
|
|
375
|
+
count: 0,
|
|
376
|
+
won: 0,
|
|
377
|
+
lost: 0,
|
|
378
|
+
inconclusive: 0,
|
|
379
|
+
winRate: null,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
byTag[tag].count++;
|
|
383
|
+
if (verdict === "won")
|
|
384
|
+
byTag[tag].won++;
|
|
385
|
+
if (verdict === "lost")
|
|
386
|
+
byTag[tag].lost++;
|
|
387
|
+
if (verdict === "inconclusive")
|
|
388
|
+
byTag[tag].inconclusive++;
|
|
389
|
+
}
|
|
390
|
+
// By month
|
|
391
|
+
const endMonth = getYearMonth(dateEnded);
|
|
392
|
+
if (endMonth) {
|
|
393
|
+
if (!byMonth[endMonth])
|
|
394
|
+
byMonth[endMonth] = { ended: 0, won: 0, lost: 0 };
|
|
395
|
+
byMonth[endMonth].ended++;
|
|
396
|
+
if (verdict === "won")
|
|
397
|
+
byMonth[endMonth].won++;
|
|
398
|
+
if (verdict === "lost")
|
|
399
|
+
byMonth[endMonth].lost++;
|
|
400
|
+
}
|
|
401
|
+
// By type
|
|
402
|
+
if (expType === "multi-armed-bandit") {
|
|
403
|
+
byType.bandit++;
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
byType.standard++;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Calculate win rates for projects and tags
|
|
410
|
+
for (const key of Object.keys(byProject)) {
|
|
411
|
+
const p = byProject[key];
|
|
412
|
+
const total = p.won + p.lost + p.inconclusive;
|
|
413
|
+
p.winRate = total > 0 ? round(p.won / total) : null;
|
|
414
|
+
}
|
|
415
|
+
for (const key of Object.keys(byTag)) {
|
|
416
|
+
const t = byTag[key];
|
|
417
|
+
const total = t.won + t.lost + t.inconclusive;
|
|
418
|
+
t.winRate = total > 0 ? round(t.won / total) : null;
|
|
419
|
+
}
|
|
420
|
+
const total = byVerdict.won + byVerdict.lost + byVerdict.inconclusive;
|
|
421
|
+
// Top winners and losers
|
|
422
|
+
const topWinners = cards
|
|
423
|
+
.filter((c) => c.verdict === "won" && c.primaryMetric?.lift != null)
|
|
424
|
+
.sort((a, b) => Math.abs(b.primaryMetric.lift) - Math.abs(a.primaryMetric.lift))
|
|
425
|
+
.slice(0, 5)
|
|
426
|
+
.map((c) => ({
|
|
427
|
+
id: c.id,
|
|
428
|
+
name: c.name,
|
|
429
|
+
lift: c.primaryMetric.lift,
|
|
430
|
+
liftFormatted: c.primaryMetric.liftFormatted,
|
|
431
|
+
metric: c.primaryMetric.name,
|
|
432
|
+
hypothesis: c.hypothesis,
|
|
433
|
+
}));
|
|
434
|
+
const topLosers = cards
|
|
435
|
+
.filter((c) => c.verdict === "lost" && c.primaryMetric?.lift != null)
|
|
436
|
+
.sort((a, b) => Math.abs(b.primaryMetric.lift) - Math.abs(a.primaryMetric.lift))
|
|
437
|
+
.slice(0, 5)
|
|
438
|
+
.map((c) => ({
|
|
439
|
+
id: c.id,
|
|
440
|
+
name: c.name,
|
|
441
|
+
lift: c.primaryMetric.lift,
|
|
442
|
+
liftFormatted: c.primaryMetric.liftFormatted,
|
|
443
|
+
metric: c.primaryMetric.name,
|
|
444
|
+
hypothesis: c.hypothesis,
|
|
445
|
+
}));
|
|
446
|
+
await reportProgress(5, "Putting on the finishing touches...");
|
|
447
|
+
return {
|
|
448
|
+
total: experiments.length,
|
|
449
|
+
byVerdict,
|
|
450
|
+
// Matches GrowthBook: winRate = won / (won + lost + inconclusive)
|
|
451
|
+
winRate: total > 0 ? round(byVerdict.won / total) : null,
|
|
452
|
+
avgDurationDays: durations.length > 0
|
|
453
|
+
? round(durations.reduce((a, b) => a + b, 0) / durations.length, 1)
|
|
454
|
+
: null,
|
|
455
|
+
medianDurationDays: median(durations),
|
|
456
|
+
totalUsers,
|
|
457
|
+
avgUsersPerExperiment: experimentsWithResults > 0
|
|
458
|
+
? Math.round(totalUsers / experimentsWithResults)
|
|
459
|
+
: null,
|
|
460
|
+
avgLiftWinners: winnerLifts.length > 0
|
|
461
|
+
? round(winnerLifts.reduce((a, b) => a + b, 0) / winnerLifts.length)
|
|
462
|
+
: null,
|
|
463
|
+
medianLiftWinners: median(winnerLifts),
|
|
464
|
+
srmFailureRate: experimentsWithResults > 0
|
|
465
|
+
? round(srmFailures / experimentsWithResults)
|
|
466
|
+
: null,
|
|
467
|
+
guardrailRegressionRate: experimentsWithResults > 0
|
|
468
|
+
? round(guardrailRegressions / experimentsWithResults)
|
|
469
|
+
: null,
|
|
470
|
+
srmIssues,
|
|
471
|
+
topWinners,
|
|
472
|
+
topLosers,
|
|
473
|
+
byProject,
|
|
474
|
+
byTag,
|
|
475
|
+
byMonth,
|
|
476
|
+
byType,
|
|
477
|
+
experiments: cards,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
export async function handleSummaryMode(experiments, baseApiUrl, apiKey, reportProgress) {
|
|
481
|
+
// Filter to stopped experiments only - matching GrowthBook's filter
|
|
482
|
+
const stoppedExperiments = experiments.filter((exp) => exp.status === "stopped");
|
|
483
|
+
const stats = await buildExperimentStats(stoppedExperiments, baseApiUrl, apiKey, reportProgress);
|
|
484
|
+
return {
|
|
485
|
+
...stats,
|
|
486
|
+
_meta: {
|
|
487
|
+
totalFetched: experiments.length,
|
|
488
|
+
excluded: {
|
|
489
|
+
draft: experiments.filter((e) => e.status === "draft").length,
|
|
490
|
+
running: experiments.filter((e) => e.status === "running").length,
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
}
|