@growthbook/mcp 1.4.4 → 1.5.0

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.
@@ -1,24 +1,76 @@
1
1
  import { z } from "zod";
2
- import { generateLinkToGrowthBook, getDocsMetadata, handleResNotOk, SUPPORTED_FILE_EXTENSIONS, paginationSchema, fetchWithRateLimit, } from "../../utils.js";
2
+ import { generateLinkToGrowthBook, getDocsMetadata, handleResNotOk, SUPPORTED_FILE_EXTENSIONS, paginationSchema, fetchWithRateLimit, fetchWithPagination, featureFlagSchema, fetchFeatureFlag, mergeRuleIntoFeatureFlag, } from "../../utils.js";
3
3
  import { getDefaults } from "../defaults.js";
4
4
  import { handleSummaryMode } from "./experiment-summary.js";
5
5
  export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin, user, }) {
6
6
  /**
7
7
  * Tool: get_experiments
8
8
  */
9
- server.tool("get_experiments", "Fetches experiments from the GrowthBook API", {
10
- project: z
11
- .string()
12
- .describe("The ID of the project to filter experiments by")
13
- .optional(),
14
- mode: z
15
- .enum(["metadata", "summary", "full"])
16
- .default("metadata")
17
- .describe("The mode to use to fetch experiments. Metadata mode returns experiment config without results. Summary mode fetches results and returns pruned key stats for quick analysis. Full mode fetches and returns complete results data. WARNING: Full mode may return large payloads."),
18
- ...paginationSchema,
19
- }, {
20
- readOnlyHint: true,
21
- }, async ({ limit, offset, mostRecent, project, mode }, extra) => {
9
+ server.registerTool("get_experiments", {
10
+ title: "Get Experiments",
11
+ description: "Lists experiments or fetches details for a specific experiment. Supports three modes: metadata (default) returns experiment config without results, good for listing; summary fetches results and returns key statistics including win rate and top performers, good for quick analysis; full returns complete results with all metrics (warning: large payloads). Use this to review recent experiments (mostRecent=true), analyze results, or check experiment status (draft, running, stopped). Single experiment fetch includes a link to view in GrowthBook.",
12
+ inputSchema: z.object({
13
+ project: z
14
+ .string()
15
+ .describe("The ID of the project to filter experiments by")
16
+ .optional(),
17
+ mode: z
18
+ .enum(["metadata", "summary", "full"])
19
+ .default("metadata")
20
+ .describe("The mode to use to fetch experiments. Metadata mode returns experiment config without results. Summary mode fetches results and returns pruned key stats for quick analysis. Full mode fetches and returns complete results data. WARNING: Full mode may return large payloads."),
21
+ experimentId: z
22
+ .string()
23
+ .describe("The ID of the experiment to fetch")
24
+ .optional(),
25
+ ...paginationSchema,
26
+ }),
27
+ annotations: {
28
+ readOnlyHint: true,
29
+ },
30
+ }, async ({ limit, offset, mostRecent, project, mode, experimentId }, extra) => {
31
+ if (experimentId) {
32
+ try {
33
+ const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experimentId}`, {
34
+ headers: {
35
+ Authorization: `Bearer ${apiKey}`,
36
+ "Content-Type": "application/json",
37
+ },
38
+ });
39
+ await handleResNotOk(res);
40
+ const data = await res.json();
41
+ // Fetch results
42
+ if (mode === "full") {
43
+ if (data.status === "draft") {
44
+ data.result = null;
45
+ }
46
+ try {
47
+ const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experimentId}/results`, {
48
+ headers: {
49
+ Authorization: `Bearer ${apiKey}`,
50
+ },
51
+ });
52
+ await handleResNotOk(resultsRes);
53
+ const resultsData = await resultsRes.json();
54
+ data.result = resultsData.result;
55
+ }
56
+ catch (error) {
57
+ console.error(`Error fetching results for experiment ${experimentId}`, error);
58
+ }
59
+ }
60
+ const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "experiment", experimentId);
61
+ const text = `
62
+ ${JSON.stringify(data)}
63
+
64
+ [View the experiment in GrowthBook](${linkToGrowthBook})
65
+ `;
66
+ return {
67
+ content: [{ type: "text", text }],
68
+ };
69
+ }
70
+ catch (error) {
71
+ throw new Error(`Error getting experiment: ${error}`);
72
+ }
73
+ }
22
74
  const progressToken = extra._meta?.progressToken;
