feat(brave): add http diagnostics flag

This commit is contained in:
Peter Steinberger
2026-05-02 07:45:54 +01:00
parent fa7de46261
commit 286e169a04
6 changed files with 190 additions and 2 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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<string, unknown>,
): void {
if (!diagnostics?.enabled) {
return;
}
braveHttpLogger.info(`brave http ${event}`, meta);
}
function describeBraveRequestUrl(url: URL): {
url: string;
query: string;
params: Record<string, string>;
} {
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<string, unknown>,
searchConfig?: SearchConfigRecord,
options?: {
diagnosticsEnabled?: boolean;
},
): Promise<Record<string, unknown>> {
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;
}

View File

@@ -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");
});
});

View File

@@ -1,3 +1,4 @@
import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime";
import type {
SearchConfigRecord,
WebSearchProviderPlugin,
@@ -111,8 +112,10 @@ function resolveBraveMode(searchConfig?: Record<string, unknown>): "web" | "llm-
function createBraveToolDefinition(
searchConfig?: SearchConfigRecord,
config?: Parameters<typeof isDiagnosticFlagEnabled>[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,
),
};
}