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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1200,7 +1200,11 @@ abs events history --event-name my-experiment --env-type production
abs events unit-data 1:user123 2:device456
abs events delete-unit-data 1:user123
abs events json-values --event-type exposure --path "variant" --experiment-id 123
abs events json-values --event-type goal --path "items/0/segment_flight_number" --goal-id 164 --match "LA8186|LA8153" # filter values (regex, client-side)
abs events json-layouts --source unit_attribute --phase after_enrichment
abs events json-layouts --source unit_goal_property --phase after_enrichment --source-id 164 --match segment_flight # filter paths (regex)
abs events json-layouts --source unit_goal_property --phase after_enrichment --source-id 164 --top-level # only top-level paths
abs events json-layouts --source unit_goal_property --phase after_enrichment --source-id 164 --max-depth 2 # paths up to 2 segments deep
abs events summary --from month-start # this month, weekly buckets, totals (default)
abs events summary --from last-month-start --to last-month-end --period month
abs events summary --from 30d --group-by team --cumulative
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@absmartly/cli",
"version": "1.7.0",
"version": "1.8.0",
"description": "ABSmartly CLI - A/B Testing and Feature Flags command-line tool for AI agents and humans",
"type": "module",
"main": "./dist/index.js",
Expand Down
142 changes: 142 additions & 0 deletions src/commands/events/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,49 @@ describe('events command', () => {
expect(printFormatted).toHaveBeenCalled();
});

const columnarValues = {
columnNames: ['value', 'last_event_at'],
columnTypes: ['String', 'Int64'],
rows: [
['LA3027 / LA3528 / LA8072', 1],
['LA8186', 2],
['DL2702 / LA8185', 3],
['', 4],
],
};

it('filters json-values by --match (case-insensitive regex on value)', async () => {
mockClient.getEventJsonValues.mockResolvedValueOnce(columnarValues);
await eventsCommand.parseAsync([
'node',
'test',
'json-values',
'--event-type',
'goal',
'--path',
'items/0/segment_flight_number',
'--match',
'la8186|la8153',
]);
const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as { rows: unknown[][] };
expect(printed.rows.map((r) => r[0])).toEqual(['LA8186']);
});

it('passes json-values output through unchanged when no --match is set', async () => {
mockClient.getEventJsonValues.mockResolvedValueOnce(columnarValues);
await eventsCommand.parseAsync([
'node',
'test',
'json-values',
'--event-type',
'goal',
'--path',
'items/0/segment_flight_number',
]);
const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as { rows: unknown[][] };
expect(printed.rows.length).toBe(4);
});