23
75
  const totalSteps = mode === "summary" ? 5 : mode === "full" ? 3 : 2;
24
76
  const reportProgress = async (progress, message) => {
@@ -36,128 +88,44 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
36
88
  };
37
89
  await reportProgress(1, "Fetching experiments...");
38
90
  try {
39
- // Default behavior
40
- if (!mostRecent || offset > 0) {
41
- const defaultQueryParams = new URLSearchParams({
42
- limit: limit.toString(),
43
- offset: offset.toString(),
44
- });
45
- if (project) {
46
- defaultQueryParams.append("projectId", project);
47
- }
48
- const defaultRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments?${defaultQueryParams.toString()}`, {
49
- headers: {
50
- Authorization: `Bearer ${apiKey}`,
51
- "Content-Type": "application/json",
52
- },
53
- });
54
- await handleResNotOk(defaultRes);
55
- const data = await defaultRes.json();
56
- const experiments = data.experiments;
57
- if (mode === "full" || mode === "summary") {
58
- await reportProgress(2, "Fetching experiment results...");
59
- for (const [index, experiment] of experiments.entries()) {
60
- if (experiment.status === "draft") {
61
- experiments[index].result = undefined;
62
- continue;
63
- }
64
- try {
65
- const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experiment.id}/results`, {
66
- headers: {
67
- Authorization: `Bearer ${apiKey}`,
68
- },
69
- });
70
- await handleResNotOk(resultsRes);
71
- const resultsData = await resultsRes.json();
72
- experiments[index].result = resultsData.result;
73
- }
74
- catch (error) {
75
- console.error(`Error fetching results for experiment ${experiment.id} (${experiment.name})`, error);
76
- }
91
+ const data = await fetchWithPagination(baseApiUrl, apiKey, "/api/v1/experiments", limit, offset, mostRecent, project ? { projectId: project } : undefined);
92
+ let experiments = data.experiments || [];
93
+ // Reverse experiments array for mostRecent to show newest-first
94
+ if (mostRecent && offset === 0 && Array.isArray(experiments)) {
95
+ experiments = experiments.reverse();
96
+ data.experiments = experiments;
97
+ }
98
+ if (mode === "full" || mode === "summary") {
99
+ await reportProgress(2, "Fetching experiment results...");
100
+ for (const [index, experiment] of experiments.entries()) {
101
+ if (experiment.status === "draft") {
102
+ experiments[index].result = undefined;
103
+ continue;
77
104
  }
78
- }
79
- if (mode === "summary") {
80
- const summaryExperiments = await handleSummaryMode(experiments, baseApiUrl, apiKey, reportProgress);
81
- const summaryExperimentsWithPagination = {
82
- summary: summaryExperiments,
83
- limit: data.limit,
84
- offset: data.offset,
85
- total: data.total,
86
- hasMore: data.hasMore,
87
- nextOffset: data.nextOffset,
88
- };
89
- return {
90
- content: [
91
- {
92
- type: "text",
93
- text: JSON.stringify(summaryExperimentsWithPagination),
105
+ try {
106
+ const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experiment.id}/results`, {
107
+ headers: {
108
+ Authorization: `Bearer ${apiKey}`,
94
109
  },
95
- ],
96
- };
97
- }
98
- await reportProgress(2, "Processing results...");
99
- return {
100
- content: [{ type: "text", text: JSON.stringify(data) }],
101
- };
102
- }
103
- // Most recent behavior
104
- const countRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments?limit=1`, {
105
- headers: {
106
- Authorization: `Bearer ${apiKey}`,
107
- },
108
- });
109
- await handleResNotOk(countRes);
110
- const countData = await countRes.json();
111
- const total = countData.total;
112
- const calculatedOffset = Math.max(0, total - limit);
113
- const mostRecentQueryParams = new URLSearchParams({
114
- limit: limit.toString(),
115
- offset: calculatedOffset.toString(),
116
- });
117
- if (project) {
118
- mostRecentQueryParams.append("projectId", project);
119
- }
120
- const mostRecentRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments?${mostRecentQueryParams.toString()}`, {
121
- headers: {
122
- Authorization: `Bearer ${apiKey}`,
123
- },
124
- });
125
- await handleResNotOk(mostRecentRes);
126
- const mostRecentData = await mostRecentRes.json();
127
- if (mostRecentData.experiments &&
128
- Array.isArray(mostRecentData.experiments)) {
129
- mostRecentData.experiments = mostRecentData.experiments.reverse();
130
- if (mode === "full" || mode === "summary") {
131
- await reportProgress(2, "Fetching experiment results...");
132
- for (const [index, experiment,] of mostRecentData.experiments.entries()) {
133
- try {
134
- const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experiment.id}/results`, {
135
- headers: {
136
- Authorization: `Bearer ${apiKey}`,
137
- },
138
- });
139
- await handleResNotOk(resultsRes);
140
- const resultsData = await resultsRes.json();
141
- mostRecentData.experiments[index].result = resultsData.result;
142
- }
143
- catch (error) {
144
- console.error(`Error fetching results for experiment ${experiment.id} (${experiment.name})`, error);
145
- }
110
+ });
111
+ await handleResNotOk(resultsRes);
112
+ const resultsData = await resultsRes.json();
113
+ experiments[index].result = resultsData.result;
114
+ }
115
+ catch (error) {
116
+ console.error(`Error fetching results for experiment ${experiment.id} (${experiment.name})`, error);
146
117
  }
