diff --git a/CHANGELOG.md b/CHANGELOG.md index bf90a540430..6956a1287fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Crestodian/CLI: exit non-zero when interactive Crestodian is invoked without a TTY, so scripts and CI no longer treat the setup error as success. Fixes #73646 and supersedes #73928 and #74059. Thanks @bittoby, @luyao618, and @Linux2010. - Cron: keep implicit/default isolated cron announce deliveries out of the main session awareness queue, so isolated jobs do not accumulate in the main conversation. Fixes #61426. Thanks @Lihannon. - Subagents: avoid duplicate parent-visible replies when a parent uses `sessions_send` on its own persistent native subagent session, while preserving announce delivery for async sends. Fixes #73550. Thanks @sylviazhang2006-design. +- Web search/Brave: add opt-in `brave.http` diagnostics for Brave request URLs/query params, response status/timing, and cache hit/miss/write events without logging API keys or response bodies. Fixes #55196. Thanks @mecampbellsoup. - Agents/sandbox: preserve existing workspace file modes when sandbox edits atomically replace files, so 0644 files do not collapse to 0600 after Write/Edit/apply_patch. Fixes #44077. Thanks @patosullivan. - Agents/models: keep legacy CLI runtime model refs such as `claude-cli/*` in the configured allowlist after canonical runtime migration, so cron `payload.model` overrides keep working. Fixes #75753. Thanks @RyanSandoval. - Codex/app-server: restart the shared Codex app-server client once when it closes during startup thread resume, preserving the existing thread binding instead of retrying `thread/start` on a closed client. Thanks @vincentkoc. diff --git a/docs/diagnostics/flags.md b/docs/diagnostics/flags.md index 9856b23a301..6b6a54c6a4c 100644 --- a/docs/diagnostics/flags.md +++ b/docs/diagnostics/flags.md @@ -31,7 +31,7 @@ Multiple flags: ```json { "diagnostics": { - "flags": ["telegram.http", "gateway.*"] + "flags": ["telegram.http", "brave.http", "gateway.*"] } } ``` @@ -111,6 +111,12 @@ Filter for Telegram HTTP diagnostics: rg "telegram http error" /tmp/openclaw/openclaw-*.log ``` +Filter for Brave Search HTTP diagnostics: + +```bash +rg "brave http" /tmp/openclaw/openclaw-*.log +``` + Or tail while reproducing: ```bash @@ -122,6 +128,7 @@ For remote gateways, you can also use `openclaw logs --follow` (see [/cli/logs]( ## Notes - If `logging.level` is set higher than `warn`, these logs may be suppressed. Default `info` is fine. +- `brave.http` logs Brave Search request URLs/query params, response status/timing, and cache hit/miss/write events. It does not log API keys or response bodies, but search queries can be sensitive. - Flags are safe to leave enabled; they only affect log volume for the specific subsystem. - Use [/logging](/logging) to change log destinations, levels, and redaction. diff --git a/docs/tools/brave-search.md b/docs/tools/brave-search.md index 494d154ffb5..8760acdd889 100644 --- a/docs/tools/brave-search.md +++ b/docs/tools/brave-search.md @@ -123,6 +123,7 @@ await web_search({ - `llm-context` mode supports `freshness` and bounded `date_after` + `date_before` ranges. It does not support `ui_lang`; `date_before` without `date_after` is rejected because Brave requires custom freshness ranges to include both start and end dates. - `ui_lang` must include a region subtag like `en-US`. - Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`). +- Enable the `brave.http` diagnostics flag to log Brave request URLs/query params, response status/timing, and search-cache hit/miss/write events while troubleshooting. The flag never logs the API key or response bodies, but search queries can be sensitive. ## Related diff --git a/extensions/brave/src/brave-web-search-provider.runtime.ts b/extensions/brave/src/brave-web-search-provider.runtime.ts index 25b06601a9e..2d31d4bfaf8 100644 --- a/extensions/brave/src/brave-web-search-provider.runtime.ts +++ b/extensions/brave/src/brave-web-search-provider.runtime.ts @@ -18,6 +18,7 @@ import { wrapWebContent, writeCachedSearchPayload, } from "openclaw/plugin-sdk/provider-web-search"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { type BraveLlmContextResponse, mapBraveLlmContextResults, @@ -29,6 +30,7 @@ import { const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context"; +const braveHttpLogger = createSubsystemLogger("brave/http"); type BraveSearchResult = { title?: string; @@ -43,6 +45,33 @@ type BraveSearchResponse = { }; }; +type BraveHttpDiagnostics = { + enabled?: boolean; +}; + +function logBraveHttp( + diagnostics: BraveHttpDiagnostics | undefined, + event: string, + meta?: Record, +): void { + if (!diagnostics?.enabled) { + return; + } + braveHttpLogger.info(`brave http ${event}`, meta); +} + +function describeBraveRequestUrl(url: URL): { + url: string; + query: string; + params: Record; +} { + return { + url: url.toString(), + query: url.searchParams.get("q") ?? "", + params: Object.fromEntries(url.searchParams.entries()), + }; +} + function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined { return ( readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ?? @@ -62,6 +91,7 @@ async function runBraveLlmContextSearch(params: { query: string; apiKey: string; timeoutSeconds: number; + diagnostics?: BraveHttpDiagnostics; country?: string; search_lang?: string; freshness?: string; @@ -95,6 +125,11 @@ async function runBraveLlmContextSearch(params: { ); } + logBraveHttp(params.diagnostics, "request", { + mode: "llm-context", + ...describeBraveRequestUrl(url), + }); + const startedAt = Date.now(); return withTrustedWebSearchEndpoint( { url: url.toString(), @@ -108,6 +143,12 @@ async function runBraveLlmContextSearch(params: { }, }, async (response) => { + logBraveHttp(params.diagnostics, "response", { + mode: "llm-context", + status: response.status, + ok: response.ok, + durationMs: Date.now() - startedAt, + }); if (!response.ok) { const detail = await response.text(); throw new Error( @@ -126,6 +167,7 @@ async function runBraveWebSearch(params: { count: number; apiKey: string; timeoutSeconds: number; + diagnostics?: BraveHttpDiagnostics; country?: string; search_lang?: string; ui_lang?: string; @@ -158,6 +200,11 @@ async function runBraveWebSearch(params: { url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`); } + logBraveHttp(params.diagnostics, "request", { + mode: "web", + ...describeBraveRequestUrl(url), + }); + const startedAt = Date.now(); return withTrustedWebSearchEndpoint( { url: url.toString(), @@ -171,6 +218,12 @@ async function runBraveWebSearch(params: { }, }, async (response) => { + logBraveHttp(params.diagnostics, "response", { + mode: "web", + status: response.status, + ok: response.ok, + durationMs: Date.now() - startedAt, + }); if (!response.ok) { const detail = await response.text(); throw new Error( @@ -199,6 +252,9 @@ async function runBraveWebSearch(params: { export async function executeBraveSearch( args: Record, searchConfig?: SearchConfigRecord, + options?: { + diagnosticsEnabled?: boolean; + }, ): Promise> { const apiKey = resolveBraveApiKey(searchConfig); if (!apiKey) { @@ -322,10 +378,13 @@ export async function executeBraveSearch( dateBefore, ], ); + const diagnostics: BraveHttpDiagnostics = { enabled: options?.diagnosticsEnabled === true }; const cached = readCachedSearchPayload(cacheKey); if (cached) { + logBraveHttp(diagnostics, "cache hit", { mode: braveMode, query, cacheKey }); return cached; } + logBraveHttp(diagnostics, "cache miss", { mode: braveMode, query, cacheKey }); const start = Date.now(); const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig); @@ -336,6 +395,7 @@ export async function executeBraveSearch( query, apiKey, timeoutSeconds, + diagnostics, country: country ?? undefined, search_lang: normalizedLanguage.search_lang, freshness, @@ -363,6 +423,13 @@ export async function executeBraveSearch( sources, }; writeCachedSearchPayload(cacheKey, payload, cacheTtlMs); + logBraveHttp(diagnostics, "cache write", { + mode: "llm-context", + query, + cacheKey, + ttlMs: cacheTtlMs, + count: results.length, + }); return payload; } @@ -371,6 +438,7 @@ export async function executeBraveSearch( count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), apiKey, timeoutSeconds, + diagnostics, country: country ?? undefined, search_lang: normalizedLanguage.search_lang, ui_lang: normalizedLanguage.ui_lang, @@ -392,5 +460,12 @@ export async function executeBraveSearch( results, }; writeCachedSearchPayload(cacheKey, payload, cacheTtlMs); + logBraveHttp(diagnostics, "cache write", { + mode: "web", + query, + cacheKey, + ttlMs: cacheTtlMs, + count: results.length, + }); return payload; } diff --git a/extensions/brave/src/brave-web-search-provider.test.ts b/extensions/brave/src/brave-web-search-provider.test.ts index e8fa2ced0a9..24a80ccdf02 100644 --- a/extensions/brave/src/brave-web-search-provider.test.ts +++ b/extensions/brave/src/brave-web-search-provider.test.ts @@ -5,6 +5,32 @@ import { __testing } from "../test-api.js"; import { createBraveWebSearchProvider as createBraveWebSearchContractProvider } from "../web-search-contract-api.js"; import { createBraveWebSearchProvider } from "./brave-web-search-provider.js"; +const loggerInfoMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ + createSubsystemLogger: () => ({ + info: loggerInfoMock, + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + trace: vi.fn(), + raw: vi.fn(), + isEnabled: () => true, + child: () => ({ + info: loggerInfoMock, + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + trace: vi.fn(), + raw: vi.fn(), + isEnabled: () => true, + child: vi.fn(), + }), + }), +})); + const braveManifest = JSON.parse( fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"), ) as { @@ -46,6 +72,7 @@ describe("brave web search provider", () => { afterEach(() => { vi.unstubAllEnvs(); + loggerInfoMock.mockClear(); global.fetch = priorFetch; }); @@ -422,4 +449,77 @@ describe("brave web search provider", () => { const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0])); expect(requestUrl.searchParams.get("country")).toBe("ALL"); }); + + it("emits brave.http diagnostics for requests, responses, and cache events", async () => { + vi.stubEnv("BRAVE_API_KEY", ""); + const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => { + return { + ok: true, + status: 200, + json: async () => ({ + web: { + results: [ + { + title: "Diagnostics", + url: "https://example.com/diagnostics", + description: "debug details", + }, + ], + }, + }), + } as Response; + }); + global.fetch = mockFetch as typeof global.fetch; + + const provider = createBraveWebSearchProvider(); + const tool = provider.createTool({ + config: { diagnostics: { flags: ["brave.http"] } }, + searchConfig: { + apiKey: "brave-test-key", + brave: { mode: "web" }, + }, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + + await tool.execute({ query: "unique brave diagnostics query", count: 1 }); + await tool.execute({ query: "unique brave diagnostics query", count: 1 }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const messages = loggerInfoMock.mock.calls.map((call) => call[0]); + expect(messages).toEqual( + expect.arrayContaining([ + "brave http cache miss", + "brave http request", + "brave http response", + "brave http cache write", + "brave http cache hit", + ]), + ); + expect(loggerInfoMock.mock.calls).toEqual( + expect.arrayContaining([ + [ + "brave http request", + expect.objectContaining({ + mode: "web", + query: "unique brave diagnostics query", + params: expect.objectContaining({ q: "unique brave diagnostics query", count: "1" }), + url: expect.stringContaining("api.search.brave.com/res/v1/web/search"), + }), + ], + [ + "brave http response", + expect.objectContaining({ + mode: "web", + status: 200, + ok: true, + durationMs: expect.any(Number), + }), + ], + ]), + ); + expect(JSON.stringify(loggerInfoMock.mock.calls)).not.toContain("brave-test-key"); + expect(JSON.stringify(loggerInfoMock.mock.calls)).not.toContain("X-Subscription-Token"); + }); }); diff --git a/extensions/brave/src/brave-web-search-provider.ts b/extensions/brave/src/brave-web-search-provider.ts index 1eafc26b955..2390a5bc101 100644 --- a/extensions/brave/src/brave-web-search-provider.ts +++ b/extensions/brave/src/brave-web-search-provider.ts @@ -1,3 +1,4 @@ +import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime"; import type { SearchConfigRecord, WebSearchProviderPlugin, @@ -111,8 +112,10 @@ function resolveBraveMode(searchConfig?: Record): "web" | "llm- function createBraveToolDefinition( searchConfig?: SearchConfigRecord, + config?: Parameters[1], ): WebSearchProviderToolDefinition { const braveMode = resolveBraveMode(searchConfig); + const diagnosticsEnabled = isDiagnosticFlagEnabled("brave.http", config); return { description: @@ -122,7 +125,7 @@ function createBraveToolDefinition( parameters: BraveSearchSchema, execute: async (args) => { const { executeBraveSearch } = await loadBraveWebSearchRuntime(); - return await executeBraveSearch(args, searchConfig); + return await executeBraveSearch(args, searchConfig, { diagnosticsEnabled }); }, }; } @@ -153,6 +156,7 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin { resolveProviderWebSearchPluginConfig(ctx.config, "brave"), { mirrorApiKeyToTopLevel: true }, ), + ctx.config, ), }; }