it('should get event json layouts', async () => {
await eventsCommand.parseAsync([
'node',
Expand All @@ -202,6 +245,105 @@ describe('events command', () => {
expect(printFormatted).toHaveBeenCalled();
});

const columnarLayouts = {
columnNames: ['key', 'value_type', 'last_event_at'],
columnTypes: ['String', 'String', 'Int64'],
rows: [
['currency', 'string', 1],
['items', 'array', 2],
['items/0', 'object', 3],
['items/0/segment_flight_number', 'string', 4],
['pageName', 'string', 5],
],
};

it('filters json-layouts paths by --match (case-insensitive regex)', async () => {
mockClient.getEventJsonLayouts.mockResolvedValueOnce(columnarLayouts);
await eventsCommand.parseAsync([
'node',
'test',
'json-layouts',
'--source',
'unit_goal_property',
'--phase',
'after_enrichment',
'--match',
'PAGE|segment_flight',
]);
const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as { rows: unknown[][] };
expect(printed.rows.map((r) => r[0])).toEqual(['items/0/segment_flight_number', 'pageName']);
});

it('filters json-layouts paths by --top-level', async () => {
mockClient.getEventJsonLayouts.mockResolvedValueOnce(columnarLayouts);
await eventsCommand.parseAsync([
'node',
'test',
'json-layouts',
'--source',
'unit_goal_property',
'--phase',
'after_enrichment',
'--top-level',
]);
const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as { rows: unknown[][] };
expect(printed.rows.map((r) => r[0])).toEqual(['currency', 'items', 'pageName']);
});

it('filters json-layouts paths by --max-depth', async () => {
mockClient.getEventJsonLayouts.mockResolvedValueOnce(columnarLayouts);
await eventsCommand.parseAsync([
'node',
'test',
'json-layouts',
'--source',
'unit_goal_property',
'--phase',
'after_enrichment',
'--max-depth',
'2',
]);
const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as { rows: unknown[][] };
expect(printed.rows.map((r) => r[0])).toEqual(['currency', 'items', 'items/0', 'pageName']);
});

it('passes json-layouts output through unchanged when no client filter is set', async () => {
mockClient.getEventJsonLayouts.mockResolvedValueOnce(columnarLayouts);
await eventsCommand.parseAsync([
'node',
'test',
'json-layouts',
'--source',
'unit_goal_property',
'--phase',
'after_enrichment',
]);
const printed = vi.mocked(printFormatted).mock.calls.at(-1)?.[0] as { rows: unknown[][] };
expect(printed.rows.length).toBe(5);
});

it('rejects an invalid --match regex with a clear error', async () => {
mockClient.getEventJsonLayouts.mockResolvedValueOnce(columnarLayouts);
try {
await eventsCommand.parseAsync([
'node',
'test',
'json-layouts',
'--source',
'unit_goal_property',
'--phase',
'after_enrichment',
'--match',
'(',
]);
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('Invalid --match regex');
}
});

it('should error on unit-data with invalid format (no colon)', async () => {
await expect(
eventsCommand.parseAsync(['node', 'test', 'unit-data', 'invalidformat'])
Expand Down
19 changes: 17 additions & 2 deletions src/commands/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
getEventJsonLayouts as coreGetEventJsonLayouts,
parseUnits,
} from '../../core/events/events.js';
import { filterColumnarRows } from '../../core/events/json-filter.js';
import { summaryCommand } from './summary.js';

function parseNumberArray(value: string, previous: number[]): number[] {
Expand Down Expand Up @@ -180,6 +181,8 @@ const jsonValuesCommand = new Command('json-values')
.option('--goal-id <id>', 'goal ID', Number)
.option('--from <date>', 'start time (e.g. 7d, 2w, 2026-01-01, epoch ms)')
.option('--to <date>', 'end time (e.g. 7d, 2w, 2026-01-01, epoch ms)')
// Client-side filter applied to the returned values.
.option('--match <regex>', 'filter values by case-insensitive regex (client-side)')
.action(
withErrorHandling(async (options) => {
const globalOptions = getGlobalOptions(jsonValuesCommand);
Expand All @@ -196,7 +199,10 @@ const jsonValuesCommand = new Command('json-values')
from: jvFrom,
to: jvTo,
});
printFormatted(result.data, globalOptions);
const filtered = filterColumnarRows(result.data, 'value', {
match: options.match as string | undefined,
});
printFormatted(filtered, globalOptions);
})
);

Expand All @@ -208,6 +214,10 @@ const jsonLayoutsCommand = new Command('json-layouts')
.option('--source-id <id>', 'source ID', Number)
.option('--from <date>', 'start time (e.g. 7d, 2w, 2026-01-01, epoch ms)')
.option('--to <date>', 'end time (e.g. 7d, 2w, 2026-01-01, epoch ms)')
// Client-side filters applied to the returned paths.
.option('--match <regex>', 'filter paths by case-insensitive regex (client-side)')
.option('--top-level', 'show only top-level paths (no slash); shorthand for --max-depth 1')
.option('--max-depth <n>', 'show only paths with at most N segments', (v) => parseInt(v, 10))
.action(
withErrorHandling(async (options) => {
const globalOptions = getGlobalOptions(jsonLayoutsCommand);
Expand All @@ -224,7 +234,12 @@ const jsonLayoutsCommand = new Command('json-layouts')
from: jlFrom,
to: jlTo,
});
printFormatted(result.data, globalOptions);
const filtered = filterColumnarRows(result.data, 'key', {
match: options.match as string | undefined,
topLevel: options.topLevel as boolean | undefined,
maxDepth: options.maxDepth as number | undefined,
});
printFormatted(filtered, globalOptions);
})
);

Expand Down
129 changes: 129 additions & 0 deletions src/core/events/json-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, it, expect, vi } from 'vitest';
import { filterColumnarRows, hasColumnarFilters } from './json-filter.js';

// Mimics the json-layouts response shape (key/value_type/last_event_at).
const layouts = () => ({
columnNames: ['key', 'value_type', 'last_event_at'],
columnTypes: ['String', 'String', 'Int64'],
rows: [
['currency', 'string', 1],
['items', 'array', 2],
['items/0', 'object', 3],
['items/0/segment_flight_number', 'string', 4],
['pageName', 'string', 5],
] as unknown[][],
});

// Mimics the json-values response shape (value/last_event_at).
const values = () => ({
columnNames: ['value', 'last_event_at'],
columnTypes: ['String', 'Int64'],
rows: [
['LA3027 / LA3528 / LA8072', 1],
['LA8186', 2],
['DL2702 / LA8185', 3],
['', 4],
] as unknown[][],
});

const keys = (d: { rows: unknown[][] }) => d.rows.map((r) => r[0]);

describe('hasColumnarFilters', () => {
it('is false when no filter option is set', () => {
expect(hasColumnarFilters({})).toBe(false);
});
it('is true when any filter option is set', () => {
expect(hasColumnarFilters({ match: 'x' })).toBe(true);
expect(hasColumnarFilters({ topLevel: true })).toBe(true);
expect(hasColumnarFilters({ maxDepth: 2 })).toBe(true);
});
});

describe('filterColumnarRows - match (regex, case-insensitive)', () => {
it('filters the key column by case-insensitive regex', () => {
const out = filterColumnarRows(layouts(), 'key', { match: 'PAGE' }) as ReturnType<
typeof layouts
>;
expect(keys(out)).toEqual(['pageName']);
});
it('supports regex alternation', () => {
const out = filterColumnarRows(layouts(), 'key', {
match: 'segment_flight|currency',
}) as ReturnType<typeof layouts>;
expect(keys(out)).toEqual(['currency', 'items/0/segment_flight_number']);
});
it('filters the value column for json-values', () => {
const out = filterColumnarRows(values(), 'value', { match: 'LA8186|LA8153' }) as ReturnType<
typeof values
>;
expect(out.rows.map((r) => r[0])).toEqual(['LA8186']);
});
it('throws a clear error on invalid regex', () => {
expect(() => filterColumnarRows(layouts(), 'key', { match: '(' })).toThrow(
/Invalid --match regex/
);
});
});

describe('filterColumnarRows - depth', () => {
it('--top-level keeps only paths with no slash', () => {
const out = filterColumnarRows(layouts(), 'key', { topLevel: true }) as ReturnType<
typeof layouts
>;
expect(keys(out)).toEqual(['currency', 'items', 'pageName']);
});
it('--max-depth N keeps paths with at most N segments', () => {
const out = filterColumnarRows(layouts(), 'key', { maxDepth: 2 }) as ReturnType<typeof layouts>;
expect(keys(out)).toEqual(['currency', 'items', 'items/0', 'pageName']);
});
it('topLevel takes precedence over maxDepth', () => {
const out = filterColumnarRows(layouts(), 'key', { topLevel: true, maxDepth: 3 }) as ReturnType<
typeof layouts
>;
expect(keys(out)).toEqual(['currency', 'items', 'pageName']);
});
it('throws on a non-positive / non-integer max-depth', () => {
expect(() => filterColumnarRows(layouts(), 'key', { maxDepth: 0 })).toThrow(/max-depth/);
expect(() => filterColumnarRows(layouts(), 'key', { maxDepth: 1.5 })).toThrow(/max-depth/);
});
});

describe('filterColumnarRows - composition & defensive behavior', () => {
it('composes match AND depth', () => {
const out = filterColumnarRows(layouts(), 'key', { match: 'items', maxDepth: 2 }) as ReturnType<
typeof layouts
>;
expect(keys(out)).toEqual(['items', 'items/0']);
});
it('returns the data unchanged when no filters are active', () => {
const d = layouts();
expect(filterColumnarRows(d, 'key', {})).toBe(d);
});
it('returns data unchanged (with a stderr warning) when the column is absent', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const d = layouts();
expect(filterColumnarRows(d, 'nope', { match: 'x' })).toBe(d);
expect(spy).toHaveBeenCalledWith(expect.stringContaining('column "nope" not found'));
spy.mockRestore();
});
it('returns data unchanged (with a stderr warning) for non-columnar shapes', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const weird = { foo: 'bar' };
expect(filterColumnarRows(weird, 'key', { match: 'x' })).toBe(weird);
expect(spy).toHaveBeenCalledWith(expect.stringContaining('not in the expected columnar shape'));
spy.mockRestore();
});
it('does NOT warn when no filters are active, even on non-columnar data', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const weird = { foo: 'bar' };
expect(filterColumnarRows(weird, 'key', {})).toBe(weird);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
it('does not mutate the input rows', () => {
const d = layouts();
const before = d.rows.length;
filterColumnarRows(d, 'key', { topLevel: true });
expect(d.rows.length).toBe(before);
});
});
Loading
Loading