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
109 changes: 109 additions & 0 deletions packages/analytics/src/__tests__/pagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';

import { AnalyticsResource } from '../index.js';

import type { HttpClient } from '@astroid/core';
import type { AgentSpendingRow, BudgetUtilizationRow } from '@astroid/types';

/** Minimal HttpClient stand-in that records GET calls and returns stubbed data. */
function makeClient() {
const calls: Array<{ path: string; query?: Record<string, unknown> }> = [];
const handler = vi.fn();

const client = {
get: vi.fn(async (path: string, opts?: { query?: Record<string, unknown> }) => {
calls.push({ path, query: opts?.query });
const data = await handler(path, opts?.query);
return { data, meta: { page: 1, limit: 20, total: data.length, totalPages: 1, hasNextPage: false, hasPreviousPage: false } };
}),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
} as unknown as HttpClient;

return { client, calls, handler };
}

describe('AnalyticsResource pagination', () => {
const agentRow: AgentSpendingRow = {
agentId: 'agt_1',
agentName: 'Payment Bot',
totalSpent: '120.50',
transactionCount: 7,
averageRisk: 12,
};

const budgetRow: BudgetUtilizationRow = {
budgetId: 'bud_1',
budgetName: 'Marketing',
limit: '1000.00',
spent: '400.00',
remaining: '600.00',
utilization: 0.4,
};

it('listAgents serializes analytics filters and pagination into the querystring', async () => {
const { client, calls, handler } = makeClient();
handler.mockResolvedValueOnce([agentRow]);
const resource = new AnalyticsResource(client);

const result = await resource.listAgents({
from: '2026-01-01',
to: '2026-01-31',
currency: 'USDC',
page: 2,
limit: 25,
order: 'desc',
sort: 'totalSpent',
});

expect(calls).toEqual([
{
path: '/analytics/agents',
query: {
from: '2026-01-01',
to: '2026-01-31',
currency: 'USDC',
page: 2,
limit: 25,
order: 'desc',
sort: 'totalSpent',
},
},
]);
expect(result.data).toEqual([agentRow]);
expect(result.meta.page).toBe(1);
});

it('listAgents returns an empty page when there are no rows', async () => {
const { client, handler } = makeClient();
handler.mockResolvedValueOnce([]);
const resource = new AnalyticsResource(client);

const result = await resource.listAgents({});
expect(result.data).toEqual([]);
expect(result.meta.total).toBe(0);
});

it('listBudgets serializes pagination and parses utilization rows', async () => {
const { client, calls, handler } = makeClient();
handler.mockResolvedValueOnce([budgetRow]);
const resource = new AnalyticsResource(client);

const result = await resource.listBudgets({
from: '2026-01-01',
page: 1,
limit: 50,
order: 'asc',
});

expect(calls).toEqual([
{
path: '/analytics/budgets',
query: { from: '2026-01-01', page: 1, limit: 50, order: 'asc' },
},
]);
expect(result.data).toEqual([budgetRow]);
expect(result.data[0].utilization).toBe(0.4);
});
});
99 changes: 95 additions & 4 deletions packages/analytics/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,95 @@
export * from './metrics.js';
export * from './aggregations.js';
export * from './exporter.js';
export * from './time-series.js';
/**
* `@astroid/analytics` — read-only reporting resource.
*
* Thin, typed wrappers over the `GET /analytics/*` endpoints. Every method
* returns a chart-ready object (time series, distributions, per-agent and
* per-budget rows) and accepts the shared {@link AnalyticsQuery} filters.
*
* @packageDocumentation
*/

import { Resource } from '@astroid/core';
import type {
AgentAnalytics,
AgentSpendingRow,
AnalyticsListParams,
AnalyticsOverview,
AnalyticsQuery,
BudgetAnalytics,
BudgetUtilizationRow,
CashflowReport,
Paginated,
RiskReport,
} from '@astroid/types';

export {
exportToCSV,
exportToJSON,
formatTransactionForExport,
flattenRecordForExport,
escapeCsvValue,
type CsvColumn,
type CsvExportOptions,
type JsonExportOptions,
} from './exporter.js';

