From 4d1d988c358b3402e25f178449ed4a7e371a4f7a Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Tue, 13 Oct 2026 07:17:31 +0000 Subject: [PATCH] Add request log --- apps/web/PRODUCT.md | 2 +- apps/web/e2e/data/request-log.mjs | 16 + apps/web/e2e/fixture-console.mjs | 2 + apps/web/e2e/request-log.spec.ts | 88 +++++ apps/web/src/ConsoleApp.tsx | 3 + apps/web/src/components/ConsoleSidebar.tsx | 2 + .../features/request-log/RequestLogPage.tsx | 105 ++++++ .../src/features/request-log/request-log.css | 6 + apps/web/src/i18n/locales/en/navigation.ts | 1 + apps/web/src/i18n/locales/en/request-log.ts | 43 +++ apps/web/src/i18n/locales/zh-CN/navigation.ts | 1 + .../web/src/i18n/locales/zh-CN/request-log.ts | 43 +++ apps/web/src/i18n/resources.ts | 4 + apps/web/src/lib/console-routes.test.ts | 2 +- apps/web/src/lib/console-routes.ts | 3 +- contracts/agents-api/admin-api.md | 13 + contracts/agents-api/core.openapi.yaml | 140 ++++++++ contracts/agents-api/zh/admin-api.md | 15 +- docs/api/index.md | 2 +- docs/getting-started/operations.md | 4 + docs/web/console-api-usage.md | 4 + docs/web/index.md | 1 + docs/zh/api/index.md | 4 +- docs/zh/getting-started/operations.md | 6 +- docs/zh/web/console-api-usage.md | 6 +- docs/zh/web/index.md | 3 +- packages/agents-client/src/admin-client.ts | 6 + .../agents-client/src/generated/core-api.ts | 29 ++ packages/agents-client/src/index.ts | 2 + .../agents-client/src/request-log.test.ts | 40 +++ packages/agents-client/src/request-log.ts | 41 +++ services/core/internal/api/core_metrics.go | 4 +- services/core/internal/api/core_routes.go | 1 + services/core/internal/api/dependencies.go | 3 +- services/core/internal/api/handler.go | 3 +- services/core/internal/api/request_logs.go | 190 +++++++++++ .../core/internal/api/request_logs_test.go | 314 ++++++++++++++++++ services/core/internal/api/routing.go | 88 ++++- 38 files changed, 1217 insertions(+), 23 deletions(-) create mode 100644 apps/web/e2e/data/request-log.mjs create mode 100644 apps/web/e2e/request-log.spec.ts create mode 100644 apps/web/src/features/request-log/RequestLogPage.tsx create mode 100644 apps/web/src/features/request-log/request-log.css create mode 100644 apps/web/src/i18n/locales/en/request-log.ts create mode 100644 apps/web/src/i18n/locales/zh-CN/request-log.ts create mode 100644 packages/agents-client/src/request-log.test.ts create mode 100644 packages/agents-client/src/request-log.ts create mode 100644 services/core/internal/api/request_logs.go create mode 100644 services/core/internal/api/request_logs_test.go diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 0b924e7c5..903a28def 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -29,7 +29,7 @@ The console runs beside the administrator's own Core, with execution, files and ## Information Architecture -- **Monitor**: Overview (service status, running Sessions, sandbox slots, Sessions needing attention, 24-hour Session activity, a compact inventory of Core and up to four nodes, prioritizing offline and degraded nodes when the list is full, with a popover glance at each, the attention table, usage by project), Core metrics (the Core process's CPU and memory, execution slots and the Turn queue, connected daemons, the database and background jobs), Agent metrics (requests, errors, duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (node capacity and hosted Runtimes across projects; a node or a sandbox opens in a dialog with its figures and CPU and memory charts), Session log (every Session, read-only, with a failed Session's reason under its status, opening one Session's history, which jumps to its failed Turns; a self-hosted Session's page also has its environment's executor credentials). Session facts and Agent metrics link to an Agent's page only when that saved resource exists; inline snapshots and snapshots of deleted Agents remain readable without a broken resource link. Agent metrics' failed Turns open the Agent's Sessions in the Session log. +- **Monitor**: Overview (service status, running Sessions, sandbox slots, Sessions needing attention, 24-hour Session activity, a compact inventory of Core and up to four nodes, prioritizing offline and degraded nodes when the list is full, with a popover glance at each, the attention table, usage by project), Core metrics (the Core process's CPU and memory, execution slots and the Turn queue, connected daemons, the database and background jobs), Agent metrics (requests, errors, duration, tokens, models, tools, Agents and API keys for 1 h / 6 h / 24 h / 7 d), Sandbox metrics (node capacity and hosted Runtimes across projects; a node or a sandbox opens in a dialog with its figures and CPU and memory charts), Request log (recent public or administration HTTP requests, status, duration, safe error code and correlation IDs; bounded to the serving Core instance, with manual refresh and no body capture), Session log (every Session, read-only, with a failed Session's reason under its status, opening one Session's history, which jumps to its failed Turns; a self-hosted Session's page also has its environment's executor credentials). Session facts and Agent metrics link to an Agent's page only when that saved resource exists; inline snapshots and snapshots of deleted Agents remain readable without a broken resource link. Agent metrics' failed Turns open the Agent's Sessions in the Session log. - **Resources**: Agents, Environment templates, Skills, Files, Vaults. Each list shows one project or all projects, with a Project column when all are shown and a Creator column naming the creating key. Detail pages show the resource's facts and offer Delete. - **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (the node list, capacity observations, host figures, allocations and individual removal operations), and Settings, the single entry for platform configuration. Settings has four groups: Sandbox and scheduling, Workspace storage, Models and credentials, and Service and deployment. Sandbox and scheduling owns setup, resource edits, rollout, reset, idle policy, per-node capacity and enrollment. Add node asks for limits before issuing its one-time command; installers use Core's public URL and require supported node artifacts. Nodes links to these forms; removal stays on the node page and offers the host's uninstall command. Models and credentials owns each harness's default model. Service and deployment owns installation facts, Domain and HTTPS actions and read-only startup settings. - A node whose provider is not ready names the reason as one Provider-neutral readiness class (provider unavailable, host unsupported, provider files or Runtime image missing, Runtime download failed, a host too small) and its fix in the help tip beside its status, wherever that status shows. diff --git a/apps/web/e2e/data/request-log.mjs b/apps/web/e2e/data/request-log.mjs new file mode 100644 index 000000000..721955846 --- /dev/null +++ b/apps/web/e2e/data/request-log.mjs @@ -0,0 +1,16 @@ +/** Synthetic current-process HTTP observations for the console acceptance fixture. */ +export function requestLog(url) { + const surface = url.searchParams.get("surface") ?? "public"; + const entry = (n, values) => ({ request_id: `req_${String(n).padStart(32, "0")}`, trace_id: String(n).padStart(32, "0"), started_at: `2026-10-13T10:00:0${n}Z`, method: "GET", route: "/v1/agents/{agent_id}", state: "completed", status: 200, duration_ms: 12, response_bytes: 512, error_code: null, ...values }); + const retained = surface === "public" ? [ + entry(4, { route: "/v1/agents/sessions/{session_id}/events", state: "active", status: 200, duration_ms: 2400, response_bytes: 1024 }), + entry(3, { route: "/v1/files/{file_id}", status: 401, error_code: "invalid_api_key", response_bytes: 128 }), + entry(2, { method: "POST", route: "/v1/agents/sessions/{session_id}/events", status: 202 }), + entry(1, { route: "/v1/agents/sessions/{session_id}/events", state: "aborted", status: 200 }), + ] : [entry(5, { route: "/core/v1/projects" })]; + const filtered = retained.filter((row) => (!url.searchParams.get("method") || row.method === url.searchParams.get("method")) && (!url.searchParams.get("request_id") || row.request_id === url.searchParams.get("request_id")) && + (url.searchParams.get("outcome") !== "active" || row.state === "active") && + (url.searchParams.get("outcome") !== "errors" || row.status >= 400 || row.state === "aborted")); + const data = filtered.slice(0, Number(url.searchParams.get("limit") ?? 100)); + return { object: "core.request_log.list", instance_id: "fixture-process", instance_started_at: "2026-10-13T09:00:00Z", surface, capacity: 1000, retained_count: retained.length, total_matching: filtered.length, has_more: data.length < filtered.length, data }; +} diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index cf363921a..f3189ffb0 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -1,3 +1,4 @@ +import { requestLog } from "./data/request-log.mjs"; // Browser acceptance fixture: the console service's routes (/console/**) and the // Core management tree it forwards (/core/v1/**: installation, projects, summary, // audit log, metrics, harness default models and /core/v1/sandbox/**), with synthetic, deterministic data and @@ -259,6 +260,7 @@ function adminRead(response, path, url) { if (path === "/installation") return send(response, 200, installation()); if (path === "/projects") return send(response, 200, { data: a.projects.map(a.publicProject), has_more: false }); if (path === "/summary") return send(response, 200, a.summary(url)); + if (path === "/request-logs") return send(response, 200, requestLog(url)); if (path === "/metrics") return send(response, 200, coreMetrics(url.searchParams.get("range") ?? "1h")); const match = path.match(/^\/projects\/([^/]+)(\/.*)?$/); const project = match && a.projects.find((entry) => entry.id === match[1]); diff --git a/apps/web/e2e/request-log.spec.ts b/apps/web/e2e/request-log.spec.ts new file mode 100644 index 000000000..45cd15625 --- /dev/null +++ b/apps/web/e2e/request-log.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from "@playwright/test"; +import { expectManagementBoundary, openConsole } from "./console"; + +test.afterEach(async ({ request }) => expectManagementBoundary(request)); +const id = (n: number) => `req_${String(n).padStart(32, "0")}`; + +test("inspects request arrival and HTTP outcomes without confusing Turn completion", async ({ page, request }) => { + const reads: string[] = []; + page.on("request", (request) => { if (request.url().includes("/request-logs")) reads.push(request.url()); }); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page.setViewportSize({ width: 1280, height: 800 }); + await openConsole(page, request, "request-log"); + await expect(page.getByRole("heading", { name: "Request log", exact: true })).toBeVisible(); + await expect(page.getByText("HTTP completion does not mean the Agent Turn has finished.")).toBeVisible(); + const table = page.getByRole("table", { name: "Request log" }); + await expect(table.getByRole("row")).toHaveCount(5); + const accepted = table.getByRole("row").filter({ hasText: "POST" }); + await expect(accepted).toContainText("202"); + await expect(accepted).toContainText("Completed"); + await expect(accepted.getByRole("cell").last()).toBeInViewport(); + await table.getByRole("button", { name: `Open request ${id(4)}`, exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Request details" }); + await expect(dialog).toContainText("Elapsed at snapshot"); + await expect(dialog).toContainText("1,024"); + await dialog.getByRole("button", { name: "Copy trace ID" }).click(); + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("00000000000000000000000000000004"); + await page.keyboard.press("Escape"); + expect(reads).toHaveLength(1); + await page.getByRole("radio", { name: "Errors", exact: true }).click(); + await expect(table.getByRole("row")).toHaveCount(3); + await expect(table).toContainText("Aborted"); + await table.getByRole("button", { name: `Open request ${id(3)}`, exact: true }).click(); + await expect(dialog).toContainText("invalid_api_key"); + await page.keyboard.press("Escape"); + await page.getByRole("radio", { name: "Active", exact: true }).click(); + await expect(table.getByRole("row")).toHaveCount(2); + await expect(table).toContainText("Active"); + await page.getByRole("radio", { name: "All", exact: true }).click(); + await page.getByRole("combobox", { name: "HTTP method" }).click(); + await page.getByRole("option", { name: "POST", exact: true }).click(); + await expect(table.getByRole("row")).toHaveCount(2); + await expect(table).toContainText("POST"); + await page.getByRole("combobox", { name: "HTTP method" }).click(); + await page.getByRole("option", { name: "All methods", exact: true }).click(); + await page.getByRole("searchbox", { name: "Request ID", exact: true }).fill(id(2)); + await page.getByRole("button", { name: "Find request", exact: true }).click(); + await expect(table.getByRole("row")).toHaveCount(2); + await expect(table).toContainText("POST"); + await page.getByRole("searchbox").fill(""); + await page.getByRole("button", { name: "Find request", exact: true }).click(); + await page.getByRole("radio", { name: "Administration API", exact: true }).click(); + await expect(table).toContainText("/core/v1/projects"); + await expect(table.getByRole("row")).toHaveCount(2); +}); + +test("keeps a failed refresh visibly stale and recovers without losing retained requests", async ({ page, request }) => { + await openConsole(page, request, "request-log"); + const table = page.getByRole("table", { name: "Request log" }); + await expect(table).toBeVisible(); + await page.route("**/core/v1/request-logs?*", (route) => route.fulfill({ status: 503, json: { error: { message: "Unavailable", type: "server_error", code: "unavailable" } } })); + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect(page.getByText("Some reads failed. Any figures and rows shown cover only data already read; they may be incomplete or out of date.")).toBeVisible(); + await expect(table.getByRole("row")).toHaveCount(5); + await page.unroute("**/core/v1/request-logs?*"); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(page.getByText("Some reads failed. Any figures and rows shown cover only data already read; they may be incomplete or out of date.")).toHaveCount(0); +}); + +test("distinguishes unavailable reads, empty retained results and bounded coverage", async ({ page, request }) => { + await page.route("**/core/v1/request-logs?*", (route) => route.fulfill({ status: 503, json: { error: { message: "Unavailable", type: "server_error", code: "unavailable" } } })); + await openConsole(page, request, "request-log"); + await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible(); + await expect(page.getByText("No retained requests match")).toHaveCount(0); + await page.unroute("**/core/v1/request-logs?*"); + await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(page.getByRole("table")).toBeVisible(); + await page.route("**/core/v1/request-logs?*", async (route) => { + const response = await route.fetch(); + const body = await response.json(); + await route.fulfill({ json: { ...body, retained_count: 1000, total_matching: 500, has_more: true } }); + }); + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect(page.getByText("Newest 4 shown. Narrow the filters to find another retained request.")).toBeVisible(); + await page.unroute("**/core/v1/request-logs?*"); + await page.getByRole("searchbox").fill(id(9)); + await page.getByRole("button", { name: "Find request", exact: true }).click(); + await expect(page.getByText("No retained requests match")).toBeVisible(); +}); diff --git a/apps/web/src/ConsoleApp.tsx b/apps/web/src/ConsoleApp.tsx index a896e3262..b9ce5881e 100644 --- a/apps/web/src/ConsoleApp.tsx +++ b/apps/web/src/ConsoleApp.tsx @@ -13,6 +13,7 @@ import { ConsoleTourContext, ConsoleTourScreen } from "./features/onboarding/Con import { withTransition } from "./features/onboarding/view-transition"; import { OverviewPage } from "./features/overview/OverviewPage"; import { SandboxManagerView } from "./features/sandbox/SandboxManagerView"; +import { RequestLogPage, requestLogQuery } from "./features/request-log/RequestLogPage"; import { SessionLogPage } from "./features/sessions/SessionLogPage"; import { SessionPage } from "./features/sessions/SessionPage"; import { SkillsPage } from "./features/skills/SkillsPage"; @@ -45,6 +46,7 @@ function ConsolePage({ view }: { view: ConsoleView }) { case "core-metrics": return ; case "agent-metrics": return ; case "sandbox-metrics": return ; + case "request-log": return ; case "sessions": return ; case "session": return ; case "agents": return ; @@ -106,6 +108,7 @@ function ConsoleShell() { const navigation = useMemo(() => ({ ...location, navigate, back, clearIntent }), [location, navigate, back, clearIntent]); const prefetch = useCallback((view: ConsoleView) => { + if (view === "request-log") { void queryClient.prefetchQuery(requestLogQuery("", "all")); return; } const spec = prefetchable[view]; if (!spec) return; for (const project of state.projects) void queryClient.prefetchQuery(collectionQuery(spec, project.id)); diff --git a/apps/web/src/components/ConsoleSidebar.tsx b/apps/web/src/components/ConsoleSidebar.tsx index fa66ee5d7..242d3f2e6 100644 --- a/apps/web/src/components/ConsoleSidebar.tsx +++ b/apps/web/src/components/ConsoleSidebar.tsx @@ -7,6 +7,7 @@ import { Layers3, LayoutDashboard, ListChecks, + ListFilter, ListTree, Network, Puzzle, @@ -27,6 +28,7 @@ const viewIcons: Record = { "core-metrics": Network, "agent-metrics": Activity, "sandbox-metrics": Cpu, + "request-log": ListFilter, sessions: ListTree, session: ListTree, agents: Bot, diff --git a/apps/web/src/features/request-log/RequestLogPage.tsx b/apps/web/src/features/request-log/RequestLogPage.tsx new file mode 100644 index 000000000..998962ced --- /dev/null +++ b/apps/web/src/features/request-log/RequestLogPage.tsx @@ -0,0 +1,105 @@ +import type { RequestLogEntry, RequestLogOptions } from "@oac/agents-client"; +import { queryOptions, useQuery } from "@tanstack/react-query"; +import { ListFilter, Search } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { ConsoleSelect } from "../../components/console-select"; +import { Modal } from "../../components/Modal"; +import { ReadFailure } from "../../components/ReadFailure"; +import { TableSkeleton } from "../../components/Skeleton"; +import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, Section, SegmentedControl, StatusDot } from "../../components/console-ui"; +import { CopyableId, ListToolbar, NameCell } from "../../components/list-ui"; +import { admin } from "../../lib/admin-view"; +import { epochSeconds, formatClock, formatDateTime, formatDuration, formatInteger, MISSING } from "../../lib/format"; +import "./request-log.css"; + +type Method = NonNullable; +const methods: readonly Method[] = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT", "TRACE", "OTHER"]; +type Surface = "public" | "admin"; +type Outcome = "all" | "errors" | "active"; +const outcomes: readonly Outcome[] = ["all", "errors", "active"]; + +export function requestLogQuery(requestId: string, outcome: Outcome, surface: Surface = "public", method: Method | "" = "") { + return queryOptions({ + queryKey: ["request-log", requestId, outcome, surface, method], + queryFn: ({ signal }) => admin.listRequestLogs({ request_id: requestId || undefined, outcome, surface, method: method || undefined, limit: 200, signal }), + }); +} + +export function RequestLogPage() { + const { t, i18n } = useTranslation("requestLog"); + const locale = i18n.resolvedLanguage; + const [draft, setDraft] = useState(""); + const [requestId, setRequestId] = useState(""); + const [method, setMethod] = useState(""); + const [surface, setSurface] = useState("public"); + const [outcome, setOutcome] = useState("all"); + const [selected, setSelected] = useState(null); + const query = useQuery(requestLogQuery(requestId, outcome, surface, method)); + const log = query.data; + const entry = log?.data.find((row) => row.request_id === selected); + const refresh = () => { void query.refetch(); }; + + return
+ } /> + +

{t("semantics")}

+ + { setSelected(null); setSurface(value); }} /> +
{ event.preventDefault(); setSelected(null); if (draft === requestId) refresh(); else setRequestId(draft); }}> + + {t("searchHelp")} + +
+ ({ value, label: value }))]} onChange={(value) => { setSelected(null); setMethod(methods.find((method) => method === value) ?? ""); }} /> + ({ value, label: t(value) }))} onChange={(value) => { setSelected(null); setOutcome(value); }} /> +
+ {query.isError ? : null} +
+ {!log ? query.isError ? null : : <> +

{t("coverage", { retained: log.retained_count, capacity: log.capacity, started: formatDateTime(epochSeconds(log.instance_started_at), locale) })}

+ {!log.data.length ? :
+ + + {log.data.map((row) => + + + + + )} +
{t("arrived")}{t("request")}{t("status")}{t("duration")}
setSelected(row.request_id)} openLabel={t("open", { id: row.request_id })} />{row.status ?? MISSING}{formatDuration(row.duration_ms / 1000)}
+
} + {log.has_more ?

{t("limited", { shown: log.data.length })}

: null} + } +
+
+ setSelected(null)}> + {entry ? <> + {query.isError ? : null} +

{t("snapshot", { time: formatClock(query.dataUpdatedAt, locale) })}

+
+
{t("instance")}
+
{t("requestId")}
+
{t("traceId")}
+
{t("arrived")}
+
{t("request")}
{entry.method} {entry.route}
+
{t("state")}
+
{t("status")}
{entry.status ?? MISSING}
+
{t(entry.state === "active" ? "elapsed" : "duration")}
{formatDuration(entry.duration_ms / 1000)}
+
{t("responseBytes")}
{formatInteger(entry.response_bytes, locale)}
+
{t("errorCode")}
{entry.error_code ? {entry.error_code} : MISSING}
+
+ : null} +
+
; +} + +function RequestState({ entry }: { entry: RequestLogEntry }) { + const { t } = useTranslation("requestLog"); + return = 400) ? "danger" : entry.state === "active" ? "pending" : "neutral"} label={t(entry.state)} />; +} diff --git a/apps/web/src/features/request-log/request-log.css b/apps/web/src/features/request-log/request-log.css new file mode 100644 index 000000000..89ee1576a --- /dev/null +++ b/apps/web/src/features/request-log/request-log.css @@ -0,0 +1,6 @@ +.request-log-search { display: flex; align-items: center; gap: 8px; } +.request-log-table { table-layout: fixed; } +.request-log-table th:first-child { width: 110px; } +.request-log-table th:nth-child(3) { width: 125px; } +.request-log-table th:last-child { width: 100px; } +.request-log-table .name-cell-link { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } diff --git a/apps/web/src/i18n/locales/en/navigation.ts b/apps/web/src/i18n/locales/en/navigation.ts index 5025c153d..d285561a6 100644 --- a/apps/web/src/i18n/locales/en/navigation.ts +++ b/apps/web/src/i18n/locales/en/navigation.ts @@ -7,6 +7,7 @@ export const navigation = { platform: "Platform", }, views: { + "request-log": "Request log", overview: "Overview", "core-metrics": "Core metrics", "agent-metrics": "Agent metrics", diff --git a/apps/web/src/i18n/locales/en/request-log.ts b/apps/web/src/i18n/locales/en/request-log.ts new file mode 100644 index 000000000..d63606b0e --- /dev/null +++ b/apps/web/src/i18n/locales/en/request-log.ts @@ -0,0 +1,43 @@ +export const requestLog = { + title: "Request log", + help: "A bounded, in-memory record of public and administration HTTP requests in this Core process. Old entries are evicted, including active requests; restarting Core clears the log. Health checks, machine traffic and reads of this log are excluded. Routes are templates: no URL parameters, headers or request/response bodies are recorded. Public and administration APIs have separate buffers. This is one instance, not a deployment-wide history. Refresh to read a new snapshot.", + semantics: "HTTP completion does not mean the Agent Turn has finished.", + recent: "Recent HTTP requests", + filters: "Request filters", + requestId: "Request ID", + search: "Find request", + searchPlaceholder: "Exact request ID", + searchHelp: "Use the complete req_ ID returned in the x-request-id response header.", + method: "HTTP method", + allMethods: "All methods", + surface: "API surface", + public: "Public API", + admin: "Administration API", + aborted: "Aborted", + outcome: "HTTP outcome", + all: "All", + errors: "Errors", + active: "Active", + completed: "Completed", + count: "{{shown}} of {{total}} matching retained requests", + limited: "Newest {{shown}} shown. Narrow the filters to find another retained request.", + coverage: "{{retained}} / {{capacity}} retained · Log started {{started}}", + loading: "Reading request log", + empty: "No retained requests match", + emptyHelp: "Try another filter or refresh after an API request.", + arrived: "Arrived", + request: "Request", + status: "HTTP status", + state: "HTTP state", + duration: "Duration", + elapsed: "Elapsed at snapshot", + responseBytes: "Response bytes", + errorCode: "Error code", + traceId: "Trace ID", + details: "Request details", + open: "Open request {{id}}", + copyRequestId: "Copy request ID", + copyTraceId: "Copy trace ID", + instance: "Log instance", + snapshot: "Snapshot at {{time}}", +} as const; diff --git a/apps/web/src/i18n/locales/zh-CN/navigation.ts b/apps/web/src/i18n/locales/zh-CN/navigation.ts index 28bef5985..d084b4485 100644 --- a/apps/web/src/i18n/locales/zh-CN/navigation.ts +++ b/apps/web/src/i18n/locales/zh-CN/navigation.ts @@ -7,6 +7,7 @@ export const navigation = { platform: "平台", }, views: { + "request-log": "请求日志", overview: "概览", "core-metrics": "Core 监控", "agent-metrics": "Agent 监控", diff --git a/apps/web/src/i18n/locales/zh-CN/request-log.ts b/apps/web/src/i18n/locales/zh-CN/request-log.ts new file mode 100644 index 000000000..7cfaa0501 --- /dev/null +++ b/apps/web/src/i18n/locales/zh-CN/request-log.ts @@ -0,0 +1,43 @@ +export const requestLog = { + title: "请求日志", + help: "当前 Core 进程在内存中保留有限条公共 API 和管理 API 的 HTTP 请求。旧记录会被移出,包括仍在进行的请求;Core 重启后日志清空。不包含健康检查、机器流量和本日志的读取请求。路由仅显示模板,不记录 URL 参数、请求头或请求/响应正文。公共 API 和管理 API 分别保留记录。这里只显示一个实例,不是整个部署的历史记录。点击刷新读取最新快照。", + semantics: "HTTP 请求完成不代表 Agent Turn 已结束。", + recent: "最近的 HTTP 请求", + filters: "请求筛选", + requestId: "请求 ID", + search: "查找请求", + searchPlaceholder: "完整请求 ID", + searchHelp: "使用 x-request-id 响应头中返回的完整 req_ ID。", + method: "HTTP 方法", + allMethods: "全部方法", + surface: "API 类型", + public: "公共 API", + admin: "管理 API", + aborted: "已中断", + outcome: "HTTP 结果", + all: "全部", + errors: "错误", + active: "进行中", + completed: "已完成", + count: "显示 {{shown}} 条,共 {{total}} 条匹配的保留记录", + limited: "显示最新 {{shown}} 条。请缩小筛选范围以查找其他保留记录。", + coverage: "已保留 {{retained}} / {{capacity}} 条 · 日志开始于 {{started}}", + loading: "正在读取请求日志", + empty: "没有匹配的保留请求", + emptyHelp: "请尝试其他筛选条件,或在 API 请求后刷新。", + arrived: "到达时间", + request: "请求", + status: "HTTP 状态码", + state: "HTTP 状态", + duration: "耗时", + elapsed: "快照时已用时", + responseBytes: "响应字节数", + errorCode: "错误代码", + traceId: "Trace ID", + details: "请求详情", + open: "打开请求 {{id}}", + copyRequestId: "复制请求 ID", + copyTraceId: "复制 Trace ID", + instance: "日志实例", + snapshot: "快照时间 {{time}}", +} as const; diff --git a/apps/web/src/i18n/resources.ts b/apps/web/src/i18n/resources.ts index e298a515f..cb6b2e141 100644 --- a/apps/web/src/i18n/resources.ts +++ b/apps/web/src/i18n/resources.ts @@ -1,3 +1,5 @@ +import { requestLog as enRequestLog } from "./locales/en/request-log"; +import { requestLog as zhRequestLog } from "./locales/zh-CN/request-log"; import { workspaceStorage as enWorkspaceStorage } from "./locales/en/workspace-storage"; import { workspaceStorage as zhWorkspaceStorage } from "./locales/zh-CN/workspace-storage"; import { sandboxNavigation as enSandboxNavigation } from "./locales/en/sandbox-navigation"; @@ -49,6 +51,7 @@ export const defaultNamespace = "common"; export const resources = { en: { + requestLog: enRequestLog, workspaceStorage: enWorkspaceStorage, common: enCommon, navigation: enNavigation, @@ -71,6 +74,7 @@ export const resources = { onboarding: enOnboarding, }, "zh-CN": { + requestLog: zhRequestLog, workspaceStorage: zhWorkspaceStorage, common: zhCNCommon, navigation: zhCNNavigation, diff --git a/apps/web/src/lib/console-routes.test.ts b/apps/web/src/lib/console-routes.test.ts index 362aa7d5d..ac0e314b9 100644 --- a/apps/web/src/lib/console-routes.test.ts +++ b/apps/web/src/lib/console-routes.test.ts @@ -7,7 +7,7 @@ describe("console routes", () => { it("groups pages as Monitor, Resources and Platform with no playground", () => { expect(consoleNavGroups.map((group) => group.id)).toEqual(["monitor", "resources", "platform"]); expect(consoleNavGroups.flatMap((group) => group.views)).toEqual([ - "overview", "core-metrics", "agent-metrics", "sandbox-metrics", "sessions", + "overview", "core-metrics", "agent-metrics", "sandbox-metrics", "sessions", "request-log", "agents", "templates", "skills", "files", "vaults", "projects", "nodes", "settings", ]); diff --git a/apps/web/src/lib/console-routes.ts b/apps/web/src/lib/console-routes.ts index 452c4df95..2c9cbe1ab 100644 --- a/apps/web/src/lib/console-routes.ts +++ b/apps/web/src/lib/console-routes.ts @@ -9,6 +9,7 @@ export type ConsoleView = | "core-metrics" | "agent-metrics" | "sandbox-metrics" + | "request-log" | "sessions" | "session" | "agents" @@ -23,7 +24,7 @@ export type ConsoleView = export type ConsoleNavGroup = "monitor" | "resources" | "platform"; export const consoleNavGroups: ReadonlyArray<{ id: ConsoleNavGroup; views: readonly ConsoleView[] }> = [ - { id: "monitor", views: ["overview", "core-metrics", "agent-metrics", "sandbox-metrics", "sessions"] }, + { id: "monitor", views: ["overview", "core-metrics", "agent-metrics", "sandbox-metrics", "sessions", "request-log"] }, { id: "resources", views: ["agents", "templates", "skills", "files", "vaults"] }, { id: "platform", views: ["projects", "nodes", "settings"] }, ]; diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index f86060870..150382472 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -38,8 +38,21 @@ Paths are relative to `/core/v1`. | `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Node enrollment tokens, nodes and their allocations and host history | [Nodes guide](../../docs/getting-started/nodes.md), [sandbox deployment](./sandbox-deployment.md), [node host history](./runtime-observability-api.md#node-host-observations-and-history) | | `summary` | Session counts and usage by Project, Agent or key | [Summary](#summary) | | `metrics` | Core's own process, execution, database and job metrics | [Core metrics](./core-metrics.md) | +| `request-logs` | Recent public or administration HTTP requests and their response metadata | [Request log](#request-log) | | `audit-log` | Administrator writes | [Audit log](#audit-log) | +## Request log + +`GET /core/v1/request-logs` is a Core-key-only diagnostic read for Web's **Request log** and operator scripts. It observes HTTP requests that reach Core's `/v1` and `/core/v1` APIs, including rejected authentication and unsuccessful responses. The log read itself, health and documentation endpoints, Web sign-in/proxy failures before Core, machine transports and background execution are outside this view. It is not an audit archive or a distributed execution trace. + +Each API instance retains the most recent 1,000 started requests for each surface independently: `public` for `/v1`, `admin` for `/core/v1`. Administration polling cannot evict application requests. Older entries, including long-lived requests, are overwritten when their surface fills. Restart clears the rings. `instance_id` and `instance_started_at` identify the serving API instance; a load balancer can return another instance's window. Core also emits each request's completion metadata through its existing structured logger; retention of that output belongs to the operator's log system, not this endpoint. + +Query parameters are `surface=public|admin` (default `public`), `limit` (1–200, default 100), optional exact `request_id`, `outcome=all|errors|active` (default `all`) and optional `method`. Supported method values are `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `CONNECT`, `TRACE` and `OTHER`; other incoming methods are recorded as `OTHER`. Unsupported, repeated or malformed query parameters return 400. `errors` includes HTTP status 400 or above and aborted requests. Filtering scans only the retained window and returns newest-started first. `retained_count` describes that surface's retained window, `total_matching` counts retained matches, and `has_more` means the response limit omitted matches; it does not promise older evicted history is available. + +The response has `object: "core.request_log.list"`, instance identity, `surface`, `capacity`, coverage counts and `data`. Each entry contains `request_id`, `trace_id`, `started_at`, `method`, `route`, `state`, `status`, `duration_ms`, `response_bytes` and nullable `error_code`. `route` is a registered route template, a partially matched authentication prefix or `(unmatched)`, never the raw resource path or query. `status` is null until final response headers are committed. `state` distinguishes `active`, `completed` and `aborted`; duration and bytes describe the observation so far for active requests. `response_bytes` counts bytes accepted by the response writer, excluding headers and transport framing; it is not a network transfer measurement. An SSE response remains active until its handler ends. HTTP status and completion do not establish the outcome of an asynchronous Agent Turn; use [Session diagnostics](./session-diagnostics.md) and the Session's recorded history for that outcome. + +Bodies, header values, credentials, prompts, file contents, raw paths, query values and error messages are not captured. Only the API's safe error code is retained. The returned request ID corresponds to `X-Request-Id` and can be used to find related existing process log entries. Trace IDs are correlation metadata, not proof of a complete component trace. + ## Workspace storage **Settings → Workspace storage** and operator scripts use `GET /core/v1/workspace-storage` and `PUT /core/v1/workspace-storage` with a Core key. The successful response is the selection: `{"id":"","adapter":"","parameters":{...},"tenant_capacity_mib":}`. The request has the same required fields and an optional write-only `credential` object. `parameters` and `credential` are adapter-owned JSON objects; the selected adapter validates them. Unknown top-level fields are rejected. diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 6ebc87e18..f0b2184a1 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -613,6 +613,82 @@ definitions: required: - name type: object + api.RequestLogEntry: + properties: + duration_ms: + type: number + error_code: + type: string + x-nullable: true + method: + type: string + request_id: + type: string + response_bytes: + type: integer + route: + type: string + started_at: + type: string + state: + enum: + - active + - completed + - aborted + type: string + status: + type: integer + x-nullable: true + trace_id: + type: string + required: + - duration_ms + - error_code + - method + - request_id + - response_bytes + - route + - started_at + - state + - status + - trace_id + type: object + api.RequestLogList: + properties: + capacity: + type: integer + data: + items: + $ref: '#/definitions/api.RequestLogEntry' + type: array + has_more: + type: boolean + instance_id: + type: string + instance_started_at: + type: string + object: + type: string + retained_count: + type: integer + surface: + enum: + - public + - admin + type: string + total_matching: + type: integer + required: + - capacity + - data + - has_more + - instance_id + - instance_started_at + - object + - retained_count + - surface + - total_matching + type: object api.ResourceOwnerList: properties: data: @@ -6802,6 +6878,70 @@ paths: summary: Query API-key write operations tags: - Write Audit + /core/v1/request-logs: + get: + description: Core key only. Process-local bounded metadata for /v1 and /core/v1, newest started first. Polling this endpoint is excluded. HTTP status is not a Turn outcome. + parameters: + - description: API surface (default public) + enum: + - public + - admin + in: query + name: surface + type: string + - description: Maximum entries (default 100) + in: query + maximum: 200 + minimum: 1 + name: limit + type: integer + - description: Exact server-issued request ID + in: query + name: request_id + type: string + - description: HTTP outcome filter (default all) + enum: + - all + - errors + - active + in: query + name: outcome + type: string + - description: HTTP method + enum: + - GET + - HEAD + - POST + - PUT + - PATCH + - DELETE + - OPTIONS + - CONNECT + - TRACE + - OTHER + in: query + name: method + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.RequestLogList' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.CoreErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.CoreErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: List recent Core HTTP requests + tags: + - Core Administration /core/v1/sandbox/deployment: get: description: Core key only. Does not grant project resource access. Responses contain only explicit safe fields. E2B template_build values are those Core read when the selection was saved; this read does not call E2B. diff --git a/contracts/agents-api/zh/admin-api.md b/contracts/agents-api/zh/admin-api.md index b65faa978..3d3fd3b31 100644 --- a/contracts/agents-api/zh/admin-api.md +++ b/contracts/agents-api/zh/admin-api.md @@ -1,7 +1,7 @@ --- title: "Core 管理 API" source: contracts/agents-api/admin-api.md -source_hash: 32b765dbeaaa6ed4329cef499a72432c41d2f0b944ee1bb14df2f17da72160e0 +source_hash: dbc53b9e7d6df095b7131238cddc5af0553504152ddae037c56f885174f30f77 --- Core 管理 API(`/core/v1`)用于管理安装实例:Project 及其 API 密钥、Project 资源的读取和删除、执行器凭据、部署默认模型、沙箱部署及其节点、监控和审计。Web 的[控制台服务器](../../../docs/zh/web/console-server.md#forwarding-to-core)会为已登录的管理员调用它;运维人员则从 Core 主机上的脚本调用它([编写 Core API 脚本](../../../docs/zh/getting-started/operations.md#script-the-core-api))。生成的架构是 [core.openapi.yaml](../core.openapi.yaml),所有错误都使用 [Core 错误封装](core-errors.md)。 @@ -40,8 +40,21 @@ Core 管理 API(`/core/v1`)用于管理安装实例:Project 及其 API 密 | `sandbox/enrollment-tokens`、`sandbox/nodes[/{node_id}[/allocations]]` | 节点注册令牌、节点及其分配和主机历史 | [节点指南](../../../docs/zh/getting-started/nodes.md)、[沙箱部署](sandbox-deployment.md)、[节点主机历史](runtime-observability-api.md#node-host-observations-and-history) | | `summary` | 按 Project、Agent 或密钥统计的 Session 数量和使用情况 | [汇总](#summary) | | `metrics` | Core 自身的进程、执行、数据库和作业指标 | [Core 指标](core-metrics.md) | +| `request-logs` | 近期应用或管理 HTTP 请求及其响应元数据 | [请求日志](#request-log) | | `audit-log` | 管理员写入 | [审计日志](#audit-log) | +## 请求日志 {#request-log} + +`GET /core/v1/request-logs` 是仅供 Core 密钥调用的诊断读取,由 Web 的 **Request log** 和运维脚本使用。它观察到达 Core `/v1` 与 `/core/v1` API 的 HTTP 请求,包括认证拒绝和失败响应。日志读取本身、健康检查与文档端点、到达 Core 前的 Web 登录或代理失败、机器传输和后台执行不在此视图中。它不是审计档案或分布式执行追踪。 + +每个 API 实例按入口分别保留最近开始的 1,000 个请求:`public` 对应 `/v1`,`admin` 对应 `/core/v1`。管理页面轮询不会挤掉应用请求。各自容量用尽后覆盖较旧条目,包括长连接请求;重启清空记录。`instance_id` 和 `instance_started_at` 标识提供响应的 API 实例;负载均衡可能返回另一个实例的窗口。Core 同时通过现有结构化日志输出请求结束时的元数据,其保留由运维日志系统负责,不属于本端点。 + +查询参数包括 `surface=public|admin`(默认 `public`)、`limit`(1–200,默认 100)、可选的精确 `request_id`、`outcome=all|errors|active`(默认 `all`)及可选 `method`。支持的方法值为 `GET`、`HEAD`、`POST`、`PUT`、`PATCH`、`DELETE`、`OPTIONS`、`CONNECT`、`TRACE` 和 `OTHER`;其他入站方法记为 `OTHER`。不支持、重复或格式错误的查询参数返回 400。`errors` 包括 HTTP 状态为 400 及以上的请求和已中断的请求。筛选仅扫描保留窗口,按开始时间从新到旧返回。`retained_count` 表示该入口的保留条目数,`total_matching` 是保留条目中匹配的数量,`has_more` 表示响应条数限制省略了匹配项,不代表仍能读取已被覆盖的历史。 + +响应包含 `object: "core.request_log.list"`、实例标识、`surface`、`capacity`、覆盖数量及 `data`。每条记录包含 `request_id`、`trace_id`、`started_at`、`method`、`route`、`state`、`status`、`duration_ms`、`response_bytes` 和可为空的 `error_code`。`route` 是注册路由模板、认证时部分匹配的前缀或 `(unmatched)`,不会包含原始资源路径或查询。最终响应头提交前,`status` 为 null。`state` 区分 `active`、`completed` 和 `aborted`;活跃请求的耗时和字节数是读取时的观测值。`response_bytes` 统计响应写入器接受的字节数,不包括响应头和传输封装,不是网络传输量测量。SSE 响应在处理函数结束前保持活跃。HTTP 状态及结束不代表异步 Agent Turn 的执行结果;执行结果以 [Session 诊断](./session-diagnostics.md)和该 Session 已记录的历史为准。 + +不采集正文、请求头值、凭据、提示词、文件内容、原始路径、查询值或错误消息,只保留 API 的安全错误码。返回的请求 ID 对应 `X-Request-Id`,可用于查找已有进程日志的关联条目。Trace ID 仅供关联,不表示完整组件链路已经被追踪。 + ## 工作区存储 {#workspace-storage} **Settings → Workspace storage** 和运维脚本使用 Core key 调用 `GET /core/v1/workspace-storage` 和 `PUT /core/v1/workspace-storage`。成功响应为选择结果:`{"id":"","adapter":"","parameters":{...},"tenant_capacity_mib":}`。请求包含相同的必需字段,以及可选的只写 `credential` 对象。`parameters` 和 `credential` 是适配器定义的 JSON 对象,由所选适配器验证。未知顶层字段会被拒绝。 diff --git a/docs/api/index.md b/docs/api/index.md index d50b8cc6c..a68c5be70 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -7,7 +7,7 @@ Core serves three namespaces. Each has one kind of caller and its own credential | Namespace | Caller | Credential | Contents | Owner | | --- | --- | --- | --- | --- | | `/v1` | Applications: business systems and the official OpenAI SDK | Project API key | Exactly the 58 method and path pairs of the pinned official Agents API, listed in [upstream-routes.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream-routes.json). Core-only fields sit inside `x_agents_core`: `harness`, `model_provider`, `harness_config`, `environment`, and the read-only Session `installation` | [Agents API guide](./public-agent-api.md) | -| `/core/v1` | Web's console server and operator scripts | [Core key](../getting-started/operations.md#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, executor credentials, default models, metrics, audit, sandbox deployment and nodes, independent workspace storage configuration | [Core administration API](../../contracts/agents-api/admin-api.md) | +| `/core/v1` | Web's console server and operator scripts | [Core key](../getting-started/operations.md#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, executor credentials, default models, metrics, request logs, audit, sandbox deployment and nodes, independent workspace storage configuration | [Core administration API](../../contracts/agents-api/admin-api.md) | | `/api/v1` | Nodes, Runtime daemons, self-hosted executors and their installers | Machine credentials: node enrollment tokens and node credentials, installation grants, executor credentials, and daemon credentials. Each works only on its own routes | Machine bootstrap and connections under `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets, and the public native installer downloads | [Machine connection API](../../contracts/agents-api/machine-api.md) | A credential used in another namespace gets 401: a Project API key on `/core/v1` or `/api/v1`, the Core key on `/v1` or `/api/v1`. How Projects and keys behave is in [Projects own assets](../concepts.md#projects-own-assets). diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index d533e0a1f..c8900140c 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -48,6 +48,10 @@ The `init` service exits after initialization. Its step logs are described in [D Don't paste `docker compose config`, `docker inspect` or raw logs into public issue reports. +## Request diagnosis + +Open **Monitor → Request log** to inspect recent requests and copy their request IDs for correlation with Core's service log. [Request log](../../contracts/agents-api/admin-api.md#request-log) defines its scope, privacy and limited in-memory retention. For a request that accepted an asynchronous Turn, inspect **Session log** for the execution result; HTTP acceptance alone is not task success. Use your existing log collector for retention across restarts or Core instances. + ## Stop and restart Let active work settle before a planned restart: diff --git a/docs/web/console-api-usage.md b/docs/web/console-api-usage.md index 9d427319a..4a75e712f 100644 --- a/docs/web/console-api-usage.md +++ b/docs/web/console-api-usage.md @@ -81,6 +81,10 @@ Wherever a new key is shown, and without any key on an active project's page, th Summary figures are cumulative per Session and are not billing records. Sessions without reported usage count toward coverage but not toward token sums, and the console shows missing values as missing, never as zero. +## Request log + +**Request log** reads `GET /core/v1/request-logs` through `AdminClient`. It filters by application or administration surface, exact request ID and request outcome, and refreshes on demand. The list and local detail dialog share one response; opening a row makes no extra request. Read failures preserve the previous response with a visible stale warning. The [request log contract](../../contracts/agents-api/admin-api.md#request-log) owns capture, bounds and response semantics. Explanations live in help tips; missing status or error data is not invented. + ## Default models | Operation | Route | Console use | diff --git a/docs/web/index.md b/docs/web/index.md index 2bf0839ed..2fc4aac7c 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -20,6 +20,7 @@ Signing in opens the Overview. While any step is still to do, its **Getting star | Monitor | Core metrics | The Core process: CPU and resident memory against their limits, execution slots and the Turn queue, connected daemons, database latency and pool, background jobs | | Monitor | Agent metrics | Requests, errors, duration, tokens, models, tools, Agents and API keys over 1 h, 6 h, 24 h or 7 d | | Monitor | Sandbox metrics | Node capacity; **Automatic suspension** when supported; **Recorded compute** lifecycle state shown separately from hosted Runtime CPU and memory observations across Projects | +| Monitor | Request log | Recent HTTP requests to Core, their status, elapsed time and safe error codes; [scope and retention](../../contracts/agents-api/admin-api.md#request-log) | | Monitor | Session log | Every Session, and each Session's read-only conversation, trace and Turns with the classified reason of a failure; a self-hosted Session's page also manages its executor credentials and gives the command that connects a host | | Resources | Agents, Environment templates, Skills, Files, Vaults | Inspection and permitted deletion, with the Project and the creating key of each resource | | Platform | Projects and keys | Create, rename and archive Projects; issue and revoke keys; each Project's usage, write history and how to call the API | diff --git a/docs/zh/api/index.md b/docs/zh/api/index.md index ebf280b70..6837204f5 100644 --- a/docs/zh/api/index.md +++ b/docs/zh/api/index.md @@ -1,7 +1,7 @@ --- title: "API 命名空间和凭据" source: docs/api/index.md -source_hash: 66a80b6c579488fe1412ede51a0f0f9c03205443bc0be935b6a79d56f0e758d4 +source_hash: 12a1933c91e95828ccd8bf8405a892726c7da265ddf2bbf6bf99bd156af0f96d --- Core 提供三个命名空间。每个命名空间都有一种调用方及其独立凭据,凭据只能在其所属命名空间中使用。 @@ -9,7 +9,7 @@ Core 提供三个命名空间。每个命名空间都有一种调用方及其独 | 命名空间 | 调用方 | 凭据 | 内容 | 所有者 | | --- | --- | --- | --- | --- | | `/v1` | 应用程序:业务系统和官方 OpenAI SDK | Project API key | 固定版本官方 Agents API 中全部且仅有的 58 个方法和路径对,列于 [upstream-routes.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/upstream-routes.json)。仅属于 Core 的字段位于 `x_agents_core` 中:`harness`、`model_provider`、`harness_config`、`environment`,以及只读的 Session `installation` | [Agents API 指南](public-agent-api.md) | -| `/core/v1` | Web 的控制台服务器和操作员脚本 | [Core key](../getting-started/operations.md#core-key) | 安装信息、Project 和密钥、资源读取和删除、Session 归档、执行器凭据、默认模型、指标、审计、沙箱部署和节点、独立工作区存储配置 | [Core 管理 API](../../../contracts/agents-api/zh/admin-api.md) | +| `/core/v1` | Web 的控制台服务器和操作员脚本 | [Core key](../getting-started/operations.md#core-key) | 安装信息、Project 和密钥、资源读取和删除、Session 归档、执行器凭据、默认模型、指标、请求日志、审计、沙箱部署和节点、独立工作区存储配置 | [Core 管理 API](../../../contracts/agents-api/zh/admin-api.md) | | `/api/v1` | 节点、Runtime 守护进程、自托管执行器及其安装程序 | 机器凭据:节点注册令牌和节点凭据、安装授权、执行器凭据和守护进程凭据。每种凭据只能用于其各自的路由 | `/api/v1/sandbox-node/*` 和 `/api/v1/agent-daemon/*` 下的机器初始化与连接(包括 WebSockets),以及公共原生安装程序下载 | [机器连接 API](../../../contracts/agents-api/zh/machine-api.md) | 在其他命名空间中使用凭据会返回 401:在 `/core/v1` 或 `/api/v1` 上使用 Project API key,或者在 `/v1` 或 `/api/v1` 上使用 Core key。有关 Project 和密钥的行为,请参阅 [Project 自有资产](../concepts.md#projects-own-assets)。 diff --git a/docs/zh/getting-started/operations.md b/docs/zh/getting-started/operations.md index 260d073ec..83180c6df 100644 --- a/docs/zh/getting-started/operations.md +++ b/docs/zh/getting-started/operations.md @@ -1,7 +1,7 @@ --- title: "运维" source: docs/getting-started/operations.md -source_hash: 8ce67b66748a8414ac3ee954b07e379b7d3a0a37790bb162c000c9ae556842fc +source_hash: f3f9b2f23898a4e39201ce12cf1f7a199aa39ecc0afd34fd964596a98d2b709a --- 安装运维人员负责 Core 主机、存储和可用性。节点主机运行各自的服务;参阅[节点](nodes.md)。设置见[配置参考](../configuration.md)。 @@ -50,6 +50,10 @@ docker compose -f "$HOME/.oac/core/compose.yaml" logs --timestamps init 不要将 `docker compose config`、`docker inspect` 或原始日志粘贴到公开问题报告。 +## 请求诊断 {#request-diagnosis} + +打开 **Monitor → Request log** 查看近期请求,并复制请求 ID 与 Core 服务日志关联。[请求日志](../../../contracts/agents-api/zh/admin-api.md#request-log)定义范围、隐私及有限的内存保留方式。请求接受了异步 Turn 时,请到 **Session log** 查看执行结果;HTTP 接受不等于任务成功。跨重启或 Core 实例的留存使用已有日志采集系统。 + ## 停止与重启 {#stop-and-restart} 计划重启前,先等待活动工作结束: diff --git a/docs/zh/web/console-api-usage.md b/docs/zh/web/console-api-usage.md index 58c64997a..97581b019 100644 --- a/docs/zh/web/console-api-usage.md +++ b/docs/zh/web/console-api-usage.md @@ -1,7 +1,7 @@ --- title: "控制台 API 使用" source: docs/web/console-api-usage.md -source_hash: 89d33fee39f736c42b68899fe12cdb0ff2402763b7074375be448be5e0d3600f +source_hash: b5bc17e6fe3870240abf18f62dcaecc4f8ccc92b0828550e374011c17e8667dd --- 本页列出各控制台页面读取和写入的 Core 路由,以及控制台如何限定读取范围。[administrator API contract](../../../contracts/agents-api/zh/admin-api.md) 定义了路由、响应结构、分页和审计记录;[API namespaces and credentials](../api/index.md) 定义了本文使用的术语。 @@ -83,6 +83,10 @@ source_hash: 89d33fee39f736c42b68899fe12cdb0ff2402763b7074375be448be5e0d3600f 汇总数值按 Session 累计,并不是计费记录。未报告使用量的 Session 会计入覆盖率,但不会计入 token 总量;控制台会将缺失值显示为缺失,绝不会显示为零。 +## 请求日志 {#request-log} + +**Request log** 通过 `AdminClient` 读取 `GET /core/v1/request-logs`,按应用或管理入口、精确请求 ID 和请求结果筛选,并按需刷新。列表和本地详情对话框共用一次响应,打开条目不会发出额外请求。读取失败保留上一次响应并明确提示数据已过期。[请求日志契约](../../../contracts/agents-api/zh/admin-api.md#request-log)定义采集、容量限制及响应语义。说明放在问号提示中,不补造缺失的状态或错误数据。 + ## 默认模型 {#default-models} | 操作 | 路由 | 控制台用途 | diff --git a/docs/zh/web/index.md b/docs/zh/web/index.md index 6d6aed598..b0daef4bd 100644 --- a/docs/zh/web/index.md +++ b/docs/zh/web/index.md @@ -1,7 +1,7 @@ --- title: "OpenAgentCore Web" source: docs/web/index.md -source_hash: 5c8449387651e26ac24cc62008693fb71eafed3dc6e07a83d780da145865db91 +source_hash: d855a260ed8979564ee94e18e3261611baba479f0406e8ca9424d69fb754f2e8 --- Web 是单个 OpenAgentCore 部署的管理员控制台。管理员用它查看健康状态、容量、用量和失败情况,检查各 Project 的资源与执行历史,并管理 Project、密钥、节点和部署设置。应用不使用 Web;它们通过自己的 Project API 密钥调用 Core 的 Agents API(`/v1`)。 @@ -22,6 +22,7 @@ Web 是单个 OpenAgentCore 部署的管理员控制台。管理员用它查看 | Monitor | Core metrics | Core 进程:CPU 与常驻内存及其限制、执行槽位与 Turn 队列、已连接守护进程、数据库延迟与连接池、后台任务 | | Monitor | Agent metrics | 1 小时、6 小时、24 小时或 7 天的请求、错误、耗时、token、模型、工具、Agent 和 API 密钥 | | Monitor | Sandbox metrics | 节点容量;支持时显示 **Automatic suspension**;**Recorded compute** 生命周期状态与各 Project 的托管 Runtime CPU、内存观测分开展示 | +| Monitor | Request log | 到达 Core 的近期 HTTP 请求、状态、耗时和安全错误码;[范围及保留方式](../../../contracts/agents-api/zh/admin-api.md#request-log) | | Monitor | Session log | 全部 Session;各 Session 的只读对话、跟踪、Turn 和失败分类原因;自托管 Session 页面还管理执行器凭据并提供主机连接命令 | | Resources | Agents、Environment templates、Skills、Files、Vaults | 检查和允许的删除操作,显示各资源所属 Project 及创建密钥 | | Platform | Projects and keys | 创建、重命名和归档 Project;签发与撤销密钥;各 Project 的用量、写入历史和 API 调用方法 | diff --git a/packages/agents-client/src/admin-client.ts b/packages/agents-client/src/admin-client.ts index 89b53aad6..c8818d2e1 100644 --- a/packages/agents-client/src/admin-client.ts +++ b/packages/agents-client/src/admin-client.ts @@ -1,3 +1,4 @@ +import { projectRequestLog, type RequestLogOptions } from "./request-log"; import { projectEnvironmentInstallation } from "./installation-projection"; import type { EnvironmentInstallation } from "./types"; import { projectSessionDiagnostics, projectTurnDiagnostics } from "./session-diagnostics"; @@ -84,6 +85,11 @@ export class AdminClient { return { blob: await response.blob(), contentType: response.headers.get("Content-Type"), contentDisposition: response.headers.get("Content-Disposition") }; } + async listRequestLogs(options: RequestLogOptions = {}) { + const path = pageQuery("/request-logs", { limit: options.limit }, { request_id: options.request_id, outcome: options.outcome, surface: options.surface, method: options.method }); + return projectRequestLog(await this.#json(path, options)); + } + async retrieveWorkspaceStorage(options?: ReadOptions): Promise { return projectWorkspaceStorage(await this.#json("/workspace-storage", options)); } diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts index b09c56f55..796f39c81 100644 --- a/packages/agents-client/src/generated/core-api.ts +++ b/packages/agents-client/src/generated/core-api.ts @@ -568,6 +568,35 @@ export interface Range { start: string; } export const rangeFields = ["end", "resolution_seconds", "start"] as const; +export interface RequestLogEntry { + duration_ms: number; + error_code: string | null; + method: string; + request_id: string; + response_bytes: number; + route: string; + started_at: string; + state: RequestLogEntryState; + status: number | null; + trace_id: string; +} +export const requestLogEntryFields = ["duration_ms", "error_code", "method", "request_id", "response_bytes", "route", "started_at", "state", "status", "trace_id"] as const; +export const requestLogEntryStateValues = ["active", "completed", "aborted"] as const; +export type RequestLogEntryState = (typeof requestLogEntryStateValues)[number]; +export interface RequestLogList { + capacity: number; + data: RequestLogEntry[]; + has_more: boolean; + instance_id: string; + instance_started_at: string; + object: string; + retained_count: number; + surface: RequestLogListSurface; + total_matching: number; +} +export const requestLogListFields = ["capacity", "data", "has_more", "instance_id", "instance_started_at", "object", "retained_count", "surface", "total_matching"] as const; +export const requestLogListSurfaceValues = ["public", "admin"] as const; +export type RequestLogListSurface = (typeof requestLogListSurfaceValues)[number]; export interface Reset { clear: ResetMode; deadline_at: string | null; diff --git a/packages/agents-client/src/index.ts b/packages/agents-client/src/index.ts index bf1462d1c..c2ca28622 100644 --- a/packages/agents-client/src/index.ts +++ b/packages/agents-client/src/index.ts @@ -19,3 +19,5 @@ export type { AdminClientOptions, AdminProject, CreateAdminProjectInput, RenameA export type { DiagnosticFailureCode, ConnectionFailureParams, ProvisioningFailureParams, DiagnosticFailure, SessionDiagnosticFailure, SessionDiagnostics, ItemDiagnosticTiming, TurnDiagnostics } from "./session-diagnostics"; export type { Selection as WorkspaceStorageSelection, WorkspaceStorageInput } from "./generated/core-api"; + +export type { RequestLogEntry, RequestLogList, RequestLogOptions } from "./request-log"; diff --git a/packages/agents-client/src/request-log.test.ts b/packages/agents-client/src/request-log.test.ts new file mode 100644 index 000000000..129f20957 --- /dev/null +++ b/packages/agents-client/src/request-log.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest"; +import { AdminClient } from "./admin-client"; +import { projectRequestLog } from "./request-log"; + +const entry = { request_id: `req_${"1".repeat(32)}`, trace_id: "2".repeat(32), started_at: "2026-10-13T10:00:00Z", method: "GET", route: "/v1/agents/{agent_id}", state: "active", status: null, duration_ms: 1.25, response_bytes: 0, error_code: null }; +const log = { object: "core.request_log.list", instance_id: "instance-1", instance_started_at: "2026-10-13T09:00:00Z", surface: "public", capacity: 1000, retained_count: 1, total_matching: 1, has_more: false, data: [entry] }; + +describe("request log", () => { + it("uses only the administration boundary and forwards filters and cancellation", async () => { + const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(log))); + const signal = new AbortController().signal; + await new AdminClient({ fetch }).listRequestLogs({ request_id: entry.request_id, surface: "public", outcome: "active", method: "GET", limit: 200, signal }); + const [url, init] = fetch.mock.calls[0]!; + expect(url).toBe(`/core/v1/request-logs?limit=200&request_id=${entry.request_id}&outcome=active&surface=public&method=GET`); + expect(init).toMatchObject({ signal, credentials: "same-origin", redirect: "error" }); + expect(new Headers(init?.headers).has("Authorization")).toBe(false); + expect(new Headers(init?.headers).has("OpenAI-Beta")).toBe(false); + }); + + it("preserves active/aborted HTTP 200 responses and null status without claiming Turn success", () => { + expect(projectRequestLog(log).data[0]).toEqual(entry); + for (const state of ["active", "aborted"]) { + expect(projectRequestLog({ ...log, data: [{ ...entry, state, status: 200 }] }).data[0]).toMatchObject({ state, status: 200 }); + } + }); + + it.each([ + { state: "succeeded" }, { status: "200" }, { state: "completed", status: null }, { duration_ms: -1 }, + { response_bytes: -1 }, { request_id: "unsafe id" }, { trace_id: "unknown" }, { started_at: "not a timestamp" }, { body: "secret" }, + ])("rejects invalid or undeclared entry fields %j", (patch) => { + expect(() => projectRequestLog({ ...log, data: [{ ...entry, ...patch }] })).toThrow(); + }); + + it.each([ + { surface: "machine" }, { instance_started_at: "invalid" }, { retained_count: 1001 }, + { total_matching: 2 }, { has_more: true }, { data: [entry, entry], total_matching: 2, retained_count: 2 }, + ])("rejects inconsistent snapshots %j", (patch) => { + expect(() => projectRequestLog({ ...log, ...patch })).toThrow(); + }); +}); diff --git a/packages/agents-client/src/request-log.ts b/packages/agents-client/src/request-log.ts new file mode 100644 index 000000000..6f996f7ad --- /dev/null +++ b/packages/agents-client/src/request-log.ts @@ -0,0 +1,41 @@ +import { requestLogEntryFields, requestLogListFields, requestLogEntryStateValues, requestLogListSurfaceValues, type RequestLogEntry, type RequestLogList } from "./generated/core-api"; +import { invalidAdminResponse } from "./admin-projection"; +import { exactFields, isOneOf, isNonnegativeInteger, isRecord } from "./response-projection"; +import type { ReadOptions } from "./types"; + +export type { RequestLogEntry, RequestLogList } from "./generated/core-api"; +export interface RequestLogOptions extends ReadOptions { + limit?: number; + request_id?: string; + outcome?: "all" | "errors" | "active"; + surface?: "public" | "admin"; + method?: "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "CONNECT" | "TRACE" | "OTHER"; +} + +const timestamp = (value: unknown): value is string => typeof value === "string" && Number.isFinite(Date.parse(value)); +const requestId = (value: unknown): value is string => typeof value === "string" && /^req_[0-9a-f]{32}$/.test(value); + +function projectEntry(value: unknown): RequestLogEntry { + if (!isRecord(value) || !exactFields(value, requestLogEntryFields) || + !requestId(value.request_id) || typeof value.trace_id !== "string" || !/^[0-9a-f]{32}$/.test(value.trace_id) || + !timestamp(value.started_at) || typeof value.method !== "string" || !value.method || typeof value.route !== "string" || !value.route || + !isOneOf(requestLogEntryStateValues, value.state) || + !(value.status === null || (Number.isInteger(value.status) && Number(value.status) >= 100 && Number(value.status) <= 599)) || + (value.state === "completed" && value.status === null) || + typeof value.duration_ms !== "number" || !Number.isFinite(value.duration_ms) || value.duration_ms < 0 || + !isNonnegativeInteger(value.response_bytes) || !(value.error_code === null || typeof value.error_code === "string")) return invalidAdminResponse(); + return value as unknown as RequestLogEntry; +} + +export function projectRequestLog(value: unknown): RequestLogList { + if (!isRecord(value) || !exactFields(value, requestLogListFields) || value.object !== "core.request_log.list" || + typeof value.instance_id !== "string" || !value.instance_id || !timestamp(value.instance_started_at) || + !isOneOf(requestLogListSurfaceValues, value.surface) || + !isNonnegativeInteger(value.capacity) || value.capacity < 1 || !isNonnegativeInteger(value.retained_count) || value.retained_count > value.capacity || + !isNonnegativeInteger(value.total_matching) || value.total_matching > value.retained_count || + typeof value.has_more !== "boolean" || !Array.isArray(value.data) || value.data.length > value.total_matching || + value.has_more !== (value.data.length < value.total_matching)) return invalidAdminResponse(); + const data = value.data.map(projectEntry); + if (new Set(data.map((entry) => entry.request_id)).size !== data.length) return invalidAdminResponse(); + return { ...value, data } as RequestLogList; +} diff --git a/services/core/internal/api/core_metrics.go b/services/core/internal/api/core_metrics.go index 46e572304..6cf714e69 100644 --- a/services/core/internal/api/core_metrics.go +++ b/services/core/internal/api/core_metrics.go @@ -48,9 +48,9 @@ func (h *Handler) getCoreMetrics(w http.ResponseWriter, r *http.Request) { } func (h *Handler) responseHeaders(next http.Handler) http.Handler { - return responseHeadersWithErrors(next, func(code string) { + return responseHeadersWithLog(next, func(code string) { if code == "execution_unavailable" { h.Metrics.RecordUnavailable() } - }) + }, h.requestLogs) } diff --git a/services/core/internal/api/core_routes.go b/services/core/internal/api/core_routes.go index 08656b28c..88013fbad 100644 --- a/services/core/internal/api/core_routes.go +++ b/services/core/internal/api/core_routes.go @@ -16,6 +16,7 @@ func (h *Handler) registerCoreRoutes(router chi.Router) { r.NotFound(func(w http.ResponseWriter, _ *http.Request) { writeError(w, http.StatusNotFound, "not_found", "This Core operation does not exist.") }) + r.Get("/request-logs", h.getRequestLogs) r.Get("/installation", h.getInstallation) r.Get("/workspace-storage", h.getWorkspaceStorage) r.Put("/workspace-storage", h.putWorkspaceStorage) diff --git a/services/core/internal/api/dependencies.go b/services/core/internal/api/dependencies.go index 48c6207d5..8feec2ab9 100644 --- a/services/core/internal/api/dependencies.go +++ b/services/core/internal/api/dependencies.go @@ -96,7 +96,8 @@ type Sandboxes struct { type Handler struct { Dependencies - harnesses map[string]bool + harnesses map[string]bool + requestLogs *requestLogs } // NewHandler builds the HTTP handler from complete dependencies. diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index dc4af3f1c..8f7d59d4b 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -54,7 +54,8 @@ type SessionsReader interface { // Beta group, has the JSON body and Allow header. func (h *Handler) routes() *chi.Mux { router := chi.NewRouter() - router.Use(h.responseHeaders, log.HTTPMiddleware, middleware.GetHead) + h.requestLogs = newRequestLogs(router) + router.Use(log.HTTPMiddleware, h.responseHeaders, middleware.GetHead) router.MethodNotAllowed(methodNotAllowed) router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) diff --git a/services/core/internal/api/request_logs.go b/services/core/internal/api/request_logs.go new file mode 100644 index 000000000..73314ad7c --- /dev/null +++ b/services/core/internal/api/request_logs.go @@ -0,0 +1,190 @@ +package api + +import ( + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/go-chi/chi/v5" +) + +const requestLogCapacity = 1000 + +// RequestLogEntry contains HTTP transport metadata, never request content. +type RequestLogEntry struct { + RequestID string `json:"request_id" binding:"required"` + TraceID string `json:"trace_id" binding:"required"` + StartedAt time.Time `json:"started_at" binding:"required"` + Method string `json:"method" binding:"required"` + Route string `json:"route" binding:"required"` + State string `json:"state" binding:"required" enums:"active,completed,aborted"` + Status *int `json:"status" binding:"required" extensions:"x-nullable"` + DurationMS float64 `json:"duration_ms" binding:"required"` + ResponseBytes int64 `json:"response_bytes" binding:"required"` + ErrorCode *string `json:"error_code" binding:"required" extensions:"x-nullable"` +} + +type RequestLogList struct { + Object string `json:"object" binding:"required"` + Surface string `json:"surface" binding:"required" enums:"public,admin"` + InstanceID string `json:"instance_id" binding:"required"` + InstanceStartedAt time.Time `json:"instance_started_at" binding:"required"` + Capacity int `json:"capacity" binding:"required"` + RetainedCount int `json:"retained_count" binding:"required"` + TotalMatching int `json:"total_matching" binding:"required"` + HasMore bool `json:"has_more" binding:"required"` + Data []RequestLogEntry `json:"data" binding:"required"` +} + +// Slots are copied while locked. A late completion can only update its own +// sequence, so eviction of a long-running stream cannot resurrect old entries. +type requestLogs struct { + mu sync.Mutex + slots [2][requestLogCapacity]RequestLogEntry + sequence [2]uint64 + instanceID string + started time.Time + routes chi.Routes +} + +func newRequestLogs(routes chi.Routes) *requestLogs { + return &requestLogs{instanceID: newRequestID(), started: time.Now().UTC(), routes: routes} +} + +func requestLogMethod(method string) string { + switch method { + case "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT", "TRACE": + return method + } + return "OTHER" +} + +func (l *requestLogs) begin(r *http.Request, id string, started time.Time, surface int) (uint64, RequestLogEntry) { + routePath := r.URL.RawPath + if routePath == "" { + routePath = r.URL.Path + } + route := "(unmatched)" + // Find returns authored patterns only; no URL values or queries are retained. + methods := []string{r.Method, "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} + for _, method := range methods { + if pattern := l.routes.Find(chi.NewRouteContext(), method, routePath); pattern != "" { + route = pattern + break + } + } + carrier, _ := log.TraceFromContext(r.Context()) + entry := RequestLogEntry{RequestID: id, TraceID: carrier.Trace.String(), StartedAt: started.UTC(), Method: requestLogMethod(r.Method), Route: route, State: "active"} + l.mu.Lock() + seq := l.sequence[surface] + l.sequence[surface]++ + l.slots[surface][seq%requestLogCapacity] = entry + l.mu.Unlock() + return seq, entry +} + +func (l *requestLogs) update(seq uint64, entry RequestLogEntry, surface int) { + l.mu.Lock() + if l.sequence[surface]-seq <= requestLogCapacity { + l.slots[surface][seq%requestLogCapacity] = entry + } + l.mu.Unlock() +} + +func (l *requestLogs) snapshot(limit int, id, outcome, method string, surface int) RequestLogList { + l.mu.Lock() + slots, sequence := l.slots[surface], l.sequence[surface] + l.mu.Unlock() + count := min(sequence, uint64(requestLogCapacity)) + result := RequestLogList{Object: "core.request_log.list", InstanceID: l.instanceID, InstanceStartedAt: l.started, Capacity: requestLogCapacity, RetainedCount: int(count), Data: make([]RequestLogEntry, 0, limit)} + result.Surface = []string{"public", "admin"}[surface] + now := time.Now() + for offset := uint64(0); offset < count; offset++ { + entry := slots[(sequence-1-offset)%requestLogCapacity] + if id != "" && entry.RequestID != id || method != "" && entry.Method != method || outcome == "active" && entry.State != "active" || outcome == "errors" && entry.State != "aborted" && (entry.Status == nil || *entry.Status < 400) { + continue + } + result.TotalMatching++ + if len(result.Data) >= limit { + continue + } + if entry.State == "active" { + entry.DurationMS = float64(now.Sub(entry.StartedAt)) / float64(time.Millisecond) + } + result.Data = append(result.Data, entry) + } + result.HasMore = result.TotalMatching > len(result.Data) + return result +} + +// @Summary List recent Core HTTP requests +// @Description Core key only. Process-local bounded metadata for /v1 and /core/v1, newest started first. Polling this endpoint is excluded. HTTP status is not a Turn outcome. +// @Tags Core Administration +// @Produce json +// @Security DeploymentAdminAuth +// @Param surface query string false "API surface (default public)" Enums(public,admin) +// @Param limit query int false "Maximum entries (default 100)" minimum(1) maximum(200) +// @Param request_id query string false "Exact server-issued request ID" +// @Param outcome query string false "HTTP outcome filter (default all)" Enums(all,errors,active) +// @Param method query string false "HTTP method" Enums(GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,CONNECT,TRACE,OTHER) +// @Success 200 {object} RequestLogList +// @Failure 400,401 {object} CoreErrorResponse +// @Router /core/v1/request-logs [get] +func (h *Handler) getRequestLogs(w http.ResponseWriter, r *http.Request) { + values, err := url.ParseQuery(r.URL.RawQuery) + limit := 100 + valid := err == nil + for key, value := range values { + if len(value) != 1 { + valid = false + } + switch key { + case "limit", "request_id", "outcome", "method", "surface": + default: + valid = false + } + } + if value, ok := values["limit"]; ok && len(value) == 1 { + limit, err = strconv.Atoi(value[0]) + valid = valid && err == nil && limit >= 1 && limit <= 200 + } + id, outcome, method := values.Get("request_id"), values.Get("outcome"), values.Get("method") + if _, ok := values["request_id"]; ok { + valid = valid && len(id) == 36 && strings.HasPrefix(id, "req_") + for _, c := range strings.TrimPrefix(id, "req_") { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + valid = false + } + } + } + if _, ok := values["outcome"]; ok { + valid = valid && (outcome == "all" || outcome == "errors" || outcome == "active") + } + if _, ok := values["method"]; ok { + valid = valid && method != "" && (requestLogMethod(method) == method) + } + surface := 0 + if v, ok := values["surface"]; ok { + valid = valid && len(v) == 1 && (v[0] == "public" || v[0] == "admin") + if len(v) == 1 && v[0] == "admin" { + surface = 1 + } + } + if !valid { + writeError(w, http.StatusBadRequest, "invalid_request", "Request log filters are invalid.") + return + } + writeJSON(w, http.StatusOK, h.requestLogs.snapshot(limit, id, outcome, method, surface)) +} + +func recordRequest(r *http.Request) bool { + path := r.URL.Path + if (r.Method == http.MethodGet || r.Method == http.MethodHead) && path == "/core/v1/request-logs" { + return false + } + return path == "/v1" || strings.HasPrefix(path, "/v1/") || path == "/core/v1" || strings.HasPrefix(path, "/core/v1/") +} diff --git a/services/core/internal/api/request_logs_test.go b/services/core/internal/api/request_logs_test.go new file mode 100644 index 000000000..94ac4d68a --- /dev/null +++ b/services/core/internal/api/request_logs_test.go @@ -0,0 +1,314 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func TestRequestLogsAdministratorContract(t *testing.T) { + deps, _ := managementFakes(t, callerBinding()) + handler := newTestHandler(t, deps) + for _, key := range []string{"", "unknown", "caller"} { + if w := projectKeyHTTP(handler, "GET", "/core/v1/request-logs", key, ""); w.Code != 401 { + t.Fatal(w.Code) + } + } + secret := "secret-query-body-key" + response := projectKeyHTTP(handler, "POST", "/v1/files/"+secret+"?secret="+secret, secret, secret) + w := projectKeyHTTP(handler, "GET", "/core/v1/request-logs", "admin", "") + var list RequestLogList + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatal(err) + } + if w.Code != 200 || list.Surface != "public" || len(list.Data) != 1 || list.Data[0].Status == nil || *list.Data[0].Status != response.Code || list.Data[0].RequestID != response.Header().Get("X-Request-Id") { + t.Fatal(w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), secret) || list.Data[0].Route != "/v1/files/{file_id}" || len(list.Data[0].TraceID) != 32 || list.InstanceStartedAt.IsZero() || list.InstanceID == "" { + t.Fatal(w.Body.String()) + } + for _, query := range []string{"limit=0", "limit=201", "limit=x", "limit=1&limit=2", "outcome=", "outcome=done", "method=evil", "method=", "surface=bad", "request_id=secret", "request_id=", "extra=secret", "limit=1;secret=x", "limit=%xx"} { + if w := projectKeyHTTP(handler, "GET", "/core/v1/request-logs?"+query, "admin", ""); w.Code != 400 || strings.Contains(w.Body.String(), secret) { + t.Fatal(query, w.Code, w.Body.String()) + } + } + w = projectKeyHTTP(handler, "GET", "/core/v1/request-logs?request_id="+list.Data[0].RequestID+"&outcome=errors&method=POST&limit=1", "admin", "") + var filtered RequestLogList + if err := json.Unmarshal(w.Body.Bytes(), &filtered); err != nil || len(filtered.Data) != 1 { + t.Fatal(err, w.Body.String()) + } + w = projectKeyHTTP(handler, "GET", "/core/v1/request-logs?surface=admin", "admin", "") + if err := json.Unmarshal(w.Body.Bytes(), &filtered); err != nil || len(filtered.Data) != 0 { + t.Fatal(err, w.Body.String()) + } +} + +func TestRequestLogsIncludeAuthenticationFailures(t *testing.T) { + deps, _ := managementFakes(t, callerBinding()) + handler := newTestHandler(t, deps) + for _, surface := range []struct{ name, path string }{{"public", "/v1/files"}, {"admin", "/core/v1/summary"}} { + response := projectKeyHTTP(handler, "GET", surface.path, "invalid-secret-token", "") + if response.Code != http.StatusUnauthorized { + t.Fatal(surface.name, response.Code) + } + query := "/core/v1/request-logs?surface=" + surface.name + "&outcome=errors&request_id=" + response.Header().Get("X-Request-Id") + result := projectKeyHTTP(handler, "GET", query, "admin", "") + var list RequestLogList + if err := json.Unmarshal(result.Body.Bytes(), &list); err != nil || len(list.Data) != 1 || list.Data[0].Status == nil || *list.Data[0].Status != http.StatusUnauthorized || list.Data[0].Route != surface.path { + t.Fatal(surface.name, err, result.Body.String()) + } + } +} + +func requestLogRouter() (*chi.Mux, *requestLogs) { + router := chi.NewRouter() + logs := newRequestLogs(router) + router.Use(log.HTTPMiddleware, func(next http.Handler) http.Handler { return responseHeadersWithLog(next, nil, logs) }, middleware.GetHead) + router.MethodNotAllowed(methodNotAllowed) + return router, logs +} + +func TestRequestLogsRoutingAndScope(t *testing.T) { + router, logs := requestLogRouter() + router.Get("/v1/items/{id}", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]string{"ok": "yes"}) }) + router.Get("/core/v1/test", func(w http.ResponseWriter, r *http.Request) { + writeError(w, 503, "execution_unavailable", "secret response") + }) + for _, tt := range []struct { + method, path, route string + status int + }{ + {"HEAD", "/v1/items/secret", "/v1/items/{id}", 200}, + {"POST", "/v1/items/secret", "/v1/items/{id}", 405}, + {"UNTRUSTED_SECRET_METHOD", "/v1/items/secret", "/v1/items/{id}", 405}, + {"GET", "/v1/secret-unknown", "(unmatched)", 404}, + } { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(tt.method, tt.path, nil)) + entry := logs.snapshot(1, "", "", "", 0).Data[0] + if entry.Route != tt.route || entry.Method != requestLogMethod(tt.method) || entry.State != "completed" || entry.Status == nil || *entry.Status != tt.status { + t.Fatalf("%+v", entry) + } + } + for _, path := range []string{"/healthz", "/docs", "/api/v1/connection", "/core/v1/request-logs"} { + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", path, nil)) + } + if got := logs.snapshot(100, "", "", "", 0).RetainedCount; got != 4 { + t.Fatal(got) + } + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/core/v1/test", nil)) + admin := logs.snapshot(1, "", "errors", "", 1) + if len(admin.Data) != 1 || admin.Data[0].ErrorCode == nil || *admin.Data[0].ErrorCode != "execution_unavailable" { + t.Fatal(admin) + } +} + +func TestRequestLogsObserveActiveStreamingAndPanic(t *testing.T) { + router, logs := requestLogRouter() + entered, flush, flushed, release := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) + router.Get("/v1/stream", func(w http.ResponseWriter, r *http.Request) { + close(entered) + <-flush + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: first\n\n") + if err := http.NewResponseController(w).Flush(); err != nil { + panic(err) + } + close(flushed) + <-release + }) + done := make(chan struct{}) + go func() { + defer close(done) + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/v1/stream", nil)) + }() + <-entered + first := logs.snapshot(1, "", "active", "", 0).Data[0] + if first.Status != nil || first.State != "active" || first.ResponseBytes != 0 { + t.Fatal(first) + } + close(flush) + <-flushed + streaming := logs.snapshot(1, "", "active", "", 0).Data[0] + if streaming.Status == nil || *streaming.Status != 200 || streaming.ResponseBytes != 13 || streaming.DurationMS < first.DurationMS { + t.Fatal(streaming) + } + close(release) + <-done + if entry := logs.snapshot(1, "", "", "", 0).Data[0]; entry.State != "completed" { + t.Fatal(entry) + } + router.Get("/v1/panic", func(http.ResponseWriter, *http.Request) { panic("private panic") }) + func() { + defer func() { + if recover() == nil { + t.Error("panic swallowed") + } + }() + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/v1/panic", nil)) + }() + entry := logs.snapshot(1, "", "errors", "", 0).Data[0] + if entry.State != "aborted" || entry.Status != nil || entry.ErrorCode != nil { + t.Fatal(entry) + } +} + +type requestLogFailWriter struct{ header http.Header } + +func (w requestLogFailWriter) Header() http.Header { return w.header } +func (requestLogFailWriter) WriteHeader(int) {} +func (requestLogFailWriter) Write([]byte) (int, error) { return 2, errors.New("private failure") } +func TestRequestLogsWriteFailure(t *testing.T) { + router, logs := requestLogRouter() + router.Get("/v1/failure", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("body")) }) + router.ServeHTTP(requestLogFailWriter{make(http.Header)}, httptest.NewRequest("GET", "/v1/failure", nil)) + entry := logs.snapshot(1, "", "errors", "", 0).Data[0] + if entry.State != "aborted" || entry.ResponseBytes != 2 || entry.Status == nil || *entry.Status != 200 { + t.Fatal(entry) + } +} + +func TestRequestLogsBoundsEvictionAndConcurrentSnapshots(t *testing.T) { + router := chi.NewRouter() + router.Get("/v1/test", func(http.ResponseWriter, *http.Request) {}) + logs := newRequestLogs(router) + request := httptest.NewRequest("GET", "/v1/test", nil) + seq, old := logs.begin(request, "old-active", time.Now(), 0) + for i := 0; i < requestLogCapacity; i++ { + logs.begin(request, "new", time.Now(), 0) + } + old.State = "completed" + logs.update(seq, old, 0) + if list := logs.snapshot(200, "", "", "", 0); list.RetainedCount != 1000 || list.TotalMatching != 1000 || !list.HasMore || len(list.Data) != 200 { + t.Fatal(list) + } + if list := logs.snapshot(1, "old-active", "", "", 0); list.TotalMatching != 0 { + t.Fatal("evicted entry resurrected") + } + for i := 0; i < requestLogCapacity+1; i++ { + logs.begin(request, "admin", time.Now(), 1) + } + if list := logs.snapshot(1, "", "", "", 0); list.Data[0].RequestID != "new" { + t.Fatal("admin evicted public") + } + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 200; j++ { + seq, entry := logs.begin(request, "concurrent", time.Now(), 0) + entry.State = "completed" + logs.update(seq, entry, 0) + logs.snapshot(20, "", "", "", 0) + } + }() + } + wg.Wait() +} + +func BenchmarkRequestLogging(b *testing.B) { + previous := slog.Default() + slog.SetDefault(slog.New(log.NewContextHandler(slog.NewJSONHandler(io.Discard, nil)))) + defer slog.SetDefault(previous) + for _, enabled := range []bool{false, true} { + name := "baseline" + if enabled { + name = "recorded" + } + b.Run(name, func(b *testing.B) { + router := chi.NewRouter() + var logs *requestLogs + if enabled { + logs = newRequestLogs(router) + } + router.Use(log.HTTPMiddleware, func(next http.Handler) http.Handler { return responseHeadersWithLog(next, nil, logs) }) + router.Get("/v1/items/{id}", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) }) + request := httptest.NewRequest("GET", "/v1/items/test", nil) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + router.ServeHTTP(httptest.NewRecorder(), request) + } + }) + } + b.Run("snapshot-full", func(b *testing.B) { + router := chi.NewRouter() + logs := newRequestLogs(router) + request := httptest.NewRequest("GET", "/v1/missing", nil) + for i := 0; i < requestLogCapacity; i++ { + logs.begin(request, "test", time.Now(), 0) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + logs.snapshot(100, "", "", "", 0) + } + }) +} + +func TestRequestLogsPreserveImplicitResponseAndCancellation(t *testing.T) { + router, logs := requestLogRouter() + router.Get("/v1/implicit", func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "hello") }) + server := httptest.NewServer(router) + defer server.Close() + for _, method := range []string{"GET", "HEAD"} { + request, _ := http.NewRequest(method, server.URL+"/v1/implicit", nil) + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil || response.StatusCode != 200 || response.Header.Get("Content-Type") != "text/html; charset=utf-8" || response.ContentLength != 18 { + t.Fatal(method, response.StatusCode, response.Header, err) + } + if method == "HEAD" && len(body) != 0 || method == "GET" && string(body) != "hello" { + t.Fatal(method, string(body)) + } + } + router.Get("/v1/canceled", func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/v1/canceled", nil).WithContext(ctx)) + entry := logs.snapshot(1, "", "errors", "", 0).Data[0] + if entry.State != "aborted" || entry.Status != nil { + t.Fatal(entry) + } +} + +func TestRequestLogsStructuredCompletion(t *testing.T) { + var output bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(log.NewContextHandler(slog.NewJSONHandler(&output, nil)))) + defer slog.SetDefault(previous) + router, _ := requestLogRouter() + router.Get("/v1/items/{id}", func(w http.ResponseWriter, r *http.Request) { + writeError(w, 400, "invalid_request", "private response") + }) + request := httptest.NewRequest("GET", "/v1/items/private-path?secret=private-query", strings.NewReader("private body")) + request.Header.Set("Authorization", "Bearer private-token") + request.Header.Set("X-Request-Id", "forged-request-id") + writer := httptest.NewRecorder() + router.ServeHTTP(writer, request) + var record map[string]any + if err := json.Unmarshal(output.Bytes(), &record); err != nil { + t.Fatal(err) + } + if strings.Contains(output.String(), "private") || strings.Contains(output.String(), "forged") || record["request_id"] != writer.Header().Get("X-Request-Id") || record["status"] != float64(400) || record["error_code"] != "invalid_request" || len(record["trace_id"].(string)) != 32 { + t.Fatal(output.String()) + } +} diff --git a/services/core/internal/api/routing.go b/services/core/internal/api/routing.go index 5f2cef602..cd595f7b2 100644 --- a/services/core/internal/api/routing.go +++ b/services/core/internal/api/routing.go @@ -124,6 +124,10 @@ func cleanPath(p string) string { // reported: Core's project scope is configured, not account-derived. report // observes each emitted API error code. func responseHeadersWithErrors(next http.Handler, report func(string)) http.Handler { + return responseHeadersWithLog(next, report, nil) +} + +func responseHeadersWithLog(next http.Handler, report func(string), logs *requestLogs) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := newRequestID() header := w.Header() @@ -131,7 +135,42 @@ func responseHeadersWithErrors(next http.Handler, report func(string)) http.Hand header.Set("Openai-Version", "2020-10-01") header.Set("X-Content-Type-Options", "nosniff") writer := &processingTimeWriter{ResponseWriter: w, started: time.Now(), report: report} - next.ServeHTTP(writer, r.WithContext(log.WithRequestID(r.Context(), id))) + r = r.WithContext(log.WithRequestID(r.Context(), id)) + if logs != nil && recordRequest(r) { + surface := 0 + if strings.HasPrefix(r.URL.Path, "/core/") { + surface = 1 + } + seq, entry := logs.begin(r, id, writer.started, surface) + returned := false + writer.observe = func() { + entry.Status, entry.ResponseBytes, entry.ErrorCode = writer.status, writer.bytes, writer.errorCode + logs.update(seq, entry, surface) + } + defer func() { + entry.State = "completed" + if !returned || writer.failed || r.Context().Err() != nil { + entry.State = "aborted" + } + entry.DurationMS = float64(time.Since(writer.started)) / float64(time.Millisecond) + writer.observe() + var status, errorCode any + if entry.Status != nil { + status = *entry.Status + } + if entry.ErrorCode != nil { + errorCode = *entry.ErrorCode + } + log.Ctx(r.Context()).Info("http request finished", "state", entry.State, "method", entry.Method, "route", entry.Route, "status", status, "duration_ms", entry.DurationMS, "response_bytes", entry.ResponseBytes, "error_code", errorCode) + }() + next.ServeHTTP(writer, r) + if writer.status == nil && r.Context().Err() == nil { + writer.WriteHeader(http.StatusOK) + } + returned = true + return + } + next.ServeHTTP(writer, r) }) } @@ -146,15 +185,26 @@ func newRequestID() string { // Unwrap keeps http.ResponseController deadlines and flushing available. type processingTimeWriter struct { http.ResponseWriter - started time.Time - stamped bool - report func(string) + started time.Time + stamped bool + report func(string) + status *int + bytes int64 + errorCode *string + observe func() + failed bool } // reportAPIError observes the emitted code without reading or retaining bodies. func (w *processingTimeWriter) reportAPIError(code string) { if !w.stamped { - w.report(code) + if w.report != nil { + w.report(code) + } + // API error codes are fixed diagnostics owned by the API and provider contract. + if code != "" && len(code) <= 80 { + w.errorCode = &code + } } } @@ -167,19 +217,39 @@ func (w *processingTimeWriter) stamp() { func (w *processingTimeWriter) WriteHeader(status int) { if status >= http.StatusOK { + if !w.stamped { + w.status = &status + } w.stamp() + if w.observe != nil { + w.observe() + } } w.ResponseWriter.WriteHeader(status) } func (w *processingTimeWriter) Write(body []byte) (int, error) { - w.stamp() - return w.ResponseWriter.Write(body) + if !w.stamped { + status := http.StatusOK + w.status = &status + w.stamp() + } + n, err := w.ResponseWriter.Write(body) + w.bytes += int64(n) + w.failed = w.failed || err != nil + if w.observe != nil { + w.observe() + } + return n, err } func (w *processingTimeWriter) FlushError() error { - w.stamp() - return http.NewResponseController(w.ResponseWriter).Flush() + if !w.stamped { + w.WriteHeader(http.StatusOK) + } + err := http.NewResponseController(w.ResponseWriter).Flush() + w.failed = w.failed || err != nil + return err } func (w *processingTimeWriter) Flush() { _ = w.FlushError() }