diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256
index 847c0115de82..e42598af92a3 100644
--- a/docs/.generated/config-baseline.sha256
+++ b/docs/.generated/config-baseline.sha256
@@ -1,4 +1,4 @@
-16942839f6254e337308a87e434c295edb1b0b499f796c4dd0b27a7c596c1751 config-baseline.json
-aa1b3a3733b7129728c66d138fa2e8e691b14ba7afe43d78fc134549fcac5b45 config-baseline.core.json
-e9a81ee89ff032033012413e161316e4d07e8f6b206382a25fed2f8485151b5e config-baseline.channel.json
+1315dd0d8e904da70f5e26f6f055e493c1ddbc4b2ca0ad02bc8608f694e0d89f config-baseline.json
+a99b96985ee0409f06f356e538eb3a68c0d0e2c7862a242e00ee919681582165 config-baseline.core.json
+292a907b48f9fd7273f12947c8001077ab69978d29985cb287a71a33e837339a config-baseline.channel.json
5d5fe4c95346cb1e7555395d40a6244967e6595b30f13912ae7754209944b734 config-baseline.plugin.json
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index 49bca4e219af..813969d4ff3c 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -1299,7 +1299,7 @@ writer is best-effort, not a lossless compliance archive.
tracesEndpoint: "https://traces.example.com/v1/traces",
metricsEndpoint: "https://metrics.example.com/v1/metrics",
logsEndpoint: "https://logs.example.com/v1/logs",
- protocol: "http/protobuf", // http/protobuf | grpc
+ protocol: "http/protobuf",
headers: { "x-tenant-id": "my-org" },
serviceName: "openclaw-gateway",
traces: true,
@@ -1323,8 +1323,8 @@ writer is best-effort, not a lossless compliance archive.
- `otel.enabled`: enables the OpenTelemetry export pipeline (default: `false`). For the full configuration, signal catalog, and privacy model, see [OpenTelemetry export](/gateway/opentelemetry).
- `otel.endpoint`: collector URL for OTel export.
- `otel.tracesEndpoint` / `otel.metricsEndpoint` / `otel.logsEndpoint`: optional signal-specific OTLP endpoints. When set, they override `otel.endpoint` for that signal only.
-- `otel.protocol`: `"http/protobuf"` (default) or `"grpc"`.
-- `otel.headers`: extra HTTP/gRPC metadata headers sent with OTel export requests.
+- `otel.protocol`: `"http/protobuf"` (default). gRPC export is retired; run [`openclaw doctor --fix`](/cli/doctor) to repair a persisted legacy value or get source-specific manual-edit guidance.
+- `otel.headers`: extra HTTP request headers sent with OTel export requests.
- `otel.serviceName`: service name for resource attributes.
- `otel.traces` / `otel.metrics` / `otel.logs`: enable trace, metrics, or log export.
- `otel.logsExporter`: log export sink: `"otlp"` (default), `"stdout"` for one JSON object per stdout line, or `"both"`.
@@ -1334,6 +1334,7 @@ writer is best-effort, not a lossless compliance archive.
- `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental`: environment toggle for latest experimental GenAI inference span shape, including `{gen_ai.operation.name} {gen_ai.request.model}` span names, `CLIENT` span kind, and `gen_ai.provider.name` instead of legacy `gen_ai.system`. By default spans keep `openclaw.model.call` and `gen_ai.system` for compatibility; GenAI metrics use bounded semantic attributes.
- `OPENCLAW_OTEL_PRELOADED=1`: environment toggle for hosts that already registered a global OpenTelemetry SDK. OpenClaw then skips plugin-owned SDK startup/shutdown while keeping diagnostic listeners active.
- `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`, and `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`: signal-specific endpoint env vars used when the matching config key is unset.
+- `OTEL_EXPORTER_OTLP_PROTOCOL`: protocol fallback used only when `otel.protocol` is unset. Set it to `http/protobuf` or leave it unset; unsupported values are rejected when an OTLP signal is enabled and are not rewritten by Doctor.
- `cacheTrace.enabled`: log cache trace snapshots for embedded runs (default: `false`).
---
diff --git a/docs/gateway/opentelemetry.md b/docs/gateway/opentelemetry.md
index 87c87fb26bdf..00cad0d94236 100644
--- a/docs/gateway/opentelemetry.md
+++ b/docs/gateway/opentelemetry.md
@@ -59,7 +59,20 @@ openclaw plugins install clawhub:@openclaw/diagnostics-otel
Or enable the plugin from the CLI: `openclaw plugins enable diagnostics-otel`.
-`protocol` supports `http/protobuf` only. Since `traces` and `metrics` default to enabled, any other value (including `grpc`) aborts the entire diagnostics-otel subscription with an `unsupported protocol` warning - this also stops stdout log export. Explicitly set `traces: false` and `metrics: false` if you only want `logsExporter: "stdout"` with a non-OTLP protocol value.
+`diagnostics.otel.protocol` accepts only `http/protobuf`. If a persisted config,
+including a value supplied through `${VAR}` interpolation, still resolves this
+field to the retired `grpc` value, run
+[`openclaw doctor --fix`](/cli/doctor). Doctor repairs directly authored values
+and a sole internal single-file include that owns the top-level `diagnostics`
+section. For root or array includes, nested include chains, sibling overrides,
+external include targets, or another ambiguous source, Doctor leaves the files
+unchanged and lists the candidate source file or files to edit manually.
+
+`OTEL_EXPORTER_OTLP_PROTOCOL` is a process-environment fallback used only when
+`diagnostics.otel.protocol` is unset. Doctor does not rewrite process
+environment variables. An unsupported fallback is rejected at runtime when an
+OTLP signal is enabled; set it to `http/protobuf` or unset it. A stdout-only log
+configuration does not use the OTLP transport and continues to work.
## Signals exported
@@ -109,7 +122,7 @@ stdout, or `both` for both.
tracesEndpoint: "http://otel-collector:4318/v1/traces",
metricsEndpoint: "http://otel-collector:4318/v1/metrics",
logsEndpoint: "http://otel-collector:4318/v1/logs",
- protocol: "http/protobuf", // grpc disables OTLP export
+ protocol: "http/protobuf",
serviceName: "openclaw-gateway", // unset falls back to OTEL_SERVICE_NAME, then "openclaw"
metricNamePrefix: "acme.", // optional; include the separator
headers: { "x-collector-token": "..." },
@@ -144,7 +157,7 @@ dashboards, alerts, and recording rules that query the old names.
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Fallback for `diagnostics.otel.endpoint` when the config key is unset. |
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` / `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` / `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | Signal-specific endpoint fallbacks used when the matching `diagnostics.otel.*Endpoint` config key is unset. Signal-specific config wins over signal-specific env, which wins over the shared endpoint. |
| `OTEL_SERVICE_NAME` | Fallback for `diagnostics.otel.serviceName` when the config key is unset. Default service name is `openclaw`. |
-| `OTEL_EXPORTER_OTLP_PROTOCOL` | Fallback for the wire protocol when `diagnostics.otel.protocol` is unset. Only `http/protobuf` enables export. |
+| `OTEL_EXPORTER_OTLP_PROTOCOL` | Process-environment fallback used only when `diagnostics.otel.protocol` is unset. Only `http/protobuf` enables OTLP export; unsupported values are rejected when an OTLP signal is enabled and are not rewritten by Doctor. |
| `OTEL_SEMCONV_STABILITY_OPT_IN` | Set to `gen_ai_latest_experimental` to emit the latest GenAI inference span shape: `{gen_ai.operation.name} {gen_ai.request.model}` span names, `CLIENT` span kind, and `gen_ai.provider.name` instead of the legacy `gen_ai.system`. GenAI metrics always use bounded, low-cardinality attributes regardless. |
| `OPENCLAW_OTEL_PRELOADED` | Set to `1` when another preload or host process already registered the global OpenTelemetry SDK. The plugin then skips its own NodeSDK lifecycle but still wires diagnostic listeners and honors `traces`/`metrics`/`logs`. |
diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts
index a2ac02eba47d..cb3836ce0705 100644
--- a/extensions/diagnostics-otel/src/service.test.ts
+++ b/extensions/diagnostics-otel/src/service.test.ts
@@ -195,7 +195,7 @@ import {
logMessageProcessed,
runWithDiagnosticTraceContext,
} from "openclaw/plugin-sdk/plugin-test-runtime";
-import { emitDiagnosticEvent } from "../api.js";
+import { emitDiagnosticEvent, type DiagnosticEventPayload } from "../api.js";
import { MAX_RETAINED_TRUSTED_SPAN_CONTEXTS } from "./service-constants.js";
import { createDiagnosticsOtelService } from "./service.js";
import {
@@ -224,6 +224,7 @@ function numberedSpanId(index: number) {
const LATE_CHILD_ELAPSED_MS = 30 * 60_000 + 1_000;
const PROTO_KEY = "__proto__";
const MAX_TEST_OTEL_CONTENT_ATTRIBUTE_CHARS = 128 * 1024;
+type TelemetryExporterEvent = Extract;
const OTEL_TRUNCATED_SUFFIX_MAX_CHARS = 20;
const OTEL_TEST_USERINFO = ["operator", "example-fixture"].join(":");
const ORIGINAL_OPENCLAW_OTEL_PRELOADED = process.env.OPENCLAW_OTEL_PRELOADED;
@@ -1108,7 +1109,7 @@ describe("diagnostics-otel service", () => {
});
test("emits and records bounded telemetry exporter health events", async () => {
- const events: Array[0]>[0]> = [];
+ const events: TelemetryExporterEvent[] = [];
const unsubscribe = onInternalDiagnosticEvent((event) => {
if (event.type === "telemetry.exporter") {
events.push(event);
@@ -1236,13 +1237,87 @@ describe("diagnostics-otel service", () => {
expect(logEmit).not.toHaveBeenCalled();
});
- test("starts stdout-only logs when OTLP protocol is unsupported", async () => {
+ test("keeps explicit HTTP exporters canonical when ambient protocol is gRPC", async () => {
+ process.env.OTEL_EXPORTER_OTLP_PROTOCOL = "grpc";
+ const { ctx } = await startOtelService({
+ protocol: "http/protobuf",
+ traces: true,
+ metrics: true,
+ logs: true,
+ });
+
+ expect(sdkCtor).toHaveBeenCalledTimes(1);
+ expect(mockCallArg(sdkCtor, 0)).toMatchObject({ logRecordProcessors: [] });
+ expect(traceExporterCtor).toHaveBeenCalledTimes(1);
+ expect(metricExporterCtor).toHaveBeenCalledTimes(1);
+ expect(logExporterCtor).toHaveBeenCalledTimes(1);
+ expect(firstExporterOptions(traceExporterCtor).url).toBe(
+ "http://otel-collector:4318/v1/traces",
+ );
+ expect(firstExporterOptions(metricExporterCtor).url).toBe(
+ "http://otel-collector:4318/v1/metrics",
+ );
+ expect(firstExporterOptions(logExporterCtor).url).toBe("http://otel-collector:4318/v1/logs");
+ expect(sdkStart).toHaveBeenCalledTimes(1);
+ expect(ctx.logger.warn).not.toHaveBeenCalledWith("diagnostics-otel: unsupported protocol grpc");
+
+ emitDiagnosticEvent({
+ type: "log.record",
+ level: "INFO",
+ message: "OpenClaw-owned OTLP log",
+ });
+ await flushDiagnosticEvents();
+
+ expect(logEmit).toHaveBeenCalledTimes(1);
+ });
+
+ test("rejects unsupported protocol env override before exporter startup", async () => {
+ const events: TelemetryExporterEvent[] = [];
+ const unsubscribe = onInternalDiagnosticEvent((event) => {
+ if (event.type === "telemetry.exporter") {
+ events.push(event);
+ }
+ });
+ process.env.OTEL_EXPORTER_OTLP_PROTOCOL = "grpc";
+ const { ctx } = await startOtelService({
+ traces: true,
+ metrics: true,
+ logs: true,
+ configure: (context) => {
+ delete context.config.diagnostics?.otel?.protocol;
+ },
+ });
+
+ expect(
+ events.map((event) => ({
+ signal: event.signal,
+ status: event.status,
+ reason: event.reason,
+ })),
+ ).toEqual([
+ { signal: "traces", status: "failure", reason: "unsupported_protocol" },
+ { signal: "metrics", status: "failure", reason: "unsupported_protocol" },
+ { signal: "logs", status: "failure", reason: "unsupported_protocol" },
+ ]);
+ expect(ctx.logger.warn).toHaveBeenCalledWith("diagnostics-otel: unsupported protocol grpc");
+ expect(traceExporterCtor).not.toHaveBeenCalled();
+ expect(metricExporterCtor).not.toHaveBeenCalled();
+ expect(logExporterCtor).not.toHaveBeenCalled();
+ expect(sdkStart).not.toHaveBeenCalled();
+
+ unsubscribe();
+ });
+
+ test("starts stdout-only logs when OTLP protocol env override is unsupported", async () => {
+ process.env.OTEL_EXPORTER_OTLP_PROTOCOL = "grpc";
const { ctx } = await startOtelService({
traces: false,
metrics: false,
logs: true,
- protocol: "grpc",
logsExporter: "stdout",
+ configure: (context) => {
+ delete context.config.diagnostics?.otel?.protocol;
+ },
});
const capture = captureStdoutWrites();
try {
@@ -1423,7 +1498,7 @@ describe("diagnostics-otel service", () => {
});
test("reports log exporter emit failures without exporting raw error text", async () => {
- const events: Array[0]>[0]> = [];
+ const events: TelemetryExporterEvent[] = [];
const unsubscribe = onInternalDiagnosticEvent((event) => {
if (event.type === "telemetry.exporter") {
events.push(event);
@@ -1839,6 +1914,7 @@ describe("diagnostics-otel service", () => {
] as const)(
"keeps NodeSDK exporter ownership explicit for $enabledSignal",
async ({ flags, metricReaderCount, tracesDisabled }) => {
+ process.env.OTEL_EXPORTER_OTLP_PROTOCOL = "grpc";
await startOtelService(flags);
const options = mockCallArg(sdkCtor, 0) as {
diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts
index c4240d20e3a7..68c1f2c9f9e1 100644
--- a/extensions/diagnostics-otel/src/service.ts
+++ b/extensions/diagnostics-otel/src/service.ts
@@ -231,6 +231,8 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
sdk = new NodeSDK({
resource,
+ // Explicit empty arrays keep NodeSDK from restoring disabled exporters
+ // from ambient OTEL_* settings; OpenClaw owns every signal exporter.
...(spanProcessors
? { spanProcessors }
: traceExporter
diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts
index bef2d2d63e0e..4ac77330dd67 100644
--- a/src/commands/doctor-config-flow.ts
+++ b/src/commands/doctor-config-flow.ts
@@ -197,6 +197,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
});
state = legacyStep.state;
const legacyMigrationPartiallyValid = legacyStep.partiallyValid === true;
+ const legacyMigrationBlocksWrite = legacyStep.blocksWrite === true;
const rosterMigrationNeeded = [snapshot.sourceConfigBeforeMigrations, snapshot.parsed].some(
(source) => source !== undefined && migratePersistedImplicitMainRoster(source).changed,
);
@@ -474,8 +475,9 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
note,
});
const cfg = finalized.cfg;
+ const shouldWriteConfig = finalized.shouldWriteConfig && !legacyMigrationBlocksWrite;
const singleTopLevelIncludeWrite =
- finalized.shouldWriteConfig &&
+ shouldWriteConfig &&
isSingleTopLevelIncludeMigration({
parsed: snapshot.parsed,
sourceConfig: snapshot.sourceConfig,
@@ -505,11 +507,11 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
return {
cfg,
path: snapshot.path ?? CONFIG_PATH,
- shouldWriteConfig: finalized.shouldWriteConfig,
+ shouldWriteConfig,
sourceConfigValid: snapshot.valid,
...(sourceLastTouchedVersion ? { sourceLastTouchedVersion } : {}),
...(legacyMigrationPartiallyValid ? { skipPluginValidationOnWrite: true } : {}),
- ...(finalized.shouldWriteConfig && explicitSetPaths.length > 0 ? { explicitSetPaths } : {}),
+ ...(shouldWriteConfig && explicitSetPaths.length > 0 ? { explicitSetPaths } : {}),
...(singleTopLevelIncludeWrite ? { skipWizardMetadataForIncludeWrite: true } : {}),
...(shouldRepairCronCodexModelRefsAfterConfigWrite
? { shouldRepairCronCodexModelRefsAfterConfigWrite: true }
diff --git a/src/commands/doctor-config-preflight.test.ts b/src/commands/doctor-config-preflight.test.ts
index 715a8380cfb6..3380af1a1ef6 100644
--- a/src/commands/doctor-config-preflight.test.ts
+++ b/src/commands/doctor-config-preflight.test.ts
@@ -180,6 +180,59 @@ describe("runDoctorConfigPreflight", () => {
});
});
+ it("reports persisted literal and interpolated OTel grpc as legacy config", async () => {
+ await withTempHome(async (home) => {
+ await writeOpenClawConfig(home, {
+ diagnostics: { otel: { enabled: false, protocol: "grpc" } },
+ });
+
+ const literal = await runDoctorConfigPreflight({
+ migrateState: false,
+ migrateLegacyConfig: false,
+ invalidConfigNote: false,
+ });
+ expect(literal.snapshot.legacyIssues).toContainEqual(
+ expect.objectContaining({ path: "diagnostics.otel.protocol" }),
+ );
+
+ const configPath = literal.snapshot.path;
+ await fs.writeFile(
+ configPath,
+ '{ diagnostics: { otel: { enabled: false, protocol: "${OTEL_PROTOCOL}" } } }\n',
+ "utf-8",
+ );
+ await withEnvOverride({ OTEL_PROTOCOL: "grpc" }, async () => {
+ const interpolated = await runDoctorConfigPreflight({
+ migrateState: false,
+ migrateLegacyConfig: false,
+ invalidConfigNote: false,
+ });
+ expect(interpolated.snapshot.legacyIssues).toContainEqual(
+ expect.objectContaining({ path: "diagnostics.otel.protocol" }),
+ );
+ });
+ });
+ });
+
+ it("does not treat the process-only OTel protocol fallback as persisted config", async () => {
+ await withTempHome(async (home) => {
+ await writeOpenClawConfig(home, {
+ diagnostics: { otel: { enabled: false } },
+ });
+
+ await withEnvOverride({ OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" }, async () => {
+ const preflight = await runDoctorConfigPreflight({
+ migrateState: false,
+ migrateLegacyConfig: false,
+ invalidConfigNote: false,
+ });
+ expect(preflight.snapshot.legacyIssues).not.toContainEqual(
+ expect.objectContaining({ path: "diagnostics.otel.protocol" }),
+ );
+ });
+ });
+ });
+
it("restores invalid config from last-known-good only during repair preflight", async () => {
await withTempHome(async (home) => {
const configPath = await writeOpenClawConfig(home, {
diff --git a/src/commands/doctor/shared/config-flow-steps.test.ts b/src/commands/doctor/shared/config-flow-steps.test.ts
index d103cf982caf..23848d50fea7 100644
--- a/src/commands/doctor/shared/config-flow-steps.test.ts
+++ b/src/commands/doctor/shared/config-flow-steps.test.ts
@@ -102,12 +102,56 @@ describe("doctor config flow steps", () => {
warnings: [],
} satisfies DoctorConfigPreflightResult["snapshot"]);
- expect(migrateLegacyConfigMock).toHaveBeenCalledWith(sourceConfig);
+ expect(migrateLegacyConfigMock).toHaveBeenCalledWith(sourceConfig, {
+ authoredRaw: { mcp: { $include: "./mcp.json5" } },
+ resolvedRaw: sourceConfig,
+ });
expect(result.state.pendingChanges).toBe(true);
expect(result.state.candidate.mcp?.servers?.local?.enabled).toBe(false);
expect(result.state.candidate.commands).toBeUndefined();
});
+ it("blocks grpc migration when include ownership is ambiguous and names every source", () => {
+ const sourceConfig = {
+ diagnostics: { otel: { enabled: true, protocol: "grpc" } },
+ } as unknown as OpenClawConfig;
+ const result = createLegacyStepResult({
+ exists: true,
+ parsed: { diagnostics: { $include: ["./a.json5", "./b.json5"] } },
+ includeProvenance: [
+ {
+ path: ["diagnostics"],
+ kind: "multiple",
+ hasSiblingOverrides: false,
+ targetPaths: ["/tmp/a.json5", "/tmp/b.json5"],
+ },
+ ],
+ legacyIssues: [
+ {
+ path: "diagnostics.otel.protocol",
+ message: "grpc is unsupported",
+ },
+ ],
+ path: "/tmp/config.json",
+ valid: false,
+ issues: [],
+ raw: "{}",
+ resolved: sourceConfig,
+ sourceConfig,
+ config: sourceConfig,
+ runtimeConfig: sourceConfig,
+ warnings: [],
+ } satisfies DoctorConfigPreflightResult["snapshot"]);
+
+ expect(migrateLegacyConfigMock).not.toHaveBeenCalled();
+ expect(result.blocksWrite).toBe(true);
+ expect(result.changeLines).toStrictEqual([]);
+ expect(result.issueLines.join("\n")).toContain(
+ 'Inspect these candidate source files and remove or replace diagnostics.otel.protocol = "grpc" from every definition: /tmp/a.json5, /tmp/b.json5.',
+ );
+ expect(result.issueLines.join("\n")).toContain("No config files were changed.");
+ });
+
it("keeps pending repair state for legacy issues even when the snapshot is already normalized", () => {
const result = createLegacyStepResult({
exists: true,
diff --git a/src/commands/doctor/shared/config-flow-steps.ts b/src/commands/doctor/shared/config-flow-steps.ts
index 0725a076ce0c..a73c8bb17186 100644
--- a/src/commands/doctor/shared/config-flow-steps.ts
+++ b/src/commands/doctor/shared/config-flow-steps.ts
@@ -4,9 +4,14 @@ import { protectActiveAuthProfileConfig } from "../../doctor-auth-profile-config
import { stripUnknownConfigKeys } from "../../doctor-config-analysis.js";
import type { DoctorConfigPreflightResult } from "../../doctor-config-preflight.js";
import type { DoctorConfigMutationState } from "./config-mutation-state.js";
-import { containsAuthoredInclude } from "./include-migration-ownership.js";
+import {
+ classifyConfigPathMigrationOwnership,
+ containsAuthoredInclude,
+} from "./include-migration-ownership.js";
import { migrateLegacyConfig } from "./legacy-config-migrate.js";
+const OTEL_GRPC_PROTOCOL_PATH = "diagnostics.otel.protocol";
+
/** Apply legacy config migrations and update preview/fix state for doctor config flow. */
export function applyLegacyCompatibilityStep(params: {
snapshot: DoctorConfigPreflightResult["snapshot"];
@@ -18,6 +23,7 @@ export function applyLegacyCompatibilityStep(params: {
issueLines: string[];
changeLines: string[];
partiallyValid?: boolean;
+ blocksWrite?: boolean;
} {
if (params.snapshot.legacyIssues.length === 0) {
return {
@@ -28,6 +34,27 @@ export function applyLegacyCompatibilityStep(params: {
}
const issueLines = formatConfigIssueLines(params.snapshot.legacyIssues, "-");
+ if (params.snapshot.legacyIssues.some((issue) => issue.path === OTEL_GRPC_PROTOCOL_PATH)) {
+ const ownership = classifyConfigPathMigrationOwnership({
+ snapshot: params.snapshot,
+ configPath: ["diagnostics", "otel", "protocol"],
+ });
+ if (ownership.kind === "manual") {
+ const targets =
+ ownership.targetPaths.length > 0
+ ? ` Inspect these candidate source files and remove or replace ${OTEL_GRPC_PROTOCOL_PATH} = "grpc" from every definition: ${ownership.targetPaths.join(", ")}.`
+ : ` Remove or replace ${OTEL_GRPC_PROTOCOL_PATH} = "grpc" in the owning $include directive or included file.`;
+ return {
+ state: params.state,
+ issueLines: [
+ ...issueLines,
+ `- ${OTEL_GRPC_PROTOCOL_PATH}: Doctor cannot safely rewrite this $include ownership.${targets} No config files were changed.`,
+ ],
+ changeLines: [],
+ blocksWrite: true,
+ };
+ }
+ }
const hasAuthoredIncludes = containsAuthoredInclude(params.snapshot.parsed);
const migrationInput = hasAuthoredIncludes
? params.snapshot.sourceConfig
@@ -37,7 +64,10 @@ export function applyLegacyCompatibilityStep(params: {
sourceConfig: migratedSource,
changes,
partiallyValid,
- } = migrateLegacyConfig(migrationInput);
+ } = migrateLegacyConfig(migrationInput, {
+ authoredRaw: params.snapshot.parsed,
+ resolvedRaw: params.snapshot.sourceConfig,
+ });
if (!migrated) {
return {
state: {
diff --git a/src/commands/doctor/shared/include-migration-ownership.test.ts b/src/commands/doctor/shared/include-migration-ownership.test.ts
index 8d647a1471b7..92121d5791d3 100644
--- a/src/commands/doctor/shared/include-migration-ownership.test.ts
+++ b/src/commands/doctor/shared/include-migration-ownership.test.ts
@@ -1,6 +1,10 @@
+import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
-import { isSingleTopLevelIncludeMigration } from "./include-migration-ownership.js";
+import {
+ classifyConfigPathMigrationOwnership,
+ isSingleTopLevelIncludeMigration,
+} from "./include-migration-ownership.js";
const sourceConfig = {
mcp: { servers: { local: { command: "node", disabled: true } } },
@@ -10,6 +14,124 @@ const candidate = {
} as OpenClawConfig;
describe("include migration ownership", () => {
+ const configDir = path.resolve("/tmp/openclaw-config");
+ const configPath = path.join(configDir, "openclaw.json");
+ const diagnosticsPath = path.join(configDir, "diagnostics.json5");
+
+ it("classifies direct config even when an unrelated include exists", () => {
+ expect(
+ classifyConfigPathMigrationOwnership({
+ snapshot: {
+ path: configPath,
+ includeProvenance: [
+ {
+ path: ["agents"],
+ kind: "single",
+ hasSiblingOverrides: false,
+ targetPath: path.join(configDir, "agents.json5"),
+ },
+ ],
+ },
+ configPath: ["diagnostics", "otel", "protocol"],
+ }),
+ ).toEqual({ kind: "direct" });
+ });
+
+ it("allows one internal top-level include that solely owns diagnostics", () => {
+ expect(
+ classifyConfigPathMigrationOwnership({
+ snapshot: {
+ path: configPath,
+ includeProvenance: [
+ {
+ path: ["diagnostics"],
+ kind: "single",
+ hasSiblingOverrides: false,
+ targetPath: diagnosticsPath,
+ },
+ ],
+ },
+ configPath: ["diagnostics", "otel", "protocol"],
+ }),
+ ).toEqual({ kind: "single-top-level-include", targetPath: diagnosticsPath });
+ });
+
+ it.each([
+ {
+ name: "root include",
+ includeProvenance: [
+ {
+ path: [],
+ kind: "single" as const,
+ hasSiblingOverrides: false,
+ targetPath: path.join(configDir, "root.json5"),
+ },
+ ],
+ targetPaths: [path.join(configDir, "root.json5")],
+ },
+ {
+ name: "include array",
+ includeProvenance: [
+ {
+ path: ["diagnostics"],
+ kind: "multiple" as const,
+ hasSiblingOverrides: false,
+ targetPaths: [
+ path.join(configDir, "diagnostics-a.json5"),
+ path.join(configDir, "diagnostics-b.json5"),
+ ],
+ },
+ ],
+ targetPaths: [
+ path.join(configDir, "diagnostics-a.json5"),
+ path.join(configDir, "diagnostics-b.json5"),
+ ],
+ },
+ {
+ name: "nested include",
+ includeProvenance: [
+ {
+ path: ["diagnostics", "otel"],
+ kind: "single" as const,
+ hasSiblingOverrides: false,
+ targetPath: path.join(configDir, "otel.json5"),
+ },
+ ],
+ targetPaths: [path.join(configDir, "otel.json5")],
+ },
+ {
+ name: "sibling override",
+ includeProvenance: [
+ {
+ path: ["diagnostics"],
+ kind: "single" as const,
+ hasSiblingOverrides: true,
+ targetPath: diagnosticsPath,
+ },
+ ],
+ targetPaths: [diagnosticsPath],
+ },
+ {
+ name: "external include",
+ includeProvenance: [
+ {
+ path: ["diagnostics"],
+ kind: "single" as const,
+ hasSiblingOverrides: false,
+ targetPath: path.resolve(configDir, "..", "external-diagnostics.json5"),
+ },
+ ],
+ targetPaths: [path.resolve(configDir, "..", "external-diagnostics.json5")],
+ },
+ ])("requires manual repair for $name ownership", ({ includeProvenance, targetPaths }) => {
+ expect(
+ classifyConfigPathMigrationOwnership({
+ snapshot: { path: configPath, includeProvenance },
+ configPath: ["diagnostics", "otel", "protocol"],
+ }),
+ ).toEqual({ kind: "manual", targetPaths });
+ });
+
it("allows one isolated direct top-level string include", () => {
expect(
isSingleTopLevelIncludeMigration({
diff --git a/src/commands/doctor/shared/include-migration-ownership.ts b/src/commands/doctor/shared/include-migration-ownership.ts
index 9cb58f5183ba..455ac0684d18 100644
--- a/src/commands/doctor/shared/include-migration-ownership.ts
+++ b/src/commands/doctor/shared/include-migration-ownership.ts
@@ -1,6 +1,8 @@
+import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { INCLUDE_KEY } from "../../../config/includes.js";
-import type { OpenClawConfig } from "../../../config/types.openclaw.js";
+import type { ConfigFileSnapshot, OpenClawConfig } from "../../../config/types.openclaw.js";
+import { isPathInside } from "../../../infra/path-safety.js";
import { isRecord } from "../../../utils.js";
export function containsAuthoredInclude(value: unknown): boolean {
@@ -14,6 +16,47 @@ export function containsAuthoredInclude(value: unknown): boolean {
return Object.hasOwn(record, INCLUDE_KEY) || Object.values(record).some(containsAuthoredInclude);
}
+type ConfigPathMigrationOwnership =
+ | { kind: "direct" }
+ | { kind: "single-top-level-include"; targetPath: string }
+ | { kind: "manual"; targetPaths: string[] };
+
+/** Classify whether Doctor can safely persist a migration at one resolved config path. */
+export function classifyConfigPathMigrationOwnership(params: {
+ snapshot: Pick;
+ configPath: readonly string[];
+}): ConfigPathMigrationOwnership {
+ const owners = (params.snapshot.includeProvenance ?? []).filter(
+ (entry) =>
+ entry.path.length <= params.configPath.length &&
+ entry.path.every((segment, index) => segment === params.configPath[index]),
+ );
+ if (owners.length === 0) {
+ return { kind: "direct" };
+ }
+
+ const targetPaths = [
+ ...new Set(
+ owners.flatMap((owner) => owner.targetPaths ?? (owner.targetPath ? [owner.targetPath] : [])),
+ ),
+ ].toSorted();
+ const owner = owners[0];
+ const configDir = path.dirname(path.resolve(params.snapshot.path));
+ if (
+ owners.length === 1 &&
+ owner?.path.length === 1 &&
+ owner.path[0] === params.configPath[0] &&
+ owner.kind === "single" &&
+ !owner.hasSiblingOverrides &&
+ owner.targetPath &&
+ isPathInside(configDir, path.resolve(owner.targetPath))
+ ) {
+ return { kind: "single-top-level-include", targetPath: owner.targetPath };
+ }
+
+ return { kind: "manual", targetPaths };
+}
+
export function isSingleTopLevelIncludeMigration(params: {
parsed: unknown;
sourceConfig: OpenClawConfig;
diff --git a/src/commands/doctor/shared/legacy-config-compat.ts b/src/commands/doctor/shared/legacy-config-compat.ts
index 3a3b5a57f54e..dfa813c3e498 100644
--- a/src/commands/doctor/shared/legacy-config-compat.ts
+++ b/src/commands/doctor/shared/legacy-config-compat.ts
@@ -1,9 +1,13 @@
// Top-level legacy config migration runner used before full config validation.
+import type { LegacyConfigMigrationContext } from "../../../config/legacy.shared.js";
import { applyChannelDoctorCompatibilityMigrations } from "./channel-legacy-config-migrate.js";
import { LEGACY_CONFIG_MIGRATIONS } from "./legacy-config-migrations.js";
/** Apply all legacy doctor migrations to raw config, returning null when nothing changed. */
-export function applyLegacyDoctorMigrations(raw: unknown): {
+export function applyLegacyDoctorMigrations(
+ raw: unknown,
+ context?: LegacyConfigMigrationContext,
+): {
next: Record | null;
changes: string[];
} {
@@ -14,7 +18,7 @@ export function applyLegacyDoctorMigrations(raw: unknown): {
const next = structuredClone(original);
const changes: string[] = [];
for (const migration of LEGACY_CONFIG_MIGRATIONS) {
- migration.apply(next, changes);
+ migration.apply(next, changes, context);
}
const compat = applyChannelDoctorCompatibilityMigrations(next);
changes.push(...compat.changes);
diff --git a/src/commands/doctor/shared/legacy-config-migrate.e2e.test.ts b/src/commands/doctor/shared/legacy-config-migrate.e2e.test.ts
index daf0150a630c..bab3c022016b 100644
--- a/src/commands/doctor/shared/legacy-config-migrate.e2e.test.ts
+++ b/src/commands/doctor/shared/legacy-config-migrate.e2e.test.ts
@@ -170,4 +170,29 @@ describe("legacy config migration end to end", () => {
expect(serialized).not.toContain(`"${key}"`);
}
});
+
+ it("repairs unsupported OTel grpc once and is then a no-op", () => {
+ const result = migrateLegacyConfig({
+ diagnostics: {
+ otel: {
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: true,
+ logsExporter: "stdout",
+ protocol: "grpc",
+ },
+ },
+ });
+
+ expect(result.config?.diagnostics?.otel).toEqual({
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: true,
+ logsExporter: "stdout",
+ });
+ expect(validateConfigObjectRaw(result.config).ok).toBe(true);
+ expect(applyLegacyDoctorMigrations(result.config)).toEqual({ next: null, changes: [] });
+ });
});
diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts
index 9bcdb769c3e9..85e698264f7b 100644
--- a/src/commands/doctor/shared/legacy-config-migrate.test.ts
+++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts
@@ -3,6 +3,7 @@
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it } from "vitest";
import { findLegacyConfigIssues } from "../../../config/legacy.js";
+import type { LegacyConfigMigrationContext } from "../../../config/legacy.shared.js";
import type { OpenClawConfig } from "../../../config/types.js";
import { legacyCodexProviderIdentityKey } from "./codex-route-model-ref.js";
import { pruneBindingsForMissingAgents } from "./legacy-config-binding-repair.js";
@@ -14,7 +15,10 @@ function repairBindingsForTest(config: OpenClawConfig) {
return { config: pruneBindingsForMissingAgents(config, changes), changes };
}
-function migrateLegacyConfigForTest(raw: unknown): {
+function migrateLegacyConfigForTest(
+ raw: unknown,
+ context?: LegacyConfigMigrationContext,
+): {
config: OpenClawConfig | null;
changes: string[];
} {
@@ -24,7 +28,7 @@ function migrateLegacyConfigForTest(raw: unknown): {
const next = structuredClone(raw) as Record;
const changes: string[] = [];
for (const migration of LEGACY_CONFIG_MIGRATIONS) {
- migration.apply(next, changes);
+ migration.apply(next, changes, context);
}
const visibleChanges = changes.filter(
(change) => change !== "Moved agents.list → keyed agents.entries.",
@@ -1608,6 +1612,193 @@ describe("legacy session parent fork migrate", () => {
});
});
+describe("legacy diagnostics OTel protocol migrate", () => {
+ it("removes unsupported grpc protocol and disables enabled telemetry", () => {
+ const res = migrateLegacyConfigForTest({
+ diagnostics: {
+ otel: {
+ enabled: true,
+ endpoint: "http://otel-collector:4317",
+ protocol: "grpc",
+ },
+ },
+ });
+
+ expect(res.config?.diagnostics?.otel).toEqual({
+ enabled: false,
+ endpoint: "http://otel-collector:4317",
+ });
+ expect(res.changes).toStrictEqual([
+ 'Removed unsupported diagnostics.otel.protocol "grpc"; use "http/protobuf" with an OTLP/HTTP collector.',
+ "Disabled diagnostics.otel.enabled because legacy grpc configs with OTLP signals cannot export telemetry; re-enable it after choosing an OTLP/HTTP collector.",
+ ]);
+ });
+
+ it("keeps enabled stdout-only logs when removing grpc protocol", () => {
+ const res = migrateLegacyConfigForTest({
+ diagnostics: {
+ otel: {
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: true,
+ logsExporter: "stdout",
+ protocol: "grpc",
+ },
+ },
+ });
+
+ expect(res.config?.diagnostics?.otel).toEqual({
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: true,
+ logsExporter: "stdout",
+ });
+ expect(res.changes).toStrictEqual([
+ 'Removed unsupported diagnostics.otel.protocol "grpc"; use "http/protobuf" with an OTLP/HTTP collector.',
+ ]);
+ });
+
+ it("uses resolved interpolated stdout-only logging without replacing the authored reference", () => {
+ const authored = {
+ diagnostics: {
+ otel: {
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: true,
+ logsExporter: "${OTEL_LOGS_EXPORTER}",
+ protocol: "grpc",
+ },
+ },
+ };
+ const resolved = {
+ diagnostics: {
+ otel: {
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: true,
+ logsExporter: "stdout",
+ protocol: "grpc",
+ },
+ },
+ };
+
+ const res = migrateLegacyConfigForTest(authored, {
+ authoredRaw: authored,
+ resolvedRaw: resolved,
+ });
+
+ expect(res.config?.diagnostics?.otel).toEqual({
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: true,
+ logsExporter: "${OTEL_LOGS_EXPORTER}",
+ });
+ });
+
+ it.each(["otlp", "both"])("disables enabled %s log export", (logsExporter) => {
+ const res = migrateLegacyConfigForTest({
+ diagnostics: {
+ otel: {
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: true,
+ logsExporter,
+ protocol: "grpc",
+ },
+ },
+ });
+
+ expect(res.config?.diagnostics?.otel?.enabled).toBe(false);
+ });
+
+ it("keeps telemetry enabled when no signals are enabled", () => {
+ const res = migrateLegacyConfigForTest({
+ diagnostics: {
+ otel: {
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: false,
+ protocol: "grpc",
+ },
+ },
+ });
+
+ expect(res.config?.diagnostics?.otel).toEqual({
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: false,
+ });
+ });
+
+ it("repairs a config-interpolated grpc protocol using the resolved value", () => {
+ const authored = {
+ diagnostics: {
+ otel: {
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: true,
+ logsExporter: "stdout",
+ protocol: "${OTEL_PROTOCOL}",
+ },
+ },
+ };
+ const resolved = {
+ diagnostics: {
+ otel: {
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: true,
+ logsExporter: "stdout",
+ protocol: "grpc",
+ },
+ },
+ };
+
+ const res = migrateLegacyConfigForTest(authored, {
+ authoredRaw: authored,
+ resolvedRaw: resolved,
+ });
+
+ expect(res.config?.diagnostics?.otel).toEqual({
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: true,
+ logsExporter: "stdout",
+ });
+ });
+
+ it("only removes grpc protocol when telemetry was already disabled", () => {
+ const res = migrateLegacyConfigForTest({
+ diagnostics: {
+ otel: {
+ enabled: false,
+ endpoint: "http://otel-collector:4317",
+ protocol: "grpc",
+ },
+ },
+ });
+
+ expect(res.config?.diagnostics?.otel).toEqual({
+ enabled: false,
+ endpoint: "http://otel-collector:4317",
+ });
+ expect(res.changes).toStrictEqual([
+ 'Removed unsupported diagnostics.otel.protocol "grpc"; use "http/protobuf" with an OTLP/HTTP collector.',
+ ]);
+ });
+});
+
describe("legacy WebChat channel config migrate", () => {
it("removes retired WebChat channel config", () => {
const raw = {
diff --git a/src/commands/doctor/shared/legacy-config-migrate.ts b/src/commands/doctor/shared/legacy-config-migrate.ts
index 6ac9bf23624e..a37bdfe5a061 100644
--- a/src/commands/doctor/shared/legacy-config-migrate.ts
+++ b/src/commands/doctor/shared/legacy-config-migrate.ts
@@ -1,20 +1,27 @@
// Validating legacy config migration wrapper used by doctor config flow.
+import type { LegacyConfigMigrationContext } from "../../../config/legacy.shared.js";
import type { OpenClawConfig } from "../../../config/types.js";
import { validateConfigObjectWithPlugins } from "../../../config/validation.js";
import { applyLegacyDoctorMigrations } from "./legacy-config-compat.js";
/** Apply legacy migrations and validate the resulting OpenClaw config shape when possible. */
-export function migrateLegacyConfig(raw: unknown): {
+export function migrateLegacyConfig(
+ raw: unknown,
+ context?: LegacyConfigMigrationContext,
+): {
config: OpenClawConfig | null;
sourceConfig?: OpenClawConfig;
changes: string[];
partiallyValid?: boolean;
} {
- const { next, changes } = applyLegacyDoctorMigrations(raw);
+ const { next, changes } = applyLegacyDoctorMigrations(raw, context);
if (!next) {
return { config: null, changes: [] };
}
- const validated = validateConfigObjectWithPlugins(next);
+ const resolvedCandidate = context
+ ? (applyLegacyDoctorMigrations(context.resolvedRaw, context).next ?? context.resolvedRaw)
+ : next;
+ const validated = validateConfigObjectWithPlugins(resolvedCandidate);
if (!validated.ok) {
changes.push("Migration applied; other validation issues remain — run doctor to review.");
return { config: next as OpenClawConfig, changes, partiallyValid: true };
diff --git a/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts b/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts
index 59ec3f5622f3..784395c056ed 100644
--- a/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts
+++ b/src/commands/doctor/shared/legacy-config-migrate.validation.test.ts
@@ -27,4 +27,60 @@ describe("legacy config migrate validation", () => {
'Set tools.profile to "full" so tools.allow controls explicit configured-section grants directly.',
]);
});
+
+ it("returns schema-valid config after removing unsupported OTel grpc", () => {
+ const res = migrateLegacyConfig({
+ diagnostics: {
+ otel: {
+ enabled: true,
+ endpoint: "http://otel-collector:4317",
+ protocol: "grpc",
+ },
+ },
+ });
+
+ expect(res.partiallyValid).toBeUndefined();
+ expect(res.config?.diagnostics?.otel).toEqual({
+ enabled: false,
+ endpoint: "http://otel-collector:4317",
+ });
+ });
+
+ it("validates resolved OTel values while retaining authored interpolation", () => {
+ const authored = {
+ diagnostics: {
+ otel: {
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: true,
+ logsExporter: "${OTEL_LOGS_EXPORTER}",
+ protocol: "grpc",
+ },
+ },
+ };
+ const resolved = {
+ diagnostics: {
+ otel: {
+ enabled: true,
+ traces: false,
+ metrics: false,
+ logs: true,
+ logsExporter: "stdout",
+ protocol: "grpc",
+ },
+ },
+ };
+
+ const res = migrateLegacyConfig(authored, {
+ authoredRaw: authored,
+ resolvedRaw: resolved,
+ });
+
+ expect(res.partiallyValid).toBeUndefined();
+ expect(res.config?.diagnostics?.otel?.logsExporter).toBe("stdout");
+ expect(res.sourceConfig?.diagnostics?.otel?.logsExporter).toBe("${OTEL_LOGS_EXPORTER}");
+ expect(res.config?.diagnostics?.otel?.protocol).toBeUndefined();
+ expect(res.sourceConfig?.diagnostics?.otel?.protocol).toBeUndefined();
+ });
});
diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.diagnostics.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.diagnostics.ts
index 70fd2f4ec272..c9f72b8a6de7 100644
--- a/src/commands/doctor/shared/legacy-config-migrations.runtime.diagnostics.ts
+++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.diagnostics.ts
@@ -1,5 +1,53 @@
-// Legacy diagnostics migrations are currently folded into the tuning-knob purge.
-import type { LegacyConfigMigrationSpec } from "../../../config/legacy.shared.js";
+// Legacy diagnostics migrations are currently folded into the tuning-knob purge,
+// except compatibility repairs that need value-aware behavior.
+import {
+ defineLegacyConfigMigration,
+ getRecord,
+ type LegacyConfigMigrationContext,
+ type LegacyConfigMigrationSpec,
+ type LegacyConfigRule,
+} from "../../../config/legacy.shared.js";
+
+const UNSUPPORTED_OTEL_GRPC_PROTOCOL_RULE: LegacyConfigRule = {
+ path: ["diagnostics", "otel", "protocol"],
+ message:
+ 'diagnostics.otel.protocol = "grpc" is no longer accepted because gRPC export is not implemented. Run "openclaw doctor --fix", then configure an OTLP/HTTP collector before re-enabling telemetry.',
+ match: (value) => value === "grpc",
+};
+
+function hasLegacyGrpcOtlpSignals(otel: Record): boolean {
+ const logsExporter = typeof otel.logsExporter === "string" ? otel.logsExporter : undefined;
+ return (
+ otel.traces !== false ||
+ otel.metrics !== false ||
+ (otel.logs === true && logsExporter !== "stdout")
+ );
+}
/** Legacy config migration specs for diagnostics runtime config. */
-export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_DIAGNOSTICS: LegacyConfigMigrationSpec[] = [];
+export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_DIAGNOSTICS: LegacyConfigMigrationSpec[] = [
+ defineLegacyConfigMigration({
+ id: "diagnostics.otel.grpc-protocol",
+ describe: "Remove unsupported diagnostics.otel.protocol grpc configs",
+ legacyRules: [UNSUPPORTED_OTEL_GRPC_PROTOCOL_RULE],
+ apply: (raw, changes, context?: LegacyConfigMigrationContext) => {
+ const otel = getRecord(getRecord(raw.diagnostics)?.otel);
+ const resolvedRoot = getRecord(context?.resolvedRaw ?? raw);
+ const resolvedOtel = getRecord(getRecord(resolvedRoot?.diagnostics)?.otel);
+ if (!otel || resolvedOtel?.protocol !== "grpc") {
+ return;
+ }
+
+ delete otel.protocol;
+ changes.push(
+ 'Removed unsupported diagnostics.otel.protocol "grpc"; use "http/protobuf" with an OTLP/HTTP collector.',
+ );
+ if (resolvedOtel.enabled === true && hasLegacyGrpcOtlpSignals(resolvedOtel)) {
+ otel.enabled = false;
+ changes.push(
+ "Disabled diagnostics.otel.enabled because legacy grpc configs with OTLP signals cannot export telemetry; re-enable it after choosing an OTLP/HTTP collector.",
+ );
+ }
+ },
+ }),
+];
diff --git a/src/config/includes.test.ts b/src/config/includes.test.ts
index f2907ee0f320..6fe9e630fcd7 100644
--- a/src/config/includes.test.ts
+++ b/src/config/includes.test.ts
@@ -215,6 +215,7 @@ describe("resolveConfigIncludes", () => {
value: { enabled: true, mode: "strict" },
kind: "multiple",
hasSiblingOverrides: true,
+ targetPaths: [configPath("second.json"), configPath("third.json")],
},
]);
});
@@ -239,21 +240,24 @@ describe("resolveConfigIncludes", () => {
),
).toEqual({ agents: { mode: "override" } });
expect(
- events.map(({ path: logicalPath, kind, targetPath }) => ({
+ events.map(({ path: logicalPath, kind, targetPath, targetPaths }) => ({
path: logicalPath,
kind,
targetPath,
+ targetPaths,
})),
).toEqual([
{
path: ["agents"],
kind: "single",
targetPath: configPath("nested.json"),
+ targetPaths: undefined,
},
{
path: ["agents"],
kind: "multiple",
targetPath: undefined,
+ targetPaths: [configPath("delegating.json"), configPath("override.json")],
},
]);
});
diff --git a/src/config/includes.ts b/src/config/includes.ts
index 8bff71980838..c21a131f9584 100644
--- a/src/config/includes.ts
+++ b/src/config/includes.ts
@@ -86,6 +86,7 @@ export type ConfigIncludeOwnership = {
kind: "single" | "multiple";
hasSiblingOverrides: boolean;
targetPath?: string;
+ targetPaths?: readonly string[];
};
export type ConfigIncludeResolutionEvent = ConfigIncludeOwnership & { value: unknown };
@@ -223,6 +224,7 @@ class IncludeProcessor {
kind: Array.isArray(includeValue) ? "multiple" : "single",
hasSiblingOverrides: otherKeys.length > 0,
...(resolved.targetPath ? { targetPath: resolved.targetPath } : {}),
+ ...(resolved.targetPaths ? { targetPaths: resolved.targetPaths } : {}),
});
if (otherKeys.length === 0) {
@@ -247,22 +249,29 @@ class IncludeProcessor {
private resolveInclude(
value: unknown,
logicalPath: readonly string[],
- ): { value: unknown; targetPath?: string } {
+ ): { value: unknown; targetPath?: string; targetPaths?: string[] } {
if (typeof value === "string") {
return this.loadFile(value, logicalPath);
}
if (Array.isArray(value)) {
- const merged = value.reduce((current, item) => {
+ const resolvedEntries = value.map((item) => {
if (typeof item !== "string") {
throw new ConfigIncludeError(
`Invalid $include array item: expected string, got ${typeof item}`,
String(item),
);
}
- return deepMerge(current, this.loadFile(item, logicalPath).value);
- }, {});
- return { value: merged };
+ return this.loadFile(item, logicalPath);
+ });
+ const merged = resolvedEntries.reduce(
+ (current, entry) => deepMerge(current, entry.value),
+ {},
+ );
+ return {
+ value: merged,
+ targetPaths: resolvedEntries.map((entry) => entry.targetPath),
+ };
}
throw new ConfigIncludeError(
diff --git a/src/config/io.snapshot-shared.ts b/src/config/io.snapshot-shared.ts
index 5d1e3f1db106..f1d6545098d0 100644
--- a/src/config/io.snapshot-shared.ts
+++ b/src/config/io.snapshot-shared.ts
@@ -31,6 +31,7 @@ export function createConfigFileSnapshot(params: {
includeProvenance: params.includeProvenance.map((entry) => ({
...entry,
path: [...entry.path],
+ ...(entry.targetPaths ? { targetPaths: [...entry.targetPaths] } : {}),
})),
}
: {}),
diff --git a/src/config/legacy.shared.ts b/src/config/legacy.shared.ts
index 9c8447c750f3..0ee95ade987b 100644
--- a/src/config/legacy.shared.ts
+++ b/src/config/legacy.shared.ts
@@ -8,10 +8,21 @@ export type LegacyConfigRule = {
requireSourceLiteral?: boolean;
};
+export type LegacyConfigMigrationContext = {
+ /** Parsed configuration exactly as authored in the root config file. */
+ authoredRaw: unknown;
+ /** Configuration after include and environment resolution. */
+ resolvedRaw: unknown;
+};
+
type LegacyConfigMigration = {
id: string;
describe: string;
- apply: (raw: Record, changes: string[]) => void;
+ apply: (
+ raw: Record,
+ changes: string[],
+ context?: LegacyConfigMigrationContext,
+ ) => void;
};
export type LegacyConfigMigrationSpec = LegacyConfigMigration & {
diff --git a/src/config/schema.help.quality.test-fixtures.ts b/src/config/schema.help.quality.test-fixtures.ts
index a8dd0bb5f2fd..cc5b5b58bc57 100644
--- a/src/config/schema.help.quality.test-fixtures.ts
+++ b/src/config/schema.help.quality.test-fixtures.ts
@@ -379,7 +379,7 @@ export const ENUM_EXPECTATIONS: Record = {
"gateway.tailscale.mode": ['"off"', '"serve"', '"funnel"'],
"browser.profiles.*.driver": ['"openclaw"', '"clawd"', '"existing-session"'],
"discovery.mdns.mode": ['"off"', '"minimal"', '"full"'],
- "diagnostics.otel.protocol": ['"http/protobuf"', '"grpc"'],
+ "diagnostics.otel.protocol": ['"http/protobuf"'],
"diagnostics.otel.logsExporter": ['"otlp"', '"stdout"', '"both"'],
"logging.level": ['"silent"', '"fatal"', '"error"', '"warn"', '"info"', '"debug"', '"trace"'],
"logging.consoleLevel": [
diff --git a/src/config/schema.help.runtime.ts b/src/config/schema.help.runtime.ts
index 7c7d7e1ec026..a520bef8c8f6 100644
--- a/src/config/schema.help.runtime.ts
+++ b/src/config/schema.help.runtime.ts
@@ -329,9 +329,9 @@ export const RUNTIME_FIELD_HELP: Record = {
"diagnostics.otel.logsEndpoint":
"Signal-specific OTLP/HTTP logs endpoint. When set, this overrides diagnostics.otel.endpoint and OTEL_EXPORTER_OTLP_ENDPOINT for log export only.",
"diagnostics.otel.protocol":
- 'OTel transport protocol for telemetry export: "http/protobuf" or "grpc" depending on collector support. Use the protocol your observability backend expects to avoid dropped telemetry payloads.',
+ 'OTel transport protocol for telemetry export. Only "http/protobuf" is accepted; run "openclaw doctor --fix" to repair a persisted legacy "grpc" value or get source-specific manual-edit guidance.',
"diagnostics.otel.headers":
- "Additional HTTP/gRPC metadata headers sent with OpenTelemetry export requests, often used for tenant auth or routing. Keep secrets in env-backed values and avoid unnecessary header sprawl.",
+ "Additional HTTP request headers sent with OpenTelemetry export requests, often used for tenant auth or routing. Keep secrets in env-backed values and avoid unnecessary header sprawl.",
"diagnostics.otel.serviceName":
"Service name reported in telemetry resource attributes to identify this gateway instance in observability backends. Use stable names so dashboards and alerts remain consistent over deployments.",
"diagnostics.otel.metricNamePrefix":
diff --git a/src/config/types.base.ts b/src/config/types.base.ts
index f69a759d053c..a97fe0817d55 100644
--- a/src/config/types.base.ts
+++ b/src/config/types.base.ts
@@ -293,7 +293,7 @@ export type DiagnosticsOtelConfig = {
tracesEndpoint?: string;
metricsEndpoint?: string;
logsEndpoint?: string;
- protocol?: "http/protobuf" | "grpc";
+ protocol?: "http/protobuf";
headers?: Record;
serviceName?: string;
/** Replacement prefix for OpenClaw-owned metric names. Empty removes the prefix; defaults to "openclaw.". */
diff --git a/src/config/validation.allowed-values.test.ts b/src/config/validation.allowed-values.test.ts
index 76819bf68b2d..1647c9014a09 100644
--- a/src/config/validation.allowed-values.test.ts
+++ b/src/config/validation.allowed-values.test.ts
@@ -35,6 +35,23 @@ describe("config validation allowed-values metadata", () => {
}
});
+ it("reports the supported diagnostics OTel protocol when grpc is configured", () => {
+ const result = validateConfigObjectRaw({
+ diagnostics: {
+ otel: {
+ protocol: "grpc",
+ },
+ },
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ const issue = requireIssue(result.issues, "diagnostics.otel.protocol");
+ expect(issue.allowedValues).toEqual(["http/protobuf"]);
+ expect(issue.allowedValuesHiddenCount).toBe(0);
+ }
+ });
+
it("skips allowed-values hints for unions with open-ended branches", () => {
const result = validateConfigObjectRaw({
cron: { sessionRetention: true },
diff --git a/src/config/zod-schema.root-shape.ts b/src/config/zod-schema.root-shape.ts
index 48e7b6f438cb..bf780dcf16f8 100644
--- a/src/config/zod-schema.root-shape.ts
+++ b/src/config/zod-schema.root-shape.ts
@@ -88,7 +88,7 @@ export const OpenClawSchemaShape = {
tracesEndpoint: z.string().optional(),
metricsEndpoint: z.string().optional(),
logsEndpoint: z.string().optional(),
- protocol: z.union([z.literal("http/protobuf"), z.literal("grpc")]).optional(),
+ protocol: z.literal("http/protobuf").optional(),
headers: z.record(z.string(), z.string()).optional(),
serviceName: z.string().optional(),
metricNamePrefix: MetricNamePrefixSchema.optional(),