Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 52 additions & 7 deletions docs/agent/asa-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,29 +123,74 @@ adapty asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31
--metric roas --by-days 7 --by-days 90 --order-by net_roas --order-by-day 90
```

### Windows the cohort has not lived through yet

A cohort metric is what has actually been observed so far, not a projection. A window longer
than the cohort's age therefore repeats the last real figure instead of returning nothing — a
July cohort read in August reports the same number at day 60, day 90 and day 300 as at day 28.

`meta.max_valid_day` in the response is how many days the youngest cohort in the date range
has lived, counted from `--date-to`. Treat any `--by-days` window above it as not reached:

- Do not divide a clipped numerator by a full-window denominator. That understates the result,
and by roughly a third in the case that prompted this note.
- Compare markets only at a window all of them have reached.
- The CLI prints a warning when a requested window is past `max_valid_day`; in `--json` the
number is there to check yourself.

## Money and currency

Money columns — `spend`, `local_spend`, and every revenue-derived metric — are in the campaign
group currency, not USD. `spend` and `local_spend` carry the same figure despite the naming.
The currency belongs to the group rather than to a row, so read it from `adapty asa orgs list`
before summing or comparing across accounts.

## Counting entities

The rows on a page are the page, not the inventory: `--order-by spend --page-size 1000` ranks
across everything the filters allow, so a page can hold a fraction of one app's keywords.
`meta.pagination.count` in the same response is the full count behind the filters — take
inventory from there, and scope the call with `--app` or `--campaign` to make the ranking mean
what you want.

That count runs higher than the matching catalog list (`campaigns list`, `keywords list`).
Both are right: an entity deleted in Apple keeps the spend it already booked, so metrics still
report it, while the catalog lists show only what exists today. Use the catalog to answer "what
do I have", metrics to answer "what did I spend".

## The analytics pool

Three commands draw on one single-slot pool per company: `metrics`, `metrics overview`, and
`search-terms list` — only one of them runs at a time, a slot held by one is a slot the
others can't use (`competitors summary` holds its own single slot). On top of that shared
concurrency, each pair also carries its own per-minute budget:

| Commands | Per-minute budget |
Budgets are raised per company; the table is the platform default. `adapty asa whoami` reports
the effective ones under `limits` — pace against those.

| Commands | Default per-minute budget |
|---|---|
| `metrics`, `metrics overview` | 5/min, burst at most 2 per 10s |
| `metrics`, `metrics overview` | 15/min, burst at most 5 per 10s |
| `search-terms list`, `competitors summary` | 30/min |

Three 429 codes, not one:

- `cli_analytics_busy` — another analytics query is still running; wait about 5 seconds.
- `cli_rate_limit_exceeded` — the per-minute window (5/min or 30/min, whichever pair) is
full.
- `cli_rate_limit_exceeded` — the per-minute window for that pair is full.
- `cli_cooldown_active` — stop entirely; tell the user when to retry.

A fourth refusal is a 503, not a 429: `cli_upstream_unavailable` means the Adapty API that
identifies the company is temporarily unreachable. Nothing ran, the token is fine, and no
cool-down strike is recorded. It carries a `Retry-After` and the CLI waits it out once, the
same as a 429 — so if it reaches you, the outage outlasted the retry. Say the dependency is
down rather than blaming the command.

One refusal is a 422, not a 429: `cli_response_too_large` — a `metrics` page would exceed
5 000 breakdown rows (see [Date window caps](#date-window-caps)). It carries no
`Retry-After` and doesn't count toward the cool-down; retrying is pointless — change the
request instead (coarsen the grouping, narrow the window, or reduce `--page-size`).
the company's `max_breakdown_rows_per_page` (see [Date window caps](#date-window-caps)). It
carries no `Retry-After` and doesn't count toward the cool-down; retrying is pointless —
change the request instead (coarsen the grouping, narrow the window, or reduce
`--page-size`). The error names the `page[size]` that fits for the grouped period; use it
verbatim.

Every 429 carries the wait in `Retry-After`. The CLI already absorbs the first 429 of
any single command on its own — it waits the exact `Retry-After` (up to 60s; cool-downs
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "adapty",
"description": "Adapty command line interface",
"version": "0.8.4",
"version": "0.8.5",
"author": "Adapty team <support@adapty.io>",
"bin": {
"adapty": "./bin/run.js"
Expand Down
22 changes: 15 additions & 7 deletions skills/adapty-cli/references/asa-agent-playbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,13 @@ an agent that ignores them gets 429s, then a token cool-down, and then it cannot

## Request budgets (per company, not per token)

| Commands | Budget |
Budgets are raised per company, so the numbers below are the platform defaults, not what this account
gets. `adapty asa whoami` reports the effective ones under `limits` — read them once at the start of a
session and pace against those instead of against this table.

| Commands | Default budget |
|---|---|
| `metrics`, `metrics overview` | 5/min, burst 2 per 10s, one at a time (pool shared with search-terms) |
| `metrics`, `metrics overview` | 15/min, burst 5 per 10s, one at a time (pool shared with search-terms) |
| `search-terms list`, `competitors summary` | 30/min, search-terms shares the single-slot analytics pool |
| `keywords list` | 30/min, burst 5 per 10s, own 2-concurrent pool, 60s timeout |
| catalog lists and gets, automation reads | 120/min |
Expand All @@ -68,11 +72,15 @@ an agent that ignores them gets 429s, then a token cool-down, and then it cannot

Every refusal is a `429` with the exact wait in `Retry-After`; `cli_analytics_busy` means another
analytics query is still running (wait ~5s), `cli_rate_limit_exceeded` means the window is full,
`cli_cooldown_active` means stop entirely and tell the user when to retry. The CLI already waits out
and retries the first 429 of a command on its own — a surfaced 429 means the second attempt failed too.
`cli_response_too_large` is the exception: a 422 (a `metrics` page over 20 000 breakdown rows) with no
`Retry-After` and no cool-down strike — waiting fixes nothing, change the request instead; with day
grouping the error names the exact `page[size]` that fits — use it verbatim on the retry.
`cli_cooldown_active` means stop entirely and tell the user when to retry. A `503
cli_upstream_unavailable` is not the caller's doing at all — the Adapty API that identifies the company
is down, nothing ran, and no cool-down strike is recorded; report the dependency, not the command. The
CLI already waits out and retries the first 429 or 503 of a command on its own — a surfaced one means
the second attempt failed too.
`cli_response_too_large` is the exception: a 422 (a `metrics` page over the company's
`max_breakdown_rows_per_page`, which `whoami` reports) with no `Retry-After` and no cool-down strike —
waiting fixes nothing, change the request instead; the error names the exact `page[size]` that fits for
the grouped period — use it verbatim on the retry.

## Metric vocabulary

Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/ad-groups/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default class AsaAdGroupsCreate extends Command {
flags.yes,
);

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const { replayed, result } = await asaWrite<AsaAdGroupMutationDTO>(client, 'post', '/ad-groups', {
body,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/ad-groups/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default class AsaAdGroupsGet extends Command {
this.error('Invalid ad group ID format.', { exit: 2 });
}

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);
const result = await client.get<AsaAdGroupDTO>(`/ad-groups/${args.ad_group_id}`);

printResponse(result, this.log.bind(this));
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/ad-groups/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default class AsaAdGroupsList extends Command {

async run(): Promise<PaginatedResponse<AsaAdGroupDTO>> {
const { flags } = await this.parse(AsaAdGroupsList);
const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const result = await client.get<PaginatedResponse<AsaAdGroupDTO>>('/ad-groups', {
...paginationParams(flags),
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/ad-groups/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export default class AsaAdGroupsUpdate extends Command {
flags.yes,
);

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const { replayed, result } = await asaWrite<AsaAdGroupMutationDTO>(client, 'put', `/ad-groups/${args.ad_group_id}`, {
body,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/ads/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default class AsaAdsCreate extends Command {

await confirmMutation(this, { body, method: 'POST', path: '/ads/', summary: `Create ad ${flags.name}` }, flags.yes);

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const { replayed, result } = await asaWrite<AsaAdMutationDTO>(client, 'post', '/ads', {
body,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/ads/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default class AsaAdsGet extends Command {
this.error('Invalid ad ID format.', { exit: 2 });
}

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);
const result = await client.get<AsaAdDTO>(`/ads/${args.ad_id}`);

printResponse(result, this.log.bind(this));
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/ads/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default class AsaAdsList extends Command {

async run(): Promise<PaginatedResponse<AsaAdDTO>> {
const { flags } = await this.parse(AsaAdsList);
const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const result = await client.get<PaginatedResponse<AsaAdDTO>>('/ads', {
...paginationParams(flags),
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/ads/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default class AsaAdsUpdate extends Command {

await confirmMutation(this, { body, method: 'PUT', path: `/ads/${args.ad_id}/`, summary: 'Update ad' }, flags.yes);

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const { replayed, result } = await asaWrite<AsaAdMutationDTO>(client, 'put', `/ads/${args.ad_id}`, {
body,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/apps/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default class AsaAppsList extends Command {

async run(): Promise<PaginatedResponse<AsaAppDTO>> {
const { flags } = await this.parse(AsaAppsList);
const client = await createAsaClient(this.config);
const client = await createAsaClient(this);
const result = await client.get<PaginatedResponse<AsaAppDTO>>('/apps', paginationParams(flags));

printList(result.data, this.log.bind(this), result.meta.pagination);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/automations/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default class AsaAutomationsCreate extends Command {
const summary = flags['run-now'] ? 'Create automation rule and run it immediately' : 'Create automation rule';
await confirmMutation(this, { body, method: 'POST', path: '/automations/', summary }, flags.yes);

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const { replayed, result } = await asaWrite<AsaAutomationMutationDTO>(client, 'post', '/automations', {
body,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/automations/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default class AsaAutomationsGet extends Command {
this.error('Invalid automation ID format.', { exit: 2 });
}

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);
const result = await client.get<AsaAutomationDTO>(`/automations/${args.automation_id}`);

printResponse(result, this.log.bind(this));
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/automations/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default class AsaAutomationsList extends Command {

async run(): Promise<PaginatedResponse<AsaAutomationDTO>> {
const { flags } = await this.parse(AsaAutomationsList);
const client = await createAsaClient(this.config);
const client = await createAsaClient(this);
const result = await client.get<PaginatedResponse<AsaAutomationDTO>>('/automations', paginationParams(flags));

printList(result.data, this.log.bind(this), result.meta.pagination);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/automations/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default class AsaAutomationsRun extends Command {
);
}

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const { replayed, result } = await asaWrite<AsaAutomationRunEnqueuedDTO>(
client,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/automations/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default class AsaAutomationsRuns extends Command {
this.error('Invalid automation ID format.', { exit: 2 });
}

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const result = await client.get<PaginatedResponse<AsaAutomationRunDTO>>(
`/automations/${args.automation_id}/runs`,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/automations/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export default class AsaAutomationsUpdate extends Command {
this.error('Remove internal_id from the file: the rule ID comes from the command line.', { exit: 2 });
}

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

if (actionFlags) {
await this.rebuildAction(client, args.automation_id, body, flags);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/campaigns/bulk-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export default class AsaCampaignsBulkCreate extends Command {

async run(): Promise<AsaBulkOperationStateDTO | Record<string, unknown>> {
const { flags } = await this.parse(AsaCampaignsBulkCreate);
const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- FIXME if you see this
const body = flags['from-file'] ? await this.convertTemplate(client, flags) : await this.readStructure(flags.file!);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/campaigns/bulk-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default class AsaCampaignsBulkList extends Command {

async run(): Promise<AsaBulkOperationListDTO> {
const { flags } = await this.parse(AsaCampaignsBulkList);
const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const result = await client.get<AsaBulkOperationListDTO>('/bulk-operations', {
...paginationParams(flags),
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/campaigns/bulk-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default class AsaCampaignsBulkStatus extends Command {
this.error('Invalid operation ID format.', { exit: 2 });
}

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);
const state = await client.get<AsaBulkOperationStateDTO>(`/bulk-operations/${operationId}`, paginationParams(flags));

printResponse(state, this.log.bind(this));
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/campaigns/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export default class AsaCampaignsCreate extends Command {

await confirmMutation(this, { body, method: 'POST', path: '/campaigns/', summary: `Create campaign ${flags.name}` }, flags.yes);

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const { replayed, result } = await asaWrite<AsaCampaignMutationDTO>(client, 'post', '/campaigns', {
body,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/campaigns/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default class AsaCampaignsGet extends Command {
this.error('Invalid campaign ID format.', { exit: 2 });
}

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);
const result = await client.get<AsaCampaignDTO>(`/campaigns/${args.campaign_id}`);

printResponse(result, this.log.bind(this));
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/campaigns/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default class AsaCampaignsList extends Command {

async run(): Promise<PaginatedResponse<AsaCampaignDTO>> {
const { flags } = await this.parse(AsaCampaignsList);
const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const result = await client.get<PaginatedResponse<AsaCampaignDTO>>('/campaigns', {
...paginationParams(flags),
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/campaigns/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export default class AsaCampaignsUpdate extends Command {
flags.yes,
);

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const { replayed, result } = await asaWrite<AsaCampaignMutationDTO>(client, 'put', `/campaigns/${args.campaign_id}`, {
body,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/competitors/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default class AsaCompetitorsSummary extends Command {
this.error('App Store IDs are numbers, e.g. --app-ids 1668337467,6503873027.', { exit: 2 });
}

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const { result } = await asaWrite<AsaCompetitorsSummaryDTO>(client, 'post', '/competitors/summary', {
body: { app_ids: appIds.map(Number) },
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default class AsaConnect extends Command {

async run(): Promise<AsaAppleOAuthDTO | AsaMeDTO> {
const { flags } = await this.parse(AsaConnect);
const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const { auth_url: authUrl } = await client.get<AsaAppleOAuthDTO>('/apple/oauth');
this.log(`If the browser doesn't open, visit: ${authUrl}\n`);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/creatives/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default class AsaCreativesList extends Command {

async run(): Promise<PaginatedResponse<AsaCreativeDTO>> {
const { flags } = await this.parse(AsaCreativesList);
const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const result = await client.get<PaginatedResponse<AsaCreativeDTO>>('/creatives', {
...paginationParams(flags),
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/keywords/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default class AsaKeywordsAdd extends Command {
flags.yes,
);

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const { replayed, result } = await asaWrite<AsaKeywordMutationDTO>(client, 'post', '/keywords', {
body,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/keywords/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default class AsaKeywordsList extends Command {

async run(): Promise<PaginatedResponse<AsaKeywordDTO>> {
const { flags } = await this.parse(AsaKeywordsList);
const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const result = await client.get<PaginatedResponse<AsaKeywordDTO>>('/keywords', {
...paginationParams(flags),
Expand Down
2 changes: 1 addition & 1 deletion src/commands/asa/keywords/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export default class AsaKeywordsUpdate extends Command {
flags.yes,
);

const client = await createAsaClient(this.config);
const client = await createAsaClient(this);

const { replayed, result } = await asaWrite<AsaKeywordMutationDTO>(client, 'put', '/keywords', {
body,
Expand Down
Loading
Loading