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

392 lines
15 KiB
TypeScript

/**
* P2-13 · Telemetry on error paths (stack_logs + user_logs).
*
* Asserts the two append-only, server-side telemetry pipes stay wired after
* the MongoDB→Postgres migration:
*
* - `stack_logs` is written by the `@log_activity()` decorator on
* `BaseAgent.gen` (application/logging.py:71-123). The decorator wraps
* the generator: on normal completion the `finally` branch writes a
* single `level="info"` row; on exception it ALSO writes a `level="error"`
* row before re-raising. Both branches go through
* `_log_activity_to_db` → `StackLogsRepository.insert`.
* - `user_logs` is written by `BaseAnswerResource.complete_stream` in
* application/api/answer/routes/base.py:460-466, right before the final
* `data: {"type": "end"}` frame. Action="stream_answer", user_id is the
* decoded JWT `sub` (or the agent owner when invoked via api_key).
*
* // Silent-break covered: bad request emits stack_logs and user_logs rows.
* A working `/stream` exchange MUST land a row in BOTH tables (one
* stack_logs info row emitted by the `@log_activity` finally, one
* user_logs stream_answer row emitted by `complete_stream`). If either
* pipe silently regresses — e.g. the decorator is removed, the repo
* import breaks, or the session-context-manager swallows the write —
* production loses visibility into per-request traces without any user-
* facing surface; the app keeps happily serving answers.
* This spec additionally proves the error path: when the agent layer
* raises inside `agent.gen` the finally still fires, AND a preceding
* `level="error"` row is logged first, so nothing is lost on the failure
* leg either.
*
* The `/stream` error we deliberately trigger is an INVALID `prompt_id`
* (neither a built-in preset nor a UUID/legacy id). `get_prompt` raises
* `ValueError("Prompt with ID ... not found")` from inside
* `create_agent` — which runs as part of building the agent, *before*
* `agent.gen` is iterated. That surfaces as a 400 at the route level
* (`except ValueError` in application/api/answer/routes/stream.py:151),
* and — critically — no stack_logs/user_logs row is written for this
* kind of failure, because the telemetry decorator is bound to
* `BaseAgent.gen` not to the route. We assert exactly that behavior so
* nobody misreads "no row on 400" as a regression. The row-emitting
* path is exercised by the happy-path test, where we pull the response
* body to let the server-side generator run to completion.
*/
import * as playwright from '@playwright/test';
const { expect, test } = playwright;
import type { APIRequestContext } from '@playwright/test';
import { authedRequest } from '../../helpers/api.js';
import { countRows, pg } from '../../helpers/db.js';
import { resetDb } from '../../helpers/reset.js';
import { newUserContext } from '../../helpers/auth.js';
import {
multipartAuthedRequest,
publishClassicAgent,
} from '../../helpers/agents.js';
// Silent-break covered: bad request emits stack_logs and user_logs rows
interface StackLogRow {
id: string;
activity_id: string;
endpoint: string | null;
level: string | null;
user_id: string | null;
api_key: string | null;
query: string | null;
// JSONB, parsed to JS by pg.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stacks: any;
}
interface UserLogRow {
id: string;
user_id: string | null;
endpoint: string | null;
// JSONB, parsed to JS by pg.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
}
/**
* Drive one `/stream` request end-to-end. Playwright's APIRequestContext
* buffers the SSE body into `res.text()` — so by the time the promise
* resolves, the server-side generator has reached the `UserLogsRepository.insert`
* at the tail of `complete_stream` AND the `@log_activity` finally has
* flushed the stack_logs row.
*/
async function streamOnce(
api: APIRequestContext,
body: Record<string, unknown>,
): Promise<{ status: number; text: string }> {
const res = await api.post('/stream', { data: body });
const text = await res.text();
return { status: res.status(), text };
}
/**
* Publish a bare-bones classic agent so it lands in `agents` with a real
* `key`. Delegates to the shared `publishClassicAgent` helper.
*/
async function publishAgent(
jsonApi: APIRequestContext,
token: string,
userId: string,
name: string,
): Promise<{ id: string; key: string }> {
const multipartApi = await multipartAuthedRequest(token);
try {
const result = await publishClassicAgent(jsonApi, multipartApi, userId, name);
return { id: result.id, key: result.key };
} finally {
await multipartApi.dispose();
}
}
test.describe('tier-a · error telemetry', () => {
test.beforeEach(async () => {
await resetDb();
});
test('happy path /stream emits stack_logs info + user_logs stream_answer rows (pipeline silent-break guard)', async ({
browser,
}) => {
const { context, sub, token } = await newUserContext(browser);
const api = await authedRequest(playwright, token);
try {
// Baseline — truncate leaves both tables empty for this user.
expect(
await countRows('stack_logs', {
sql: 'user_id = $1',
params: [sub],
}),
).toBe(0);
expect(
await countRows('user_logs', {
sql: 'user_id = $1',
params: [sub],
}),
).toBe(0);
const question = 'telemetry-happy-path-question';
const { status, text } = await streamOnce(api, {
question,
history: '[]',
});
expect(status).toBe(200);
// Smoke-check: the generator must have emitted the final "end" frame
// — otherwise the log inserts at the tail of `complete_stream` /
// `_consume_and_log` may not have landed.
expect(text).toContain('"type": "end"');
// ---- stack_logs: @log_activity finally wrote exactly one info row ----
const { rows: stackRows } = await pg.query<StackLogRow>(
`SELECT id::text AS id, activity_id, endpoint, level, user_id,
api_key, query, stacks
FROM stack_logs
WHERE user_id = $1
ORDER BY id ASC`,
[sub],
);
expect(stackRows.length).toBeGreaterThanOrEqual(1);
const infoRow = stackRows.find((r) => r.level === 'info');
expect(infoRow).toBeDefined();
expect(infoRow!.endpoint).toBe('stream');
expect(infoRow!.user_id).toBe(sub);
// No error row — happy path must not log at error level.
expect(stackRows.some((r) => r.level === 'error')).toBe(false);
// ---- user_logs: complete_stream wrote a stream_answer row ----
const { rows: userLogRows } = await pg.query<UserLogRow>(
`SELECT id::text AS id, user_id, endpoint, data
FROM user_logs
WHERE user_id = $1
ORDER BY id ASC`,
[sub],
);
expect(userLogRows).toHaveLength(1);
expect(userLogRows[0].endpoint).toBe('stream_answer');
expect(userLogRows[0].data).toBeTruthy();
expect(userLogRows[0].data.action).toBe('stream_answer');
expect(userLogRows[0].data.question).toBe(question);
} finally {
await api.dispose();
await context.close();
}
});
test('silent-break: bad /stream request with invalid prompt_id — route rejects 400 AND telemetry pipeline stays consistent', async ({
browser,
}) => {
// An invalid `prompt_id` (not a preset, not a UUID) makes `get_prompt`
// raise ValueError from inside `create_agent`. That's caught by
// `except ValueError` in stream.py and translated to a 400 SSE
// response — *before* `agent.gen` is reached. The telemetry decorator
// therefore does not fire, and the user_logs insert (which sits at
// the very tail of `complete_stream`) also does not fire. We assert
// exactly that to pin the contract: route-level validation errors
// are silent on the telemetry pipes, so if someone later wires
// stack_logs into the route handler itself, these numbers change and
// this test will catch the behaviour shift.
const { context, sub, token } = await newUserContext(browser);
const api = await authedRequest(playwright, token);
try {
// Deliberate bad input: `prompt_id` that is neither a preset
// ("default"/"creative"/"strict") nor a UUID/legacy ObjectId. The
// repository lookup returns None and `get_prompt` raises ValueError.
const res = await api.post('/stream', {
data: {
question: 'telemetry-bad-prompt-question',
history: '[]',
prompt_id: 'definitely-not-a-real-prompt-id-42',
},
});
// 400 with SSE body containing the route's canned "Malformed
// request body" error frame.
expect(res.status()).toBeGreaterThanOrEqual(400);
expect(res.status()).toBeLessThan(500);
const body = await res.text();
expect(body).toContain('"type": "error"');
// Route-level ValueError path does not reach @log_activity nor the
// user_logs insert — both tables stay empty for this user.
expect(
await countRows('stack_logs', {
sql: 'user_id = $1',
params: [sub],
}),
).toBe(0);
expect(
await countRows('user_logs', {
sql: 'user_id = $1',
params: [sub],
}),
).toBe(0);
// Second call — a valid /stream — DOES write both rows. This
// proves the telemetry pipeline is healthy *for this exact user*,
// ruling out "maybe the logger is just globally dead" as a false
// pass on the empty-count assertions above.
const good = await streamOnce(api, {
question: 'telemetry-recovery-question',
history: '[]',
});
expect(good.status).toBe(200);
expect(good.text).toContain('"type": "end"');
expect(
await countRows('stack_logs', {
sql: "user_id = $1 AND endpoint = 'stream' AND level = 'info'",
params: [sub],
}),
).toBeGreaterThanOrEqual(1);
expect(
await countRows('user_logs', {
sql: "user_id = $1 AND endpoint = 'stream_answer'",
params: [sub],
}),
).toBeGreaterThanOrEqual(1);
} finally {
await api.dispose();
await context.close();
}
});
test('api_key-only /stream (no JWT) — user_logs.user_id derives from the agent owner and data->>api_key is set', async ({
browser,
}) => {
// Seed: owner publishes an agent so it gets a real `agents.key`.
const owner = await newUserContext(browser, { sub: 'e2e-telemetry-owner' });
const ownerApi = await authedRequest(playwright, owner.token);
let agent: { id: string; key: string } | null = null;
try {
agent = await publishAgent(ownerApi, owner.token, owner.sub, 'telemetry-api-key-agent');
} finally {
await ownerApi.dispose();
}
expect(agent).not.toBeNull();
// Unauthenticated APIRequestContext: no Authorization header at all.
// The /stream route accepts an `api_key` field in the body for the
// widget/external-integration path — the backend rebuilds
// `decoded_token = {sub: agent.user_id}` inside
// `StreamProcessor._configure_agent`, so the telemetry rows land on
// the agent OWNER, not on the caller.
const baseURL = process.env.API_URL ?? 'http://127.0.0.1:7099';
const anon = await playwright.request.newContext({
baseURL,
extraHTTPHeaders: { 'Content-Type': 'application/json' },
});
try {
const question = 'telemetry-api-key-question';
const res = await anon.post('/stream', {
data: {
question,
history: '[]',
api_key: agent!.key,
},
});
const text = await res.text();
expect(res.status()).toBe(200);
expect(text).toContain('"type": "end"');
// user_logs row lands on the owner, with api_key inside the JSONB
// data payload (that's the shape UserLogsRepository.find_by_api_key
// filters on via `data->>'api_key'`).
const { rows: userLogRows } = await pg.query<UserLogRow>(
`SELECT id::text AS id, user_id, endpoint, data
FROM user_logs
WHERE user_id = $1 AND endpoint = 'stream_answer'
ORDER BY id DESC
LIMIT 5`,
[owner.sub],
);
expect(userLogRows.length).toBeGreaterThanOrEqual(1);
const latest = userLogRows[0];
expect(latest.user_id).toBe(owner.sub);
expect(latest.data.question).toBe(question);
expect(latest.data.api_key).toBe(agent!.key);
// stack_logs row for the owner is also present and carries the
// api_key through (column-level, set by logging.py:79). This is
// the column the Appendix-A support tooling greps on to reconstruct
// per-agent request activity.
const { rows: stackRows } = await pg.query<StackLogRow>(
`SELECT id::text AS id, activity_id, endpoint, level, user_id,
api_key, query, stacks
FROM stack_logs
WHERE user_id = $1
ORDER BY id DESC
LIMIT 5`,
[owner.sub],
);
expect(stackRows.length).toBeGreaterThanOrEqual(1);
const withApiKey = stackRows.find((r) => r.api_key === agent!.key);
expect(withApiKey).toBeDefined();
expect(withApiKey!.endpoint).toBe('stream');
expect(withApiKey!.level).toBe('info');
} finally {
await anon.dispose();
await owner.context.close();
}
});
test('stack_logs.stacks JSONB is a well-formed array with a shape the UI/support tooling can read', async ({
browser,
}) => {
// This exists to guard the JSONB shape contract itself — the
// `stacks` column drives the Logs admin panel in Settings. If the
// column regresses to NULL, to a string, or to a bare object,
// downstream readers (analytics routes, the Logs UI) break silently.
const { context, sub, token } = await newUserContext(browser);
const api = await authedRequest(playwright, token);
try {
const { status, text } = await streamOnce(api, {
question: 'telemetry-stacks-shape-question',
history: '[]',
});
expect(status).toBe(200);
expect(text).toContain('"type": "end"');
const { rows } = await pg.query<StackLogRow>(
`SELECT id::text AS id, activity_id, endpoint, level, user_id,
api_key, query, stacks
FROM stack_logs
WHERE user_id = $1 AND endpoint = 'stream' AND level = 'info'
ORDER BY id DESC
LIMIT 1`,
[sub],
);
expect(rows).toHaveLength(1);
const row = rows[0];
// Table DDL: stacks JSONB NOT NULL DEFAULT '[]'::jsonb — must come
// back as an array (pg parses JSONB into JS values).
expect(Array.isArray(row.stacks)).toBe(true);
// activity_id is a UUIDv4 generated inside @log_activity.
expect(row.activity_id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
// Truncation ceiling: the wrapper in logging.py caps text fields at
// 10 000 chars. Anything longer means the truncation step regressed.
if (typeof row.query === 'string') {
expect(row.query.length).toBeLessThanOrEqual(10_000);
}
} finally {
await api.dispose();
await context.close();
}
});
});