From 942a7176845a9012d7a8a50aee7e2390c60816eb Mon Sep 17 00:00:00 2001
From: Matthew Feroz
Date: Sun, 4 Oct 2026 06:32:35 +0000
Subject: [PATCH 1/9] feat(web): configure OpenTelemetry exports in diagnostics
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../settings/DiagnosticsSettings.tsx | 8 +-
.../settings/SettingsSidebarNav.tsx | 13 +-
.../settings/TelemetryExportSettings.tsx | 116 ++++++++++++++++++
.../settings/settingsSearch.test.ts | 22 ++++
.../src/components/settings/settingsSearch.ts | 12 ++
docs/operations/observability.md | 4 +
docs/user/telemetry.md | 11 ++
7 files changed, 180 insertions(+), 6 deletions(-)
create mode 100644 apps/web/src/components/settings/TelemetryExportSettings.tsx
diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx
index 3acc22b5d1a2..4b0ab08b6fab 100644
--- a/apps/web/src/components/settings/DiagnosticsSettings.tsx
+++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx
@@ -34,6 +34,7 @@ import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";
import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";
import { useAtomCommand } from "../../state/use-atom-command";
import { useSettingsScope } from "./SettingsScopeContext";
+import { TelemetryExportSettings } from "./TelemetryExportSettings";
const NUMBER_FORMAT = new Intl.NumberFormat();
@@ -805,6 +806,7 @@ export function DiagnosticsSettingsPanel() {
const isInitialLoading = isPending && data === null;
const isProcessInitialLoading = isProcessPending && processData === null;
+ const isResourceInitialLoading = isResourcePending && resourceData === null;
const signalProcess = useCallback(
async (pid: number, signal: ServerProcessSignal) => {
const targetEnvironmentId = environmentIdRef.current;
@@ -1023,7 +1025,7 @@ export function DiagnosticsSettingsPanel() {
)}
+ {/* Let initial tables settle before the search target scrolls into view. */}
+ {!isInitialLoading && !isProcessInitialLoading && !isResourceInitialLoading && (
+
+ )}
);
}
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx
index 048e19b7a6b5..d10abed376ba 100644
--- a/apps/web/src/components/settings/SettingsSidebarNav.tsx
+++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx
@@ -78,6 +78,7 @@ const SETTINGS_SECTION_ICONS: Readonly<
Record>
> = {
"/settings/general": Settings2Icon,
+ "/settings/diagnostics": Settings2Icon,
"/settings/appearance": PaletteIcon,
"/settings/projects": PanelsTopLeftIcon,
"/settings/keybindings": KeyboardIcon,
@@ -95,11 +96,13 @@ const SETTINGS_NAV_ITEMS: ReadonlyArray<{
label: string;
to: SettingsPath;
icon: ComponentType<{ className?: string }>;
-}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[]).map((to) => ({
- to,
- label: SETTINGS_SECTION_LABELS[to],
- icon: SETTINGS_SECTION_ICONS[to],
-}));
+}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[])
+ .filter((to) => to !== "/settings/diagnostics")
+ .map((to) => ({
+ to,
+ label: SETTINGS_SECTION_LABELS[to],
+ icon: SETTINGS_SECTION_ICONS[to],
+ }));
function SettingsSectionIcon({ to }: { to: SettingsPath }) {
const Icon = SETTINGS_SECTION_ICONS[to];
diff --git a/apps/web/src/components/settings/TelemetryExportSettings.tsx b/apps/web/src/components/settings/TelemetryExportSettings.tsx
new file mode 100644
index 000000000000..6973a3407156
--- /dev/null
+++ b/apps/web/src/components/settings/TelemetryExportSettings.tsx
@@ -0,0 +1,116 @@
+import { useState } from "react";
+
+import { serverEnvironment } from "../../state/server";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { Button } from "../ui/button";
+import { Input } from "../ui/input";
+import { Label } from "../ui/label";
+import { useSettingsScope } from "./SettingsScopeContext";
+import { useScopedSettings } from "./useScopedSettings";
+import { SettingsSection } from "./settingsLayout";
+import { SettingsScopeNotice } from "./SettingsScopeNotice";
+
+const SIGNALS = [
+ { key: "otlpTracesUrl", label: "Traces", path: "traces" },
+ { key: "otlpMetricsUrl", label: "Metrics", path: "metrics" },
+ { key: "otlpLogsUrl", label: "Logs", path: "logs" },
+] as const;
+
+export function TelemetryExportSettings() {
+ const { environment, scope } = useSettingsScope();
+ const saved = useScopedSettings((settings) => settings.observability);
+ const [draft, setDraft] = useState>({});
+ const [saving, setSaving] = useState(false);
+ const [message, setMessage] = useState(null);
+ const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: true });
+ const values = { ...saved, ...draft };
+ const changed = SIGNALS.some(({ key }) => values[key].trim() !== saved[key]);
+ const running = environment?.serverConfig?.observability;
+
+ if (scope.kind !== "environment") {
+ return (
+
+
+ Choose one environment to configure its telemetry exports.
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts
index 76f26b2eeab6..528f198de524 100644
--- a/apps/web/src/components/settings/settingsSearch.test.ts
+++ b/apps/web/src/components/settings/settingsSearch.test.ts
@@ -45,6 +45,19 @@ const ITEMS: ReadonlyArray = [
];
describe("searchSettings", () => {
+ it.each(["OTel", "OTLP", "telemetry export", "Grafana"])(
+ "finds environment telemetry settings for %s",
+ (query) => {
+ expect(searchSettings(query)).toContainEqual(
+ expect.objectContaining({
+ id: "telemetry-export",
+ to: "/settings/diagnostics",
+ targetId: "telemetry-export",
+ scope: "environment",
+ }),
+ );
+ },
+ );
it.each(["send shortcut", "multiline", "new line"])("finds Send shortcut for %s", (query) => {
expect(searchSettings(query).map((item) => item.id)).toContain("send-shortcut");
});
@@ -368,6 +381,15 @@ describe("searchSettings", () => {
});
describe("settings search targets", () => {
+ it("requires an environment for the resolved telemetry export destination", () => {
+ const result = searchSettings("OpenTelemetry export").find(
+ (item) => item.id === "telemetry-export",
+ )!;
+ const target = getSettingsSearchTargetScope(result.targetId ?? result.id)!;
+ expect(isSettingsSearchScopeAvailable(target.scope, "all")).toBe(false);
+ expect(isSettingsSearchScopeAvailable(target.scope, "project")).toBe(false);
+ expect(isSettingsSearchScopeAvailable(target.scope, "environment")).toBe(true);
+ });
it.each([
"auto-settle-inactive-threads",
"auto-settle-merged-threads",
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index ae55a327d163..c1f1a32fcaf5 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -14,6 +14,7 @@ import {
export type SettingsPath =
| "/settings/projects"
| "/settings/general"
+ | "/settings/diagnostics"
| "/settings/appearance"
| "/settings/keybindings"
| "/settings/snap-shot"
@@ -84,6 +85,7 @@ export interface SettingsSearchAvailability {
* subtitles both render from this record, so each label exists once.
*/
export const SETTINGS_SECTION_LABELS: Readonly> = {
+ "/settings/diagnostics": "Diagnostics",
"/settings/projects": "Project",
"/settings/general": "General",
"/settings/appearance": "Appearance",
@@ -493,10 +495,19 @@ export const SETTINGS_SEARCH_ITEMS = [
scope: "project-defaults",
searchTerms: ["generated thread titles source control content default provider"],
},
+ {
+ id: "telemetry-export",
+ title: "OpenTelemetry export",
+ to: "/settings/diagnostics",
+ targetId: "telemetry-export",
+ scope: "environment",
+ searchTerms: ["OTel OTLP telemetry export endpoints logs traces metrics Grafana"],
+ },
{
id: "diagnostics",
title: "Diagnostics",
to: "/settings/general",
+ scope: "environment",
searchTerms: ["logs traces processes resource history failures spans cpu memory"],
},
{
@@ -879,6 +890,7 @@ export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"];
const SEARCH_ITEMS_BY_ID = new Map(SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item] as const));
const SETTINGS_CATEGORY_SCOPES: Readonly> = {
+ "/settings/diagnostics": "environment",
"/settings/projects": "project",
"/settings/general": null,
"/settings/appearance": null,
diff --git a/docs/operations/observability.md b/docs/operations/observability.md
index b83a9015abb2..7511374050bd 100644
--- a/docs/operations/observability.md
+++ b/docs/operations/observability.md
@@ -172,6 +172,10 @@ Default Grafana login:
#### 2. Export OTLP env vars
+For endpoint settings and startup precedence, see the
+[user telemetry guide](../user/telemetry.md#export-diagnostics-to-your-own-receiver).
+In a worktree, use `vp run dev` instead of `npx t3` to keep state isolated.
+
```bash
export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces
export T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics
diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md
index 5fd4f91c9327..f2f38d41489f 100644
--- a/docs/user/telemetry.md
+++ b/docs/user/telemetry.md
@@ -9,3 +9,14 @@ raw provider events, or child-agent output. Child-agent token use is excluded fr
To disable collection, set `T3CODE_TELEMETRY_ENABLED=false` in the server's environment before
starting it. This stops product events from being recorded or sent.
+
+## Export diagnostics to your own receiver
+
+To send traces, metrics, or logs to an OpenTelemetry receiver, open
+**Settings > General > Diagnostics**, select an environment, and enter each signal's OTLP HTTP
+endpoint under **OpenTelemetry export** (for example `http://localhost:4318/v1/traces`). Save, then
+restart that environment's server. The receiver must be reachable from the server's machine.
+
+`T3CODE_OTLP_*_URL` and standard `OTEL_EXPORTER_OTLP_*` environment variables override the saved
+settings. Set `T3CODE_OTLP_HEADERS` for receivers that need authentication, and
+`T3CODE_OTLP_PROTOCOL=http/protobuf` for receivers that do not accept JSON.
From 7b268289437c2cad7157850b4b63e03e0d06bf28 Mon Sep 17 00:00:00 2001
From: Matthew Feroz
Date: Sun, 4 Oct 2026 07:00:37 +0000
Subject: [PATCH 2/9] feat(web): check OpenTelemetry endpoints from the export
settings
Each endpoint shows a connection status and a Test button. The server posts
an empty export with the protocol and credentials a saved endpoint would use,
and reports whether the receiver accepted it, rejected it, or never answered.
Saved endpoints are checked when the section opens.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
apps/server/src/auth/RpcAuthorization.ts | 1 +
.../observability/OtlpEndpointCheck.test.ts | 116 ++++++++++++++
.../src/observability/OtlpEndpointCheck.ts | 92 ++++++++++++
apps/server/src/ws.ts | 7 +
.../settings/TelemetryExportSettings.tsx | 142 ++++++++++++++----
docs/user/telemetry.md | 2 +
packages/client-runtime/src/state/server.ts | 4 +
packages/contracts/src/rpc.ts | 10 ++
packages/contracts/src/server.ts | 20 +++
9 files changed, 366 insertions(+), 28 deletions(-)
create mode 100644 apps/server/src/observability/OtlpEndpointCheck.test.ts
create mode 100644 apps/server/src/observability/OtlpEndpointCheck.ts
diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts
index bba03dd2d600..96a6a05fd8f1 100644
--- a/apps/server/src/auth/RpcAuthorization.ts
+++ b/apps/server/src/auth/RpcAuthorization.ts
@@ -82,6 +82,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope,
[WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope,
+ [WS_METHODS.serverCheckOtlpEndpoint]: AuthOrchestrationOperateScope,
[WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope,
[WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope,
[WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope,
diff --git a/apps/server/src/observability/OtlpEndpointCheck.test.ts b/apps/server/src/observability/OtlpEndpointCheck.test.ts
new file mode 100644
index 000000000000..5e691f859ba9
--- /dev/null
+++ b/apps/server/src/observability/OtlpEndpointCheck.test.ts
@@ -0,0 +1,116 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, describe, it } from "@effect/vitest";
+import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
+import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
+
+import * as ServerConfig from "../config.ts";
+import * as OtlpEndpointCheck from "./OtlpEndpointCheck.ts";
+
+interface PostedRequest {
+ readonly url: string;
+ readonly headers: Readonly>;
+ readonly body: string;
+}
+
+/** Answers every post with `status`, or fails like a refused connection when it is null. */
+const checkWith = (
+ status: number | null,
+ overrides: Partial = {},
+) => {
+ const requests: Array = [];
+ const httpClient = HttpClient.make((request) => {
+ requests.push({
+ url: request.url,
+ headers: request.headers,
+ body: request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "",
+ });
+ return status === null
+ ? Effect.fail(
+ new HttpClientError.HttpClientError({
+ reason: new HttpClientError.TransportError({ request }),
+ }),
+ )
+ : Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status })));
+ });
+ const configLayer = Layer.effect(
+ ServerConfig.ServerConfig,
+ Effect.gen(function* () {
+ const config = yield* ServerConfig.ServerConfig;
+ return ServerConfig.make({ ...config, ...overrides });
+ }),
+ ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-otlp-check-" })));
+ const layer = OtlpEndpointCheck.layer.pipe(
+ Layer.provide(Layer.succeed(HttpClient.HttpClient, httpClient)),
+ Layer.provide(configLayer),
+ Layer.provide(NodeServices.layer),
+ );
+ return { requests, layer };
+};
+
+const check = (input: Parameters[0]) =>
+ Effect.gen(function* () {
+ const service = yield* OtlpEndpointCheck.OtlpEndpointCheck;
+ return yield* service.check(input);
+ });
+
+describe("OtlpEndpointCheck", () => {
+ it.effect("posts an empty export for the signal with the T3 headers", () => {
+ const { requests, layer } = checkWith(200, {
+ otlpMetricsExport: { ...DEFAULT_SIGNAL_EXPORT, headers: { authorization: "Bearer t3" } },
+ });
+ return Effect.gen(function* () {
+ const result = yield* check({ signal: "metrics", url: "http://collector:4318/v1/metrics" });
+
+ assert.strictEqual(result._tag, "Accepted");
+ assert.lengthOf(requests, 1);
+ assert.strictEqual(requests[0]?.url, "http://collector:4318/v1/metrics");
+ assert.strictEqual(requests[0]?.headers.authorization, "Bearer t3");
+ assert.strictEqual(requests[0]?.body, `{"resourceMetrics":[]}`);
+ }).pipe(Effect.provide(layer));
+ });
+
+ it.effect("reports the status a receiver rejects the export with", () => {
+ const { layer } = checkWith(401);
+ return Effect.gen(function* () {
+ const result = yield* check({ signal: "traces", url: "http://collector:4318/v1/traces" });
+
+ assert.deepStrictEqual(result, { _tag: "Rejected", status: 401 });
+ }).pipe(Effect.provide(layer));
+ });
+
+ it.effect("reports an endpoint that cannot be reached", () => {
+ const { layer } = checkWith(null);
+ return Effect.gen(function* () {
+ const result = yield* check({ signal: "logs", url: "http://localhost:1/v1/logs" });
+
+ assert.deepStrictEqual(result, { _tag: "Unreachable", timedOut: false });
+ }).pipe(Effect.provide(layer));
+ });
+
+ it.effect("keeps OTEL endpoint credentials off other endpoints", () => {
+ const otelUrl = "https://otel.example.com/v1/traces";
+ const otelExport = { ...DEFAULT_SIGNAL_EXPORT, headers: { "x-api-key": "secret" } };
+ const { requests, layer } = checkWith(200, {
+ otlpTracesUrl: otelUrl,
+ otlpTracesExport: otelExport,
+ otelEnvironment: {
+ ...OtelEnvironment.none,
+ traces: OtelEnvironment.OtelSignal.Export({
+ url: otelUrl,
+ protocol: otelExport.protocol,
+ headers: otelExport.headers,
+ }),
+ },
+ });
+ return Effect.gen(function* () {
+ yield* check({ signal: "traces", url: otelUrl });
+ yield* check({ signal: "traces", url: "https://elsewhere.example.com/v1/traces" });
+
+ assert.strictEqual(requests[0]?.headers["x-api-key"], "secret");
+ assert.isUndefined(requests[1]?.headers["x-api-key"]);
+ }).pipe(Effect.provide(layer));
+ });
+});
diff --git a/apps/server/src/observability/OtlpEndpointCheck.ts b/apps/server/src/observability/OtlpEndpointCheck.ts
new file mode 100644
index 000000000000..e8f0cc8d4652
--- /dev/null
+++ b/apps/server/src/observability/OtlpEndpointCheck.ts
@@ -0,0 +1,92 @@
+import type {
+ OtlpEndpointCheckInput,
+ OtlpEndpointCheckResult,
+ OtlpSignal,
+} from "@t3tools/contracts";
+import { otlpSerializationLayer } from "@t3tools/shared/observability";
+import * as Clock from "effect/Clock";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import { HttpClient } from "effect/unstable/http";
+import { OtlpSerialization } from "effect/unstable/observability";
+
+import * as ServerConfig from "../config.ts";
+
+const CHECK_TIMEOUT = "5 seconds";
+
+export class OtlpEndpointCheck extends Context.Service<
+ OtlpEndpointCheck,
+ {
+ /**
+ * Posts an empty export for the signal to `url` from this server, with the
+ * protocol and credentials a saved endpoint would export with. Receivers
+ * accept an empty export without storing anything.
+ */
+ readonly check: (input: OtlpEndpointCheckInput) => Effect.Effect;
+ }
+>()("t3/observability/OtlpEndpointCheck") {}
+
+const emptyExport = (
+ serialization: OtlpSerialization.OtlpSerialization["Service"],
+ signal: OtlpSignal,
+) => {
+ switch (signal) {
+ case "traces":
+ return serialization.traces({ resourceSpans: [] });
+ case "metrics":
+ return serialization.metrics({ resourceMetrics: [] });
+ case "logs":
+ return serialization.logs({ resourceLogs: [] });
+ }
+};
+
+const make = Effect.gen(function* () {
+ const config = yield* ServerConfig.ServerConfig;
+ const httpClient = yield* HttpClient.HttpClient;
+
+ const running = {
+ traces: { url: config.otlpTracesUrl, export: config.otlpTracesExport },
+ metrics: { url: config.otlpMetricsUrl, export: config.otlpMetricsExport },
+ logs: { url: config.otlpLogsUrl, export: config.otlpLogsExport },
+ } satisfies Record;
+
+ const check = Effect.fn("OtlpEndpointCheck.check")(function* ({
+ signal,
+ url,
+ }: OtlpEndpointCheckInput) {
+ const { url: runningUrl, export: signalExport } = running[signal];
+ // Credentials from an OTEL_EXPORTER_OTLP_* endpoint stay paired with that
+ // endpoint. Every other source shares T3CODE_OTLP_HEADERS, which a saved
+ // endpoint would also export with.
+ const headers =
+ url === runningUrl || config.otelEnvironment[signal]._tag !== "Export"
+ ? signalExport.headers
+ : undefined;
+ const body = yield* Effect.gen(function* () {
+ const serialization = yield* OtlpSerialization.OtlpSerialization;
+ return emptyExport(serialization, signal);
+ }).pipe(Effect.provide(otlpSerializationLayer(signalExport.protocol)));
+ const startedAt = yield* Clock.currentTimeMillis;
+ const response = yield* httpClient
+ .post(url, { body, headers })
+ .pipe(Effect.timeoutOption(CHECK_TIMEOUT), Effect.option);
+ if (Option.isNone(response)) {
+ return { _tag: "Unreachable", timedOut: false } as const;
+ }
+ if (Option.isNone(response.value)) {
+ return { _tag: "Unreachable", timedOut: true } as const;
+ }
+ const { status } = response.value.value;
+ if (status < 200 || status >= 300) {
+ return { _tag: "Rejected", status } as const;
+ }
+ const latencyMs = (yield* Clock.currentTimeMillis) - startedAt;
+ return { _tag: "Accepted", latencyMs } as const;
+ });
+
+ return OtlpEndpointCheck.of({ check });
+});
+
+export const layer = Layer.effect(OtlpEndpointCheck, make);
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index ae331995d201..15e9458191cb 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -225,6 +225,7 @@ import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts";
import * as SqlClient from "effect/unstable/sql/SqlClient";
import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts";
import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";
+import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts";
import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
@@ -1293,6 +1294,7 @@ const makeWsRpcLayer = (
);
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery;
+ const otlpEndpointCheck = yield* OtlpEndpointCheck.OtlpEndpointCheck;
const automaticGitFetchInterval = serverSettings.getSettings.pipe(
Effect.map(
(settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval,
@@ -2618,6 +2620,10 @@ const makeWsRpcLayer = (
observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, {
"rpc.aggregate": "server",
}),
+ [WS_METHODS.serverCheckOtlpEndpoint]: (input) =>
+ observeRpcEffect(WS_METHODS.serverCheckOtlpEndpoint, otlpEndpointCheck.check(input), {
+ "rpc.aggregate": "server",
+ }),
[WS_METHODS.serverSignalProcess]: (input) =>
observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), {
"rpc.aggregate": "server",
@@ -3828,6 +3834,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
// One server-lifetime service means clients share the same PR caches, and a WS
// mutation invalidates the HTTP diff cache that every client reads from.
Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)),
+ Layer.provide(OtlpEndpointCheck.layer),
Layer.provide(
SourceControlDiscovery.layer.pipe(
Layer.provide(
diff --git a/apps/web/src/components/settings/TelemetryExportSettings.tsx b/apps/web/src/components/settings/TelemetryExportSettings.tsx
index 6973a3407156..91237baff64a 100644
--- a/apps/web/src/components/settings/TelemetryExportSettings.tsx
+++ b/apps/web/src/components/settings/TelemetryExportSettings.tsx
@@ -1,32 +1,97 @@
-import { useState } from "react";
+import type { OtlpEndpointCheckResult, OtlpSignal } from "@t3tools/contracts";
+import { useEffect, useState } from "react";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
+import { ConnectionStatusDot } from "../ConnectionStatusDot";
import { useSettingsScope } from "./SettingsScopeContext";
import { useScopedSettings } from "./useScopedSettings";
import { SettingsSection } from "./settingsLayout";
import { SettingsScopeNotice } from "./SettingsScopeNotice";
const SIGNALS = [
- { key: "otlpTracesUrl", label: "Traces", path: "traces" },
- { key: "otlpMetricsUrl", label: "Metrics", path: "metrics" },
- { key: "otlpLogsUrl", label: "Logs", path: "logs" },
+ { key: "otlpTracesUrl", label: "Traces", signal: "traces" },
+ { key: "otlpMetricsUrl", label: "Metrics", signal: "metrics" },
+ { key: "otlpLogsUrl", label: "Logs", signal: "logs" },
] as const;
+type SignalKey = (typeof SIGNALS)[number]["key"];
+
+/** The latest check for a signal; it describes the field only while `url` is still its value. */
+type EndpointCheck = { readonly url: string; readonly result: OtlpEndpointCheckResult | null };
+
+const isHttpUrl = (url: string) => /^https?:\/\/./.test(url);
+
+function endpointStatus(url: string, check: EndpointCheck | undefined) {
+ if (url === "") return { label: "Off", dot: "bg-muted-foreground/40" };
+ if (check?.url !== url) return { label: "Not checked", dot: "bg-muted-foreground/40" };
+ const { result } = check;
+ if (result === null) return { label: "Checking…", dot: "bg-warning" };
+ switch (result._tag) {
+ case "Accepted":
+ return { label: `Connected · ${Math.round(result.latencyMs)} ms`, dot: "bg-success" };
+ case "Rejected":
+ return { label: `Rejected · HTTP ${result.status}`, dot: "bg-destructive" };
+ case "Unreachable":
+ return { label: result.timedOut ? "Timed out" : "Unreachable", dot: "bg-destructive" };
+ }
+}
+
export function TelemetryExportSettings() {
const { environment, scope } = useSettingsScope();
const saved = useScopedSettings((settings) => settings.observability);
const [draft, setDraft] = useState>({});
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState(null);
+ const environmentId = scope.kind === "environment" ? environment?.environmentId : undefined;
+ // Saved endpoints open as pending; the effect below sends their checks.
+ const [checks, setChecks] = useState>>(() =>
+ environmentId === undefined
+ ? {}
+ : Object.fromEntries(
+ SIGNALS.filter(({ key }) => isHttpUrl(saved[key])).map(({ key }) => [
+ key,
+ { url: saved[key], result: null },
+ ]),
+ ),
+ );
const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: true });
+ const checkOtlpEndpoint = useAtomCommand(serverEnvironment.checkOtlpEndpoint);
const values = { ...saved, ...draft };
const changed = SIGNALS.some(({ key }) => values[key].trim() !== saved[key]);
const running = environment?.serverConfig?.observability;
+ const sendCheck = async (key: SignalKey, signal: OtlpSignal, url: string) => {
+ if (!environmentId) return;
+ const response = await checkOtlpEndpoint({ environmentId, input: { signal, url } });
+ // A newer check for the same field replaces this one.
+ setChecks((previous) => {
+ if (previous[key]?.url !== url || previous[key].result !== null) return previous;
+ const next = { ...previous };
+ if (response._tag === "Success") next[key] = { url, result: response.value };
+ else delete next[key];
+ return next;
+ });
+ };
+
+ const checkEndpoint = (key: SignalKey, signal: OtlpSignal, url: string) => {
+ if (!isHttpUrl(url)) return;
+ setChecks((previous) => ({ ...previous, [key]: { url, result: null } }));
+ void sendCheck(key, signal, url);
+ };
+
+ useEffect(() => {
+ for (const { key, signal } of SIGNALS) {
+ const check = checks[key];
+ // oxlint-disable-next-line react/set-state-in-effect -- State changes only after the response arrives.
+ if (check?.result === null) void sendCheck(key, signal, check.url);
+ }
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Runs once; the section remounts per environment.
+ }, []);
+
if (scope.kind !== "environment") {
return (
@@ -64,30 +129,51 @@ export function TelemetryExportSettings() {
Send traces, metrics, and logs to an OTLP HTTP receiver. Restart the server to apply.
Environment variables override these settings.
- {SIGNALS.map(({ key, label, path }) => (
-
-
-
{
- setDraft((previous) => ({ ...previous, [key]: event.target.value }));
- setMessage(null);
- }}
- />
- {saved[key] !== (running?.[key] ?? "") && (
-
- Running: {running?.[key] || "Disabled"}
-
- )}
-
- ))}
+ {SIGNALS.map(({ key, label, signal }) => {
+ const url = values[key].trim();
+ const status = endpointStatus(url, checks[key]);
+ return (
+
+
+
+
+
+
+
{status.label}
+
+
+ {
+ setDraft((previous) => ({ ...previous, [key]: event.target.value }));
+ setMessage(null);
+ }}
+ />
+
+
+ {saved[key] !== (running?.[key] ?? "") && (
+
+ Running: {running?.[key] || "Disabled"}
+
+ )}
+
+ );
+ })}
{message}
diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md
index f2f38d41489f..b4628073c5f5 100644
--- a/docs/user/telemetry.md
+++ b/docs/user/telemetry.md
@@ -16,6 +16,8 @@ To send traces, metrics, or logs to an OpenTelemetry receiver, open
**Settings > General > Diagnostics**, select an environment, and enter each signal's OTLP HTTP
endpoint under **OpenTelemetry export** (for example `http://localhost:4318/v1/traces`). Save, then
restart that environment's server. The receiver must be reachable from the server's machine.
+**Test** sends an empty export from the server to check an endpoint before or after you save it;
+saved endpoints are checked when the section opens.
`T3CODE_OTLP_*_URL` and standard `OTEL_EXPORTER_OTLP_*` environment variables override the saved
settings. Set `T3CODE_OTLP_HEADERS` for receivers that need authentication, and
diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts
index 5f1818399640..d0665622019f 100644
--- a/packages/client-runtime/src/state/server.ts
+++ b/packages/client-runtime/src/state/server.ts
@@ -1293,6 +1293,10 @@ export function createServerEnvironmentAtoms(
key: ({ environmentId }) => environmentId,
},
}),
+ checkOtlpEndpoint: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:server:check-otlp-endpoint",
+ tag: WS_METHODS.serverCheckOtlpEndpoint,
+ }),
retryResourceTelemetry: createEnvironmentRpcCommand(runtime, {
label: "environment-data:server:retry-resource-telemetry",
tag: WS_METHODS.serverRetryResourceTelemetry,
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index 6df5f4c28f90..e92e2beb287a 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -269,6 +269,8 @@ import {
import {
ServerConfigStreamEvent,
DesktopUpdateCommitInput,
+ OtlpEndpointCheckInput,
+ OtlpEndpointCheckResult,
ServerConfig,
ServerProviderUpdateError,
ServerProviderUpdateInput,
@@ -460,6 +462,7 @@ export const WS_METHODS = {
serverGetProcessResourceHistory: "server.getProcessResourceHistory",
serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory",
serverRetryResourceTelemetry: "server.retryResourceTelemetry",
+ serverCheckOtlpEndpoint: "server.checkOtlpEndpoint",
serverSignalProcess: "server.signalProcess",
serverReportClientActivity: "server.reportClientActivity",
serverReportHostPowerState: "server.reportHostPowerState",
@@ -830,6 +833,12 @@ const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourc
error: EnvironmentAuthorizationError,
});
+const WsServerCheckOtlpEndpointRpc = Rpc.make(WS_METHODS.serverCheckOtlpEndpoint, {
+ payload: OtlpEndpointCheckInput,
+ success: OtlpEndpointCheckResult,
+ error: EnvironmentAuthorizationError,
+});
+
const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, {
payload: UsageSummaryInput,
success: UsageSummary,
@@ -1738,6 +1747,7 @@ export const WsRpcGroup = RpcGroup.make(
WsServerGetProcessResourceHistoryRpc,
WsServerGetResourceTelemetryHistoryRpc,
WsServerRetryResourceTelemetryRpc,
+ WsServerCheckOtlpEndpointRpc,
WsServerGetUsageSummaryRpc,
WsServerRefreshUsageRatesRpc,
WsServerSignalProcessRpc,
diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts
index e152a3dbf072..c69b499be30e 100644
--- a/packages/contracts/src/server.ts
+++ b/packages/contracts/src/server.ts
@@ -320,6 +320,26 @@ export const ServerObservability = Schema.Struct({
});
export type ServerObservability = typeof ServerObservability.Type;
+export const OtlpSignal = Schema.Literals(["traces", "metrics", "logs"]);
+export type OtlpSignal = typeof OtlpSignal.Type;
+
+export const OtlpEndpointCheckInput = Schema.Struct({
+ signal: OtlpSignal,
+ url: TrimmedNonEmptyString,
+});
+export type OtlpEndpointCheckInput = typeof OtlpEndpointCheckInput.Type;
+
+/**
+ * What the server saw when it sent an empty export to an endpoint: accepted,
+ * answered with an error status, or never answered.
+ */
+export const OtlpEndpointCheckResult = Schema.Union([
+ Schema.TaggedStruct("Accepted", { latencyMs: Schema.Number }),
+ Schema.TaggedStruct("Rejected", { status: Schema.Number }),
+ Schema.TaggedStruct("Unreachable", { timedOut: Schema.Boolean }),
+]);
+export type OtlpEndpointCheckResult = typeof OtlpEndpointCheckResult.Type;
+
export const ServerTraceDiagnosticsErrorKind = Schema.Literals([
"trace-file-not-found",
"trace-file-read-failed",
From 3b2bbb543b012a1027278828cf1a68f3bdd7e273 Mon Sep 17 00:00:00 2001
From: Matthew Feroz
Date: Sun, 4 Oct 2026 07:50:27 +0000
Subject: [PATCH 3/9] fix(server): keep OTLP check credentials on configured
endpoints
---
.../observability/OtlpEndpointCheck.test.ts | 97 ++++++++++++++++++-
.../src/observability/OtlpEndpointCheck.ts | 22 ++---
docs/user/telemetry.md | 4 +-
3 files changed, 108 insertions(+), 15 deletions(-)
diff --git a/apps/server/src/observability/OtlpEndpointCheck.test.ts b/apps/server/src/observability/OtlpEndpointCheck.test.ts
index 5e691f859ba9..a6ff717a804f 100644
--- a/apps/server/src/observability/OtlpEndpointCheck.test.ts
+++ b/apps/server/src/observability/OtlpEndpointCheck.test.ts
@@ -1,10 +1,21 @@
+import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import * as NodeServices from "@effect/platform-node/NodeServices";
-import { assert, describe, it } from "@effect/vitest";
+import { assert, it } from "@effect/vitest";
import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
-import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
+import * as NetAddress from "effect/unstable/net/NetAddress";
+import {
+ FetchHttpClient,
+ HttpClient,
+ HttpClientError,
+ HttpClientResponse,
+ HttpServer,
+ HttpServerRequest,
+ HttpServerResponse,
+} from "effect/unstable/http";
+import { describe } from "vite-plus/test";
import * as ServerConfig from "../config.ts";
import * as OtlpEndpointCheck from "./OtlpEndpointCheck.ts";
@@ -19,6 +30,7 @@ interface PostedRequest {
const checkWith = (
status: number | null,
overrides: Partial = {},
+ httpClientLayer?: Layer.Layer,
) => {
const requests: Array = [];
const httpClient = HttpClient.make((request) => {
@@ -43,7 +55,7 @@ const checkWith = (
}),
).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-otlp-check-" })));
const layer = OtlpEndpointCheck.layer.pipe(
- Layer.provide(Layer.succeed(HttpClient.HttpClient, httpClient)),
+ Layer.provide(httpClientLayer ?? Layer.succeed(HttpClient.HttpClient, httpClient)),
Layer.provide(configLayer),
Layer.provide(NodeServices.layer),
);
@@ -59,6 +71,7 @@ const check = (input: Parameters {
it.effect("posts an empty export for the signal with the T3 headers", () => {
const { requests, layer } = checkWith(200, {
+ otlpMetricsUrl: "http://collector:4318/v1/metrics",
otlpMetricsExport: { ...DEFAULT_SIGNAL_EXPORT, headers: { authorization: "Bearer t3" } },
});
return Effect.gen(function* () {
@@ -72,6 +85,84 @@ describe("OtlpEndpointCheck", () => {
}).pipe(Effect.provide(layer));
});
+ it.effect("keeps T3 credentials paired with each running signal endpoint", () => {
+ const signalExport = { ...DEFAULT_SIGNAL_EXPORT, headers: { "x-api-key": "secret" } };
+ const { requests, layer } = checkWith(200, {
+ otlpTracesUrl: "https://collector.example.com/v1/traces",
+ otlpMetricsUrl: "https://collector.example.com/v1/metrics",
+ otlpLogsUrl: "https://collector.example.com/v1/logs",
+ otlpTracesExport: signalExport,
+ otlpMetricsExport: signalExport,
+ otlpLogsExport: signalExport,
+ otelEnvironment: OtelEnvironment.none,
+ });
+ return Effect.gen(function* () {
+ for (const signal of ["traces", "metrics", "logs"] as const) {
+ const runningUrl = `https://collector.example.com/v1/${signal}`;
+ yield* check({ signal, url: runningUrl });
+ yield* check({ signal, url: "https://elsewhere.example.com/collect" });
+ yield* check({ signal, url: `${runningUrl}/other` });
+ yield* check({ signal, url: `${runningUrl}?tenant=other` });
+ }
+
+ assert.lengthOf(requests, 12);
+ requests.forEach((request, index) => {
+ if (index % 4 === 0) assert.strictEqual(request.headers["x-api-key"], "secret");
+ else assert.isUndefined(request.headers["x-api-key"]);
+ });
+ }).pipe(Effect.provide(layer));
+ });
+
+ it.effect("does not send export credentials when the signal has no running endpoint", () => {
+ const { requests, layer } = checkWith(200, {
+ otlpLogsUrl: undefined,
+ otlpLogsExport: { ...DEFAULT_SIGNAL_EXPORT, headers: { authorization: "Bearer t3" } },
+ otelEnvironment: OtelEnvironment.none,
+ });
+ return Effect.gen(function* () {
+ const result = yield* check({ signal: "logs", url: "https://new.example.com/v1/logs" });
+
+ assert.strictEqual(result._tag, "Accepted");
+ assert.lengthOf(requests, 1);
+ assert.isUndefined(requests[0]?.headers.authorization);
+ }).pipe(Effect.provide(layer));
+ });
+
+ it.effect("does not forward credentials through a receiver redirect", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const requests: Array<{ url: string; apiKey: string | undefined }> = [];
+ const server = yield* HttpServer.HttpServer;
+ yield* server.serve(
+ Effect.gen(function* () {
+ const request = yield* HttpServerRequest.HttpServerRequest;
+ yield* request.text;
+ requests.push({ url: request.url, apiKey: request.headers["x-api-key"] });
+ return request.url === "/v1/traces"
+ ? HttpServerResponse.redirect("/other", { status: 307 })
+ : HttpServerResponse.empty({ status: 200 });
+ }),
+ );
+ if (!NetAddress.isInetAddress(server.address)) {
+ return yield* Effect.die(new Error("Expected a TCP address"));
+ }
+ const url = `http://127.0.0.1:${server.address.port}/v1/traces`;
+ const { layer } = checkWith(
+ 200,
+ {
+ otlpTracesUrl: url,
+ otlpTracesExport: { ...DEFAULT_SIGNAL_EXPORT, headers: { "x-api-key": "secret" } },
+ },
+ FetchHttpClient.layer,
+ );
+ const result = yield* check({ signal: "traces", url }).pipe(Effect.provide(layer));
+
+ assert.deepStrictEqual(result, { _tag: "Rejected", status: 307 });
+ assert.deepStrictEqual(requests, [{ url: "/v1/traces", apiKey: "secret" }]);
+ }),
+ ).pipe(Effect.provide(NodeHttpServer.layerTest)),
+ );
+
it.effect("reports the status a receiver rejects the export with", () => {
const { layer } = checkWith(401);
return Effect.gen(function* () {
diff --git a/apps/server/src/observability/OtlpEndpointCheck.ts b/apps/server/src/observability/OtlpEndpointCheck.ts
index e8f0cc8d4652..a58f14eea2e5 100644
--- a/apps/server/src/observability/OtlpEndpointCheck.ts
+++ b/apps/server/src/observability/OtlpEndpointCheck.ts
@@ -9,7 +9,7 @@ import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
-import { HttpClient } from "effect/unstable/http";
+import { FetchHttpClient, HttpClient } from "effect/unstable/http";
import { OtlpSerialization } from "effect/unstable/observability";
import * as ServerConfig from "../config.ts";
@@ -21,8 +21,9 @@ export class OtlpEndpointCheck extends Context.Service<
{
/**
* Posts an empty export for the signal to `url` from this server, with the
- * protocol and credentials a saved endpoint would export with. Receivers
- * accept an empty export without storing anything.
+ * running signal's protocol. Credentials are used only for its exact
+ * running endpoint, and redirects are not followed. Receivers accept an
+ * empty export without storing anything.
*/
readonly check: (input: OtlpEndpointCheckInput) => Effect.Effect;
}
@@ -57,13 +58,8 @@ const make = Effect.gen(function* () {
url,
}: OtlpEndpointCheckInput) {
const { url: runningUrl, export: signalExport } = running[signal];
- // Credentials from an OTEL_EXPORTER_OTLP_* endpoint stay paired with that
- // endpoint. Every other source shares T3CODE_OTLP_HEADERS, which a saved
- // endpoint would also export with.
- const headers =
- url === runningUrl || config.otelEnvironment[signal]._tag !== "Export"
- ? signalExport.headers
- : undefined;
+ // Keep credentials paired with the running endpoint, regardless of source.
+ const headers = url === runningUrl ? signalExport.headers : undefined;
const body = yield* Effect.gen(function* () {
const serialization = yield* OtlpSerialization.OtlpSerialization;
return emptyExport(serialization, signal);
@@ -71,7 +67,11 @@ const make = Effect.gen(function* () {
const startedAt = yield* Clock.currentTimeMillis;
const response = yield* httpClient
.post(url, { body, headers })
- .pipe(Effect.timeoutOption(CHECK_TIMEOUT), Effect.option);
+ .pipe(
+ Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),
+ Effect.timeoutOption(CHECK_TIMEOUT),
+ Effect.option,
+ );
if (Option.isNone(response)) {
return { _tag: "Unreachable", timedOut: false } as const;
}
diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md
index b4628073c5f5..f288c7e30638 100644
--- a/docs/user/telemetry.md
+++ b/docs/user/telemetry.md
@@ -17,7 +17,9 @@ To send traces, metrics, or logs to an OpenTelemetry receiver, open
endpoint under **OpenTelemetry export** (for example `http://localhost:4318/v1/traces`). Save, then
restart that environment's server. The receiver must be reachable from the server's machine.
**Test** sends an empty export from the server to check an endpoint before or after you save it;
-saved endpoints are checked when the section opens.
+saved endpoints are checked when the section opens. Checks send authentication headers only to
+the signal's currently running endpoint and do not follow redirects. To check a new endpoint
+that requires authentication, save it and restart the server first.
`T3CODE_OTLP_*_URL` and standard `OTEL_EXPORTER_OTLP_*` environment variables override the saved
settings. Set `T3CODE_OTLP_HEADERS` for receivers that need authentication, and
From 619ded947d5327bf0555d5ef3d1be42f61ebf42b Mon Sep 17 00:00:00 2001
From: Matthew Feroz
Date: Sun, 4 Oct 2026 07:55:13 +0000
Subject: [PATCH 4/9] fix(web): save only edited telemetry endpoints
---
.../settings/TelemetryExportSettings.tsx | 34 ++++++++++++++-----
1 file changed, 25 insertions(+), 9 deletions(-)
diff --git a/apps/web/src/components/settings/TelemetryExportSettings.tsx b/apps/web/src/components/settings/TelemetryExportSettings.tsx
index 91237baff64a..ac9abbf7e81f 100644
--- a/apps/web/src/components/settings/TelemetryExportSettings.tsx
+++ b/apps/web/src/components/settings/TelemetryExportSettings.tsx
@@ -20,8 +20,14 @@ const SIGNALS = [
type SignalKey = (typeof SIGNALS)[number]["key"];
-/** The latest check for a signal; it describes the field only while `url` is still its value. */
-type EndpointCheck = { readonly url: string; readonly result: OtlpEndpointCheckResult | null };
+/**
+ * The latest check for a signal; it describes the field only while `url` is still its value.
+ * `null` is in flight. "unavailable" means the server could not run it, such as an older server.
+ */
+type EndpointCheck = {
+ readonly url: string;
+ readonly result: OtlpEndpointCheckResult | "unavailable" | null;
+};
const isHttpUrl = (url: string) => /^https?:\/\/./.test(url);
@@ -30,6 +36,7 @@ function endpointStatus(url: string, check: EndpointCheck | undefined) {
if (check?.url !== url) return { label: "Not checked", dot: "bg-muted-foreground/40" };
const { result } = check;
if (result === null) return { label: "Checking…", dot: "bg-warning" };
+ if (result === "unavailable") return { label: "Couldn't check", dot: "bg-muted-foreground/40" };
switch (result._tag) {
case "Accepted":
return { label: `Connected · ${Math.round(result.latencyMs)} ms`, dot: "bg-success" };
@@ -59,9 +66,14 @@ export function TelemetryExportSettings() {
),
);
const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: true });
- const checkOtlpEndpoint = useAtomCommand(serverEnvironment.checkOtlpEndpoint);
+ // Failures show in the status instead of a toast per field.
+ const checkOtlpEndpoint = useAtomCommand(serverEnvironment.checkOtlpEndpoint, {
+ reportFailure: false,
+ reportDefect: false,
+ });
const values = { ...saved, ...draft };
- const changed = SIGNALS.some(({ key }) => values[key].trim() !== saved[key]);
+ const changedSignals = SIGNALS.filter(({ key }) => values[key].trim() !== saved[key]);
+ const changed = changedSignals.length > 0;
const running = environment?.serverConfig?.observability;
const sendCheck = async (key: SignalKey, signal: OtlpSignal, url: string) => {
@@ -70,10 +82,8 @@ export function TelemetryExportSettings() {
// A newer check for the same field replaces this one.
setChecks((previous) => {
if (previous[key]?.url !== url || previous[key].result !== null) return previous;
- const next = { ...previous };
- if (response._tag === "Success") next[key] = { url, result: response.value };
- else delete next[key];
- return next;
+ const result = response._tag === "Success" ? response.value : "unavailable";
+ return { ...previous, [key]: { url, result } };
});
};
@@ -114,7 +124,13 @@ export function TelemetryExportSettings() {
try {
const result = await updateSettings({
environmentId: environment.environmentId,
- input: { patch: { observability: values } },
+ input: {
+ patch: {
+ observability: Object.fromEntries(
+ changedSignals.map(({ key }) => [key, values[key]]),
+ ),
+ },
+ },
});
if (result._tag === "Success") {
setDraft({});
From 0bc6a24f89804402e9459ec01fe9c0839fc4e0b1 Mon Sep 17 00:00:00 2001
From: Matthew Feroz
Date: Sun, 4 Oct 2026 07:55:39 +0000
Subject: [PATCH 5/9] chore(web): keep unrelated endpoint status edits local
---
.../settings/TelemetryExportSettings.tsx | 23 ++++++-------------
1 file changed, 7 insertions(+), 16 deletions(-)
diff --git a/apps/web/src/components/settings/TelemetryExportSettings.tsx b/apps/web/src/components/settings/TelemetryExportSettings.tsx
index ac9abbf7e81f..bfd2de6147cd 100644
--- a/apps/web/src/components/settings/TelemetryExportSettings.tsx
+++ b/apps/web/src/components/settings/TelemetryExportSettings.tsx
@@ -20,14 +20,8 @@ const SIGNALS = [
type SignalKey = (typeof SIGNALS)[number]["key"];
-/**
- * The latest check for a signal; it describes the field only while `url` is still its value.
- * `null` is in flight. "unavailable" means the server could not run it, such as an older server.
- */
-type EndpointCheck = {
- readonly url: string;
- readonly result: OtlpEndpointCheckResult | "unavailable" | null;
-};
+/** The latest check for a signal; it describes the field only while `url` is still its value. */
+type EndpointCheck = { readonly url: string; readonly result: OtlpEndpointCheckResult | null };
const isHttpUrl = (url: string) => /^https?:\/\/./.test(url);
@@ -36,7 +30,6 @@ function endpointStatus(url: string, check: EndpointCheck | undefined) {
if (check?.url !== url) return { label: "Not checked", dot: "bg-muted-foreground/40" };
const { result } = check;
if (result === null) return { label: "Checking…", dot: "bg-warning" };
- if (result === "unavailable") return { label: "Couldn't check", dot: "bg-muted-foreground/40" };
switch (result._tag) {
case "Accepted":
return { label: `Connected · ${Math.round(result.latencyMs)} ms`, dot: "bg-success" };
@@ -66,11 +59,7 @@ export function TelemetryExportSettings() {
),
);
const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: true });
- // Failures show in the status instead of a toast per field.
- const checkOtlpEndpoint = useAtomCommand(serverEnvironment.checkOtlpEndpoint, {
- reportFailure: false,
- reportDefect: false,
- });
+ const checkOtlpEndpoint = useAtomCommand(serverEnvironment.checkOtlpEndpoint);
const values = { ...saved, ...draft };
const changedSignals = SIGNALS.filter(({ key }) => values[key].trim() !== saved[key]);
const changed = changedSignals.length > 0;
@@ -82,8 +71,10 @@ export function TelemetryExportSettings() {
// A newer check for the same field replaces this one.
setChecks((previous) => {
if (previous[key]?.url !== url || previous[key].result !== null) return previous;
- const result = response._tag === "Success" ? response.value : "unavailable";
- return { ...previous, [key]: { url, result } };
+ const next = { ...previous };
+ if (response._tag === "Success") next[key] = { url, result: response.value };
+ else delete next[key];
+ return next;
});
};
From 78edd26b01276c1bd6bb212eb1f4c83e64ada10d Mon Sep 17 00:00:00 2001
From: Matthew Feroz
Date: Sun, 4 Oct 2026 08:17:14 +0000
Subject: [PATCH 6/9] fix(web): open Diagnostics from settings search
---
apps/web/src/components/settings/settingsSearch.test.ts | 8 ++++++++
apps/web/src/components/settings/settingsSearch.ts | 2 +-
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts
index 528f198de524..fc31fcdcb489 100644
--- a/apps/web/src/components/settings/settingsSearch.test.ts
+++ b/apps/web/src/components/settings/settingsSearch.test.ts
@@ -45,6 +45,14 @@ const ITEMS: ReadonlyArray = [
];
describe("searchSettings", () => {
+ it("opens the Diagnostics page from the Diagnostics search result", () => {
+ expect(searchSettings("Diagnostics")[0]).toMatchObject({
+ id: "diagnostics",
+ to: "/settings/diagnostics",
+ scope: "environment",
+ });
+ });
+
it.each(["OTel", "OTLP", "telemetry export", "Grafana"])(
"finds environment telemetry settings for %s",
(query) => {
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index c1f1a32fcaf5..7627d673c037 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -506,7 +506,7 @@ export const SETTINGS_SEARCH_ITEMS = [
{
id: "diagnostics",
title: "Diagnostics",
- to: "/settings/general",
+ to: "/settings/diagnostics",
scope: "environment",
searchTerms: ["logs traces processes resource history failures spans cpu memory"],
},
From 8c2186c6c5faba0d0f80212d4227f245a859cd9b Mon Sep 17 00:00:00 2001
From: Matthew Feroz
Date: Sat, 10 Oct 2026 09:51:24 -0700
Subject: [PATCH 7/9] fix: keep OTLP endpoint checks out of traces and gate
them by permission
Endpoint checks no longer trace their requests, since request spans record the
URL and custom credential headers. Each check releases its request once the
status is known, and the RPC accepts only HTTP and HTTPS URLs.
Checks now need settings:write, the same grant as saving endpoints, through
CLIENT_GUARDED_RPC_SCOPES. The form disables itself without that grant and
hides Test on servers that predate endpoint checks. Changing the resource
history window no longer remounts the form and drops drafts. The user guide
now says which header and protocol variables apply to OTEL endpoints.
---
apps/server/src/auth/RpcAuthorization.ts | 1 -
.../src/environment/ServerEnvironment.ts | 1 +
.../observability/OtlpEndpointCheck.test.ts | 44 +++++++++++++++-
.../src/observability/OtlpEndpointCheck.ts | 8 ++-
.../settings/DiagnosticsSettings.tsx | 17 +++++--
.../settings/TelemetryExportSettings.tsx | 51 ++++++++++++-------
docs/user/telemetry.md | 6 ++-
.../src/state/commandPermissions.test.ts | 21 ++++++++
.../contracts/src/clientRpcPermissions.ts | 2 +
packages/contracts/src/environment.ts | 2 +
packages/contracts/src/server.test.ts | 14 +++++
packages/contracts/src/server.ts | 3 +-
12 files changed, 143 insertions(+), 27 deletions(-)
diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts
index 5bb74a31febd..9c6243fde230 100644
--- a/apps/server/src/auth/RpcAuthorization.ts
+++ b/apps/server/src/auth/RpcAuthorization.ts
@@ -106,7 +106,6 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverGetProcessResourceHistory]: AuthDiagnosticsReadScope,
[WS_METHODS.serverGetResourceTelemetryHistory]: AuthDiagnosticsReadScope,
[WS_METHODS.serverRetryResourceTelemetry]: AuthDiagnosticsReadScope,
- [WS_METHODS.serverCheckOtlpEndpoint]: AuthOrchestrationOperateScope,
[WS_METHODS.serverGetUsageSummary]: AuthDiagnosticsReadScope,
[WS_METHODS.serverRefreshUsageRates]: AuthDiagnosticsReadScope,
[WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope,
diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts
index 60589ac9106f..bb5ce86e60d2 100644
--- a/apps/server/src/environment/ServerEnvironment.ts
+++ b/apps/server/src/environment/ServerEnvironment.ts
@@ -229,6 +229,7 @@ export const make = Effect.gen(function* () {
threadAutoSettlement: true,
storageCleanup: true,
storageCleanupRun: true,
+ otlpEndpointCheck: true,
projectWorktreeCleanup: true,
worktreesDirectory: true,
threadRestartContinuation: true,
diff --git a/apps/server/src/observability/OtlpEndpointCheck.test.ts b/apps/server/src/observability/OtlpEndpointCheck.test.ts
index 9459423a51d0..e7551d722ad4 100644
--- a/apps/server/src/observability/OtlpEndpointCheck.test.ts
+++ b/apps/server/src/observability/OtlpEndpointCheck.test.ts
@@ -5,6 +5,7 @@ import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
+import * as Tracer from "effect/Tracer";
import * as NetAddress from "effect/net/NetAddress";
import {
FetchHttpClient,
@@ -24,6 +25,7 @@ interface PostedRequest {
readonly url: string;
readonly headers: Readonly>;
readonly body: string;
+ readonly signal: AbortSignal;
}
/** Answers every post with `status`, or fails like a refused connection when it is null. */
@@ -33,11 +35,12 @@ const checkWith = (
httpClientLayer?: Layer.Layer,
) => {
const requests: Array = [];
- const httpClient = HttpClient.make((request) => {
+ const httpClient = HttpClient.make((request, _url, signal) => {
requests.push({
url: request.url,
headers: request.headers,
body: request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "",
+ signal,
});
return status === null
? Effect.fail(
@@ -128,6 +131,45 @@ describe("OtlpEndpointCheck", () => {
}).pipe(Effect.provide(layer));
});
+ it.effect("releases the request once the status is known", () => {
+ const { requests, layer } = checkWith(200);
+ return Effect.gen(function* () {
+ yield* check({ signal: "traces", url: "http://collector:4318/v1/traces" });
+
+ assert.isTrue(requests[0]?.signal.aborted);
+ }).pipe(Effect.provide(layer));
+ });
+
+ it.effect("keeps endpoint URLs and credentials out of traces", () => {
+ const url = "https://collector.example.com/v1/logs?api_key=query-secret";
+ const { requests, layer } = checkWith(200, {
+ otlpLogsUrl: url,
+ otlpLogsExport: {
+ ...DEFAULT_SIGNAL_EXPORT,
+ headers: { "x-honeycomb-team": "header-secret" },
+ },
+ otelEnvironment: OtelEnvironment.none,
+ });
+ const recorded: Array = [];
+ const tracer = Tracer.make({
+ span: (options) => {
+ const span = new Tracer.NativeSpan(options);
+ const end = span.end.bind(span);
+ span.end = (endTime, exit) => {
+ end(endTime, exit);
+ recorded.push({ name: span.name, attributes: Object.fromEntries(span.attributes) });
+ };
+ return span;
+ },
+ });
+ return Effect.gen(function* () {
+ yield* check({ signal: "logs", url }).pipe(Effect.withTracer(tracer));
+
+ assert.strictEqual(requests[0]?.headers["x-honeycomb-team"], "header-secret");
+ assert.notInclude(JSON.stringify(recorded), "secret");
+ }).pipe(Effect.provide(layer));
+ });
+
it.effect("does not forward credentials through a receiver redirect", () =>
Effect.scoped(
Effect.gen(function* () {
diff --git a/apps/server/src/observability/OtlpEndpointCheck.ts b/apps/server/src/observability/OtlpEndpointCheck.ts
index 1f17611bcf91..919f83ec2e56 100644
--- a/apps/server/src/observability/OtlpEndpointCheck.ts
+++ b/apps/server/src/observability/OtlpEndpointCheck.ts
@@ -65,10 +65,16 @@ const make = Effect.gen(function* () {
return emptyExport(serialization, signal);
}).pipe(Effect.provide(layerOtlpSerialization(signalExport.protocol)));
const startedAt = yield* Clock.currentTimeMillis;
- const response = yield* httpClient
+ // Only the status matters. The scope aborts the request once it is read, so a receiver
+ // that keeps its body open cannot hold the connection. Tracing stays off, as it does for
+ // real exports, because request spans record the URL and custom credential headers.
+ const response = yield* HttpClient.withScope(httpClient)
.post(url, { body, headers })
.pipe(
+ Effect.map(({ status }) => ({ status })),
+ Effect.scoped,
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),
+ Effect.withTracerEnabled(false),
Effect.timeoutOption(CHECK_TIMEOUT),
Effect.option,
);
diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx
index 31f07ea6cbb5..1ec2ed73b03b 100644
--- a/apps/web/src/components/settings/DiagnosticsSettings.tsx
+++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx
@@ -813,6 +813,18 @@ export function DiagnosticsSettingsPanel() {
const isInitialLoading = isPending && data === null;
const isProcessInitialLoading = isProcessPending && processData === null;
const isResourceInitialLoading = isResourcePending && resourceData === null;
+ // The export form waits for each environment's first tables so a search target below them
+ // stays in view. Later loads, such as a new resource window, must not remount it and drop drafts.
+ const [exportFormEnvironmentId, setExportFormEnvironmentId] =
+ useState(null);
+ if (
+ exportFormEnvironmentId !== environmentId &&
+ !isInitialLoading &&
+ !isProcessInitialLoading &&
+ !isResourceInitialLoading
+ ) {
+ setExportFormEnvironmentId(environmentId);
+ }
const signalProcess = useCallback(
async (pid: number, signal: ServerProcessSignal) => {
const targetEnvironmentId = environmentIdRef.current;
@@ -1332,10 +1344,7 @@ export function DiagnosticsSettingsPanel() {
)}
- {/* Let initial tables settle before the search target scrolls into view. */}
- {!isInitialLoading && !isProcessInitialLoading && !isResourceInitialLoading && (
-
- )}
+ {exportFormEnvironmentId === environmentId && }
);
}
diff --git a/apps/web/src/components/settings/TelemetryExportSettings.tsx b/apps/web/src/components/settings/TelemetryExportSettings.tsx
index bfd2de6147cd..32fcb6ae6e46 100644
--- a/apps/web/src/components/settings/TelemetryExportSettings.tsx
+++ b/apps/web/src/components/settings/TelemetryExportSettings.tsx
@@ -1,3 +1,4 @@
+import { useAtomValue } from "@effect/atom-react";
import type { OtlpEndpointCheckResult, OtlpSignal } from "@t3tools/contracts";
import { useEffect, useState } from "react";
@@ -47,6 +48,13 @@ export function TelemetryExportSettings() {
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState(null);
const environmentId = scope.kind === "environment" ? environment?.environmentId : undefined;
+ // Checks need the same grant as saving, so one permission covers the whole form.
+ const canEdit = useAtomValue(
+ serverEnvironment.checkOtlpEndpoint.permissionAtom(environmentId ?? null),
+ );
+ // Servers from before endpoint checks keep the editor without Test.
+ const canCheck =
+ canEdit && environment?.serverConfig?.environment.capabilities.otlpEndpointCheck === true;
// Saved endpoints open as pending; the effect below sends their checks.
const [checks, setChecks] = useState>>(() =>
environmentId === undefined
@@ -85,13 +93,14 @@ export function TelemetryExportSettings() {
};
useEffect(() => {
+ if (!canCheck) return;
for (const { key, signal } of SIGNALS) {
const check = checks[key];
// oxlint-disable-next-line react/set-state-in-effect -- State changes only after the response arrives.
if (check?.result === null) void sendCheck(key, signal, check.url);
}
- // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Runs once; the section remounts per environment.
- }, []);
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Sends the saved endpoints' checks once they are allowed; the section remounts per environment.
+ }, [canCheck]);
if (scope.kind !== "environment") {
return (
@@ -136,17 +145,23 @@ export function TelemetryExportSettings() {
Send traces, metrics, and logs to an OTLP HTTP receiver. Restart the server to apply.
Environment variables override these settings.
+ {!canEdit && (
+
+ This connection lacks permission to change settings on{" "}
+ {environment?.label ?? "the selected environment"}.
+
+ )}
{SIGNALS.map(({ key, label, signal }) => {
const url = values[key].trim();
- const status = endpointStatus(url, checks[key]);
+ const status = canCheck ? endpointStatus(url, checks[key]) : null;
return (
-
+ {status && }
-
{status.label}
+ {status &&
{status.label}}
{
setDraft((previous) => ({ ...previous, [key]: event.target.value }));
setMessage(null);
}}
/>
-
+ {canCheck && (
+
+ )}
{saved[key] !== (running?.[key] ?? "") && (
@@ -190,7 +207,7 @@ export function TelemetryExportSettings() {
type="button"
size="xs"
variant="outline"
- disabled={!changed || saving}
+ disabled={!changed || saving || !canEdit}
onClick={() => {
setDraft({});
setMessage(null);
@@ -198,7 +215,7 @@ export function TelemetryExportSettings() {
>
Discard
-
diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md
index d39282db0113..61925062d3e9 100644
--- a/docs/user/telemetry.md
+++ b/docs/user/telemetry.md
@@ -25,5 +25,7 @@ the signal's currently running endpoint and do not follow redirects. To check a
that requires authentication, save it and restart the server first.
`T3CODE_OTLP_*_URL` and standard `OTEL_EXPORTER_OTLP_*` environment variables override the saved
-settings. Set `T3CODE_OTLP_HEADERS` for receivers that need authentication, and
-`T3CODE_OTLP_PROTOCOL=http/protobuf` for receivers that do not accept JSON.
+settings. For endpoints saved here or set with `T3CODE_OTLP_*_URL`, set `T3CODE_OTLP_HEADERS` for
+receivers that need authentication and `T3CODE_OTLP_PROTOCOL=http/protobuf` for receivers that do
+not accept JSON. An endpoint set with `OTEL_EXPORTER_OTLP_*` takes its headers and protocol from
+`OTEL_EXPORTER_OTLP_HEADERS` and `OTEL_EXPORTER_OTLP_PROTOCOL` instead.
diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts
index b2c6933fabb7..58a77730a401 100644
--- a/packages/client-runtime/src/state/commandPermissions.test.ts
+++ b/packages/client-runtime/src/state/commandPermissions.test.ts
@@ -85,6 +85,27 @@ describe("command permissions", () => {
),
);
+ it.effect("requires the destination settings grant to check OTLP endpoints", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const registry = yield* setup;
+ const check = createCommandPermissions(runtime, WS_METHODS.serverCheckOtlpEndpoint);
+ registry.set(sessions(env), AsyncResult.success(grant(true)));
+ expect(registry.get(check.permissionAtom(env))).toBe(false);
+ registry.set(
+ sessions(env),
+ AsyncResult.success({
+ ...grant(false),
+ scopes: [AuthSettingsWriteScope],
+ permissions: [AuthSettingsWriteScope],
+ }),
+ );
+ expect(registry.get(check.permissionAtom(env))).toBe(true);
+ yield* check.authorize(registry, env);
+ }),
+ ),
+ );
+
it.effect("uses the target grant for both availability and execution", () =>
Effect.scoped(
Effect.gen(function* () {
diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts
index c84169e6e33f..533f7b75e888 100644
--- a/packages/contracts/src/clientRpcPermissions.ts
+++ b/packages/contracts/src/clientRpcPermissions.ts
@@ -11,6 +11,8 @@ import { WS_METHODS } from "./rpc.ts";
/** Incremental client enforcement; the server still authorizes every request. */
export const CLIENT_GUARDED_RPC_SCOPES = {
[WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope,
+ // Endpoint checks exist to configure exports, so they need the same grant as saving them.
+ [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope,
[WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope,
[WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope,
[WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope,
diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts
index 2a61480a907b..08bfb3225a68 100644
--- a/packages/contracts/src/environment.ts
+++ b/packages/contracts/src/environment.ts
@@ -126,6 +126,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
storageCleanup: Schema.optionalKey(Schema.Boolean),
storageCleanupRun: Schema.optionalKey(Schema.Boolean),
+ /** Server checks OTLP endpoints for the telemetry export settings. */
+ otlpEndpointCheck: Schema.optionalKey(Schema.Boolean),
projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean),
/** Server honors the `worktreesDirectory` setting. */
worktreesDirectory: Schema.optionalKey(Schema.Boolean),
diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts
index fdb6b82b3dbc..091c41cfc4d9 100644
--- a/packages/contracts/src/server.test.ts
+++ b/packages/contracts/src/server.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import { ExecutionEnvironmentDescriptor } from "./environment.ts";
import {
+ OtlpEndpointCheckInput,
resolveEnvironmentMachineKind,
ServerConfig,
ServerObservability,
@@ -266,3 +267,16 @@ describe("resolveEnvironmentMachineKind", () => {
).toBe("server");
});
});
+
+describe("OtlpEndpointCheckInput", () => {
+ const decode = Schema.decodeUnknownSync(OtlpEndpointCheckInput);
+
+ it("accepts HTTP receivers only", () => {
+ expect(decode({ signal: "traces", url: "https://collector.example.com/v1/traces" }).url).toBe(
+ "https://collector.example.com/v1/traces",
+ );
+ for (const url of ["data:text/plain,ok", "file:///etc/hosts", "collector:4318/v1/traces"]) {
+ expect(() => decode({ signal: "traces", url })).toThrow();
+ }
+ });
+});
diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts
index ece299283f8b..985e6bbc126e 100644
--- a/packages/contracts/src/server.ts
+++ b/packages/contracts/src/server.ts
@@ -362,7 +362,8 @@ export type OtlpSignal = typeof OtlpSignal.Type;
export const OtlpEndpointCheckInput = Schema.Struct({
signal: OtlpSignal,
- url: TrimmedNonEmptyString,
+ // Receivers are HTTP only; other schemes, such as data:, answer without contacting one.
+ url: TrimmedNonEmptyString.check(Schema.isPattern(/^https?:\/\/./i)),
});
export type OtlpEndpointCheckInput = typeof OtlpEndpointCheckInput.Type;
From b4984f95d8e17bef74219d230d60b94efcfd9ca2 Mon Sep 17 00:00:00 2001
From: Matthew Feroz
Date: Sat, 10 Oct 2026 09:57:59 -0700
Subject: [PATCH 8/9] fix(web): mount the telemetry export form once
diagnostics are readable
The form waited for the diagnostics tables, but those queries do not start
until diagnostics access resolves, so it could mount above still-loading
tables. It now waits for access too.
The form also mounts per environment, so switching from all environments to
one checks that environment's saved endpoints.
---
.../settings/DiagnosticsSettings.tsx | 3 +-
.../settings/TelemetryExportSettings.tsx | 43 ++++++++++---------
2 files changed, 25 insertions(+), 21 deletions(-)
diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx
index 1ec2ed73b03b..645c043dee7a 100644
--- a/apps/web/src/components/settings/DiagnosticsSettings.tsx
+++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx
@@ -819,6 +819,7 @@ export function DiagnosticsSettingsPanel() {
useState(null);
if (
exportFormEnvironmentId !== environmentId &&
+ canReadDiagnostics &&
!isInitialLoading &&
!isProcessInitialLoading &&
!isResourceInitialLoading
@@ -1344,7 +1345,7 @@ export function DiagnosticsSettingsPanel() {
)}
- {exportFormEnvironmentId === environmentId && }
+ {exportFormEnvironmentId === environmentId && }
);
}
diff --git a/apps/web/src/components/settings/TelemetryExportSettings.tsx b/apps/web/src/components/settings/TelemetryExportSettings.tsx
index 32fcb6ae6e46..076205891ea1 100644
--- a/apps/web/src/components/settings/TelemetryExportSettings.tsx
+++ b/apps/web/src/components/settings/TelemetryExportSettings.tsx
@@ -43,11 +43,26 @@ function endpointStatus(url: string, check: EndpointCheck | undefined) {
export function TelemetryExportSettings() {
const { environment, scope } = useSettingsScope();
+ if (scope.kind !== "environment" || !environment) {
+ return (
+
+
+ Choose one environment to configure its telemetry exports.
+
+
+ );
+ }
+ // Mounts per environment so its saved endpoints are the ones checked on open.
+ return ;
+}
+
+function TelemetryExportForm() {
+ const { environment } = useSettingsScope();
const saved = useScopedSettings((settings) => settings.observability);
const [draft, setDraft] = useState>({});
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState(null);
- const environmentId = scope.kind === "environment" ? environment?.environmentId : undefined;
+ const environmentId = environment?.environmentId;
// Checks need the same grant as saving, so one permission covers the whole form.
const canEdit = useAtomValue(
serverEnvironment.checkOtlpEndpoint.permissionAtom(environmentId ?? null),
@@ -57,14 +72,12 @@ export function TelemetryExportSettings() {
canEdit && environment?.serverConfig?.environment.capabilities.otlpEndpointCheck === true;
// Saved endpoints open as pending; the effect below sends their checks.
const [checks, setChecks] = useState>>(() =>
- environmentId === undefined
- ? {}
- : Object.fromEntries(
- SIGNALS.filter(({ key }) => isHttpUrl(saved[key])).map(({ key }) => [
- key,
- { url: saved[key], result: null },
- ]),
- ),
+ Object.fromEntries(
+ SIGNALS.filter(({ key }) => isHttpUrl(saved[key])).map(({ key }) => [
+ key,
+ { url: saved[key], result: null },
+ ]),
+ ),
);
const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: true });
const checkOtlpEndpoint = useAtomCommand(serverEnvironment.checkOtlpEndpoint);
@@ -99,19 +112,9 @@ export function TelemetryExportSettings() {
// oxlint-disable-next-line react/set-state-in-effect -- State changes only after the response arrives.
if (check?.result === null) void sendCheck(key, signal, check.url);
}
- // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Sends the saved endpoints' checks once they are allowed; the section remounts per environment.
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Sends the saved endpoints' checks once they are allowed; the form remounts per environment.
}, [canCheck]);
- if (scope.kind !== "environment") {
- return (
-
-
- Choose one environment to configure its telemetry exports.
-
-
- );
- }
-
return (