@configdirector/cli 0.19.0 → 0.21.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.
- package/CHANGELOG.md +23 -0
- package/README.md +40 -8
- package/dist/configdirector.mjs +480 -37
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ All notable changes to `@configdirector/cli` are documented here. The format fol
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.21.0] - 2026-11-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `configdirector metrics list` lists the project's metrics, each an event name your application sends with `track`, with the kinds it reports, its direction and how many experiments use it, and `configdirector metrics get <event name>` shows one metric with each experiment that uses it, its role there, and the environments where a run is in progress. `configdirector metrics create --event-name <event name> --kind <kind> --direction <direction>`, with `--kind` repeatable and an optional `--description`, creates a metric; `configdirector metrics update <event name>` changes its kinds, direction or description; and `configdirector metrics delete <event name>` deletes it and detaches it from its experiments. They exit with 2, listing the experiments, when ConfigDirector refuses to remove a kind an experiment's primary metric decides on, or to delete a metric an experiment with a run in progress uses. `--json` prints the Admin API's response as received, `metrics get --json` the metric as the Admin API listed it for its event name, and `metrics delete --json` `{ "deletedEventName": ... }`.
|
|
12
|
+
- `configdirector configs runs` shows the results of the metrics an experiment measures under each rollout: per metric kind, each variation's value against the control, with the lift and the range it is likely in, the chance to beat control, the chance to be best with three or more variations, and the verdict, likely better, likely worse or no clear difference. It says when a rollout needs more exposures before the variations can be compared and how many contexts were exposed to more than one variation, that results can be up to 25 minutes behind on a running run, and when an ended run's final results were taken. Each run also shows its hypothesis and the note it ended with, and lists the events sent with `track` during the run that no metric measures. `--json` prints the Admin API's response as received, as before.
|
|
13
|
+
- `configdirector configs ship` and `configs stop` take `--note <text>`, up to 1,000 characters on why, which the ended run keeps.
|
|
14
|
+
- `configdirector configs create` takes `--hypothesis <text>`, what an experiment is expected to do, which each of its runs keeps as it was when the run started.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- `configdirector configs copy --help` lists the hypothesis among what a copy gets.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `configdirector configs runs` describes the evaluations it leaves out of the exposures as coming from anonymous contexts: in a server SDK, a context without an identifier skips the split and is served the default targeting rule value, and any other anonymous context sees its share of the split without being stored. It said every such evaluation was served the default targeting rule value.
|
|
23
|
+
|
|
24
|
+
## [0.20.0] - 2026-11-06
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- `configdirector evaluate` says when a percentage rollout was skipped because the context has no identifier. The rule's line ends with `rollout skipped: the context has no identifier`, where it showed `not matched` with no reason, both for a percentage rule and for a conditional rule that serves a percentage rollout once its conditions matched. The rules after it show `not evaluated` and the context gets the default targeting rule value, as before. `--json` prints the Admin API's response as received, as before.
|
|
29
|
+
|
|
7
30
|
## [0.19.0] - 2026-11-04
|
|
8
31
|
|
|
9
32
|
### Added
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ConfigDirector CLI
|
|
2
2
|
|
|
3
|
-
`configdirector` is the command line tool for [ConfigDirector](https://www.configdirector.com), the managed configuration and feature flag service. Commands and output may change before 1.0. This release signs in to one or more organizations, links a repository to a project, lists where the repository's code reads config keys, checks those keys against the project's configs, shows the value a context gets for a config and why, reads a project's environments, configs, targeting rules and
|
|
3
|
+
`configdirector` is the command line tool for [ConfigDirector](https://www.configdirector.com), the managed configuration and feature flag service. Commands and output may change before 1.0. This release signs in to one or more organizations, links a repository to a project, lists where the repository's code reads config keys, checks those keys against the project's configs, shows the value a context gets for a config and why, reads a project's environments, configs, targeting rules, segments and metrics, creates and copies configs, replaces a config's targeting rules in an environment, lists an experiment's runs with their results and ships or stops them, archives configs, creates, updates and deletes segments and metrics, uploads the repository's code references to the project, and lists and removes the project's scanned repositories.
|
|
4
4
|
|
|
5
5
|
The [CLI page of the ConfigDirector docs](https://docs.configdirector.com/integrations/cli) covers the API token, CI setup for GitHub Actions and GitLab, and what each problem `validate` reports means for your build.
|
|
6
6
|
|
|
@@ -159,7 +159,7 @@ true, served by rule 2
|
|
|
159
159
|
Default targeting rule: false
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
-
A rule that did not match names the first condition that failed and the context's value for it, as in `plan is one of pro, team, but it is "free"`, or the bucket a percentage rollout put the context in. A rule after the one that served the value is not evaluated, and a rule that could not be evaluated is shown as errored.
|
|
162
|
+
A rule that did not match names the first condition that failed and the context's value for it, as in `plan is one of pro, team, but it is "free"`, or the bucket a percentage rollout put the context in. A context without an identifier gets no bucket: the first percentage rollout it reaches shows `rollout skipped: the context has no identifier`, and the context gets the default targeting rule value. A rule after the one that served the value, or after a skipped rollout, is not evaluated, and a rule that could not be evaluated is shown as errored.
|
|
163
163
|
|
|
164
164
|
`--environment` takes the environment's slug and is required. Give the context one of three ways:
|
|
165
165
|
|
|
@@ -181,6 +181,8 @@ configdirector configs get <key>
|
|
|
181
181
|
configdirector configs activity <key>
|
|
182
182
|
configdirector segments list
|
|
183
183
|
configdirector segments get <key>
|
|
184
|
+
configdirector metrics list
|
|
185
|
+
configdirector metrics get <event name>
|
|
184
186
|
```
|
|
185
187
|
|
|
186
188
|
`projects list` lists your organization's projects with their slugs and names, whichever project is selected. The other commands read the project from `--project`, `CONFIGDIRECTOR_PROJECT` or `configdirector.json`, as `scan` does:
|
|
@@ -190,6 +192,7 @@ configdirector segments get <key>
|
|
|
190
192
|
- `configs get <key>` shows the config's settings, including its type's constraints, then each environment's targeting rules in order, written as `evaluate` writes them, with the default targeting rule value last. An old key works, and the output then starts with a line naming the current key.
|
|
191
193
|
- `configs activity <key>` shows when SDKs last evaluated the config, one line per environment, SDK key kind, SDK name and SDK version, with the time in UTC.
|
|
192
194
|
- `segments list` lists the segments with how many configs use each, and `segments get <key>` shows a segment's condition groups and its environment overrides.
|
|
195
|
+
- `metrics list` lists the metrics with their kinds, their direction and how many experiments use each, and `metrics get <event name>` shows a metric's kinds and direction, and each experiment that uses it, with its role and the environments of its runs in progress.
|
|
193
196
|
|
|
194
197
|
```txt
|
|
195
198
|
new-checkout
|
|
@@ -213,11 +216,11 @@ production (live)
|
|
|
213
216
|
|
|
214
217
|
On an experiment, each value comes after the name of its variation, as in `40% get control (Start free trial), 60% get treatment-a (Try it free for 14 days)`. `evaluate` names the variation it served the same way, as in `treatment-a (Try it free for 14 days), served by rule 2`, and `configs create` names the variation of the default targeting rule value it starts with.
|
|
215
218
|
|
|
216
|
-
`projects list`, `environments list`, `configs list`, `configs activity` and `
|
|
219
|
+
`projects list`, `environments list`, `configs list`, `configs activity`, `segments list` and `metrics list` print a header row that names their columns above their lines, such as `KEY TYPE ROLE LIFETIME STATE` for `configs list`, and `--no-headers` leaves it out. An empty list prints only a sentence, such as `No configs found in web-app.`
|
|
217
220
|
|
|
218
|
-
With `--json`, each prints the Admin API's response as received, except `configs list --state`, which prints only the configs in that state. A key no config or segment has exits with 2.
|
|
221
|
+
With `--json`, each prints the Admin API's response as received, except `configs list --state`, which prints only the configs in that state, and `metrics get`, which prints the metric as the Admin API listed it for its event name. A key no config or segment has, or an event name no metric has, exits with 2.
|
|
219
222
|
|
|
220
|
-
`projects list` needs `projects:read`. The others need `projects:read` to find the project, and `environments list` needs `environments:read`, `configs list`, `configs get` and `configs activity` need `configs:read`, plus `tags:read` for `--tag`,
|
|
223
|
+
`projects list` needs `projects:read`. The others need `projects:read` to find the project, and `environments list` needs `environments:read`, `configs list`, `configs get` and `configs activity` need `configs:read`, plus `tags:read` for `--tag`, `segments list` and `segments get` need `segments:read`, and `metrics list` and `metrics get` need `metrics:read`. Without `segments:read`, `configs get` shows a segment condition's segment ID instead of its name; without `environments:read`, `segments get` names an override's environment by its ID instead of its slug, and `metrics get` a run's environment.
|
|
221
224
|
|
|
222
225
|
## Create a config
|
|
223
226
|
|
|
@@ -234,7 +237,7 @@ Default targeting rule in every environment: false
|
|
|
234
237
|
|
|
235
238
|
- `--key`, `--type` (`boolean`, `string`, `integer`, `float`, `timespan`, `enum`, `url` or `json`), `--role` (`config`, `flag`, `kill-switch` or `experiment`) and `--default-value` are required. `--default-value` is the default targeting rule value every environment starts with, not the in-code default value an application passes to the SDK.
|
|
236
239
|
- `--lifetime` (`temporary` or `permanent`) is required for the `config` role. The role decides the lifetime of the others, so you can leave it out: a feature flag and an experiment are always temporary, and a kill switch is always permanent. A lifetime the role does not have is a usage error.
|
|
237
|
-
- `--description <text>`; `--client-only` or `--server-only`, without which client and server SDKs both receive the config;
|
|
240
|
+
- `--description <text>`; `--client-only` or `--server-only`, without which client and server SDKs both receive the config; `--variation <name=value>`, repeatable, for an experiment's variations; and `--hypothesis <text>`, what an experiment is expected to do, which each of its runs keeps as it was when the run started.
|
|
238
241
|
- `--tag <name>`, repeatable, names a tag the project already has, and `--new-tag <name>`, repeatable, creates a tag with that name in the project's tag catalog. The config gets the tags of both, at most 20 in all. Every `--tag` name is looked up before any tag is created, so a misspelled `--tag` creates nothing; a name no tag has, a `--new-tag` name a tag already has, a `--new-tag` name given twice and a name given with both options exit with 2. When a later step fails, such as a key that is taken, the tags already created stay in the catalog, and the command names them so you can pass them with `--tag` next time.
|
|
239
242
|
- The values of `--default-value` and `--variation` are read in the config's type: `true` or `false`, a number for an integer, float or timespan, the text as written for a string, enum or URL, and JSON for a JSON config, with `@file` reading it from a file. A value that does not fit the type is a usage error.
|
|
240
243
|
- Constraints, each accepted only by its types: `--min` and `--max` (a value may equal them) for an integer, float or timespan; `--exclusive-min` and `--exclusive-max` for an integer or float; `--unit` (`milliseconds`, `seconds` or `minutes`) and `--allow-fractions` for a timespan, which needs `--unit`, `--min` and `--max`; `--allowed-value`, repeatable, and `--enum-value-type string|number` (default `string`) for an enum; `--https-only` for a URL, without which any HTTP or HTTPS URL is accepted; and `--json-schema <file>` for a JSON config. An option of another type is a usage error naming the types it belongs to. A timespan without `--unit`, `--min` or `--max` is a usage error too.
|
|
@@ -326,22 +329,33 @@ configdirector configs ship checkout-cta-copy --environment production --variati
|
|
|
326
329
|
configdirector configs stop checkout-cta-copy --environment production
|
|
327
330
|
```
|
|
328
331
|
|
|
329
|
-
A run is a period during which an experiment's targeting rules in one environment split traffic between its variations. `configs runs` lists the experiment's runs in an environment, newest first, with how many distinct contexts each variation reached, called exposures, against the share configured when the run started:
|
|
332
|
+
A run is a period during which an experiment's targeting rules in one environment split traffic between its variations. `configs runs` lists the experiment's runs in an environment, newest first, with how many distinct contexts each variation reached, called exposures, against the share configured when the run started, and the results of the metrics the experiment measures:
|
|
330
333
|
|
|
331
334
|
```txt
|
|
332
335
|
Runs of checkout-cta-copy in production (live), newest first:
|
|
333
336
|
|
|
334
337
|
Running since 2026-11-03 12:00 UTC
|
|
338
|
+
Hypothesis: Urgency copy raises checkout completion by 5%
|
|
335
339
|
Rollout: everyone not caught by a rule above
|
|
336
340
|
control (Start free trial) 412 exposures configured 50%
|
|
337
341
|
treatment-a (Try it free for 14 days) 398 exposures configured 50%
|
|
338
342
|
810 contexts exposed, 3201 evaluations. The split matches the configured shares.
|
|
343
|
+
Primary metric: checkout_completed, conversion, higher is better
|
|
344
|
+
control (Start free trial) 2.9% control
|
|
345
|
+
treatment-a (Try it free for 14 days) 4.5% +55% (+12% to +98%) >99% chance to beat control likely better
|
|
346
|
+
Guardrail: support_ticket_opened, count per context, lower is better
|
|
347
|
+
control (Start free trial) 0.12 control
|
|
348
|
+
treatment-a (Try it free for 14 days) 0.13 +8% (-10% to +29%) 22% chance to beat control no clear difference
|
|
349
|
+
Events with no metric: add_to_cart (340 contexts)
|
|
350
|
+
Results can be up to 25 minutes behind.
|
|
339
351
|
```
|
|
340
352
|
|
|
353
|
+
Each kind a metric reports gets a line with the metric's role, its event name, the kind and its direction, then one row per variation, the control first: its value, and for every other variation the lift against the control with the range it is likely in, the chance to beat control and the verdict, `likely better`, `likely worse` or `no clear difference`. With three or more variations, each row also shows the variation's chance to be best. Until every variation has enough exposures, the rollout says the run needs more exposures and shows the values alone. An ended run's last line says when its final results were taken, once they are in. A run also shows its hypothesis and the note it ended with, and lists the events sent with `track` during the run that no metric measures.
|
|
354
|
+
|
|
341
355
|
- `configs ship` serves one variation to everyone in the environment and ends the run. `--variation` takes its name or its ID. The default targeting rule value becomes the variation's value, every percentage rollout is removed, including one a conditional rule serves, and a conditional rule serving a single value stays.
|
|
342
356
|
- `configs stop` ends the run without choosing a variation: every percentage rollout is removed, including one a conditional rule serves, and the default targeting rule value and any conditional rule serving a single value stay.
|
|
343
357
|
|
|
344
|
-
Both print the rules they saved, ask for no confirmation and treat a live environment like any other, so check the environment before you run them.
|
|
358
|
+
Both take `--note <text>`, up to 1,000 characters on why, which the ended run keeps. Both print the rules they saved, ask for no confirmation and treat a live environment like any other, so check the environment before you run them.
|
|
345
359
|
|
|
346
360
|
`configs ship` exits with 2 when `--variation` matches none of the experiment's variations, or several with the same name, and lists them with their IDs, and when the config has no variations, since only an experiment has them. `configs stop` exits with 2 when the config is not an experiment or the environment holds no percentage rollout. All three exit with 2 when no config has the key or no environment has the slug. With `--json`, they print the Admin API's response as received.
|
|
347
361
|
|
|
@@ -400,6 +414,24 @@ configdirector segments delete beta-testers
|
|
|
400
414
|
|
|
401
415
|
`segments create` needs `projects:read` and `segments:create`, `segments update` needs `projects:read`, `segments:read` and `segments:update`, and `segments delete` needs `projects:read` and `segments:delete`. Without `environments:read`, they name environments by ID instead of by slug. The change shows in the audit log with the source CLI.
|
|
402
416
|
|
|
417
|
+
## Change metrics
|
|
418
|
+
|
|
419
|
+
```bash
|
|
420
|
+
configdirector metrics create --event-name checkout_completed --kind conversion --kind value-per-context --direction higher-is-better
|
|
421
|
+
configdirector metrics update checkout_completed --direction lower-is-better
|
|
422
|
+
configdirector metrics delete checkout_completed
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
A metric is what an experiment measures: an event name the application sends with the SDK's `track` call, the numbers it reports and whether a higher or a lower number is better. A project has one metric per event name, and the commands name a metric by its event name. Attaching metrics to an experiment happens on its Settings tab in the dashboard, or through the Admin API or the MCP server.
|
|
426
|
+
|
|
427
|
+
- `metrics create` needs `--event-name`, exactly as the application passes it to `track`; `--kind`, repeatable: `conversion`, `count-per-context` or `value-per-context`; and `--direction`: `higher-is-better` or `lower-is-better`. `--description <text>` is optional. It prints `Created metric checkout_completed in web-app.`, then the metric as `metrics get` shows it.
|
|
428
|
+
- `metrics update <event name>` changes what its options name: `--kind`, repeated, replaces the kinds, and `--direction` and `--description` replace theirs; `--description ""` removes the description. A metric keeps its event name. Removing a kind an experiment's primary metric decides on is refused, and the command exits with 2 listing those experiments.
|
|
429
|
+
- `metrics delete <event name>` deletes the metric and detaches it from the experiments that use it, and prints `Deleted metric checkout_completed from web-app.`; `--json` prints `{ "deletedEventName": "checkout_completed" }` with the event name as given. While an experiment that uses the metric has a run in progress, it is not deleted: the command exits with 2 and lists each experiment with the environments of its runs.
|
|
430
|
+
|
|
431
|
+
None of them asks for confirmation. An event name no metric has exits with 2 for `update` and `delete`, and so does a metric ConfigDirector refuses, such as a second metric for an event name. With `--json`, `create` and `update` print the metric as the Admin API returned it.
|
|
432
|
+
|
|
433
|
+
`metrics create` needs `projects:read` and `metrics:create`, `metrics update` needs `projects:read`, `metrics:read` and `metrics:update`, and `metrics delete` needs `projects:read`, `metrics:read` and `metrics:delete`. Without `environments:read`, `metrics update` and `metrics delete` name environments by ID instead of by slug. The change shows in the audit log with the source CLI.
|
|
434
|
+
|
|
403
435
|
## Archive a config
|
|
404
436
|
|
|
405
437
|
```bash
|
package/dist/configdirector.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { setDefaultAutoSelectFamilyAttemptTimeout } from "node:net";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { Command, CommanderError, Option } from "commander";
|
|
4
|
+
import { Command, CommanderError, InvalidArgumentError, Option } from "commander";
|
|
5
5
|
import { setTimeout } from "node:timers/promises";
|
|
6
6
|
import * as v from "valibot";
|
|
7
7
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
@@ -14,7 +14,7 @@ import { promisify } from "node:util";
|
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
15
15
|
import { text } from "node:stream/consumers";
|
|
16
16
|
//#region package.json
|
|
17
|
-
var version = "0.
|
|
17
|
+
var version = "0.21.0";
|
|
18
18
|
//#endregion
|
|
19
19
|
//#region src/CliError.ts
|
|
20
20
|
var CliError = class extends Error {
|
|
@@ -134,11 +134,50 @@ const RolloutExposuresSchema = v.object({
|
|
|
134
134
|
exposures: v.number()
|
|
135
135
|
}))
|
|
136
136
|
});
|
|
137
|
+
const VariationResultSchema = v.object({
|
|
138
|
+
variationId: v.string(),
|
|
139
|
+
value: v.number(),
|
|
140
|
+
lift: v.optional(v.number()),
|
|
141
|
+
interval: v.optional(v.object({
|
|
142
|
+
low: v.number(),
|
|
143
|
+
high: v.number()
|
|
144
|
+
})),
|
|
145
|
+
chanceToBeatControl: v.optional(v.number()),
|
|
146
|
+
chanceToBeBest: v.optional(v.number()),
|
|
147
|
+
verdict: v.optional(v.string())
|
|
148
|
+
});
|
|
149
|
+
const MetricKindResultSchema = v.object({
|
|
150
|
+
kind: v.string(),
|
|
151
|
+
rollouts: v.array(v.object({
|
|
152
|
+
ruleId: v.string(),
|
|
153
|
+
variations: v.array(VariationResultSchema)
|
|
154
|
+
}))
|
|
155
|
+
});
|
|
156
|
+
const MetricResultSchema = v.object({
|
|
157
|
+
eventName: v.string(),
|
|
158
|
+
direction: v.string(),
|
|
159
|
+
kinds: v.array(MetricKindResultSchema)
|
|
160
|
+
});
|
|
161
|
+
const RolloutResultsSchema = v.object({
|
|
162
|
+
ruleId: v.string(),
|
|
163
|
+
controlVariationId: v.string(),
|
|
164
|
+
state: v.string(),
|
|
165
|
+
crossovers: v.number()
|
|
166
|
+
});
|
|
167
|
+
const ResultsSchema = v.object({
|
|
168
|
+
asOf: v.optional(TimestampSchema),
|
|
169
|
+
rollouts: v.array(RolloutResultsSchema),
|
|
170
|
+
primary: v.array(MetricResultSchema),
|
|
171
|
+
secondary: v.array(MetricResultSchema),
|
|
172
|
+
guardrail: v.array(MetricResultSchema)
|
|
173
|
+
});
|
|
137
174
|
const ExperimentRunSchema = v.object({
|
|
138
175
|
startedAt: TimestampSchema,
|
|
139
176
|
endedAt: v.optional(TimestampSchema),
|
|
140
177
|
outcome: v.optional(v.string()),
|
|
141
178
|
shippedVariationId: v.optional(v.string()),
|
|
179
|
+
hypothesis: v.optional(v.string()),
|
|
180
|
+
note: v.optional(v.string()),
|
|
142
181
|
rollouts: v.array(v.object({
|
|
143
182
|
ruleId: v.string(),
|
|
144
183
|
conditions: v.optional(v.string())
|
|
@@ -147,10 +186,48 @@ const ExperimentRunSchema = v.object({
|
|
|
147
186
|
rollouts: v.array(RolloutExposuresSchema),
|
|
148
187
|
evaluationsWithoutIdentifier: v.number(),
|
|
149
188
|
evaluationsFromSdksNotReportingExposures: v.number()
|
|
150
|
-
})
|
|
189
|
+
}),
|
|
190
|
+
trackedEvents: v.object({ events: v.array(v.object({
|
|
191
|
+
name: v.string(),
|
|
192
|
+
contexts: v.number()
|
|
193
|
+
})) }),
|
|
194
|
+
results: ResultsSchema
|
|
151
195
|
});
|
|
152
196
|
const ExperimentRunsSchema = v.array(ExperimentRunSchema);
|
|
153
197
|
//#endregion
|
|
198
|
+
//#region src/admin-api/metrics.ts
|
|
199
|
+
const OpenRunsSchema = v.array(v.object({ environmentId: v.string() }));
|
|
200
|
+
const MetricUsageSchema = v.object({
|
|
201
|
+
configKey: v.string(),
|
|
202
|
+
role: v.string(),
|
|
203
|
+
primaryKind: v.nullable(v.string()),
|
|
204
|
+
openRuns: OpenRunsSchema
|
|
205
|
+
});
|
|
206
|
+
const MetricSchema = v.object({
|
|
207
|
+
id: v.string(),
|
|
208
|
+
eventName: v.string(),
|
|
209
|
+
description: v.nullable(v.string()),
|
|
210
|
+
direction: v.string(),
|
|
211
|
+
kinds: v.array(v.string()),
|
|
212
|
+
usages: v.array(MetricUsageSchema)
|
|
213
|
+
});
|
|
214
|
+
const DecidingExperimentSchema = v.object({
|
|
215
|
+
configKey: v.string(),
|
|
216
|
+
primaryKind: v.string()
|
|
217
|
+
});
|
|
218
|
+
const MetricKindInUseBodySchema = v.object({
|
|
219
|
+
code: v.literal("METRIC_KIND_IN_USE"),
|
|
220
|
+
usages: v.array(DecidingExperimentSchema)
|
|
221
|
+
});
|
|
222
|
+
const ExperimentWithOpenRunsSchema = v.object({
|
|
223
|
+
configKey: v.string(),
|
|
224
|
+
openRuns: OpenRunsSchema
|
|
225
|
+
});
|
|
226
|
+
const MetricInUseBodySchema = v.object({
|
|
227
|
+
code: v.literal("METRIC_IN_USE"),
|
|
228
|
+
usages: v.array(ExperimentWithOpenRunsSchema)
|
|
229
|
+
});
|
|
230
|
+
//#endregion
|
|
154
231
|
//#region src/admin-api/config-creation.ts
|
|
155
232
|
const CreatedConfigSchema = v.object({
|
|
156
233
|
key: v.string(),
|
|
@@ -399,7 +476,6 @@ const ConditionExplanationSchema = v.union([
|
|
|
399
476
|
SegmentConditionExplanationSchema
|
|
400
477
|
]);
|
|
401
478
|
const BucketSchema = v.object({
|
|
402
|
-
defaultedToPredefinedBucket: v.boolean(),
|
|
403
479
|
assignedPercentage: v.number(),
|
|
404
480
|
shares: v.array(v.object({
|
|
405
481
|
percentageId: v.string(),
|
|
@@ -417,7 +493,8 @@ const RuleExplanationSchema = v.object({
|
|
|
417
493
|
"errored"
|
|
418
494
|
]),
|
|
419
495
|
conditions: v.array(ConditionExplanationSchema),
|
|
420
|
-
bucket: v.optional(BucketSchema)
|
|
496
|
+
bucket: v.optional(BucketSchema),
|
|
497
|
+
rolloutSkippedNoIdentifier: v.boolean()
|
|
421
498
|
});
|
|
422
499
|
const ConfigEvaluationSchema = v.object({
|
|
423
500
|
key: v.string(),
|
|
@@ -547,6 +624,28 @@ const segmentDeletionOutcomeOf = (received) => {
|
|
|
547
624
|
if (!received.response.ok) throw failureFor(received.url, received.response, received.body);
|
|
548
625
|
return { outcome: "deleted" };
|
|
549
626
|
};
|
|
627
|
+
const isMetricKindInUse = (received) => received.response.status === 409 && v.is(MetricKindInUseBodySchema, received.body);
|
|
628
|
+
const metricUpdateOutcomeOf = (received) => {
|
|
629
|
+
if (isMissingResource(received)) return { outcome: "not-found" };
|
|
630
|
+
if (isMetricKindInUse(received)) return {
|
|
631
|
+
outcome: "kind-in-use",
|
|
632
|
+
decidingExperiments: received.body.usages
|
|
633
|
+
};
|
|
634
|
+
return {
|
|
635
|
+
outcome: "updated",
|
|
636
|
+
metric: parsedResponseOf(received, MetricSchema)
|
|
637
|
+
};
|
|
638
|
+
};
|
|
639
|
+
const isMetricInUse = (received) => received.response.status === 409 && v.is(MetricInUseBodySchema, received.body);
|
|
640
|
+
const metricDeletionOutcomeOf = (received) => {
|
|
641
|
+
if (isMissingResource(received)) return { outcome: "not-found" };
|
|
642
|
+
if (isMetricInUse(received)) return {
|
|
643
|
+
outcome: "in-use",
|
|
644
|
+
experimentsWithOpenRuns: received.body.usages
|
|
645
|
+
};
|
|
646
|
+
if (!received.response.ok) throw failureFor(received.url, received.response, received.body);
|
|
647
|
+
return { outcome: "deleted" };
|
|
648
|
+
};
|
|
550
649
|
const parsedResponseOf = ({ url, response, body }, schema) => {
|
|
551
650
|
if (!response.ok) throw failureFor(url, response, body);
|
|
552
651
|
const parsedBody = v.safeParse(schema, body);
|
|
@@ -604,6 +703,33 @@ var AdminApiClient = class {
|
|
|
604
703
|
async deleteSegment(projectId, segmentKey) {
|
|
605
704
|
return segmentDeletionOutcomeOf(await this.send(`/v1/projects/${encodeURIComponent(projectId)}/segments/${encodeURIComponent(segmentKey)}`, { method: "DELETE" }));
|
|
606
705
|
}
|
|
706
|
+
listMetrics(projectId) {
|
|
707
|
+
return this.get(`/v1/projects/${encodeURIComponent(projectId)}/metrics`, v.array(MetricSchema));
|
|
708
|
+
}
|
|
709
|
+
async findMetricByEventName(projectId, eventName) {
|
|
710
|
+
const { parsedBody: metrics, responseBody } = await this.get(`/v1/projects/${encodeURIComponent(projectId)}/metrics?${new URLSearchParams({ eventName })}`, v.array(MetricSchema));
|
|
711
|
+
const index = metrics.findIndex((metric) => metric.eventName === eventName);
|
|
712
|
+
const metric = metrics[index];
|
|
713
|
+
return metric === void 0 ? void 0 : {
|
|
714
|
+
parsedBody: metric,
|
|
715
|
+
responseBody: v.parse(v.array(v.unknown()), responseBody)[index]
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
createMetric(projectId, request) {
|
|
719
|
+
return this.requestJson(`/v1/projects/${encodeURIComponent(projectId)}/metrics`, MetricSchema, {
|
|
720
|
+
method: "POST",
|
|
721
|
+
body: request
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
async updateMetric(projectId, metricId, request) {
|
|
725
|
+
return metricUpdateOutcomeOf(await this.send(`/v1/projects/${encodeURIComponent(projectId)}/metrics/${encodeURIComponent(metricId)}`, {
|
|
726
|
+
method: "PATCH",
|
|
727
|
+
body: request
|
|
728
|
+
}));
|
|
729
|
+
}
|
|
730
|
+
async deleteMetric(projectId, metricId) {
|
|
731
|
+
return metricDeletionOutcomeOf(await this.send(`/v1/projects/${encodeURIComponent(projectId)}/metrics/${encodeURIComponent(metricId)}`, { method: "DELETE" }));
|
|
732
|
+
}
|
|
607
733
|
async findTagByName(projectId, name) {
|
|
608
734
|
return (await this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/tags/by-name?${new URLSearchParams({ name })}`, TagSchema))?.parsedBody;
|
|
609
735
|
}
|
|
@@ -628,16 +754,16 @@ var AdminApiClient = class {
|
|
|
628
754
|
listRuns(projectId, configKey, environmentId) {
|
|
629
755
|
return this.getIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/runs`, ExperimentRunsSchema);
|
|
630
756
|
}
|
|
631
|
-
shipVariation(projectId, configKey, environmentId,
|
|
757
|
+
shipVariation(projectId, configKey, environmentId, request) {
|
|
632
758
|
return this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/ship`, ConfigWithTargetingRulesSchema, {
|
|
633
759
|
method: "POST",
|
|
634
|
-
body:
|
|
760
|
+
body: request
|
|
635
761
|
});
|
|
636
762
|
}
|
|
637
|
-
stopRun(projectId, configKey, environmentId) {
|
|
763
|
+
stopRun(projectId, configKey, environmentId, request) {
|
|
638
764
|
return this.requestIfFound(`/v1/projects/${encodeURIComponent(projectId)}/configs/${encodeURIComponent(configKey)}/environments/${encodeURIComponent(environmentId)}/stop`, ConfigWithTargetingRulesSchema, {
|
|
639
765
|
method: "POST",
|
|
640
|
-
body:
|
|
766
|
+
body: request
|
|
641
767
|
});
|
|
642
768
|
}
|
|
643
769
|
async evaluateConfig(projectId, configKey, request) {
|
|
@@ -1065,6 +1191,11 @@ const describeCondition = (condition, segments) => {
|
|
|
1065
1191
|
};
|
|
1066
1192
|
const valueWithVariationName = (value, variationName) => variationName ? `${variationName} (${String(value)})` : String(value);
|
|
1067
1193
|
const describeVariation = (variation) => valueWithVariationName(variation.value, variation.name);
|
|
1194
|
+
const describeVariationById = (variationId, variations) => {
|
|
1195
|
+
const variation = variations.find((candidate) => candidate.id === variationId);
|
|
1196
|
+
return variation && describeVariation(variation);
|
|
1197
|
+
};
|
|
1198
|
+
const describeVariationOrNoLongerListed = (variationId, variations) => describeVariationById(variationId, variations) ?? "a variation no longer listed";
|
|
1068
1199
|
const describeValue = (value, variationId, variations) => valueWithVariationName(value, variations.find((variation) => variation.id === variationId)?.name);
|
|
1069
1200
|
const describePercentages = (percentages, variations) => percentages.map((percentage) => `${formatPercentage(percentage.percentage)}% get ${describeValue(percentage.value, percentage.variationId, variations)}`).join(", ");
|
|
1070
1201
|
const describeRule = (rule, segments, variations) => {
|
|
@@ -1444,6 +1575,7 @@ const newConfigOf = async (options, workingDirectory) => {
|
|
|
1444
1575
|
typeOptions: await CONFIG_TYPE_RULES[options.type].typeOptionsOf(options, workingDirectory),
|
|
1445
1576
|
...availabilityOf(options),
|
|
1446
1577
|
variations: await variationsOf(options, workingDirectory),
|
|
1578
|
+
hypothesis: options.hypothesis,
|
|
1447
1579
|
defaultValue: await typedValueOf(options.type, {
|
|
1448
1580
|
argumentLabel: "--default-value",
|
|
1449
1581
|
text: options.defaultValue,
|
|
@@ -1452,6 +1584,170 @@ const newConfigOf = async (options, workingDirectory) => {
|
|
|
1452
1584
|
};
|
|
1453
1585
|
};
|
|
1454
1586
|
//#endregion
|
|
1587
|
+
//#region src/metrics/metric-words.ts
|
|
1588
|
+
const METRIC_KINDS = [
|
|
1589
|
+
"conversion",
|
|
1590
|
+
"count-per-context",
|
|
1591
|
+
"value-per-context"
|
|
1592
|
+
];
|
|
1593
|
+
const METRIC_DIRECTIONS = ["higher-is-better", "lower-is-better"];
|
|
1594
|
+
const METRIC_ROLES = [
|
|
1595
|
+
"primary",
|
|
1596
|
+
"secondary",
|
|
1597
|
+
"guardrail"
|
|
1598
|
+
];
|
|
1599
|
+
const isOneOf = (values, candidate) => values.includes(candidate);
|
|
1600
|
+
const isMetricKind = (kind) => isOneOf(METRIC_KINDS, kind);
|
|
1601
|
+
const KIND_WORDS = {
|
|
1602
|
+
conversion: "conversion",
|
|
1603
|
+
"count-per-context": "count per context",
|
|
1604
|
+
"value-per-context": "value per context"
|
|
1605
|
+
};
|
|
1606
|
+
const DIRECTION_WORDS = {
|
|
1607
|
+
"higher-is-better": "higher is better",
|
|
1608
|
+
"lower-is-better": "lower is better"
|
|
1609
|
+
};
|
|
1610
|
+
const ROLE_WORDS = {
|
|
1611
|
+
primary: "primary metric",
|
|
1612
|
+
secondary: "secondary metric",
|
|
1613
|
+
guardrail: "guardrail"
|
|
1614
|
+
};
|
|
1615
|
+
const kindWords = (kind) => isMetricKind(kind) ? KIND_WORDS[kind] : kind;
|
|
1616
|
+
const directionWords = (direction) => isOneOf(METRIC_DIRECTIONS, direction) ? DIRECTION_WORDS[direction] : direction;
|
|
1617
|
+
const roleWords = (role) => isOneOf(METRIC_ROLES, role) ? ROLE_WORDS[role] : role;
|
|
1618
|
+
//#endregion
|
|
1619
|
+
//#region src/configs/run-results-text.ts
|
|
1620
|
+
const VERDICT_WORDS = {
|
|
1621
|
+
"likely-better": "likely better",
|
|
1622
|
+
"likely-worse": "likely worse",
|
|
1623
|
+
"no-clear-difference": "no clear difference"
|
|
1624
|
+
};
|
|
1625
|
+
const formatAverage = (value) => value.toLocaleString("en-US", {
|
|
1626
|
+
minimumFractionDigits: 2,
|
|
1627
|
+
maximumFractionDigits: 2
|
|
1628
|
+
});
|
|
1629
|
+
const VALUE_FORMATS = {
|
|
1630
|
+
conversion: (value) => `${(value * 100).toFixed(1)}%`,
|
|
1631
|
+
"count-per-context": formatAverage,
|
|
1632
|
+
"value-per-context": formatAverage
|
|
1633
|
+
};
|
|
1634
|
+
const formatResultValue = (kind, value) => isMetricKind(kind) ? VALUE_FORMATS[kind](value) : formatAverage(value);
|
|
1635
|
+
const formatLift = (lift) => {
|
|
1636
|
+
const wholePercent = Math.round(lift * 100);
|
|
1637
|
+
if (wholePercent > 0) return `+${wholePercent.toLocaleString("en-US")}%`;
|
|
1638
|
+
if (wholePercent < 0) return `${wholePercent.toLocaleString("en-US")}%`;
|
|
1639
|
+
return "0%";
|
|
1640
|
+
};
|
|
1641
|
+
const formatChance = (chance) => {
|
|
1642
|
+
const wholePercent = Math.round(chance * 100);
|
|
1643
|
+
if (wholePercent < 1) return "<1%";
|
|
1644
|
+
if (wholePercent > 99) return ">99%";
|
|
1645
|
+
return `${wholePercent}%`;
|
|
1646
|
+
};
|
|
1647
|
+
const liftCell = (variation) => {
|
|
1648
|
+
if (variation.lift === void 0) return "-";
|
|
1649
|
+
if (variation.interval === void 0) return formatLift(variation.lift);
|
|
1650
|
+
return `${formatLift(variation.lift)} (${formatLift(variation.interval.low)} to ${formatLift(variation.interval.high)})`;
|
|
1651
|
+
};
|
|
1652
|
+
const chanceCell = (chance, description) => chance === void 0 ? "" : `${formatChance(chance)} ${description}`;
|
|
1653
|
+
const comparisonOf = (rollout) => rollout.state === "needs-more-exposures" ? "needs-more-exposures" : "variations-compared";
|
|
1654
|
+
const resultRowFormOf = (variation, table) => {
|
|
1655
|
+
if (variation.variationId === table.controlVariationId) return "control";
|
|
1656
|
+
return table.comparison === "needs-more-exposures" ? "value-alone" : "compared";
|
|
1657
|
+
};
|
|
1658
|
+
const resultRow = (variation, table) => {
|
|
1659
|
+
const nameAndValue = [describeVariationOrNoLongerListed(variation.variationId, table.variations), formatResultValue(table.kind, variation.value)];
|
|
1660
|
+
const chanceToBeBestCells = table.showsChanceToBeBest ? [chanceCell(variation.chanceToBeBest, "chance to be best")] : [];
|
|
1661
|
+
switch (resultRowFormOf(variation, table)) {
|
|
1662
|
+
case "control": return [
|
|
1663
|
+
...nameAndValue,
|
|
1664
|
+
"control",
|
|
1665
|
+
"",
|
|
1666
|
+
...chanceToBeBestCells
|
|
1667
|
+
];
|
|
1668
|
+
case "value-alone": return nameAndValue;
|
|
1669
|
+
case "compared": return [
|
|
1670
|
+
...nameAndValue,
|
|
1671
|
+
liftCell(variation),
|
|
1672
|
+
chanceCell(variation.chanceToBeatControl, "chance to beat control"),
|
|
1673
|
+
...chanceToBeBestCells,
|
|
1674
|
+
variation.verdict === void 0 ? "" : VERDICT_WORDS[variation.verdict] ?? variation.verdict
|
|
1675
|
+
];
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
const capitalized = (text) => `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
1679
|
+
const kindResultLines = (role, metric, kindResult, rollout, variations) => {
|
|
1680
|
+
const variationResults = kindResult.rollouts.find((candidate) => candidate.ruleId === rollout.ruleId)?.variations;
|
|
1681
|
+
if (variationResults === void 0) return [];
|
|
1682
|
+
const table = {
|
|
1683
|
+
kind: kindResult.kind,
|
|
1684
|
+
controlVariationId: rollout.controlVariationId,
|
|
1685
|
+
comparison: comparisonOf(rollout),
|
|
1686
|
+
variations,
|
|
1687
|
+
showsChanceToBeBest: variationResults.some((variation) => variation.chanceToBeBest !== void 0)
|
|
1688
|
+
};
|
|
1689
|
+
return [`${capitalized(roleWords(role))}: ${metric.eventName}, ${kindWords(kindResult.kind)}, ${directionWords(metric.direction)}`, ...alignedColumns(variationResults.map((variation) => resultRow(variation, table))).map((line) => ` ${line}`.trimEnd())];
|
|
1690
|
+
};
|
|
1691
|
+
const crossoverLines = (crossovers) => {
|
|
1692
|
+
if (crossovers === 0) return [];
|
|
1693
|
+
return [crossovers === 1 ? "1 context was exposed to more than one variation and is left out of every metric." : `${crossovers} contexts were exposed to more than one variation and are left out of every metric.`];
|
|
1694
|
+
};
|
|
1695
|
+
const comparisonLines = (comparison) => {
|
|
1696
|
+
switch (comparison) {
|
|
1697
|
+
case "variations-compared": return [];
|
|
1698
|
+
case "needs-more-exposures": return ["The run needs more exposures before the variations can be compared."];
|
|
1699
|
+
}
|
|
1700
|
+
};
|
|
1701
|
+
const runResultsOf = (run) => METRIC_ROLES.some((role) => run.results[role].length > 0) ? {
|
|
1702
|
+
form: "measured",
|
|
1703
|
+
results: run.results
|
|
1704
|
+
} : { form: "no-metrics" };
|
|
1705
|
+
const rolloutResultLines = (runResults, ruleId, variations) => {
|
|
1706
|
+
switch (runResults.form) {
|
|
1707
|
+
case "no-metrics": return [];
|
|
1708
|
+
case "measured": {
|
|
1709
|
+
const { results } = runResults;
|
|
1710
|
+
const rollout = results.rollouts.find((candidate) => candidate.ruleId === ruleId);
|
|
1711
|
+
if (rollout === void 0) return [];
|
|
1712
|
+
return [
|
|
1713
|
+
...comparisonLines(comparisonOf(rollout)),
|
|
1714
|
+
...crossoverLines(rollout.crossovers),
|
|
1715
|
+
...METRIC_ROLES.flatMap((role) => results[role].flatMap((metric) => metric.kinds.flatMap((kindResult) => kindResultLines(role, metric, kindResult, rollout, variations))))
|
|
1716
|
+
];
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
const measuredEventNamesOf = (runResults) => {
|
|
1721
|
+
switch (runResults.form) {
|
|
1722
|
+
case "no-metrics": return /* @__PURE__ */ new Set();
|
|
1723
|
+
case "measured": return new Set(METRIC_ROLES.flatMap((role) => runResults.results[role].map((metric) => metric.eventName)));
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
const eventsWithoutAMetricLines = (run, runResults) => {
|
|
1727
|
+
const measuredEventNames = measuredEventNamesOf(runResults);
|
|
1728
|
+
const events = run.trackedEvents.events.filter((event) => !measuredEventNames.has(event.name));
|
|
1729
|
+
if (events.length === 0) return [];
|
|
1730
|
+
return [`Events with no metric: ${events.map((event) => `${event.name} (${event.contexts} ${plural(event.contexts, "context", "contexts")})`).join(", ")}`];
|
|
1731
|
+
};
|
|
1732
|
+
const decideResultsFreshness = (run, runResults) => {
|
|
1733
|
+
if (runResults.form === "no-metrics") return { form: "no-results" };
|
|
1734
|
+
if (run.endedAt === void 0) return { form: "running" };
|
|
1735
|
+
if (runResults.results.asOf === void 0) return { form: "ended-before-final-results" };
|
|
1736
|
+
return {
|
|
1737
|
+
form: "final",
|
|
1738
|
+
asOf: runResults.results.asOf
|
|
1739
|
+
};
|
|
1740
|
+
};
|
|
1741
|
+
const resultsFreshnessLines = (run, runResults) => {
|
|
1742
|
+
const freshness = decideResultsFreshness(run, runResults);
|
|
1743
|
+
switch (freshness.form) {
|
|
1744
|
+
case "no-results": return [];
|
|
1745
|
+
case "running": return ["Results can be up to 25 minutes behind."];
|
|
1746
|
+
case "final": return [`Final results, as of ${utcMinute(freshness.asOf)}, when the run ended.`];
|
|
1747
|
+
case "ended-before-final-results": return [];
|
|
1748
|
+
}
|
|
1749
|
+
};
|
|
1750
|
+
//#endregion
|
|
1455
1751
|
//#region src/configs/run-text.ts
|
|
1456
1752
|
const OUTCOME_LABELS = {
|
|
1457
1753
|
shipped: "Shipped",
|
|
@@ -1463,36 +1759,40 @@ const VERDICT_SENTENCES = {
|
|
|
1463
1759
|
mismatch: "The split does not match the configured shares. This usually means some contexts evaluate without a stable identifier, or a rule above the split catches part of the traffic. Results from this run are not reliable.",
|
|
1464
1760
|
"needs-more-exposures": "The run needs more exposures before the split can be checked against the configured shares."
|
|
1465
1761
|
};
|
|
1466
|
-
const variationDescription = (variationId, variations) => {
|
|
1467
|
-
const variation = variations.find((candidate) => candidate.id === variationId);
|
|
1468
|
-
return variation && describeVariation(variation);
|
|
1469
|
-
};
|
|
1470
1762
|
const runHeadline = (run, variations) => {
|
|
1471
1763
|
if (run.outcome === void 0 || run.endedAt === void 0) return `Running since ${utcMinute(run.startedAt)}`;
|
|
1472
1764
|
const outcomeLabel = OUTCOME_LABELS[run.outcome] ?? run.outcome;
|
|
1473
|
-
const shippedVariation = run.shippedVariationId &&
|
|
1765
|
+
const shippedVariation = run.shippedVariationId && describeVariationById(run.shippedVariationId, variations);
|
|
1474
1766
|
return `${shippedVariation ? `${outcomeLabel} ${shippedVariation}` : outcomeLabel}, ${utcMinute(run.startedAt)} to ${utcMinute(run.endedAt)}`;
|
|
1475
1767
|
};
|
|
1476
1768
|
const rolloutAudience = (run, rollout) => run.rollouts.find((candidate) => candidate.ruleId === rollout.ruleId)?.conditions ?? "everyone not caught by a rule above";
|
|
1477
1769
|
const rolloutSummary = (rollout) => `${rollout.exposures} ${plural(rollout.exposures, "context exposed", "contexts exposed")}, ${rollout.evaluations} ${plural(rollout.evaluations, "evaluation", "evaluations")}. ` + (VERDICT_SENTENCES[rollout.verdict] ?? rollout.verdict);
|
|
1478
|
-
const rolloutLines = (run, rollout, variations) => [
|
|
1770
|
+
const rolloutLines = (run, runResults, rollout, variations) => [
|
|
1479
1771
|
`Rollout: ${rolloutAudience(run, rollout)}`,
|
|
1480
1772
|
...alignedColumns(rollout.variations.map((variation) => [
|
|
1481
|
-
|
|
1773
|
+
describeVariationOrNoLongerListed(variation.variationId, variations),
|
|
1482
1774
|
`${variation.exposures} ${plural(variation.exposures, "exposure", "exposures")}`,
|
|
1483
1775
|
`configured ${formatPercentage(variation.percentage)}%`
|
|
1484
1776
|
])).map((line) => ` ${line}`),
|
|
1485
|
-
` ${rolloutSummary(rollout)}
|
|
1777
|
+
` ${rolloutSummary(rollout)}`,
|
|
1778
|
+
...rolloutResultLines(runResults, rollout.ruleId, variations).map((line) => ` ${line}`)
|
|
1486
1779
|
];
|
|
1487
1780
|
const uncountedEvaluationsLines = (evaluations, one, several) => {
|
|
1488
1781
|
if (evaluations === 0) return [];
|
|
1489
1782
|
return [evaluations === 1 ? `1 evaluation ${one}` : `${evaluations} evaluations ${several}`];
|
|
1490
1783
|
};
|
|
1491
|
-
const runLines = (run, variations) =>
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1784
|
+
const runLines = (run, variations) => {
|
|
1785
|
+
const runResults = runResultsOf(run);
|
|
1786
|
+
return [runHeadline(run, variations), ...[
|
|
1787
|
+
...run.hypothesis === void 0 ? [] : [`Hypothesis: ${run.hypothesis}`],
|
|
1788
|
+
...run.note === void 0 ? [] : [`Ended with note: ${run.note}`],
|
|
1789
|
+
...run.exposures.rollouts.flatMap((rollout) => rolloutLines(run, runResults, rollout, variations)),
|
|
1790
|
+
...uncountedEvaluationsLines(run.exposures.evaluationsFromSdksNotReportingExposures, "came from an SDK version that does not report exposures yet. It is not counted above; update that SDK to count it.", "came from SDK versions that do not report exposures yet. They are not counted above; update those SDKs to count them."),
|
|
1791
|
+
...uncountedEvaluationsLines(run.exposures.evaluationsWithoutIdentifier, "came from an anonymous context, so it is not an exposure. In a server SDK, a context without an identifier skips the split and is served the default targeting rule value; any other anonymous context sees its share of the split without being stored.", "came from anonymous contexts, so they are not exposures. In a server SDK, a context without an identifier skips the split and is served the default targeting rule value; any other anonymous context sees its share of the split without being stored."),
|
|
1792
|
+
...eventsWithoutAMetricLines(run, runResults),
|
|
1793
|
+
...resultsFreshnessLines(run, runResults)
|
|
1794
|
+
].map((line) => ` ${line}`)];
|
|
1795
|
+
};
|
|
1496
1796
|
const runsText = (config, requestedKey, environment, runs) => {
|
|
1497
1797
|
const environmentName = environmentLabel(environment);
|
|
1498
1798
|
return [
|
|
@@ -1530,6 +1830,10 @@ const findEnvironmentBySlug = async (client, project, slug) => {
|
|
|
1530
1830
|
if (environment === void 0) throw new CliError(`No environment in ${project.slug} has the slug "${slug}". ${environmentSlugsSentence(environments)}`);
|
|
1531
1831
|
return environment;
|
|
1532
1832
|
};
|
|
1833
|
+
const environmentSlugsById = async (client, project) => {
|
|
1834
|
+
const environments = await ifPermitted(client.listEnvironments(project.id));
|
|
1835
|
+
return new Map((environments?.parsedBody ?? []).map((environment) => [environment.id, environment.slug]));
|
|
1836
|
+
};
|
|
1533
1837
|
//#endregion
|
|
1534
1838
|
//#region src/project-selection.ts
|
|
1535
1839
|
const requireProjectSlug = (context) => {
|
|
@@ -1804,13 +2108,17 @@ const showConfigRuns = async (context, [configKey], options) => {
|
|
|
1804
2108
|
exitCode: ExitCode.success
|
|
1805
2109
|
};
|
|
1806
2110
|
};
|
|
2111
|
+
const runEndRequestOf = (options) => options.note === void 0 ? {} : { note: options.note };
|
|
1807
2112
|
const shipVariation = async (context, [configKey], options) => {
|
|
1808
2113
|
const { client, project } = await connectToLinkedProject(context);
|
|
1809
2114
|
const environment = await findEnvironmentBySlug(client, project, options.environment);
|
|
1810
2115
|
const receivedConfig = await client.getConfig(project.id, configKey);
|
|
1811
2116
|
if (receivedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1812
2117
|
const variation = findVariationToShip(receivedConfig.parsedBody, options.variation);
|
|
1813
|
-
const savedConfig = await client.shipVariation(project.id, configKey, environment.id,
|
|
2118
|
+
const savedConfig = await client.shipVariation(project.id, configKey, environment.id, {
|
|
2119
|
+
variationId: variation.id,
|
|
2120
|
+
...runEndRequestOf(options)
|
|
2121
|
+
});
|
|
1814
2122
|
if (savedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1815
2123
|
const segments = await segmentNamesFor(client, project, savedConfig.parsedBody);
|
|
1816
2124
|
return {
|
|
@@ -1827,7 +2135,7 @@ const shipVariation = async (context, [configKey], options) => {
|
|
|
1827
2135
|
const stopRun = async (context, [configKey], options) => {
|
|
1828
2136
|
const { client, project } = await connectToLinkedProject(context);
|
|
1829
2137
|
const environment = await findEnvironmentBySlug(client, project, options.environment);
|
|
1830
|
-
const savedConfig = await client.stopRun(project.id, configKey, environment.id);
|
|
2138
|
+
const savedConfig = await client.stopRun(project.id, configKey, environment.id, runEndRequestOf(options));
|
|
1831
2139
|
if (savedConfig === void 0) throw new CliError(`No config in ${project.slug} has the key "${configKey}".`);
|
|
1832
2140
|
const segments = await segmentNamesFor(client, project, savedConfig.parsedBody);
|
|
1833
2141
|
return {
|
|
@@ -2009,8 +2317,7 @@ const bucketPlacement = (bucket) => {
|
|
|
2009
2317
|
if (selectedShare !== void 0) return `${assigned} is in ${describeShareRange(selectedShare.from, selectedShare.to)}`;
|
|
2010
2318
|
return `${assigned} is outside ${describeShareRange(0, bucket.shares.at(-1)?.to ?? 0)}`;
|
|
2011
2319
|
};
|
|
2012
|
-
const
|
|
2013
|
-
const withBucket = (outcome, bucket) => bucket === void 0 ? outcome : `${outcome}: ${describeBucket(bucket)}`;
|
|
2320
|
+
const withBucket = (outcome, bucket) => bucket === void 0 ? outcome : `${outcome}: ${bucketPlacement(bucket)}`;
|
|
2014
2321
|
const conditionsOf = (rule) => rule?.type === "conditional" ? rule.conditions : [];
|
|
2015
2322
|
const segmentMismatchEvidence = (explanation) => {
|
|
2016
2323
|
if (!explanation.segmentFound) return "the segment was not found";
|
|
@@ -2032,17 +2339,19 @@ const describeNotMatched = (explanation, conditions, segments) => {
|
|
|
2032
2339
|
const failingCondition = failingConditionDescription(explanation, conditions, segments);
|
|
2033
2340
|
return failingCondition === void 0 ? withBucket("not matched", explanation.bucket) : `not matched: ${failingCondition}`;
|
|
2034
2341
|
};
|
|
2342
|
+
const evaluatorRuleOutcomeOf = (explanation) => explanation.rolloutSkippedNoIdentifier ? "rollout-skipped-no-identifier" : explanation.outcome;
|
|
2035
2343
|
const RULE_OUTCOME_DESCRIPTIONS = {
|
|
2036
2344
|
matched: (explanation) => withBucket("matched", explanation.bucket),
|
|
2037
2345
|
"not-matched": describeNotMatched,
|
|
2038
2346
|
"not-evaluated": () => "not evaluated",
|
|
2039
|
-
errored: () => "errored"
|
|
2347
|
+
errored: () => "errored",
|
|
2348
|
+
"rollout-skipped-no-identifier": () => "rollout skipped: the context has no identifier"
|
|
2040
2349
|
};
|
|
2041
2350
|
const ruleLines = (evaluation) => {
|
|
2042
2351
|
const rulesById = new Map(evaluation.targetingRules.rules.map((rule) => [rule.id, rule]));
|
|
2043
2352
|
return alignedColumns(evaluation.rules.map((explanation, index) => {
|
|
2044
2353
|
const rule = rulesById.get(explanation.ruleId);
|
|
2045
|
-
return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments, evaluation.variations)}`, RULE_OUTCOME_DESCRIPTIONS[explanation
|
|
2354
|
+
return [`${index + 1}. ${rule === void 0 ? explanation.ruleId : describeRule(rule, evaluation.segments, evaluation.variations)}`, RULE_OUTCOME_DESCRIPTIONS[evaluatorRuleOutcomeOf(explanation)](explanation, conditionsOf(rule), evaluation.segments)];
|
|
2046
2355
|
}));
|
|
2047
2356
|
};
|
|
2048
2357
|
const evaluationText = (evaluation, requested) => [
|
|
@@ -2279,6 +2588,134 @@ const logout = async (context) => {
|
|
|
2279
2588
|
};
|
|
2280
2589
|
};
|
|
2281
2590
|
//#endregion
|
|
2591
|
+
//#region src/metrics/metric-changes.ts
|
|
2592
|
+
const metricChangesOf = (options) => {
|
|
2593
|
+
const changes = {
|
|
2594
|
+
...options.kind === void 0 ? {} : { kinds: options.kind },
|
|
2595
|
+
...options.direction === void 0 ? {} : { direction: options.direction },
|
|
2596
|
+
...options.description === void 0 ? {} : { description: options.description }
|
|
2597
|
+
};
|
|
2598
|
+
if (Object.keys(changes).length === 0) throw new CliError("Say what to change with --kind, --direction or --description.");
|
|
2599
|
+
return changes;
|
|
2600
|
+
};
|
|
2601
|
+
//#endregion
|
|
2602
|
+
//#region src/metrics/metric-text.ts
|
|
2603
|
+
const openRunEnvironmentNames = (openRuns, environmentSlugs) => openRuns.map((run) => environmentSlugs.get(run.environmentId) ?? run.environmentId).join(", ");
|
|
2604
|
+
const usageLine$1 = (usage, environmentSlugs) => {
|
|
2605
|
+
const primaryKind = usage.primaryKind === null ? "" : ` on ${kindWords(usage.primaryKind)}`;
|
|
2606
|
+
const runningIn = usage.openRuns.length === 0 ? "" : `, running in ${openRunEnvironmentNames(usage.openRuns, environmentSlugs)}`;
|
|
2607
|
+
return ` ${usage.configKey}: ${roleWords(usage.role)}${primaryKind}${runningIn}`;
|
|
2608
|
+
};
|
|
2609
|
+
const usageLines = (metric, environmentSlugs) => metric.usages.length === 0 ? ["No experiment uses it."] : ["Used by", ...metric.usages.map((usage) => usageLine$1(usage, environmentSlugs))];
|
|
2610
|
+
const metricText = (metric, environmentSlugs) => [
|
|
2611
|
+
metric.eventName,
|
|
2612
|
+
...metric.description === null ? [] : [` Description: ${metric.description}`],
|
|
2613
|
+
` Kinds: ${metric.kinds.map(kindWords).join(", ")}`,
|
|
2614
|
+
` Direction: ${directionWords(metric.direction)}`,
|
|
2615
|
+
"",
|
|
2616
|
+
...usageLines(metric, environmentSlugs),
|
|
2617
|
+
""
|
|
2618
|
+
].join("\n");
|
|
2619
|
+
//#endregion
|
|
2620
|
+
//#region src/metrics/new-metric.ts
|
|
2621
|
+
const newMetricOf = (options) => ({
|
|
2622
|
+
eventName: options.eventName,
|
|
2623
|
+
kinds: options.kind,
|
|
2624
|
+
direction: options.direction,
|
|
2625
|
+
...options.description === void 0 ? {} : { description: options.description }
|
|
2626
|
+
});
|
|
2627
|
+
//#endregion
|
|
2628
|
+
//#region src/commands/metrics.ts
|
|
2629
|
+
const usageDescription$1 = (metric) => `used by ${metric.usages.length} ${plural(metric.usages.length, "experiment", "experiments")}`;
|
|
2630
|
+
const METRIC_HEADER_ROW = [
|
|
2631
|
+
"EVENT NAME",
|
|
2632
|
+
"KINDS",
|
|
2633
|
+
"DIRECTION",
|
|
2634
|
+
"USED BY"
|
|
2635
|
+
];
|
|
2636
|
+
const metricLines = (projectSlug, metrics, options) => metrics.length === 0 ? [`No metrics found in ${projectSlug}.`] : alignedListing(METRIC_HEADER_ROW, metrics.map((metric) => [
|
|
2637
|
+
metric.eventName,
|
|
2638
|
+
metric.kinds.map(kindWords).join(", "),
|
|
2639
|
+
directionWords(metric.direction),
|
|
2640
|
+
usageDescription$1(metric)
|
|
2641
|
+
]), options);
|
|
2642
|
+
const listMetrics = async (context, _commandArguments, options) => {
|
|
2643
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
2644
|
+
const { parsedBody: metrics, responseBody } = await client.listMetrics(project.id);
|
|
2645
|
+
return {
|
|
2646
|
+
json: responseBody,
|
|
2647
|
+
text: [...metricLines(project.slug, metrics, options), ""].join("\n"),
|
|
2648
|
+
exitCode: ExitCode.success
|
|
2649
|
+
};
|
|
2650
|
+
};
|
|
2651
|
+
const openRunEnvironmentSlugsOf = async (client, project, metric) => metric.usages.some((usage) => usage.openRuns.length > 0) ? environmentSlugsById(client, project) : /* @__PURE__ */ new Map();
|
|
2652
|
+
const noMetricWithEventName = (project, eventName) => new CliError(`No metric in ${project.slug} has the event name "${eventName}".`);
|
|
2653
|
+
const getMetric = async (context, [eventName]) => {
|
|
2654
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
2655
|
+
const receivedMetric = await client.findMetricByEventName(project.id, eventName);
|
|
2656
|
+
if (receivedMetric === void 0) throw noMetricWithEventName(project, eventName);
|
|
2657
|
+
const { parsedBody: metric, responseBody } = receivedMetric;
|
|
2658
|
+
return {
|
|
2659
|
+
json: responseBody,
|
|
2660
|
+
text: metricText(metric, await openRunEnvironmentSlugsOf(client, project, metric)),
|
|
2661
|
+
exitCode: ExitCode.success
|
|
2662
|
+
};
|
|
2663
|
+
};
|
|
2664
|
+
const createMetric = async (context, _commandArguments, options) => {
|
|
2665
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
2666
|
+
const { parsedBody: metric, responseBody } = await client.createMetric(project.id, newMetricOf(options));
|
|
2667
|
+
return {
|
|
2668
|
+
json: responseBody,
|
|
2669
|
+
text: [`Created metric ${metric.eventName} in ${project.slug}.`, metricText(metric, /* @__PURE__ */ new Map())].join("\n"),
|
|
2670
|
+
exitCode: ExitCode.success
|
|
2671
|
+
};
|
|
2672
|
+
};
|
|
2673
|
+
const kindInUseError = (eventName, experiments) => new CliError([
|
|
2674
|
+
`${experiments.length} ${plural(experiments.length, "experiment decides", "experiments decide")} on a kind this change removes, so ${eventName} was not updated:`,
|
|
2675
|
+
...experiments.map((experiment) => ` ${experiment.configKey} decides on ${kindWords(experiment.primaryKind)}`),
|
|
2676
|
+
`Give ${plural(experiments.length, "it", "each of them")} another primary kind first, on the experiment's Settings tab in the dashboard.`
|
|
2677
|
+
].join("\n"));
|
|
2678
|
+
const updateMetric = async (context, [eventName], options) => {
|
|
2679
|
+
const changes = metricChangesOf(options);
|
|
2680
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
2681
|
+
const currentMetric = await client.findMetricByEventName(project.id, eventName);
|
|
2682
|
+
if (currentMetric === void 0) throw noMetricWithEventName(project, eventName);
|
|
2683
|
+
const update = await client.updateMetric(project.id, currentMetric.parsedBody.id, changes);
|
|
2684
|
+
switch (update.outcome) {
|
|
2685
|
+
case "not-found": throw noMetricWithEventName(project, eventName);
|
|
2686
|
+
case "kind-in-use": throw kindInUseError(eventName, update.decidingExperiments);
|
|
2687
|
+
case "updated": {
|
|
2688
|
+
const { parsedBody: metric, responseBody } = update.metric;
|
|
2689
|
+
return {
|
|
2690
|
+
json: responseBody,
|
|
2691
|
+
text: [`Updated metric ${eventName} in ${project.slug}.`, metricText(metric, await openRunEnvironmentSlugsOf(client, project, metric))].join("\n"),
|
|
2692
|
+
exitCode: ExitCode.success
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
};
|
|
2697
|
+
const experimentWithOpenRunsLine = (experiment, environmentSlugs) => ` ${experiment.configKey}, running in ${openRunEnvironmentNames(experiment.openRuns, environmentSlugs)}`;
|
|
2698
|
+
const metricInUseError = (eventName, experiments, environmentSlugs) => new CliError([
|
|
2699
|
+
`${experiments.length} ${plural(experiments.length, "experiment", "experiments")} with a run in progress ${plural(experiments.length, "uses", "use")} ${eventName}, so it was not deleted:`,
|
|
2700
|
+
...experiments.map((experiment) => experimentWithOpenRunsLine(experiment, environmentSlugs)),
|
|
2701
|
+
"Ship or stop each run first, with configdirector configs ship or configs stop, or detach the metric on the experiment's Settings tab in the dashboard."
|
|
2702
|
+
].join("\n"));
|
|
2703
|
+
const deleteMetric = async (context, [eventName]) => {
|
|
2704
|
+
const { client, project } = await connectToLinkedProject(context);
|
|
2705
|
+
const currentMetric = await client.findMetricByEventName(project.id, eventName);
|
|
2706
|
+
if (currentMetric === void 0) throw noMetricWithEventName(project, eventName);
|
|
2707
|
+
const deletion = await client.deleteMetric(project.id, currentMetric.parsedBody.id);
|
|
2708
|
+
switch (deletion.outcome) {
|
|
2709
|
+
case "deleted": return {
|
|
2710
|
+
json: { deletedEventName: eventName },
|
|
2711
|
+
text: [`Deleted metric ${eventName} from ${project.slug}.`, ""].join("\n"),
|
|
2712
|
+
exitCode: ExitCode.success
|
|
2713
|
+
};
|
|
2714
|
+
case "not-found": throw noMetricWithEventName(project, eventName);
|
|
2715
|
+
case "in-use": throw metricInUseError(eventName, deletion.experimentsWithOpenRuns, await environmentSlugsById(client, project));
|
|
2716
|
+
}
|
|
2717
|
+
};
|
|
2718
|
+
//#endregion
|
|
2282
2719
|
//#region src/commands/projects.ts
|
|
2283
2720
|
const PROJECT_HEADER_ROW = ["SLUG", "NAME"];
|
|
2284
2721
|
const projectLines = (projects, options) => projects.length === 0 ? ["No projects found."] : alignedListing(PROJECT_HEADER_ROW, projects.map((project) => [project.slug, project.name]), options);
|
|
@@ -5033,10 +5470,6 @@ const listSegments = async (context, _commandArguments, options) => {
|
|
|
5033
5470
|
exitCode: ExitCode.success
|
|
5034
5471
|
};
|
|
5035
5472
|
};
|
|
5036
|
-
const environmentSlugsById = async (client, project) => {
|
|
5037
|
-
const environments = await ifPermitted(client.listEnvironments(project.id));
|
|
5038
|
-
return new Map((environments?.parsedBody ?? []).map((environment) => [environment.id, environment.slug]));
|
|
5039
|
-
};
|
|
5040
5473
|
const overrideEnvironmentSlugsOf = async (client, project, segment) => Object.keys(segment.overrides).length === 0 ? /* @__PURE__ */ new Map() : environmentSlugsById(client, project);
|
|
5041
5474
|
const noSegmentWithKey = (project, segmentKey) => new CliError(`No segment in ${project.slug} has the key "${segmentKey}".`);
|
|
5042
5475
|
const getSegment = async (context, [segmentKey]) => {
|
|
@@ -5384,6 +5817,10 @@ const withoutTrailingSlashes = (url) => {
|
|
|
5384
5817
|
return trimmedUrl;
|
|
5385
5818
|
};
|
|
5386
5819
|
const repeatedOption = (value, values) => [...values ?? [], value];
|
|
5820
|
+
const repeatableChoiceOption = (flags, description, choices) => new Option(flags, description).choices(choices).argParser((value, values) => {
|
|
5821
|
+
if (!choices.includes(value)) throw new InvalidArgumentError(`Allowed choices are ${choices.join(", ")}.`);
|
|
5822
|
+
return repeatedOption(value, values);
|
|
5823
|
+
});
|
|
5387
5824
|
const runCommand = async (invocation, command, actionArguments) => {
|
|
5388
5825
|
const parsedCommand = actionArguments.at(-1);
|
|
5389
5826
|
const options = parsedCommand.optsWithGlobals();
|
|
@@ -5428,11 +5865,11 @@ const run = async (invocation) => {
|
|
|
5428
5865
|
configsCommand.command("list").description("list the project's configs with their type, role, lifetime and lifecycle state").option("--tag <name>", "list only the configs that have this tag; repeatable, for configs with any of them", repeatedOption).addOption(new Option("--untagged", "list only the configs without tags").conflicts("tag")).addOption(new Option("--state <state>", "list only the configs in this lifecycle state").choices(LIFECYCLE_STATES)).option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listConfigs));
|
|
5429
5866
|
configsCommand.command("get").argument("<key>", "the config's key, or an old key of it").description("show a config's settings and its targeting rules in every environment").action(commandAction(getConfig));
|
|
5430
5867
|
configsCommand.command("activity").argument("<key>", "the config's key, or an old key of it").description("show when SDKs last evaluated a config, per environment, SDK key kind, SDK name and SDK version").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(showConfigActivity));
|
|
5431
|
-
configsCommand.command("create").description("create a config from options that mirror the Admin API's fields").requiredOption("--key <key>", "the config's key (required)").option("--description <text>", "a description shown with the config in the dashboard").addOption(new Option("--type <type>", "the type of the config's values (required)").choices(CONFIG_TYPES).makeOptionMandatory()).addOption(new Option("--role <role>", "what the config is for (required)").choices(CONFIG_ROLES).makeOptionMandatory()).addOption(new Option("--lifetime <lifetime>", "whether the config is expected to be removed; required with --role config, while a feature flag and an experiment are always temporary and a kill switch always permanent").choices(CONFIG_LIFETIMES)).requiredOption("--default-value <value>", "the default targeting rule value every environment starts with, in the type given by --type; not the in-code default value an application passes to the SDK (required)").option("--client-only", "serve the config to client SDKs only (default: client and server SDKs)").addOption(new Option("--server-only", "serve the config to server SDKs only (default: client and server SDKs)").conflicts("clientOnly")).option("--tag <name>", "assign the project's tag with this name; repeatable", repeatedOption).option("--new-tag <name>", "create a tag with this name in the project's tag catalog and assign it; repeatable", repeatedOption).option("--variation <name=value>", "a variation of an experiment, its value in the type given by --type; repeatable", repeatedOption).addOption(new Option("--min <number>", "the smallest value allowed, included").helpGroup(typeConstraintHelpGroup("min"))).addOption(new Option("--max <number>", "the largest value allowed, included").helpGroup(typeConstraintHelpGroup("max"))).addOption(new Option("--exclusive-min <number>", "values must be greater than this number").conflicts("min").helpGroup(typeConstraintHelpGroup("exclusiveMin"))).addOption(new Option("--exclusive-max <number>", "values must be less than this number").conflicts("max").helpGroup(typeConstraintHelpGroup("exclusiveMax"))).addOption(new Option("--unit <unit>", "the unit of the number the SDKs receive (required)").choices(TIMESPAN_UNITS).helpGroup(typeConstraintHelpGroup("unit"))).addOption(new Option("--allow-fractions", "allow values with a fraction of the unit").helpGroup(typeConstraintHelpGroup("allowFractions"))).addOption(new Option("--allowed-value <value>", "a value the config can take; repeatable").argParser(repeatedOption).helpGroup(typeConstraintHelpGroup("allowedValue"))).addOption(new Option("--enum-value-type <type>", "whether the allowed values are strings or numbers (default: string)").choices(ENUM_VALUE_TYPES).helpGroup(typeConstraintHelpGroup("enumValueType"))).addOption(new Option("--https-only", "allow only HTTPS URLs").helpGroup(typeConstraintHelpGroup("httpsOnly"))).addOption(new Option("--json-schema <file>", "a file holding the JSON Schema every value must match").helpGroup(typeConstraintHelpGroup("jsonSchema"))).action(commandAction(createConfig));
|
|
5432
|
-
configsCommand.command("copy").argument("<key>", "the key of the config to copy, or an old key of it").description("copy a config under a new key, in its project or in another project of the organization, and print the plan").requiredOption("--key <key>", "the copy's key (required)").option("--to <project slug>", "slug of the project to copy the config into, another project of the organization (default: the config's project)").option("--environment <destination slug>=<source slug>", "the source environment whose targeting rules an environment of the destination project starts with, needed for each destination environment with no source environment of its slug; repeatable", repeatedOption).option("--dry-run", "print the plan without creating anything").addHelpText("after", "\nThe copy gets the config's role, lifetime, type, constraints, variations, SDK availability, description and tags, and every environment's targeting rules and default targeting rule value. It starts as a new config, with no old keys, evaluations or code references, and shares nothing with the config it came from. An archived config cannot be copied; restore it first. In another project, environments are matched by slug, tags by name and segments by key: a source environment with no environment of its slug there is skipped, and an environment there with no source environment of its slug needs --environment. A targeting rule that uses a segment the other project doesn't have is dropped. A tag it doesn't have is created when the API token can create tags, and skipped otherwise. The plan names every skipped environment, dropped rule and tag; --dry-run shows it without creating anything.").action(commandAction(copyConfig));
|
|
5433
|
-
configsCommand.command("runs").argument("<key>", "the experiment's key, or an old key of it").description("list an experiment's runs in an environment with their exposures, newest first").requiredOption("--environment <slug>", "slug of the environment to list the runs of (required)").action(commandAction(showConfigRuns));
|
|
5434
|
-
configsCommand.command("ship").argument("<key>", "the experiment's key, or an old key of it").description("serve one variation of an experiment to everyone in an environment, ending its run").requiredOption("--environment <slug>", "slug of the environment to ship the variation in (required)").requiredOption("--variation <variation>", "the name or id of the variation to ship (required)").addHelpText("after", "\nIn one targeting rules save, the default targeting rule value becomes the variation's value, every percentage rollout is removed, including one a conditional rule serves, and a conditional rule serving a single value stays. It asks for no confirmation and treats a live environment like any other.").action(commandAction(shipVariation));
|
|
5435
|
-
configsCommand.command("stop").argument("<key>", "the experiment's key, or an old key of it").description("end an experiment's run in an environment without choosing a variation").requiredOption("--environment <slug>", "slug of the environment to stop the run in (required)").addHelpText("after", "\nIn one targeting rules save, every percentage rollout is removed, including one a conditional rule serves, the default targeting rule value stays, and a conditional rule serving a single value stays. It asks for no confirmation and treats a live environment like any other. When the config is not an experiment, or no percentage rollout is saved in the environment, there is no run to stop, and the command exits with 2.").action(commandAction(stopRun));
|
|
5868
|
+
configsCommand.command("create").description("create a config from options that mirror the Admin API's fields").requiredOption("--key <key>", "the config's key (required)").option("--description <text>", "a description shown with the config in the dashboard").addOption(new Option("--type <type>", "the type of the config's values (required)").choices(CONFIG_TYPES).makeOptionMandatory()).addOption(new Option("--role <role>", "what the config is for (required)").choices(CONFIG_ROLES).makeOptionMandatory()).addOption(new Option("--lifetime <lifetime>", "whether the config is expected to be removed; required with --role config, while a feature flag and an experiment are always temporary and a kill switch always permanent").choices(CONFIG_LIFETIMES)).requiredOption("--default-value <value>", "the default targeting rule value every environment starts with, in the type given by --type; not the in-code default value an application passes to the SDK (required)").option("--client-only", "serve the config to client SDKs only (default: client and server SDKs)").addOption(new Option("--server-only", "serve the config to server SDKs only (default: client and server SDKs)").conflicts("clientOnly")).option("--tag <name>", "assign the project's tag with this name; repeatable", repeatedOption).option("--new-tag <name>", "create a tag with this name in the project's tag catalog and assign it; repeatable", repeatedOption).option("--variation <name=value>", "a variation of an experiment, its value in the type given by --type; repeatable", repeatedOption).option("--hypothesis <text>", "what an experiment is expected to do; each run keeps the hypothesis it started with").addOption(new Option("--min <number>", "the smallest value allowed, included").helpGroup(typeConstraintHelpGroup("min"))).addOption(new Option("--max <number>", "the largest value allowed, included").helpGroup(typeConstraintHelpGroup("max"))).addOption(new Option("--exclusive-min <number>", "values must be greater than this number").conflicts("min").helpGroup(typeConstraintHelpGroup("exclusiveMin"))).addOption(new Option("--exclusive-max <number>", "values must be less than this number").conflicts("max").helpGroup(typeConstraintHelpGroup("exclusiveMax"))).addOption(new Option("--unit <unit>", "the unit of the number the SDKs receive (required)").choices(TIMESPAN_UNITS).helpGroup(typeConstraintHelpGroup("unit"))).addOption(new Option("--allow-fractions", "allow values with a fraction of the unit").helpGroup(typeConstraintHelpGroup("allowFractions"))).addOption(new Option("--allowed-value <value>", "a value the config can take; repeatable").argParser(repeatedOption).helpGroup(typeConstraintHelpGroup("allowedValue"))).addOption(new Option("--enum-value-type <type>", "whether the allowed values are strings or numbers (default: string)").choices(ENUM_VALUE_TYPES).helpGroup(typeConstraintHelpGroup("enumValueType"))).addOption(new Option("--https-only", "allow only HTTPS URLs").helpGroup(typeConstraintHelpGroup("httpsOnly"))).addOption(new Option("--json-schema <file>", "a file holding the JSON Schema every value must match").helpGroup(typeConstraintHelpGroup("jsonSchema"))).action(commandAction(createConfig));
|
|
5869
|
+
configsCommand.command("copy").argument("<key>", "the key of the config to copy, or an old key of it").description("copy a config under a new key, in its project or in another project of the organization, and print the plan").requiredOption("--key <key>", "the copy's key (required)").option("--to <project slug>", "slug of the project to copy the config into, another project of the organization (default: the config's project)").option("--environment <destination slug>=<source slug>", "the source environment whose targeting rules an environment of the destination project starts with, needed for each destination environment with no source environment of its slug; repeatable", repeatedOption).option("--dry-run", "print the plan without creating anything").addHelpText("after", "\nThe copy gets the config's role, lifetime, type, constraints, variations, SDK availability, description, hypothesis and tags, and every environment's targeting rules and default targeting rule value. It starts as a new config, with no old keys, evaluations or code references, and shares nothing with the config it came from. An archived config cannot be copied; restore it first. In another project, environments are matched by slug, tags by name and segments by key: a source environment with no environment of its slug there is skipped, and an environment there with no source environment of its slug needs --environment. A targeting rule that uses a segment the other project doesn't have is dropped. A tag it doesn't have is created when the API token can create tags, and skipped otherwise. The plan names every skipped environment, dropped rule and tag; --dry-run shows it without creating anything.").action(commandAction(copyConfig));
|
|
5870
|
+
configsCommand.command("runs").argument("<key>", "the experiment's key, or an old key of it").description("list an experiment's runs in an environment with their exposures and results, newest first").requiredOption("--environment <slug>", "slug of the environment to list the runs of (required)").action(commandAction(showConfigRuns));
|
|
5871
|
+
configsCommand.command("ship").argument("<key>", "the experiment's key, or an old key of it").description("serve one variation of an experiment to everyone in an environment, ending its run").requiredOption("--environment <slug>", "slug of the environment to ship the variation in (required)").requiredOption("--variation <variation>", "the name or id of the variation to ship (required)").option("--note <text>", "why the variation is shipped, kept on the ended run").addHelpText("after", "\nIn one targeting rules save, the default targeting rule value becomes the variation's value, every percentage rollout is removed, including one a conditional rule serves, and a conditional rule serving a single value stays. It asks for no confirmation and treats a live environment like any other.").action(commandAction(shipVariation));
|
|
5872
|
+
configsCommand.command("stop").argument("<key>", "the experiment's key, or an old key of it").description("end an experiment's run in an environment without choosing a variation").requiredOption("--environment <slug>", "slug of the environment to stop the run in (required)").option("--note <text>", "why the run is stopped, kept on the ended run").addHelpText("after", "\nIn one targeting rules save, every percentage rollout is removed, including one a conditional rule serves, the default targeting rule value stays, and a conditional rule serving a single value stays. It asks for no confirmation and treats a live environment like any other. When the config is not an experiment, or no percentage rollout is saved in the environment, there is no run to stop, and the command exits with 2.").action(commandAction(stopRun));
|
|
5436
5873
|
configsCommand.command("archive").argument("<key>", "the config's key, or an old key of it").description("archive a config, so SDKs stop receiving it; the dashboard can restore it for 30 days").addHelpText("after", "\nThe config is archived with no confirmation and no check of whether the code still reads its key, so run configdirector scan first. Applications that still read it get their in-code default value.").action(commandAction(archiveConfig));
|
|
5437
5874
|
const segmentsCommand = program.command("segments").description("list, show, create, update and delete a project's segments");
|
|
5438
5875
|
segmentsCommand.command("list").description("list the project's segments with how many configs use each").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listSegments));
|
|
@@ -5440,6 +5877,12 @@ const run = async (invocation) => {
|
|
|
5440
5877
|
segmentsCommand.command("create").description("create a segment from a JSON file").requiredOption("--segment <file>", SEGMENT_FILE_DESCRIPTION).addHelpText("after", "\nThe file holds {\"key\": ..., \"name\": ..., \"groups\": [...], \"overrides\": {...}}, like the output of segments get --json, so you can edit that output and send it. groups lists the condition groups, and a context is in the segment when every condition of any one group matches. overrides is optional: it maps an environment's id, which environments list --json shows, to the condition groups used there instead. A condition without an id gets a new one.").action(commandAction(createSegment));
|
|
5441
5878
|
segmentsCommand.command("update").argument("<key>", "the segment's key").description("replace a segment's key, name and condition groups with a JSON file, and its environment overrides when the file has them").requiredOption("--segment <file>", SEGMENT_FILE_DESCRIPTION).addHelpText("after", "\nThe file takes the same form as for segments create, and the segment keeps its id whatever id the file holds. Its key, name and condition groups are replaced, so a different key renames it. Without overrides in the file, the environment overrides stay as they are; with \"overrides\": {} they are all removed, and a map leaves exactly those environments overridden. A condition without an id gets a new one.").action(commandAction(updateSegment));
|
|
5442
5879
|
segmentsCommand.command("delete").argument("<key>", "the segment's key").description("delete a segment and its environment overrides").addHelpText("after", "\nThe segment is deleted with no confirmation. While the targeting rules of a config use it, the deletion is refused and the configs are listed with their environments.").action(commandAction(deleteSegment));
|
|
5880
|
+
const metricsCommand = program.command("metrics").description("list, show, create, update and delete the metrics a project's experiments measure");
|
|
5881
|
+
metricsCommand.command("list").description("list the project's metrics with their kinds, direction and how many experiments use each").option("--no-headers", NO_HEADERS_DESCRIPTION).action(commandAction(listMetrics));
|
|
5882
|
+
metricsCommand.command("get").argument("<event name>", "the metric's event name, as the application passes it to track").description("show a metric's kinds and direction, and the experiments that use it").action(commandAction(getMetric));
|
|
5883
|
+
metricsCommand.command("create").description("create the project's metric for an event name the application passes to track").requiredOption("--event-name <event name>", "the event name, exactly as the application passes it to track (required)").addOption(repeatableChoiceOption("--kind <kind>", "a number the metric reports: conversion, the share of exposed contexts that sent the event; count-per-context, how many times each sent it on average; value-per-context, the value sent with it per context on average; repeatable (required)", METRIC_KINDS).makeOptionMandatory()).addOption(new Option("--direction <direction>", "whether a higher or a lower number is better (required)").choices(METRIC_DIRECTIONS).makeOptionMandatory()).option("--description <text>", "what the event means").action(commandAction(createMetric));
|
|
5884
|
+
metricsCommand.command("update").argument("<event name>", "the metric's event name").description("change a metric's kinds, direction or description, leaving the others as they are").addOption(repeatableChoiceOption("--kind <kind>", "a number the metric reports; repeatable, and the kinds given replace the metric's", METRIC_KINDS)).addOption(new Option("--direction <direction>", "whether a higher or a lower number is better").choices(METRIC_DIRECTIONS)).option("--description <text>", "what the event means; --description \"\" removes it").addHelpText("after", "\nA metric keeps its event name: for another event name, create another metric. Removing a kind that an experiment's primary metric decides on is refused, and the experiments are listed.").action(commandAction(updateMetric));
|
|
5885
|
+
metricsCommand.command("delete").argument("<event name>", "the metric's event name").description("delete a metric and detach it from the experiments that use it").addHelpText("after", "\nThe metric is deleted with no confirmation. While an experiment that uses it has a run in progress, the deletion is refused and the experiments are listed with the environments of their runs. Events with its name keep being counted, so a metric created again for the name and attached again shows their history.").action(commandAction(deleteMetric));
|
|
5443
5886
|
const targetingRulesCommand = program.command("targeting-rules").description("replace a config's targeting rules in one environment and show the format they take");
|
|
5444
5887
|
targetingRulesCommand.command("set").argument("<key>", "the config's key, or an old key of it").description("replace a config's targeting rules in one environment with the rules of a JSON file").requiredOption("--environment <slug>", "slug of the environment whose targeting rules to replace (required)").requiredOption("--rules <file>", "a JSON file holding defaultValue and rules as the Admin API takes them, or - to read it from stdin (required)").addHelpText("after", "\nThe file holds {\"defaultValue\": ..., \"rules\": [...]}, like an entry of targets in configs get --json, so you can edit that output and write it back. Every rule of the environment is replaced. A rule, condition or percentage without an id gets a new one. configdirector targeting-rules reference lists the condition kinds, attributes and operators.").action(commandAction(setTargetingRules));
|
|
5445
5888
|
targetingRulesCommand.command("reference").description("show the format targeting rules take: the condition kinds, the attributes a condition can test and the operators").action(commandAction(showTargetingRulesReference));
|
package/package.json
CHANGED