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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,17 @@ abs metrics list --owners "jane@example.com" --teams Growth # by name/email
abs metrics list --review-status pending
abs metrics list --sort name --asc
abs metrics list --ids 1,2,3

# Client-side filters (applied across all pages)
abs metrics list --metric-type goal_ratio,custom_sql
abs metrics list --goal purchase # name or ID, incl. denominator
abs metrics list --outlier-limiting # has limiting; --no-outlier-limiting for none
abs metrics list --outlier-method quantile,stdev
abs metrics list --has-property-filter # --no-property-filter for none
abs metrics list --property-filter-path page_name
abs metrics list --property-filter-contains BookingFullDetails
abs metrics list --impact-direction positive,negative
abs metrics list --cuped # --no-cuped for none
abs metrics get 123
abs metrics create --name "Revenue" --type count
abs metrics update 123 --description "Updated"
Expand All @@ -953,6 +964,22 @@ abs metrics access grant-user 123 --user 1 --role 2
abs metrics access revoke-user 123 --user 1 --role 2
```

#### Metric list filters (client-side)

These filters run client-side: when any is set, the CLI fetches every metric and filters locally. They combine with AND across flags and OR within a comma-separated list. Because the full set is fetched and filtered, `--items`/`--page` do not apply while filtering — every match is shown. (Server-side equivalents are a planned follow-up.)

| Filter | Description |
|---|---|
| `--metric-type <values>` | `goal_count`, `goal_unique_count`, `goal_time_to_achievement`, `goal_property`, `goal_property_unique_count`, `goal_ratio`, `goal_retention`, `goal_activity_period_count`, `custom_sql` |
| `--goal <values>` | Goal name (substring) or ID; matches numerator and denominator goals |
| `--outlier-limiting` / `--no-outlier-limiting` | Has / does not have outlier limiting (method other than `unlimited`) |
| `--outlier-method <values>` | `unlimited`, `quantile`, `stdev`, `fixed` |
| `--has-property-filter` / `--no-property-filter` | Has / does not have a goal property filter |
| `--property-filter-path <values>` | Property path(s) referenced inside the property filter (substring) |
| `--property-filter-contains <text>` | Substring anywhere in the serialized property filter |
| `--impact-direction <values>` | `positive`, `negative`, `unknown` |
| `--cuped` / `--no-cuped` | Has / does not have CUPED (variance reduction) enabled |

### Teams

Aliases: `teams`, `team`
Expand Down
60 changes: 56 additions & 4 deletions src/commands/metrics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ import {
summarizeMetricRow,
} from '../../api-client/entity-summary.js';
import { createListCommand } from '../../lib/utils/list-command.js';
import { listMetrics as coreListMetrics } from '../../core/metrics/list.js';
import { listMetrics as coreListMetrics, listAllMetrics } from '../../core/metrics/list.js';
import {
parseMetricFilters,
validateMetricFilters,
hasActiveMetricFilters,
filterMetrics,
} from '../../core/metrics/filter.js';
import { getMetric } from '../../core/metrics/get.js';
import { createMetric } from '../../core/metrics/create.js';
import { updateMetric } from '../../core/metrics/update.js';
Expand All @@ -33,7 +39,7 @@ const listCommand = createListCommand({
description: 'List all metrics',
defaultItems: 100,
fetch: async (client, options) => {
const result = await coreListMetrics(client, {
const baseParams = {
items: options.items as number,
page: options.page as number,
archived: options.archived as boolean,
Expand All @@ -45,16 +51,62 @@ const listCommand = createListCommand({
owners: options.owners as string | undefined,
teams: options.teams as string | undefined,
reviewStatus: options.reviewStatus as string | undefined,
});
};

const filters = parseMetricFilters(options);
if (hasActiveMetricFilters(filters)) {
validateMetricFilters(options, filters);
// Client-side filters need the full set, not a single page.
const all = await listAllMetrics(client, baseParams);
return filterMetrics(all.data as Array<Record<string, unknown>>, filters);
}

const result = await coreListMetrics(client, baseParams);
return result.data;
},
summarizeRow: summarizeMetricRow,
isClientFiltered: (options) => hasActiveMetricFilters(parseMetricFilters(options)),
extraOptions: (cmd) =>
cmd
.option('--include-drafts', 'include draft (non-activated) metrics')
.option('--owners <values>', 'filter by owners (comma-separated IDs, names, or emails)')
.option('--teams <values>', 'filter by teams (comma-separated IDs or names)')
.option('--review-status <status>', 'filter by review status (pending, approved, none)'),
.option('--review-status <status>', 'filter by review status (pending, approved, none)')
// Client-side filters (applied across all pages). API-side equivalents are a follow-up.
.option(
'--metric-type <values>',
'filter by metric type(s), comma-separated (goal_count, goal_unique_count, goal_time_to_achievement, goal_property, goal_property_unique_count, goal_ratio, goal_retention, goal_activity_period_count, custom_sql)'
)
.option(
'--goal <values>',
'filter by goal name or ID, comma-separated (matches numerator and denominator goals)'
)
// positive flag declared before negative so the boolean stays tri-state
.option(
'--outlier-limiting',
'only metrics that have outlier limiting (method other than unlimited)'
)
.option('--no-outlier-limiting', 'only metrics without outlier limiting')
.option(
'--outlier-method <values>',
'filter by outlier limit method(s), comma-separated (unlimited, quantile, stdev, fixed)'
)
.option('--has-property-filter', 'only metrics that have a goal property filter')
.option('--no-property-filter', 'only metrics without a goal property filter')
.option(
'--property-filter-path <values>',
'filter by property path(s) referenced in the property filter, comma-separated (substring match)'
)
.option(
'--property-filter-contains <text>',
'filter by a substring anywhere in the serialized property filter'
)
.option(
'--impact-direction <values>',
'filter by impact direction(s), comma-separated (positive, negative, unknown)'
)
.option('--cuped', 'only metrics with CUPED (variance reduction) enabled')
.option('--no-cuped', 'only metrics without CUPED'),
});

const getCommand = new Command('get')
Expand Down
125 changes: 125 additions & 0 deletions src/commands/metrics/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,131 @@ describe('metrics command', () => {
expect(printFormatted).toHaveBeenCalled();
});

const richMetrics = [
{
id: 1,
name: 'a',
type: 'goal_count',
effect: 'positive',
goal_id: 1,
goal: { id: 1, name: 'page_view' },
outlier_limit_method: 'unlimited',
vr_lookback_interval: null,
property_filter: null,
},
{
id: 2,
name: 'b',
type: 'goal_ratio',
effect: 'negative',
goal_id: 2,
goal: { id: 2, name: 'checkout' },
outlier_limit_method: 'quantile',
vr_lookback_interval: '2w',
property_filter: '{"filter":{"and":[{"var":{"path":"page_name"}}]}}',
},
{
id: 3,
name: 'c',
type: 'custom_sql',
effect: 'unknown',
goal_id: 3,
goal: { id: 3, name: 'purchase' },
outlier_limit_method: 'unlimited',
vr_lookback_interval: null,
property_filter: null,
},
];

it('filters metrics list by --metric-type (client-side)', async () => {
mockClient.listMetrics.mockResolvedValue(richMetrics);
await metricsCommand.parseAsync([
'node',
'test',
'list',
'--metric-type',
'goal_ratio,custom_sql',
]);

const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as Array<{ id: number }>;
expect(printed.map((m) => m.id)).toEqual([2, 3]);
});

it('filters metrics list by --cuped (client-side)', async () => {
mockClient.listMetrics.mockResolvedValue(richMetrics);
await metricsCommand.parseAsync(['node', 'test', 'list', '--cuped']);

const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as Array<{ id: number }>;
expect(printed.map((m) => m.id)).toEqual([2]);
});

it('filters metrics list by --no-cuped (client-side)', async () => {
mockClient.listMetrics.mockResolvedValue(richMetrics);
await metricsCommand.parseAsync(['node', 'test', 'list', '--no-cuped']);

const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as Array<{ id: number }>;
expect(printed.map((m) => m.id)).toEqual([1, 3]);
});

it('filters metrics list by --has-property-filter (client-side)', async () => {
mockClient.listMetrics.mockResolvedValue(richMetrics);
await metricsCommand.parseAsync(['node', 'test', 'list', '--has-property-filter']);

const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as Array<{ id: number }>;
expect(printed.map((m) => m.id)).toEqual([2]);
});

it('filters metrics list by --no-property-filter (client-side)', async () => {
mockClient.listMetrics.mockResolvedValue(richMetrics);
await metricsCommand.parseAsync(['node', 'test', 'list', '--no-property-filter']);

const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as Array<{ id: number }>;
expect(printed.map((m) => m.id)).toEqual([1, 3]);
});

it('rejects combining --has-property-filter with --no-property-filter', async () => {
mockClient.listMetrics.mockResolvedValue(richMetrics);
try {
await metricsCommand.parseAsync([
'node',
'test',
'list',
'--has-property-filter',
'--no-property-filter',
]);
throw new Error('Should have thrown');
} catch (error) {
if (!(error as Error).message.startsWith('process.exit')) throw error;
const errorOutput = consoleErrorSpy.mock.calls.flat().join(' ');
expect(errorOutput).toContain('property-filter');
}
});

it('filters metrics list by --goal name (client-side)', async () => {
mockClient.listMetrics.mockResolvedValue(richMetrics);
await metricsCommand.parseAsync(['node', 'test', 'list', '--goal', 'checkout']);

const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as Array<{ id: number }>;
expect(printed.map((m) => m.id)).toEqual([2]);
});

it('rejects an invalid --impact-direction value', async () => {
mockClient.listMetrics.mockResolvedValue(richMetrics);
try {
await metricsCommand.parseAsync(['node', 'test', 'list', '--impact-direction', 'sideways']);
throw new Error('Should have thrown');
} catch (error) {
if (!(error as Error).message.startsWith('process.exit')) throw error;
const errorOutput = consoleErrorSpy.mock.calls.flat().join(' ');
expect(errorOutput).toContain('impact-direction');
}
});

it('does not client-filter (single page) when no new filters are passed', async () => {
await metricsCommand.parseAsync(['node', 'test', 'list']);
expect(mockClient.listMetrics).toHaveBeenCalledWith(defaultListParams);
});

it('should get metric by id', async () => {
await metricsCommand.parseAsync(['node', 'test', 'get', '1']);

Expand Down
Loading
Loading