Files
wehub-resource-sync fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

530 lines
20 KiB
TypeScript

/**
* P2-04 · Token usage / message / feedback analytics.
*
* Silent-break covered: analytics bucket keys use YYYY-MM-DD HH24:00 format
* (Postgres `to_char`). A drift to the Python `%Y-%m-%d %H:00` strftime
* shape would look identical in the happy case but differ at the second /
* minute boundary when `datetime.now()` crosses an hour just as the
* bucket labels are being generated for empty intervals. The assertions
* below verify the server emits the Postgres format for every
* filter_option so the intervals dict and the grouped rows key-match.
*
* Tables touched (reads only — analytics never writes):
* - `token_usage` (seeded directly below)
* - `conversation_messages` (feedback) (seeded directly below)
* - `conversations` (joined on c.user_id / c.api_key)
* - `agents` (resolves api_key_id → key)
*
* Why seed token_usage via INSERT rather than via `/stream`:
* the streaming path produces tokens via the mock LLM whose exact count
* and timing are not deterministic enough for `expect().toBe(42)` — but
* analytics itself is a pure read aggregator. We are testing the
* aggregation shape, not the writer. A direct INSERT yields the exact
* same row shape `TokenUsageRepository.insert` produces (see
* `application/storage/db/repositories/token_usage.py`). One test
* cross-checks the writer path implicitly by using an agent api_key
* created through the real `/api/create_agent` endpoint.
*/
import * as playwright from '@playwright/test';
const { expect, test } = playwright;
import { authedRequest } from '../../helpers/api.js';
import { newUserContext, signJwt } from '../../helpers/auth.js';
import { pg } from '../../helpers/db.js';
import { resetDb } from '../../helpers/reset.js';
// Bucket format regexes — these are the "silent-break" assertions.
// Postgres `to_char` with `YYYY-MM-DD HH24:MI:00` / `YYYY-MM-DD HH24:00` /
// `YYYY-MM-DD`. If a future refactor ever routes the bucket generation
// through Python `strftime('%Y-%m-%d %H:00')` instead, these regexes will
// still match the happy case but the JOIN on bucket key (rows vs
// intervals) can mis-align at minute boundaries. The test-3 empty-user
// zero-fill assertion guards the join invariant.
const MINUTE_BUCKET_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:00$/; // last_hour
const HOUR_BUCKET_RE = /^\d{4}-\d{2}-\d{2} \d{2}:00$/; // last_24_hour
const DAY_BUCKET_RE = /^\d{4}-\d{2}-\d{2}$/; // last_7/15/30_days
type AnalyticsResponse = {
success: boolean;
token_usage?: Record<string, number>;
messages?: Record<string, number>;
feedback?: Record<string, { positive: number; negative: number }>;
};
/**
* Seed one token_usage row at a specific timestamp. Mirrors what
* `TokenUsageRepository.insert` writes — if that shape ever drifts, this
* helper breaks loudly.
*/
async function seedTokenUsage(args: {
userId: string;
apiKey?: string | null;
promptTokens: number;
generatedTokens: number;
timestamp: Date;
}): Promise<void> {
await pg.query(
`INSERT INTO token_usage (user_id, api_key, prompt_tokens, generated_tokens, timestamp)
VALUES ($1, $2, $3, $4, $5)`,
[
args.userId,
args.apiKey ?? null,
args.promptTokens,
args.generatedTokens,
args.timestamp.toISOString(),
],
);
}
test.describe('tier-a · token usage analytics', () => {
test.beforeEach(async () => {
await resetDb();
});
test('token analytics returns non-zero buckets after seeded usage', async ({
browser,
}) => {
const { context, sub, token } = await newUserContext(browser);
try {
// Two rows in the last 24h — one an hour ago, one half an hour ago.
// Total = (100+50) + (200+80) = 150 + 280 = 430 tokens across two
// buckets. Exact bucket boundaries are to_char-driven — we only
// assert "some bucket is non-zero and the sum matches".
const now = new Date();
const hourAgo = new Date(now.getTime() - 60 * 60 * 1000 + 5_000);
const halfHourAgo = new Date(now.getTime() - 30 * 60 * 1000);
await seedTokenUsage({
userId: sub,
promptTokens: 100,
generatedTokens: 50,
timestamp: hourAgo,
});
await seedTokenUsage({
userId: sub,
promptTokens: 200,
generatedTokens: 80,
timestamp: halfHourAgo,
});
const api = await authedRequest(playwright, token);
try {
const res = await api.post('/api/get_token_analytics', {
data: { filter_option: 'last_24_hour' },
});
expect(res.status()).toBe(200);
const body = (await res.json()) as AnalyticsResponse;
expect(body.success).toBe(true);
expect(body.token_usage).toBeDefined();
const buckets = body.token_usage ?? {};
const total = Object.values(buckets).reduce((a, b) => a + b, 0);
expect(total).toBe(430);
// At least one bucket is non-zero. The precise bucket depends on
// the wall clock at query time (and whether the two seeds land in
// the same hour); we only assert "something non-zero surfaced".
const nonZeroBuckets = Object.entries(buckets).filter(
([, v]) => v > 0,
);
expect(nonZeroBuckets.length).toBeGreaterThanOrEqual(1);
} finally {
await api.dispose();
}
} finally {
await context.close();
}
});
test('bucket key format matches YYYY-MM-DD HH24:00 contract for every filter', async ({
browser,
}) => {
const { context, sub, token } = await newUserContext(browser);
try {
// Seed a single row within the last minute so every filter window
// covers it — we need real rows in at least one bucket to prove the
// GROUP BY path also emits the expected format (not just the
// Python-generated zero intervals).
await seedTokenUsage({
userId: sub,
promptTokens: 12,
generatedTokens: 34,
timestamp: new Date(),
});
const api = await authedRequest(playwright, token);
try {
// last_hour → minute buckets.
const lastHour = await api.post('/api/get_token_analytics', {
data: { filter_option: 'last_hour' },
});
expect(lastHour.status()).toBe(200);
const lastHourBody = (await lastHour.json()) as AnalyticsResponse;
expect(lastHourBody.success).toBe(true);
const lastHourKeys = Object.keys(lastHourBody.token_usage ?? {});
expect(lastHourKeys.length).toBeGreaterThan(0);
for (const key of lastHourKeys) {
expect(
key,
`last_hour bucket key "${key}" must match YYYY-MM-DD HH:MM:00`,
).toMatch(MINUTE_BUCKET_RE);
}
// last_24_hour → hour buckets (the exact silent-break format).
const last24 = await api.post('/api/get_token_analytics', {
data: { filter_option: 'last_24_hour' },
});
expect(last24.status()).toBe(200);
const last24Body = (await last24.json()) as AnalyticsResponse;
const last24Keys = Object.keys(last24Body.token_usage ?? {});
expect(last24Keys.length).toBeGreaterThan(0);
for (const key of last24Keys) {
expect(
key,
`last_24_hour bucket key "${key}" must match YYYY-MM-DD HH24:00`,
).toMatch(HOUR_BUCKET_RE);
}
// last_7_days → day buckets.
const last7 = await api.post('/api/get_token_analytics', {
data: { filter_option: 'last_7_days' },
});
expect(last7.status()).toBe(200);
const last7Body = (await last7.json()) as AnalyticsResponse;
const last7Keys = Object.keys(last7Body.token_usage ?? {});
expect(last7Keys.length).toBeGreaterThan(0);
for (const key of last7Keys) {
expect(
key,
`last_7_days bucket key "${key}" must match YYYY-MM-DD`,
).toMatch(DAY_BUCKET_RE);
}
// Same contract applies to the messages endpoint — it derives
// the pg_fmt from the same `_FILTER_BUCKETS` table.
const msgs24 = await api.post('/api/get_message_analytics', {
data: { filter_option: 'last_24_hour' },
});
expect(msgs24.status()).toBe(200);
const msgs24Body = (await msgs24.json()) as AnalyticsResponse;
for (const key of Object.keys(msgs24Body.messages ?? {})) {
expect(key).toMatch(HOUR_BUCKET_RE);
}
// And to the feedback endpoint.
const fb30 = await api.post('/api/get_feedback_analytics', {
data: { filter_option: 'last_30_days' },
});
expect(fb30.status()).toBe(200);
const fb30Body = (await fb30.json()) as AnalyticsResponse;
for (const key of Object.keys(fb30Body.feedback ?? {})) {
expect(key).toMatch(DAY_BUCKET_RE);
}
} finally {
await api.dispose();
}
} finally {
await context.close();
}
});
test('fresh user returns all-zero buckets (zero-filled, not empty object)', async ({
browser,
}) => {
// Invariant: even with zero rows the response dict must contain every
// interval key pre-populated to 0. This is what the UI line-chart
// depends on — an empty `{}` would render an empty axis, not a flat
// zero line. The Python-generated intervals dict must survive the
// "no rows" path.
const { context, token } = await newUserContext(browser);
try {
const api = await authedRequest(playwright, token);
try {
const tokenRes = await api.post('/api/get_token_analytics', {
data: { filter_option: 'last_24_hour' },
});
expect(tokenRes.status()).toBe(200);
const tokenBody = (await tokenRes.json()) as AnalyticsResponse;
expect(tokenBody.success).toBe(true);
const tokenBuckets = tokenBody.token_usage ?? {};
// 25 hourly intervals spanning `now - 24h` through `now` (inclusive).
expect(Object.keys(tokenBuckets).length).toBeGreaterThanOrEqual(24);
for (const [key, value] of Object.entries(tokenBuckets)) {
expect(key).toMatch(HOUR_BUCKET_RE);
expect(value).toBe(0);
}
const msgRes = await api.post('/api/get_message_analytics', {
data: { filter_option: 'last_7_days' },
});
expect(msgRes.status()).toBe(200);
const msgBody = (await msgRes.json()) as AnalyticsResponse;
const msgBuckets = msgBody.messages ?? {};
expect(Object.keys(msgBuckets).length).toBe(7);
for (const [key, value] of Object.entries(msgBuckets)) {
expect(key).toMatch(DAY_BUCKET_RE);
expect(value).toBe(0);
}
const fbRes = await api.post('/api/get_feedback_analytics', {
data: { filter_option: 'last_7_days' },
});
expect(fbRes.status()).toBe(200);
const fbBody = (await fbRes.json()) as AnalyticsResponse;
const fbBuckets = fbBody.feedback ?? {};
expect(Object.keys(fbBuckets).length).toBe(7);
for (const [key, value] of Object.entries(fbBuckets)) {
expect(key).toMatch(DAY_BUCKET_RE);
expect(value).toEqual({ positive: 0, negative: 0 });
}
} finally {
await api.dispose();
}
} finally {
await context.close();
}
});
test('filter by api_key_id includes only that agent\'s usage', async ({
browser,
}) => {
const { context, sub, token } = await newUserContext(browser);
try {
const api = await authedRequest(playwright, token);
try {
// Create a published agent to get a real `key` value (the api_key
// written into token_usage rows). `api_key_id` in analytics is
// the agent's `id`, not its `key` — `_resolve_api_key` looks up
// the agent and returns `agent.key`.
const createRes = await api.post('/api/create_agent', {
data: {
name: 'e2e-analytics-agent',
description: 'for analytics api_key filtering',
status: 'published',
source: 'default',
prompt_id: 'default',
chunks: 2,
retriever: 'classic',
agent_type: 'classic',
},
});
expect(createRes.status()).toBe(201);
const created = (await createRes.json()) as { id: string; key: string };
expect(created.id).toBeTruthy();
expect(created.key).toBeTruthy();
const now = new Date();
const twoMinAgo = new Date(now.getTime() - 2 * 60 * 1000);
const oneMinAgo = new Date(now.getTime() - 60 * 1000);
// Row attributed to the agent's key.
await seedTokenUsage({
userId: sub,
apiKey: created.key,
promptTokens: 111,
generatedTokens: 22,
timestamp: twoMinAgo,
});
// Row attributed to the user but NOT the agent (direct chat, no
// api_key) — should be excluded when filtering by api_key_id.
await seedTokenUsage({
userId: sub,
apiKey: null,
promptTokens: 999,
generatedTokens: 999,
timestamp: oneMinAgo,
});
// Unfiltered: both rows contribute. 111+22 + 999+999 = 2131.
const allRes = await api.post('/api/get_token_analytics', {
data: { filter_option: 'last_24_hour' },
});
expect(allRes.status()).toBe(200);
const allBody = (await allRes.json()) as AnalyticsResponse;
const allTotal = Object.values(allBody.token_usage ?? {}).reduce(
(a, b) => a + b,
0,
);
expect(allTotal).toBe(111 + 22 + 999 + 999);
// Filtered by api_key_id: only the agent's row. 111+22 = 133.
const filteredRes = await api.post('/api/get_token_analytics', {
data: {
filter_option: 'last_24_hour',
api_key_id: created.id,
},
});
expect(filteredRes.status()).toBe(200);
const filteredBody = (await filteredRes.json()) as AnalyticsResponse;
const filteredTotal = Object.values(
filteredBody.token_usage ?? {},
).reduce((a, b) => a + b, 0);
expect(filteredTotal).toBe(111 + 22);
} finally {
await api.dispose();
}
} finally {
await context.close();
}
});
test('cross-tenant: filtering by another user\'s api_key_id does not leak their usage', async ({
browser,
}) => {
// User A and user B each have data. A queries with B's api_key_id.
// `_resolve_agent(conn, b_agent_id, a_sub)` finds nothing (agent
// lookup is user-scoped), and the endpoint short-circuits to an
// explicit EMPTY result — a filter that doesn't resolve must never
// silently widen to "all your data" (and certainly not to B's).
const aSub = 'e2e-analytics-cross-a';
const bSub = 'e2e-analytics-cross-b';
const aToken = signJwt(aSub);
const bToken = signJwt(bSub);
const apiA = await authedRequest(playwright, aToken);
const apiB = await authedRequest(playwright, bToken);
try {
// B creates a published agent — B's `agents.id` is the `api_key_id`
// A will try to probe.
const bAgentRes = await apiB.post('/api/create_agent', {
data: {
name: 'b-agent',
description: 'owned by user B',
status: 'published',
source: 'default',
prompt_id: 'default',
chunks: 2,
retriever: 'classic',
agent_type: 'classic',
},
});
expect(bAgentRes.status()).toBe(201);
const bAgent = (await bAgentRes.json()) as { id: string; key: string };
// Seed usage for both users in the last 24h.
const now = new Date();
const oneMinAgo = new Date(now.getTime() - 60 * 1000);
await seedTokenUsage({
userId: aSub,
promptTokens: 10,
generatedTokens: 5,
timestamp: oneMinAgo,
});
await seedTokenUsage({
userId: bSub,
apiKey: bAgent.key,
promptTokens: 777,
generatedTokens: 333,
timestamp: oneMinAgo,
});
// A queries analytics with B's agent id as api_key_id.
const res = await apiA.post('/api/get_token_analytics', {
data: {
filter_option: 'last_24_hour',
api_key_id: bAgent.id,
},
});
expect(res.status()).toBe(200);
const body = (await res.json()) as AnalyticsResponse;
expect(body.success).toBe(true);
const buckets = body.token_usage ?? {};
// An unresolved agent filter returns all-zero buckets. B's
// 777+333=1110 MUST NOT appear, and neither must A's own 15 —
// the filter explicitly matched nothing.
const total = Object.values(buckets).reduce((a, b) => a + b, 0);
expect(total).toBe(0);
// Double-check: B's bucket value cannot have bled in under any
// key.
for (const value of Object.values(buckets)) {
expect(value).toBe(0);
}
} finally {
await apiA.dispose();
await apiB.dispose();
}
});
test('stale agent: deleting an agent leaves historical token_usage in the all-agents view', async ({
browser,
}) => {
// `agent_id` in `token_usage` has `ON DELETE SET NULL` (see
// `token_usage_agent_fk` in 0001_initial.py:312). Deleting the agent
// nulls the agent_id column on historical rows but keeps the tokens
// — the unfiltered "all agents" analytics view must still surface
// them. The api_key column is NOT cleared by the trigger (it's a
// plain string copy, not a FK), so `api_key`-scoped filters would
// still match — but agent resolution by api_key_id is now impossible
// because the agents row is gone.
const { context, sub, token } = await newUserContext(browser);
try {
const api = await authedRequest(playwright, token);
try {
const createRes = await api.post('/api/create_agent', {
data: {
name: 'soon-to-die',
description: 'deleted mid-spec',
status: 'published',
source: 'default',
prompt_id: 'default',
chunks: 2,
retriever: 'classic',
agent_type: 'classic',
},
});
expect(createRes.status()).toBe(201);
const agent = (await createRes.json()) as { id: string; key: string };
const oneMinAgo = new Date(Date.now() - 60 * 1000);
await pg.query(
`INSERT INTO token_usage (user_id, api_key, agent_id, prompt_tokens, generated_tokens, timestamp)
VALUES ($1, $2, CAST($3 AS uuid), $4, $5, $6)`,
[sub, agent.key, agent.id, 42, 58, oneMinAgo.toISOString()],
);
// Sanity: token_usage row is linked to the agent.
const before = await pg.query<{ n: string }>(
`SELECT count(*)::text AS n FROM token_usage
WHERE user_id = $1 AND agent_id = CAST($2 AS uuid)`,
[sub, agent.id],
);
expect(Number(before.rows[0].n)).toBe(1);
// Delete the agent via `DELETE /api/delete_agent?id=<uuid>`.
// The `ON DELETE SET NULL` FK (`token_usage_agent_fk`) nulls the
// `agent_id` column on the historical token_usage row.
const delRes = await api.delete('/api/delete_agent', {
params: { id: agent.id },
});
expect(delRes.status()).toBe(200);
const after = await pg.query<{ n: string; agent_id: string | null }>(
`SELECT count(*)::text AS n, MAX(agent_id::text) AS agent_id
FROM token_usage WHERE user_id = $1`,
[sub],
);
expect(Number(after.rows[0].n)).toBe(1);
expect(after.rows[0].agent_id).toBeNull();
// All-agents analytics view still reports the 100 tokens.
const analyticsRes = await api.post('/api/get_token_analytics', {
data: { filter_option: 'last_24_hour' },
});
expect(analyticsRes.status()).toBe(200);
const analytics = (await analyticsRes.json()) as AnalyticsResponse;
const total = Object.values(analytics.token_usage ?? {}).reduce(
(a, b) => a + b,
0,
);
expect(total).toBe(100);
} finally {
await api.dispose();
}
} finally {
await context.close();
}
});
});