fix(config): reject unsupported diagnostics otel grpc (#93087)

This commit is contained in:
kiranmagic7
2026-08-04 01:58:08 +05:30
committed by GitHub
parent 990fdcf6fd
commit 682f60ce56
26 changed files with 803 additions and 44 deletions

View File

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

View File

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

View File

@@ -59,7 +59,20 @@ openclaw plugins install clawhub:@openclaw/diagnostics-otel
Or enable the plugin from the CLI: `openclaw plugins enable diagnostics-otel`.
<Note>
`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.
</Note>
## 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`. |

View File

@@ -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<DiagnosticEventPayload, { type: "telemetry.exporter" }>;
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<Parameters<Parameters<typeof onInternalDiagnosticEvent>[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<Parameters<Parameters<typeof onInternalDiagnosticEvent>[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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ConfigFileSnapshot, "path" | "includeProvenance">;
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;

View File

@@ -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<string, unknown> | 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);

View File

@@ -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: [] });
});
});

View File

@@ -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<string, unknown>;
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 = {

View File

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

View File

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

View File

@@ -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<string, unknown>): 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.",
);
}
},
}),
];

View File

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

View File

@@ -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<unknown>((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<unknown>(
(current, entry) => deepMerge(current, entry.value),
{},
);
return {
value: merged,
targetPaths: resolvedEntries.map((entry) => entry.targetPath),
};
}
throw new ConfigIncludeError(

View File

@@ -31,6 +31,7 @@ export function createConfigFileSnapshot(params: {
includeProvenance: params.includeProvenance.map((entry) => ({
...entry,
path: [...entry.path],
...(entry.targetPaths ? { targetPaths: [...entry.targetPaths] } : {}),
})),
}
: {}),

View File

@@ -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<string, unknown>, changes: string[]) => void;
apply: (
raw: Record<string, unknown>,
changes: string[],
context?: LegacyConfigMigrationContext,
) => void;
};
export type LegacyConfigMigrationSpec = LegacyConfigMigration & {

View File

@@ -379,7 +379,7 @@ export const ENUM_EXPECTATIONS: Record<string, string[]> = {
"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": [

View File

@@ -329,9 +329,9 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
"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":

View File

@@ -293,7 +293,7 @@ export type DiagnosticsOtelConfig = {
tracesEndpoint?: string;
metricsEndpoint?: string;
logsEndpoint?: string;
protocol?: "http/protobuf" | "grpc";
protocol?: "http/protobuf";
headers?: Record<string, string>;
serviceName?: string;
/** Replacement prefix for OpenClaw-owned metric names. Empty removes the prefix; defaults to "openclaw.". */

View File

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

View File

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