/**
* The `analytics` namespace on the Astroid client.
*
* All methods are read-only and safe to call frequently; they aggregate over
* the organization's transactions, agents, and budgets for the requested window
* and granularity.
*/
export class AnalyticsResource extends Resource {
/** Headline dashboard metrics plus the spending trend. */
async overview(query: AnalyticsQuery = {}): Promise<AnalyticsOverview> {
return this.getData<AnalyticsOverview>('/analytics/overview', { ...query });
}

/** Inflow/outflow/net cashflow over the requested window. */
async cashflow(query: AnalyticsQuery = {}): Promise<CashflowReport> {
return this.getData<CashflowReport>('/analytics/cashflow', { ...query });
}

/** Spending report (alias of the cashflow endpoint's outflow view). */
async spending(query: AnalyticsQuery = {}): Promise<CashflowReport> {
return this.getData<CashflowReport>('/analytics/spending', { ...query });
}

/** Risk distribution, average score, and trend. */
async risk(query: AnalyticsQuery = {}): Promise<RiskReport> {
return this.getData<RiskReport>('/analytics/risk', { ...query });
}

/** Per-agent spending and risk breakdown. */
async agents(query: AnalyticsQuery = {}): Promise<AgentAnalytics> {
return this.getData<AgentAnalytics>('/analytics/agents', { ...query });
}

/** Per-budget utilization breakdown. */
async budgets(query: AnalyticsQuery = {}): Promise<BudgetAnalytics> {
return this.getData<BudgetAnalytics>('/analytics/budgets', { ...query });
}

/**
* Densely paginated per-agent performance rows.
*
* Unlike {@link AnalyticsResource.agents} (which returns the full aggregate
* in one payload), this endpoint is cursor/page-aware so clients can page
* through large historical sets without loading everything at once. Accepts
* the shared {@link AnalyticsListParams} filters plus pagination controls.
*/
async listAgents(query: AnalyticsListParams = {}): Promise<Paginated<AgentSpendingRow>> {
return this.listData<AgentSpendingRow>('/analytics/agents', { ...query });
}

/**
* Densely paginated per-budget utilization rows.
*
* Use when there are many budgets and you want to page through them with
* `page`/`limit`/`order` rather than fetch every row in a single response.
*/
async listBudgets(query: AnalyticsListParams = {}): Promise<Paginated<BudgetUtilizationRow>> {
return this.listData<BudgetUtilizationRow>('/analytics/budgets', { ...query });
}
}
41 changes: 28 additions & 13 deletions packages/types/src/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
export type Timeframe = 'hour' | 'day' | 'week' | 'month' | 'year' | string;

export interface AnalyticsQueryParams {
startDate?: string;
endDate?: string;
timeframe?: Timeframe;
asset?: string;
walletId?: string;
import type { DecimalString } from './entities.js';
import type { RiskBand } from './enums.js';
import type { Paginated, PaginationParams } from './common.js';

/** Query parameters accepted by analytics endpoints. */
export interface AnalyticsQuery {
from?: string;
to?: string;
agentId?: string;
}

export interface TimeSeriesMetricPoint {
timestamp: string;
volume: string;
fee: string;
count: number;
successCount: number;
failureCount: number;
/**
* Analytics list queries: the shared {@link AnalyticsQuery} filters plus
* standard pagination controls (`page`, `limit`, `sort`, `order`, `search`).
*
* Applied to the tabular analytics endpoints (per-agent and per-budget rows) so
* clients can page through large historical result sets without pulling the
* full payload into memory.
*/
export interface AnalyticsListParams extends AnalyticsQuery, PaginationParams {}

/**
* Alias for a paginated analytics results payload. Keeps the narrow, row-level
* item type explicit at call sites (e.g. {@link AgentSpendingRow}).
*/
export type PaginatedResponse<TItem> = Paginated<TItem>;

/** A single (timestamp, value) point in a time series. */
export interface TimeSeriesPoint {
date: string;
value: number;
}

export interface VolumeSummary {
Expand Down
Loading