mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 22:17:00 +00:00
fix(config): reject unsupported diagnostics otel grpc (#93087)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
---
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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")],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -31,6 +31,7 @@ export function createConfigFileSnapshot(params: {
|
||||
includeProvenance: params.includeProvenance.map((entry) => ({
|
||||
...entry,
|
||||
path: [...entry.path],
|
||||
...(entry.targetPaths ? { targetPaths: [...entry.targetPaths] } : {}),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.". */
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user