147
118
  }
148
119
  }
149
120
  if (mode === "summary") {
150
- const experiments = Array.isArray(mostRecentData.experiments)
151
- ? mostRecentData.experiments
152
- : [];
153
121
  const summaryExperiments = await handleSummaryMode(experiments, baseApiUrl, apiKey, reportProgress);
154
122
  const summaryExperimentsWithPagination = {
155
123
  summary: summaryExperiments,
156
- limit: mostRecentData.limit,
157
- offset: mostRecentData.offset,
158
- total: mostRecentData.total,
159
- hasMore: mostRecentData.hasMore,
160
- nextOffset: mostRecentData.nextOffset,
124
+ limit: data.limit,
125
+ offset: data.offset,
126
+ total: data.total,
127
+ hasMore: data.hasMore,
128
+ nextOffset: data.nextOffset,
161
129
  };
162
130
  return {
163
131
  content: [
@@ -168,73 +136,25 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
168
136
  ],
169
137
  };
170
138
  }
139
+ await reportProgress(2, "Processing results...");
171
140
  return {
172
- content: [{ type: "text", text: JSON.stringify(mostRecentData) }],
141
+ content: [{ type: "text", text: JSON.stringify(data) }],
173
142
  };
174
143
  }
175
144
  catch (error) {
176
145
  throw new Error(`Error fetching experiments: ${error}`);
177
146
  }
178
147
  });
179
- /**
180
- * Tool: get_experiment
181
- */
182
- server.tool("get_experiment", "Gets a single experiment from GrowthBook", {
183
- experimentId: z.string().describe("The ID of the experiment to get"),
184
- mode: z
185
- .enum(["metadata", "full"])
186
- .default("metadata")
187
- .describe("The mode to use to fetch the experiment. Metadata mode returns summary info about the experiment. Full mode fetches results and returns complete results data."),
188
- }, {
189
- readOnlyHint: true,
190
- }, async ({ experimentId, mode }) => {
191
- try {
192
- const res = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experimentId}`, {
193
- headers: {
194
- Authorization: `Bearer ${apiKey}`,
195
- "Content-Type": "application/json",
196
- },
197
- });
198
- await handleResNotOk(res);
199
- const data = await res.json();
200
- // If analyze or summary mode, fetch results
201
- if (mode === "full") {
202
- if (data.status === "draft") {
203
- data.result = null;
204
- }
205
- try {
206
- const resultsRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/experiments/${experimentId}/results`, {
207
- headers: {
208
- Authorization: `Bearer ${apiKey}`,
209
- },
210
- });
211
- await handleResNotOk(resultsRes);
212
- const resultsData = await resultsRes.json();
213
- data.result = resultsData.result;
214
- }
215
- catch (error) {
216
- console.error(`Error fetching results for experiment ${experimentId}`, error);
217
- }
218
- }
219
- const linkToGrowthBook = generateLinkToGrowthBook(appOrigin, "experiment", experimentId);
220
- const text = `
221
- ${JSON.stringify(data)}
222
-
223
- [View the experiment in GrowthBook](${linkToGrowthBook})
224
- `;
225
- return {
226
- content: [{ type: "text", text }],
227
- };
228
- }
229
- catch (error) {
230
- throw new Error(`Error getting experiment: ${error}`);
231
- }
232
- });
233
148
  /**
234
149
  * Tool: get_attributes
235
150
  */
