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(),