236
- server.tool("get_attributes", "Get all attributes", {}, {
237
- readOnlyHint: true,
151
+ server.registerTool("get_attributes", {
152
+ title: "Get Attributes",
153
+ description: "Lists all user attributes configured in GrowthBook. Attributes are user properties (like country, plan type, user ID) used for targeting in feature flags and experiments. Use this to see available attributes for targeting conditions in create_force_rule, understand targeting options when setting up experiments, or verify attribute names before writing conditions. Common examples: id, email, country, plan, deviceType, isEmployee. Attributes must be passed to the GrowthBook SDK at runtime for targeting to work.",
154
+ inputSchema: z.object({}),
155
+ annotations: {
156
+ readOnlyHint: true,
157
+ },
238
158
  }, async () => {
239
159
  try {
240
160
  const queryParams = new URLSearchParams();
@@ -258,44 +178,55 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
258
178
  /**
259
179
  * Tool: create_experiment
260
180
  */
261
- server.tool("create_experiment", "IMPORTANT: Call get_defaults before creating an experiment, and use its output to guide the arguments. Creates a new feature flag and experiment (A/B test).", {
262
- name: z
263
- .string()
264
- .describe("Experiment name. Base name off the examples from get_defaults. If none are available, use a short, descriptive name that captures the essence of the experiment."),
265
- description: z.string().optional().describe("Experiment description."),
266
- hypothesis: z
267
- .string()
268
- .optional()
269
- .describe("Experiment hypothesis. Base hypothesis off the examples from get_defaults. If none are available, use a falsifiable statement about what will happen if the experiment succeeds or fails."),
270
- variations: z
271
- .array(z.object({
181
+ server.registerTool("create_experiment", {
182
+ title: "Create Experiment",
183
+ description: "Creates a new A/B test experiment in GrowthBook. An experiment randomly assigns users to different variations and measures which performs better against your metrics. Prerequisites: 1) Call get_defaults first to review naming conventions and configuration, 2) If testing via a feature flag, provide its featureId OR create the flag first using create_feature_flag. Returns a draft experiment that the user must review and launch in the GrowthBook UI, including a link and SDK integration code. Do NOT use for simple feature toggles (use create_feature_flag) or targeting without measurement (use create_force_rule).",
184
+ inputSchema: z.object({
272
185
  name: z
273
186
  .string()
274
- .describe("Variation name. Base name off the examples from get_defaults. If none are available, use a short, descriptive name that captures the essence of the variation."),
275
- value: z
276
- .union([
277
- z.string(),
278
- z.number(),
279
- z.boolean(),
280
- z.record(z.string(), z.any()),
281
- ])
282
- .describe("The value of the control and each of the variations. The value should be a string, number, boolean, or object. If it's an object, it should be a valid JSON object."),
283
- }))
284
- .describe("Experiment variations. The key should be the variation name and the value should be the variation value. Look to variations included in preview experiments for guidance on generation. The default or control variation should always be first."),
285
- project: z
286
- .string()
287
- .describe("The ID of the project to create the experiment in")
288
- .optional(),
289
- fileExtension: z
290
- .enum(SUPPORTED_FILE_EXTENSIONS)
291
- .describe("The extension of the current file. If it's unclear, ask the user."),
292
- confirmedDefaultsReviewed: z
293
- .boolean()
294
- .describe("Set to true to confirm you have called get_defaults and reviewed the output to guide these parameters."),
295
- }, {
296
- readOnlyHint: false,
297
- destructiveHint: false,
298
- }, async ({ description, hypothesis, name, variations, fileExtension, confirmedDefaultsReviewed, project, }) => {
187
+ .describe("Experiment name. Base name off the examples from get_defaults. If none are available, use a short, descriptive name that captures the essence of the experiment."),
188
+ description: z.string().optional().describe("Experiment description."),
189
+ hypothesis: z
190
+ .string()
191
+ .optional()
192
+ .describe("Experiment hypothesis. Base hypothesis off the examples from get_defaults. If none are available, use a falsifiable statement about what will happen if the experiment succeeds or fails."),
193
+ valueType: z
194
+ .enum(["string", "number", "boolean", "json"])
195
+ .describe("The value type for all experiment variations"),
196
+ variations: z
197
+ .array(z.object({
198
+ name: z
199
+ .string()
200
+ .describe("Variation name. Base name off the examples from get_defaults. If none are available, use a short, descriptive name that captures the essence of the variation."),
201
+ value: z
202
+ .union([
203
+ z.string(),
204
+ z.number(),
205
+ z.boolean(),
206
+ z.record(z.string(), z.any()),
207
+ ])
208
+ .describe("The value of this variation. Must match the specified valueType: provide actual booleans (true/false) not strings, actual numbers, strings, or valid JSON objects."),
209
+ }))
210
+ .describe('Array of experiment variations. Each has a name (displayed in GrowthBook UI) and value (what users receive). The first variation should be the control/default. Example: [{name: "Control", value: false}, {name: "Treatment", value: true}]'),
211
+ project: z
212
+ .string()
213
+ .describe("The ID of the project to create the experiment in")
214
+ .optional(),
215
+ featureId: featureFlagSchema.id
216
+ .optional()
217
+ .describe("The ID of the feature flag to create the experiment on."),
218
+ fileExtension: z
219
+ .enum(SUPPORTED_FILE_EXTENSIONS)
220
+ .describe("The extension of the current file. If it's unclear, ask the user."),
221
+ confirmedDefaultsReviewed: z
222
+ .boolean()
223
+ .describe("Set to true to confirm you have called get_defaults and reviewed the output to guide these parameters."),
224
+ }),
225
+ annotations: {
226
+ readOnlyHint: false,
227
+ destructiveHint: false,
228
+ },
229
+ }, async ({ description, hypothesis, name, valueType, variations, fileExtension, confirmedDefaultsReviewed, project, featureId, }) => {
299
230
  if (!confirmedDefaultsReviewed) {
300
231
  return {
301
232
  content: [
@@ -335,65 +266,42 @@ export function registerExperimentTools({ server, baseApiUrl, apiKey, appOrigin,
335
266
  });
336
267
  await handleResNotOk(experimentRes);
337
268
  const experimentData = await experimentRes.json();
338
- const flagId = `flag_${name.toLowerCase().replace(/[^a-z0-9]/g, "_")}`;
339
- const flagPayload = {
340
- id: flagId,
341
- owner: user,
342
- defaultValue: stringifyValue(variations[0].value),
343
- valueType: typeof variations[0].value === "string"
344
- ? "string"
345
- : typeof variations[0].value === "number"
346
- ? "number"
347
- : typeof variations[0].value === "boolean"
348
- ? "boolean"
349
- : "json",
350
- description,
351
- environments: {
352
- ...experimentDefaults.environments.reduce((acc, env) => {
353
- acc[env] = {
354
- enabled: false,
355
- rules: [
356
- {
357
- type: "experiment-ref",
358
- experimentId: experimentData.experiment.id,
359
- variations: experimentData.experiment.variations.map((expVariation, idx) => ({
360
- value: stringifyValue(variations[idx].value),
361
- variationId: expVariation.variationId,
362
- })),
363
- },
364
- ],
365
- };
366
- return acc;
367
- }, {}),
368
- },
369
- };
370
- const flagRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/features`, {
371
- method: "POST",
372
- headers: {
373
- Authorization: `Bearer ${apiKey}`,
374
- "Content-Type": "application/json",
375
- },
376
- body: JSON.stringify(flagPayload),
377
- });
378
- await handleResNotOk(flagRes);
379
- const flagData = await flagRes.json();
269
+ let flagData = null;
270
+ if (featureId) {
271
+ // Fetch the existing feature flag first to preserve existing rules
272
+ const existingFeature = await fetchFeatureFlag(baseApiUrl, apiKey, featureId);
273
+ // Create new experiment-ref rule
274
+ const newRule = {
275
+ type: "experiment-ref",
276
+ experimentId: experimentData.experiment.id,
277
+ variations: experimentData.experiment.variations.map((expVariation, idx) => ({
278
+ value: stringifyValue(variations[idx].value),
279
+ variationId: expVariation.variationId,
280
+ })),
281
+ };
282
+ // Merge new rule into existing feature flag
283
+ const flagPayload = mergeRuleIntoFeatureFlag(existingFeature, newRule, experimentDefaults.environments);
284
+ const flagRes = await fetchWithRateLimit(`${baseApiUrl}/api/v1/features/${featureId}`, {
285
+ method: "POST",
286
+ headers: {
287
+ Authorization: `Bearer ${apiKey}`,
288
+ "Content-Type": "application/json",
289
+ },
290
+ body: JSON.stringify(flagPayload),
291
+ });
292
+ await handleResNotOk(flagRes);
293
+ flagData = await flagRes.json();
294
+ }
380
295
  const experimentLink = generateLinkToGrowthBook(appOrigin, "experiment", experimentData.experiment.id);
381
- const flagLink = generateLinkToGrowthBook(appOrigin, "features", flagData.feature.id);
382
296
  const { stub, docs, language } = getDocsMetadata(fileExtension);
383
- const text = `
384
- (To the LLM. It's essential to show the links below to the user. This is the only way they can review and launch the experiment.)
385
- **✅ Your draft experiment \`${name}\` is ready!.** [View the experiment in GrowthBook](${experimentLink}) to review and launch.
386
-
387
- A linked feature flag was also created: \`${flagId}\`. [View the feature flag in GrowthBook](${flagLink}).
388
-
389
- **How to use it in your code:**
390
- (To the LLM agent. GrowthBook uses feature flags to implement experiments. Use the code snippets below to properly update the file to use the new feature flag)
391
-
297
+ const flagText = featureId &&
298
+ `**How to implement the feature flag experiment in your code:**
299
+ ---
392
300
  ${stub}
393
-
301
+ ---
394
302
  **Learn more about implementing experiments in your codebase:**
395
- See the [GrowthBook ${language} docs](${docs})
396
- `;
303
+ See the [GrowthBook ${language} docs](${docs}).`;
304
+ const text = `**✅ Your draft experiment \`${name}\` is ready!.** [View the experiment in GrowthBook](${experimentLink}) to review and launch.\n\n${flagText}`;
397
305
  return {
398
306
  content: [{ type: "text", text }],
399
307
  };
@@ -0,0 +1,134 @@
1
+ export function median(arr) {
2
+ if (arr.length === 0)
3
+ return null;
4
+ const sorted = [...arr].sort((a, b) => a - b);
5
+ const mid = Math.floor(sorted.length / 2);
6
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
7
+ }
8
+ export function round(n, decimals = 4) {
9
+ if (n === null || n === undefined || isNaN(n))
10
+ return null;
11
+ return Math.round(n * 10 ** decimals) / 10 ** decimals;
12
+ }
13
+ export function formatLift(lift) {
14
+ if (lift === null)
15
+ return "N/A";
16
+ const sign = lift >= 0 ? "+" : "";
17
+ return `${sign}${(lift * 100).toFixed(1)}%`;
18
+ }
19
+ export function getYearMonth(dateStr) {
20
+ if (!dateStr)
21
+ return null;
22
+ const d = new Date(dateStr);
23
+ if (isNaN(d.getTime()))
24
+ return null;
25
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
26
+ }
27
+ export function computePrimaryMetricResult(resultData, metricLookup, goalIds) {
28
+ const primaryMetricId = goalIds[0];
29
+ if (!primaryMetricId)
30
+ return null;
31
+ const primaryMetricData = resultData.metrics?.find((m) => m.metricId === primaryMetricId);
32
+ if (!primaryMetricData || primaryMetricData.variations.length <= 1) {
33
+ return null;
34
+ }
35
+ const metricInfo = metricLookup.get(primaryMetricId);
36
+ const isInverse = metricInfo?.inverse ?? false;
37
+ // Find best performing variation (excluding control at index 0)
38
+ let bestVariation = primaryMetricData.variations[1];
39
+ let bestLift = bestVariation?.analyses?.[0]?.percentChange ?? 0;
40
+ for (let i = 2; i < primaryMetricData.variations.length; i++) {
41
+ const v = primaryMetricData.variations[i];
42
+ const lift = v.analyses?.[0]?.percentChange ?? 0;
43
+ const isBetter = isInverse ? lift < bestLift : lift > bestLift;
44
+ if (isBetter) {
45
+ bestVariation = v;
46
+ bestLift = lift;
47
+ }
48
+ }
49
+ const analysis = bestVariation?.analyses?.[0];
50
+ if (!analysis)
51
+ return null;
52
+ const lift = analysis.percentChange;
53
+ const chanceToBeatControl = analysis.chanceToBeatControl;
54
+ let significant = false;
55
+ if (chanceToBeatControl !== undefined) {
56
+ significant = chanceToBeatControl > 0.95 || chanceToBeatControl < 0.05;
57
+ }
58
+ else {
59
+ significant =
60
+ analysis.ciLow !== undefined &&
61
+ analysis.ciHigh !== undefined &&
62
+ (analysis.ciLow > 0 || analysis.ciHigh < 0);
63
+ }
64
+ let direction = "flat";
65
+ if (significant) {
66
+ const rawPositive = lift > 0;
67
+ const isWinning = isInverse ? !rawPositive : rawPositive;
68
+ direction = isWinning ? "winning" : "losing";
69
+ }
70
+ return {
71
+ id: primaryMetricId,
72
+ name: metricInfo?.name || primaryMetricId,
73
+ lift: round(lift),
74
+ significant,
75
+ direction,
76
+ };
77
+ }
78
+ export function computeVerdict(exp, metricLookup) {
79
+ const resultData = exp.result?.results?.[0];
80
+ const srmPValue = resultData?.checks?.srm ?? null;
81
+ const totalUsers = resultData?.totalUsers || 0;
82
+ const srmPassing = srmPValue !== null ? srmPValue > 0.001 : true;
83
+ // Get goal and guardrail metric IDs
84
+ const goalIds = exp.settings?.goals?.map((g) => g.metricId) || [];
85
+ const guardrailIds = new Set(exp.settings?.guardrails?.map((g) => g.metricId) || []);
86
+ // Check guardrail regression
87
+ const guardrailsRegressed = resultData
88
+ ? (resultData.metrics || [])
89
+ .filter((m) => guardrailIds.has(m.metricId))
90
+ .some((m) => {
91
+ const metricInfo = metricLookup.get(m.metricId);
92
+ const isInverse = metricInfo?.inverse ?? false;
93
+ return m.variations.slice(1).some((v) => {
94
+ const analysis = v.analyses?.[0];
95
+ if (!analysis)
96
+ return false;
97
+ if (analysis.chanceToBeatControl !== undefined) {
98
+ return isInverse
99
+ ? analysis.chanceToBeatControl > 0.95
100
+ : analysis.chanceToBeatControl < 0.05;
101
+ }
102
+ return isInverse
103
+ ? (analysis.ciLow ?? 0) > 0
104
+ : (analysis.ciHigh ?? 0) < 0;
105
+ });
106
+ })
107
+ : false;
108
+ // Verdict: Match GrowthBook's ExperimentWinRate.tsx exactly
109
+ // exp.results maps to resultSummary.status in the API
110
+ const userResult = exp.resultSummary?.status?.toLowerCase() || "";
111
+ let verdict;
112
+ if (userResult === "won") {
113
+ verdict = "won";
114
+ }
115
+ else if (userResult === "lost") {
116
+ verdict = "lost";
117
+ }
118
+ else {
119
+ // Everything else is "inconclusive": dnf, inconclusive, undefined, null, ""
120
+ verdict = "inconclusive";
121
+ }
122
+ // Compute primary metric result for display
123
+ const primaryMetricResult = resultData
124
+ ? computePrimaryMetricResult(resultData, metricLookup, goalIds)
125
+ : null;
126
+ return {
127
+ verdict,
128
+ primaryMetricResult,
129
+ guardrailsRegressed,
130
+ srmPassing,
131
+ srmPValue,
132
+ totalUsers,
133
+ };
134
+ }