authorgravatar for 136640686+MatthewFeroz@users.noreply.github.comMatthew Feroz <136640686+MatthewFeroz@users.noreply.github.com> 2026-10-10 19:52:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 21:52:02-05:00
loge7db61f6799faf9d72a898567c52b1c954f2826e
tree5df89031465368bf7d33961a6614b0626a9eab1a
parentc448e63f7095ed155e93de1bca4c79cea6917c31
signature Signed by PGP key B5690EEEBB952194

feat(web): configure OpenTelemetry exports in diagnostics (#12538)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

20 files changed, 822 insertions(+), 6 deletions(-)

apps/server/src/environment/ServerEnvironment.ts+1
...@@ -229,6 +229,7 @@ export const make = Effect.gen(function* () {...@@ -229,6 +229,7 @@ export const make = Effect.gen(function* () {
229 threadAutoSettlement: true,229 threadAutoSettlement: true,
230 storageCleanup: true,230 storageCleanup: true,
231 storageCleanupRun: true,231 storageCleanupRun: true,
232 otlpEndpointCheck: true,
232 projectWorktreeCleanup: true,233 projectWorktreeCleanup: true,
233 worktreesDirectory: true,234 worktreesDirectory: true,
234 threadRestartContinuation: true,235 threadRestartContinuation: true,
apps/server/src/observability/OtlpEndpointCheck.test.ts created+249
...@@ -0,0 +1,249 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
5import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Tracer from "effect/Tracer";
9import * as NetAddress from "effect/net/NetAddress";
10import {
11 FetchHttpClient,
12 HttpClient,
13 HttpClientError,
14 HttpClientResponse,
15 HttpServer,
16 HttpServerRequest,
17 HttpServerResponse,
18} from "effect/http";
19import { describe } from "vite-plus/test";
20
21import * as ServerConfig from "../config.ts";
22import * as OtlpEndpointCheck from "./OtlpEndpointCheck.ts";
23
24interface PostedRequest {
25 readonly url: string;
26 readonly headers: Readonly<Record<string, string>>;
27 readonly body: string;
28 readonly signal: AbortSignal;
29}
30
31/** Answers every post with `status`, or fails like a refused connection when it is null. */
32const checkWith = (
33 status: number | null,
34 overrides: Partial<ServerConfig.ServerConfig["Service"]> = {},
35 httpClientLayer?: Layer.Layer<HttpClient.HttpClient>,
36) => {
37 const requests: Array<PostedRequest> = [];
38 const httpClient = HttpClient.make((request, _url, signal) => {
39 requests.push({
40 url: request.url,
41 headers: request.headers,
42 body: request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "",
43 signal,
44 });
45 return status === null
46 ? Effect.fail(
47 new HttpClientError.HttpClientError({
48 reason: new HttpClientError.TransportError({ request }),
49 }),
50 )
51 : Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status })));
52 });
53 const configLayer = Layer.effect(
54 ServerConfig.ServerConfig,
55 Effect.gen(function* () {
56 const config = yield* ServerConfig.ServerConfig;
57 return ServerConfig.make({ ...config, ...overrides });
58 }),
59 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-otlp-check-" })));
60 const layer = OtlpEndpointCheck.layer.pipe(
61 Layer.provide(httpClientLayer ?? Layer.succeed(HttpClient.HttpClient, httpClient)),
62 Layer.provide(configLayer),
63 Layer.provide(NodeServices.layer),
64 );
65 return { requests, layer };
66};
67
68const check = (input: Parameters<OtlpEndpointCheck.OtlpEndpointCheck["Service"]["check"]>[0]) =>
69 Effect.gen(function* () {
70 const service = yield* OtlpEndpointCheck.OtlpEndpointCheck;
71 return yield* service.check(input);
72 });
73
74describe("OtlpEndpointCheck", () => {
75 it.effect("posts an empty export for the signal with the T3 headers", () => {
76 const { requests, layer } = checkWith(200, {
77 otlpMetricsUrl: "http://collector:4318/v1/metrics",
78 otlpMetricsExport: { ...DEFAULT_SIGNAL_EXPORT, headers: { authorization: "Bearer t3" } },
79 });
80 return Effect.gen(function* () {
81 const result = yield* check({ signal: "metrics", url: "http://collector:4318/v1/metrics" });
82
83 assert.strictEqual(result._tag, "Accepted");
84 assert.lengthOf(requests, 1);
85 assert.strictEqual(requests[0]?.url, "http://collector:4318/v1/metrics");
86 assert.strictEqual(requests[0]?.headers.authorization, "Bearer t3");
87 assert.strictEqual(requests[0]?.body, `{"resourceMetrics":[]}`);
88 }).pipe(Effect.provide(layer));
89 });
90
91 it.effect("keeps T3 credentials paired with each running signal endpoint", () => {
92 const signalExport = { ...DEFAULT_SIGNAL_EXPORT, headers: { "x-api-key": "secret" } };
93 const { requests, layer } = checkWith(200, {
94 otlpTracesUrl: "https://collector.example.com/v1/traces",
95 otlpMetricsUrl: "https://collector.example.com/v1/metrics",
96 otlpLogsUrl: "https://collector.example.com/v1/logs",
97 otlpTracesExport: signalExport,
98 otlpMetricsExport: signalExport,
99 otlpLogsExport: signalExport,
100 otelEnvironment: OtelEnvironment.none,
101 });
102 return Effect.gen(function* () {
103 for (const signal of ["traces", "metrics", "logs"] as const) {
104 const runningUrl = `https://collector.example.com/v1/${signal}`;
105 yield* check({ signal, url: runningUrl });
106 yield* check({ signal, url: "https://elsewhere.example.com/collect" });
107 yield* check({ signal, url: `${runningUrl}/other` });
108 yield* check({ signal, url: `${runningUrl}?tenant=other` });
109 }
110
111 assert.lengthOf(requests, 12);
112 requests.forEach((request, index) => {
113 if (index % 4 === 0) assert.strictEqual(request.headers["x-api-key"], "secret");
114 else assert.isUndefined(request.headers["x-api-key"]);
115 });
116 }).pipe(Effect.provide(layer));
117 });
118
119 it.effect("does not send export credentials when the signal has no running endpoint", () => {
120 const { requests, layer } = checkWith(200, {
121 otlpLogsUrl: undefined,
122 otlpLogsExport: { ...DEFAULT_SIGNAL_EXPORT, headers: { authorization: "Bearer t3" } },
123 otelEnvironment: OtelEnvironment.none,
124 });
125 return Effect.gen(function* () {
126 const result = yield* check({ signal: "logs", url: "https://new.example.com/v1/logs" });
127
128 assert.strictEqual(result._tag, "Accepted");
129 assert.lengthOf(requests, 1);
130 assert.isUndefined(requests[0]?.headers.authorization);
131 }).pipe(Effect.provide(layer));
132 });
133
134 it.effect("releases the request once the status is known", () => {
135 const { requests, layer } = checkWith(200);
136 return Effect.gen(function* () {
137 yield* check({ signal: "traces", url: "http://collector:4318/v1/traces" });
138
139 assert.isTrue(requests[0]?.signal.aborted);
140 }).pipe(Effect.provide(layer));
141 });
142
143 it.effect("keeps endpoint URLs and credentials out of traces", () => {
144 const url = "https://collector.example.com/v1/logs?api_key=query-secret";
145 const { requests, layer } = checkWith(200, {
146 otlpLogsUrl: url,
147 otlpLogsExport: {
148 ...DEFAULT_SIGNAL_EXPORT,
149 headers: { "x-honeycomb-team": "header-secret" },
150 },
151 otelEnvironment: OtelEnvironment.none,
152 });
153 const recorded: Array<unknown> = [];
154 const tracer = Tracer.make({
155 span: (options) => {
156 const span = new Tracer.NativeSpan(options);
157 const end = span.end.bind(span);
158 span.end = (endTime, exit) => {
159 end(endTime, exit);
160 recorded.push({ name: span.name, attributes: Object.fromEntries(span.attributes) });
161 };
162 return span;
163 },
164 });
165 return Effect.gen(function* () {
166 yield* check({ signal: "logs", url }).pipe(Effect.withTracer(tracer));
167
168 assert.strictEqual(requests[0]?.headers["x-honeycomb-team"], "header-secret");
169 assert.notInclude(JSON.stringify(recorded), "secret");
170 }).pipe(Effect.provide(layer));
171 });
172
173 it.effect("does not forward credentials through a receiver redirect", () =>
174 Effect.scoped(
175 Effect.gen(function* () {
176 const requests: Array<{ url: string; apiKey: string | undefined }> = [];
177 const server = yield* HttpServer.HttpServer;
178 yield* server.serve(
179 Effect.gen(function* () {
180 const request = yield* HttpServerRequest.HttpServerRequest;
181 yield* request.text;
182 requests.push({ url: request.url, apiKey: request.headers["x-api-key"] });
183 return request.url === "/v1/traces"
184 ? HttpServerResponse.redirect("/other", { status: 307 })
185 : HttpServerResponse.empty({ status: 200 });
186 }),
187 );
188 if (!NetAddress.isInetAddress(server.address)) {
189 return yield* Effect.die(new Error("Expected a TCP address"));
190 }
191 const url = `http://127.0.0.1:${server.address.port}/v1/traces`;
192 const { layer } = checkWith(
193 200,
194 {
195 otlpTracesUrl: url,
196 otlpTracesExport: { ...DEFAULT_SIGNAL_EXPORT, headers: { "x-api-key": "secret" } },
197 },
198 FetchHttpClient.layer,
199 );
200 const result = yield* check({ signal: "traces", url }).pipe(Effect.provide(layer));
201
202 assert.deepStrictEqual(result, { _tag: "Rejected", status: 307 });
203 assert.deepStrictEqual(requests, [{ url: "/v1/traces", apiKey: "secret" }]);
204 }),
205 ).pipe(Effect.provide(NodeHttpServer.layerTest)),
206 );
207
208 it.effect("reports the status a receiver rejects the export with", () => {
209 const { layer } = checkWith(401);
210 return Effect.gen(function* () {
211 const result = yield* check({ signal: "traces", url: "http://collector:4318/v1/traces" });
212
213 assert.deepStrictEqual(result, { _tag: "Rejected", status: 401 });
214 }).pipe(Effect.provide(layer));
215 });
216
217 it.effect("reports an endpoint that cannot be reached", () => {
218 const { layer } = checkWith(null);
219 return Effect.gen(function* () {
220 const result = yield* check({ signal: "logs", url: "http://localhost:1/v1/logs" });
221
222 assert.deepStrictEqual(result, { _tag: "Unreachable", timedOut: false });
223 }).pipe(Effect.provide(layer));
224 });
225
226 it.effect("keeps OTEL endpoint credentials off other endpoints", () => {
227 const otelUrl = "https://otel.example.com/v1/traces";
228 const otelExport = { ...DEFAULT_SIGNAL_EXPORT, headers: { "x-api-key": "secret" } };
229 const { requests, layer } = checkWith(200, {
230 otlpTracesUrl: otelUrl,
231 otlpTracesExport: otelExport,
232 otelEnvironment: {
233 ...OtelEnvironment.none,
234 traces: OtelEnvironment.OtelSignal.Export({
235 url: otelUrl,
236 protocol: otelExport.protocol,
237 headers: otelExport.headers,
238 }),
239 },
240 });
241 return Effect.gen(function* () {
242 yield* check({ signal: "traces", url: otelUrl });
243 yield* check({ signal: "traces", url: "https://elsewhere.example.com/v1/traces" });
244
245 assert.strictEqual(requests[0]?.headers["x-api-key"], "secret");
246 assert.isUndefined(requests[1]?.headers["x-api-key"]);
247 }).pipe(Effect.provide(layer));
248 });
249});
apps/server/src/observability/OtlpEndpointCheck.ts created+98
...@@ -0,0 +1,98 @@
1import type {
2 OtlpEndpointCheckInput,
3 OtlpEndpointCheckResult,
4 OtlpSignal,
5} from "@t3tools/contracts";
6import { layerOtlpSerialization } from "@t3tools/shared/observability";
7import * as Clock from "effect/Clock";
8import * as Context from "effect/Context";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import { FetchHttpClient, HttpClient } from "effect/http";
13import { OtlpSerialization } from "effect/observability";
14
15import * as ServerConfig from "../config.ts";
16
17const CHECK_TIMEOUT = "5 seconds";
18
19export class OtlpEndpointCheck extends Context.Service<
20 OtlpEndpointCheck,
21 {
22 /**
23 * Posts an empty export for the signal to `url` from this server, with the
24 * running signal's protocol. Credentials are used only for its exact
25 * running endpoint, and redirects are not followed. Receivers accept an
26 * empty export without storing anything.
27 */
28 readonly check: (input: OtlpEndpointCheckInput) => Effect.Effect<OtlpEndpointCheckResult>;
29 }
30>()("t3/observability/OtlpEndpointCheck") {}
31
32const emptyExport = (
33 serialization: OtlpSerialization.OtlpSerialization["Service"],
34 signal: OtlpSignal,
35) => {
36 switch (signal) {
37 case "traces":
38 return serialization.traces({ resourceSpans: [] });
39 case "metrics":
40 return serialization.metrics({ resourceMetrics: [] });
41 case "logs":
42 return serialization.logs({ resourceLogs: [] });
43 }
44};
45
46const make = Effect.gen(function* () {
47 const config = yield* ServerConfig.ServerConfig;
48 const httpClient = yield* HttpClient.HttpClient;
49
50 const running = {
51 traces: { url: config.otlpTracesUrl, export: config.otlpTracesExport },
52 metrics: { url: config.otlpMetricsUrl, export: config.otlpMetricsExport },
53 logs: { url: config.otlpLogsUrl, export: config.otlpLogsExport },
54 } satisfies Record<OtlpSignal, unknown>;
55
56 const check = Effect.fn("OtlpEndpointCheck.check")(function* ({
57 signal,
58 url,
59 }: OtlpEndpointCheckInput) {
60 const { url: runningUrl, export: signalExport } = running[signal];
61 // Keep credentials paired with the running endpoint, regardless of source.
62 const headers = url === runningUrl ? signalExport.headers : undefined;
63 const body = yield* Effect.gen(function* () {
64 const serialization = yield* OtlpSerialization.OtlpSerialization;
65 return emptyExport(serialization, signal);
66 }).pipe(Effect.provide(layerOtlpSerialization(signalExport.protocol)));
67 const startedAt = yield* Clock.currentTimeMillis;
68 // Only the status matters. The scope aborts the request once it is read, so a receiver
69 // that keeps its body open cannot hold the connection. Tracing stays off, as it does for
70 // real exports, because request spans record the URL and custom credential headers.
71 const response = yield* HttpClient.withScope(httpClient)
72 .post(url, { body, headers })
73 .pipe(
74 Effect.map(({ status }) => ({ status })),
75 Effect.scoped,
76 Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),
77 Effect.withTracerEnabled(false),
78 Effect.timeoutOption(CHECK_TIMEOUT),
79 Effect.option,
80 );
81 if (Option.isNone(response)) {
82 return { _tag: "Unreachable", timedOut: false } as const;
83 }
84 if (Option.isNone(response.value)) {
85 return { _tag: "Unreachable", timedOut: true } as const;
86 }
87 const { status } = response.value.value;
88 if (status < 200 || status >= 300) {
89 return { _tag: "Rejected", status } as const;
90 }
91 const latencyMs = (yield* Clock.currentTimeMillis) - startedAt;
92 return { _tag: "Accepted", latencyMs } as const;
93 });
94
95 return OtlpEndpointCheck.of({ check });
96});
97
98export const layer = Layer.effect(OtlpEndpointCheck, make);
apps/server/src/observability/RpcInstrumentation.ts+1
...@@ -76,6 +76,7 @@ const RPC_AGGREGATES = {...@@ -76,6 +76,7 @@ const RPC_AGGREGATES = {
76 [WS_METHODS.serverGetProcessResourceHistory]: "server",76 [WS_METHODS.serverGetProcessResourceHistory]: "server",
77 [WS_METHODS.serverGetResourceTelemetryHistory]: "server",77 [WS_METHODS.serverGetResourceTelemetryHistory]: "server",
78 [WS_METHODS.serverRetryResourceTelemetry]: "server",78 [WS_METHODS.serverRetryResourceTelemetry]: "server",
79 [WS_METHODS.serverCheckOtlpEndpoint]: "server",
79 [WS_METHODS.serverGetUsageSummary]: "server",80 [WS_METHODS.serverGetUsageSummary]: "server",
80 [WS_METHODS.serverRefreshUsageRates]: "server",81 [WS_METHODS.serverRefreshUsageRates]: "server",
81 [WS_METHODS.serverSignalProcess]: "server",82 [WS_METHODS.serverSignalProcess]: "server",
apps/server/src/ws.ts+4
...@@ -229,6 +229,7 @@ import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts";...@@ -229,6 +229,7 @@ import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts";
229import * as SqlClient from "effect/sql/SqlClient";229import * as SqlClient from "effect/sql/SqlClient";
230import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts";230import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts";
231import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";231import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts";
232import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts";
232import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";233import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
233import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";234import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
234import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";235import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
...@@ -1303,6 +1304,7 @@ const layerWsRpc = (...@@ -1303,6 +1304,7 @@ const layerWsRpc = (
1303 );1304 );
1304 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;1305 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
1305 const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery;1306 const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery;
1307 const otlpEndpointCheck = yield* OtlpEndpointCheck.OtlpEndpointCheck;
1306 const automaticGitFetchInterval = serverSettings.getSettings.pipe(1308 const automaticGitFetchInterval = serverSettings.getSettings.pipe(
1307 Effect.map(1309 Effect.map(
1308 (settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval,1310 (settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval,
...@@ -2416,6 +2418,7 @@ const layerWsRpc = (...@@ -2416,6 +2418,7 @@ const layerWsRpc = (
2416 [WS_METHODS.serverGetUsageSummary]: (input) => usage.readSummary(input),2418 [WS_METHODS.serverGetUsageSummary]: (input) => usage.readSummary(input),
2417 [WS_METHODS.serverRefreshUsageRates]: (_input) => usage.refreshRates,2419 [WS_METHODS.serverRefreshUsageRates]: (_input) => usage.refreshRates,
2418 [WS_METHODS.serverRetryResourceTelemetry]: (_input) => resourceTelemetry.retry,2420 [WS_METHODS.serverRetryResourceTelemetry]: (_input) => resourceTelemetry.retry,
2421 [WS_METHODS.serverCheckOtlpEndpoint]: (input) => otlpEndpointCheck.check(input),
2419 [WS_METHODS.serverSignalProcess]: (input) => processDiagnostics.signal(input),2422 [WS_METHODS.serverSignalProcess]: (input) => processDiagnostics.signal(input),
2420 [WS_METHODS.serverReportClientActivity]: (input, metadata) =>2423 [WS_METHODS.serverReportClientActivity]: (input, metadata) =>
2421 Ref.update(rpcClientIds, (clientIds) => {2424 Ref.update(rpcClientIds, (clientIds) => {
...@@ -3200,6 +3203,7 @@ export const layer = Layer.unwrap(...@@ -3200,6 +3203,7 @@ export const layer = Layer.unwrap(
3200 // One server-lifetime service means clients share the same PR caches, and a WS3203 // One server-lifetime service means clients share the same PR caches, and a WS
3201 // mutation invalidates the HTTP diff cache that every client reads from.3204 // mutation invalidates the HTTP diff cache that every client reads from.
3202 Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)),3205 Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)),
3206 Layer.provide(OtlpEndpointCheck.layer),
3203 Layer.provide(3207 Layer.provide(
3204 SourceControlDiscovery.layer.pipe(3208 SourceControlDiscovery.layer.pipe(
3205 Layer.provide(3209 Layer.provide(
apps/web/src/components/settings/DiagnosticsSettings.tsx+17-1
...@@ -38,6 +38,7 @@ import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";...@@ -38,6 +38,7 @@ import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics";
38import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";38import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout";
39import { useAtomCommand } from "../../state/use-atom-command";39import { useAtomCommand } from "../../state/use-atom-command";
40import { useSettingsScope } from "./SettingsScopeContext";40import { useSettingsScope } from "./SettingsScopeContext";
41import { TelemetryExportSettings } from "./TelemetryExportSettings";
4142
42const NUMBER_FORMAT = new Intl.NumberFormat();43const NUMBER_FORMAT = new Intl.NumberFormat();
4344
...@@ -811,6 +812,20 @@ export function DiagnosticsSettingsPanel() {...@@ -811,6 +812,20 @@ export function DiagnosticsSettingsPanel() {
811812
812 const isInitialLoading = isPending && data === null;813 const isInitialLoading = isPending && data === null;
813 const isProcessInitialLoading = isProcessPending && processData === null;814 const isProcessInitialLoading = isProcessPending && processData === null;
815 const isResourceInitialLoading = isResourcePending && resourceData === null;
816 // The export form waits for each environment's first tables so a search target below them
817 // stays in view. Later loads, such as a new resource window, must not remount it and drop drafts.
818 const [exportFormEnvironmentId, setExportFormEnvironmentId] =
819 useState<typeof environmentId>(null);
820 if (
821 exportFormEnvironmentId !== environmentId &&
822 canReadDiagnostics &&
823 !isInitialLoading &&
824 !isProcessInitialLoading &&
825 !isResourceInitialLoading
826 ) {
827 setExportFormEnvironmentId(environmentId);
828 }
814 const signalProcess = useCallback(829 const signalProcess = useCallback(
815 async (pid: number, signal: ServerProcessSignal) => {830 async (pid: number, signal: ServerProcessSignal) => {
816 const targetEnvironmentId = environmentIdRef.current;831 const targetEnvironmentId = environmentIdRef.current;
...@@ -1054,7 +1069,7 @@ export function DiagnosticsSettingsPanel() {...@@ -1054,7 +1069,7 @@ export function DiagnosticsSettingsPanel() {
1054 <ProcessResourceHistoryTable1069 <ProcessResourceHistoryTable
1055 processes={resourceData?.topProcesses ?? []}1070 processes={resourceData?.topProcesses ?? []}
1056 emptyLabel={1071 emptyLabel={
1057 isResourcePending && resourceData === null1072 isResourceInitialLoading
1058 ? "Collecting process resource samples..."1073 ? "Collecting process resource samples..."
1059 : "No process resource samples found for this window."1074 : "No process resource samples found for this window."
1060 }1075 }
...@@ -1330,6 +1345,7 @@ export function DiagnosticsSettingsPanel() {...@@ -1330,6 +1345,7 @@ export function DiagnosticsSettingsPanel() {
1330 <EmptyRows label={isInitialLoading ? "Loading span names..." : "No spans found."} />1345 <EmptyRows label={isInitialLoading ? "Loading span names..." : "No spans found."} />
1331 )}1346 )}
1332 </SettingsSection>1347 </SettingsSection>
1348 {exportFormEnvironmentId === environmentId && <TelemetryExportSettings />}
1333 </SettingsPageContainer>1349 </SettingsPageContainer>
1334 );1350 );
1335}1351}
apps/web/src/components/settings/SettingsSidebarNav.tsx+8-5
...@@ -78,6 +78,7 @@ const SETTINGS_SECTION_ICONS: Readonly<...@@ -78,6 +78,7 @@ const SETTINGS_SECTION_ICONS: Readonly<
78 Record<SettingsPath, ComponentType<{ className?: string }>>78 Record<SettingsPath, ComponentType<{ className?: string }>>
79> = {79> = {
80 "/settings/general": Settings2Icon,80 "/settings/general": Settings2Icon,
81 "/settings/diagnostics": Settings2Icon,
81 "/settings/appearance": PaletteIcon,82 "/settings/appearance": PaletteIcon,
82 "/settings/projects": PanelsTopLeftIcon,83 "/settings/projects": PanelsTopLeftIcon,
83 "/settings/keybindings": KeyboardIcon,84 "/settings/keybindings": KeyboardIcon,
...@@ -95,11 +96,13 @@ const SETTINGS_NAV_ITEMS: ReadonlyArray<{...@@ -95,11 +96,13 @@ const SETTINGS_NAV_ITEMS: ReadonlyArray<{
95 label: string;96 label: string;
96 to: SettingsPath;97 to: SettingsPath;
97 icon: ComponentType<{ className?: string }>;98 icon: ComponentType<{ className?: string }>;
98}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[]).map((to) => ({99}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[])
99 to,100 .filter((to) => to !== "/settings/diagnostics")
100 label: SETTINGS_SECTION_LABELS[to],101 .map((to) => ({
101 icon: SETTINGS_SECTION_ICONS[to],102 to,
102}));103 label: SETTINGS_SECTION_LABELS[to],
104 icon: SETTINGS_SECTION_ICONS[to],
105 }));
103106
104function SettingsSectionIcon({ to }: { to: SettingsPath }) {107function SettingsSectionIcon({ to }: { to: SettingsPath }) {
105 const Icon = SETTINGS_SECTION_ICONS[to];108 const Icon = SETTINGS_SECTION_ICONS[to];
apps/web/src/components/settings/TelemetryExportSettings.test.tsx created+73
...@@ -0,0 +1,73 @@
1import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
2import { act, type ReactNode } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, describe, expect, it, vi } from "vite-plus/test";
5
6const { state, updateSettings } = vi.hoisted(() => ({
7 state: { observability: {} as typeof DEFAULT_SERVER_SETTINGS.observability },
8 updateSettings: vi.fn(async () => ({ _tag: "Success" as const, value: undefined })),
9}));
10
11vi.mock("@effect/atom-react", () => ({ useAtomValue: () => true }));
12vi.mock("../../state/server", () => ({
13 serverEnvironment: {
14 updateSettings: "updateSettings",
15 checkOtlpEndpoint: { permissionAtom: () => null },
16 },
17}));
18vi.mock("../../state/use-atom-command", () => ({
19 useAtomCommand: (command: unknown) => (command === "updateSettings" ? updateSettings : vi.fn()),
20}));
21vi.mock("./SettingsScopeContext", () => ({
22 useSettingsScope: () => ({
23 scope: { kind: "environment" },
24 environment: {
25 environmentId: "env",
26 label: "MAC1",
27 serverConfig: { environment: { capabilities: {} } },
28 },
29 }),
30}));
31vi.mock("./useScopedSettings", () => ({
32 useScopedSettings: (select: (settings: typeof state) => unknown) => select(state),
33}));
34vi.mock("./settingsLayout", () => ({
35 SettingsSection: ({ children }: { children: ReactNode }) => <section>{children}</section>,
36}));
37
38import { TelemetryExportSettings } from "./TelemetryExportSettings";
39
40let renderer: ReactTestRenderer | null = null;
41afterEach(async () => {
42 await act(() => renderer?.unmount());
43 renderer = null;
44 updateSettings.mockClear();
45});
46
47const type = (id: string, value: string) =>
48 act(() => renderer!.root.findByProps({ id }).props.onChange({ target: { value } }));
49
50describe("TelemetryExportSettings", () => {
51 it("does not resend a field the user restored after another client changed it", async () => {
52 state.observability = {
53 ...DEFAULT_SERVER_SETTINGS.observability,
54 otlpTracesUrl: "http://old/v1/traces",
55 };
56 await act(() => {
57 renderer = create(<TelemetryExportSettings />);
58 });
59
60 await type("otlpTracesUrl", "http://draft/v1/traces");
61 await type("otlpTracesUrl", "http://old/v1/traces");
62 // Another client saves a new traces endpoint.
63 state.observability = { ...state.observability, otlpTracesUrl: "http://other/v1/traces" };
64 await act(() => renderer!.update(<TelemetryExportSettings />));
65 await type("otlpMetricsUrl", "http://new/v1/metrics");
66 await act(() => renderer!.root.findByType("form").props.onSubmit({ preventDefault() {} }));
67
68 expect(updateSettings).toHaveBeenCalledWith({
69 environmentId: "env",
70 input: { patch: { observability: { otlpMetricsUrl: "http://new/v1/metrics" } } },
71 });
72 });
73});
apps/web/src/components/settings/TelemetryExportSettings.tsx created+243
...@@ -0,0 +1,243 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { OtlpEndpointCheckResult, OtlpSignal } from "@t3tools/contracts";
3import { useEffect, useEffectEvent, useState } from "react";
4
5import { serverEnvironment } from "../../state/server";
6import { useAtomCommand } from "../../state/use-atom-command";
7import { Button } from "../ui/button";
8import { Input } from "../ui/input";
9import { Label } from "../ui/label";
10import { ConnectionStatusDot } from "../ConnectionStatusDot";
11import { useSettingsScope } from "./SettingsScopeContext";
12import { useScopedSettings } from "./useScopedSettings";
13import { SettingsSection } from "./settingsLayout";
14import { SettingsScopeNotice } from "./SettingsScopeNotice";
15
16const SIGNALS = [
17 { key: "otlpTracesUrl", label: "Traces", signal: "traces" },
18 { key: "otlpMetricsUrl", label: "Metrics", signal: "metrics" },
19 { key: "otlpLogsUrl", label: "Logs", signal: "logs" },
20] as const;
21
22type SignalKey = (typeof SIGNALS)[number]["key"];
23
24/** The latest check for a signal; it describes the field only while `url` is still its value. */
25type EndpointCheck = { readonly url: string; readonly result: OtlpEndpointCheckResult | null };
26
27const isHttpUrl = (url: string) => /^https?:\/\/./i.test(url);
28
29function endpointStatus(url: string, check: EndpointCheck | undefined) {
30 if (url === "") return { label: "Off", dot: "bg-muted-foreground/40" };
31 if (check?.url !== url) return { label: "Not checked", dot: "bg-muted-foreground/40" };
32 const { result } = check;
33 if (result === null) return { label: "Checking…", dot: "bg-warning" };
34 switch (result._tag) {
35 case "Accepted":
36 return { label: `Connected · ${Math.round(result.latencyMs)} ms`, dot: "bg-success" };
37 case "Rejected":
38 return { label: `Rejected · HTTP ${result.status}`, dot: "bg-destructive" };
39 case "Unreachable":
40 return { label: result.timedOut ? "Timed out" : "Unreachable", dot: "bg-destructive" };
41 }
42}
43
44export function TelemetryExportSettings() {
45 const { environment, scope } = useSettingsScope();
46 if (scope.kind !== "environment" || !environment) {
47 return (
48 <SettingsSection title="OpenTelemetry export" id="telemetry-export">
49 <SettingsScopeNotice target="environment">
50 Choose one environment to configure its telemetry exports.
51 </SettingsScopeNotice>
52 </SettingsSection>
53 );
54 }
55 // Mounts per environment so its saved endpoints are the ones checked on open.
56 return <TelemetryExportForm key={environment.environmentId} />;
57}
58
59function TelemetryExportForm() {
60 const { environment } = useSettingsScope();
61 const saved = useScopedSettings((settings) => settings.observability);
62 const [draft, setDraft] = useState<Partial<typeof saved>>({});
63 const [saving, setSaving] = useState(false);
64 const [message, setMessage] = useState<string | null>(null);
65 const environmentId = environment?.environmentId;
66 // Checks need the same grant as saving, so one permission covers the whole form.
67 const canEdit = useAtomValue(
68 serverEnvironment.checkOtlpEndpoint.permissionAtom(environmentId ?? null),
69 );
70 // Servers from before endpoint checks keep the editor without Test.
71 const canCheck =
72 canEdit && environment?.serverConfig?.environment.capabilities.otlpEndpointCheck === true;
73 // Saved endpoints open as pending; the effect below sends their checks.
74 const [checks, setChecks] = useState<Partial<Record<SignalKey, EndpointCheck>>>(() =>
75 Object.fromEntries(
76 SIGNALS.filter(({ key }) => isHttpUrl(saved[key])).map(({ key }) => [
77 key,
78 { url: saved[key], result: null },
79 ]),
80 ),
81 );
82 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: true });
83 const checkOtlpEndpoint = useAtomCommand(serverEnvironment.checkOtlpEndpoint);
84 const values = { ...saved, ...draft };
85 const changedSignals = SIGNALS.filter(({ key }) => values[key].trim() !== saved[key]);
86 const changed = changedSignals.length > 0;
87 const running = environment?.serverConfig?.observability;
88
89 const sendCheck = async (key: SignalKey, signal: OtlpSignal, url: string) => {
90 if (!environmentId) return;
91 const response = await checkOtlpEndpoint({ environmentId, input: { signal, url } });
92 // A newer check for the same field replaces this one.
93 setChecks((previous) => {
94 if (previous[key]?.url !== url || previous[key].result !== null) return previous;
95 const next = { ...previous };
96 if (response._tag === "Success") next[key] = { url, result: response.value };
97 else delete next[key];
98 return next;
99 });
100 };
101
102 const checkEndpoint = (key: SignalKey, signal: OtlpSignal, url: string) => {
103 if (!isHttpUrl(url)) return;
104 setChecks((previous) => ({ ...previous, [key]: { url, result: null } }));
105 void sendCheck(key, signal, url);
106 };
107
108 // Sends checks still pending, such as the saved endpoints' when the form opens.
109 const sendPendingChecks = useEffectEvent(() => {
110 for (const { key, signal } of SIGNALS) {
111 const check = checks[key];
112 if (check?.result === null) void sendCheck(key, signal, check.url);
113 }
114 });
115 useEffect(() => {
116 // oxlint-disable-next-line react/set-state-in-effect -- State changes only after the response arrives.
117 if (canCheck) sendPendingChecks();
118 }, [canCheck]);
119
120 return (
121 <SettingsSection title="OpenTelemetry export" id="telemetry-export">
122 <form
123 className="grid gap-4 px-4 py-4 sm:px-5"
124 onSubmit={async (event) => {
125 event.preventDefault();
126 if (!environment || saving) return;
127 setSaving(true);
128 setMessage(null);
129 try {
130 const result = await updateSettings({
131 environmentId: environment.environmentId,
132 input: {
133 patch: {
134 observability: Object.fromEntries(
135 changedSignals.map(({ key }) => [key, values[key]]),
136 ),
137 },
138 },
139 });
140 if (result._tag === "Success") {
141 setDraft({});
142 setMessage("Saved. Restart the server to apply.");
143 }
144 } finally {
145 setSaving(false);
146 }
147 }}
148 >
149 <p className="text-xs text-muted-foreground">
150 Send traces, metrics, and logs to an OTLP HTTP receiver. Restart the server to apply.
151 Environment variables override these settings.
152 </p>
153 {!canEdit && (
154 <p className="text-xs text-muted-foreground">
155 This connection lacks permission to change settings on{" "}
156 {environment?.label ?? "the selected environment"}.
157 </p>
158 )}
159 {SIGNALS.map(({ key, label, signal }) => {
160 const url = values[key].trim();
161 const status = canCheck ? endpointStatus(url, checks[key]) : null;
162 return (
163 <div key={key} className="grid gap-1.5">
164 <div className="flex items-center justify-between gap-3">
165 <div className="flex items-center gap-1.5">
166 {status && <ConnectionStatusDot dotClassName={status.dot} />}
167 <Label htmlFor={key}>{label} endpoint</Label>
168 </div>
169 {status && <span className="text-xs text-muted-foreground">{status.label}</span>}
170 </div>
171 <div className="flex gap-2">
172 <Input
173 id={key}
174 size="sm"
175 type="url"
176 pattern="[Hh][Tt][Tt][Pp][Ss]?://.*"
177 title="Enter an HTTP or HTTPS endpoint, or leave empty to disable export."
178 placeholder={`http://localhost:4318/v1/${signal}`}
179 value={values[key]}
180 disabled={saving || !canEdit}
181 onChange={(event) => {
182 const value = event.target.value;
183 // Restoring the saved value drops the draft, so a later save can't resend it
184 // over another client's change.
185 setDraft((previous) => {
186 const next = { ...previous, [key]: value };
187 if (value.trim() === saved[key]) delete next[key];
188 return next;
189 });
190 setMessage(null);
191 }}
192 />
193 {canCheck && (
194 <Button
195 type="button"
196 size="sm"
197 variant="outline"
198 disabled={!isHttpUrl(url) || checks[key]?.result === null}
199 onClick={() => void checkEndpoint(key, signal, url)}
200 >
201 Test
202 </Button>
203 )}
204 </div>
205 {saved[key] !== (running?.[key] ?? "") && (
206 <p className="break-all text-xs text-muted-foreground">
207 Running: {running?.[key] || "Disabled"}
208 </p>
209 )}
210 </div>
211 );
212 })}
213 <div className="flex items-center justify-between gap-3">
214 <p role="status" className="text-xs text-muted-foreground">
215 {message}
216 </p>
217 <div className="flex shrink-0 gap-2">
218 <Button
219 type="button"
220 size="xs"
221 variant="outline"
222 disabled={!changed || saving || !canEdit}
223 onClick={() => {
224 setDraft({});
225 setMessage(null);
226 // A tested draft replaced the saved endpoint's status; check the saved one again.
227 if (!canCheck) return;
228 for (const { key, signal } of SIGNALS) {
229 if (checks[key]?.url !== saved[key]) checkEndpoint(key, signal, saved[key]);
230 }
231 }}
232 >
233 Discard
234 </Button>
235 <Button type="submit" size="xs" disabled={!changed || saving || !canEdit}>
236 {saving ? "Saving…" : "Save"}
237 </Button>
238 </div>
239 </div>
240 </form>
241 </SettingsSection>
242 );
243}
apps/web/src/components/settings/settingsSearch.test.ts+22
...@@ -45,6 +45,19 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [...@@ -45,6 +45,19 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [
45];45];
4646
47describe("searchSettings", () => {47describe("searchSettings", () => {
48 it.each(["OTel", "OTLP", "telemetry export", "Grafana"])(
49 "finds environment telemetry settings for %s",
50 (query) => {
51 expect(searchSettings(query)).toContainEqual(
52 expect.objectContaining({
53 id: "telemetry-export",
54 to: "/settings/diagnostics",
55 targetId: "telemetry-export",
56 scope: "environment",
57 }),
58 );
59 },
60 );
48 it.each(["send shortcut", "multiline", "new line"])("finds Send shortcut for %s", (query) => {61 it.each(["send shortcut", "multiline", "new line"])("finds Send shortcut for %s", (query) => {
49 expect(searchSettings(query).map((item) => item.id)).toContain("send-shortcut");62 expect(searchSettings(query).map((item) => item.id)).toContain("send-shortcut");
50 });63 });
...@@ -397,6 +410,15 @@ describe("searchSettings", () => {...@@ -397,6 +410,15 @@ describe("searchSettings", () => {
397});410});
398411
399describe("settings search targets", () => {412describe("settings search targets", () => {
413 it("requires an environment for the resolved telemetry export destination", () => {
414 const result = searchSettings("OpenTelemetry export").find(
415 (item) => item.id === "telemetry-export",
416 )!;
417 const target = getSettingsSearchTargetScope(result.targetId ?? result.id)!;
418 expect(isSettingsSearchScopeAvailable(target.scope, "all")).toBe(false);
419 expect(isSettingsSearchScopeAvailable(target.scope, "project")).toBe(false);
420 expect(isSettingsSearchScopeAvailable(target.scope, "environment")).toBe(true);
421 });
400 it.each([422 it.each([
401 "auto-settle-inactive-threads",423 "auto-settle-inactive-threads",
402 "auto-settle-merged-threads",424 "auto-settle-merged-threads",
apps/web/src/components/settings/settingsSearch.ts+11
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16export type SettingsPath =16export type SettingsPath =
17 | "/settings/projects"17 | "/settings/projects"
18 | "/settings/general"18 | "/settings/general"
19 | "/settings/diagnostics"
19 | "/settings/appearance"20 | "/settings/appearance"
20 | "/settings/keybindings"21 | "/settings/keybindings"
21 | "/settings/snap-shot"22 | "/settings/snap-shot"
...@@ -92,6 +93,7 @@ export interface SettingsSearchAvailability {...@@ -92,6 +93,7 @@ export interface SettingsSearchAvailability {
92 * subtitles both render from this record, so each label exists once.93 * subtitles both render from this record, so each label exists once.
93 */94 */
94export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {95export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
96 "/settings/diagnostics": "Diagnostics",
95 "/settings/projects": "Project",97 "/settings/projects": "Project",
96 "/settings/general": "General",98 "/settings/general": "General",
97 "/settings/appearance": "Appearance",99 "/settings/appearance": "Appearance",
...@@ -574,6 +576,14 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -574,6 +576,14 @@ export const SETTINGS_SEARCH_ITEMS = [
574 to: "/settings/general",576 to: "/settings/general",
575 searchTerms: ["telemetry analytics usage data tracking legal opt out"],577 searchTerms: ["telemetry analytics usage data tracking legal opt out"],
576 },578 },
579 {
580 id: "telemetry-export",
581 title: "OpenTelemetry export",
582 to: "/settings/diagnostics",
583 targetId: "telemetry-export",
584 scope: "environment",
585 searchTerms: ["OTel OTLP telemetry export endpoints logs traces metrics Grafana"],
586 },
577 {587 {
578 id: "diagnostics",588 id: "diagnostics",
579 title: "Diagnostics",589 title: "Diagnostics",
...@@ -979,6 +989,7 @@ export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"];...@@ -979,6 +989,7 @@ export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"];
979const SEARCH_ITEMS_BY_ID = new Map(SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item] as const));989const SEARCH_ITEMS_BY_ID = new Map(SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item] as const));
980990
981const SETTINGS_CATEGORY_SCOPES: Readonly<Record<SettingsPath, SettingsSearchScope | null>> = {991const SETTINGS_CATEGORY_SCOPES: Readonly<Record<SettingsPath, SettingsSearchScope | null>> = {
992 "/settings/diagnostics": "environment",
982 "/settings/projects": "project",993 "/settings/projects": "project",
983 "/settings/general": null,994 "/settings/general": null,
984 "/settings/appearance": null,995 "/settings/appearance": null,
docs/operations/observability.md+4
...@@ -172,6 +172,10 @@ Default Grafana login:...@@ -172,6 +172,10 @@ Default Grafana login:
172172
173#### 2. Export OTLP env vars173#### 2. Export OTLP env vars
174174
175For endpoint settings and startup precedence, see the
176[user telemetry guide](../user/telemetry.md#export-diagnostics-to-your-own-receiver).
177In a worktree, use `vp run dev` instead of `npx t3` to keep state isolated.
178
175```bash179```bash
176export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces180export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces
177export T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics181export T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics
docs/user/telemetry.md+17
...@@ -12,3 +12,20 @@ starting it. This stops product events from being recorded or sent....@@ -12,3 +12,20 @@ starting it. This stops product events from being recorded or sent.
1212
13The desktop app reads the variable from your shell profile (for example `~/.zshrc`) on macOS and13The desktop app reads the variable from your shell profile (for example `~/.zshrc`) on macOS and
14Linux, so export it there and restart the app. On Windows, set it as a user environment variable.14Linux, so export it there and restart the app. On Windows, set it as a user environment variable.
15
16## Export diagnostics to your own receiver
17
18To send traces, metrics, or logs to an OpenTelemetry receiver, open
19**Settings > General > Diagnostics**, select an environment, and enter each signal's OTLP HTTP
20endpoint under **OpenTelemetry export** (for example `http://localhost:4318/v1/traces`). Save, then
21restart that environment's server. The receiver must be reachable from the server's machine.
22**Test** sends an empty export from the server to check an endpoint before or after you save it;
23saved endpoints are checked when the section opens. Checks send authentication headers only to
24the signal's currently running endpoint and do not follow redirects. To check a new endpoint
25that requires authentication, save it and restart the server first.
26
27`T3CODE_OTLP_*_URL` and standard `OTEL_EXPORTER_OTLP_*` environment variables override the saved
28settings. For endpoints saved here or set with `T3CODE_OTLP_*_URL`, set `T3CODE_OTLP_HEADERS` for
29receivers that need authentication and `T3CODE_OTLP_PROTOCOL=http/protobuf` for receivers that do
30not accept JSON. An endpoint set with `OTEL_EXPORTER_OTLP_*` takes its headers and protocol from
31`OTEL_EXPORTER_OTLP_HEADERS` and `OTEL_EXPORTER_OTLP_PROTOCOL` instead.
packages/client-runtime/src/state/commandPermissions.test.ts+21
...@@ -85,6 +85,27 @@ describe("command permissions", () => {...@@ -85,6 +85,27 @@ describe("command permissions", () => {
85 ),85 ),
86 );86 );
8787
88 it.effect("requires the destination settings grant to check OTLP endpoints", () =>
89 Effect.scoped(
90 Effect.gen(function* () {
91 const registry = yield* setup;
92 const check = createCommandPermissions(runtime, WS_METHODS.serverCheckOtlpEndpoint);
93 registry.set(sessions(env), AsyncResult.success(grant(true)));
94 expect(registry.get(check.permissionAtom(env))).toBe(false);
95 registry.set(
96 sessions(env),
97 AsyncResult.success({
98 ...grant(false),
99 scopes: [AuthSettingsWriteScope],
100 permissions: [AuthSettingsWriteScope],
101 }),
102 );
103 expect(registry.get(check.permissionAtom(env))).toBe(true);
104 yield* check.authorize(registry, env);
105 }),
106 ),
107 );
108
88 it.effect("uses the target grant for both availability and execution", () =>109 it.effect("uses the target grant for both availability and execution", () =>
89 Effect.scoped(110 Effect.scoped(
90 Effect.gen(function* () {111 Effect.gen(function* () {
packages/client-runtime/src/state/server.ts+4
...@@ -1350,6 +1350,10 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -1350,6 +1350,10 @@ export function createServerEnvironmentAtoms<R, E>(
1350 key: ({ environmentId }) => environmentId,1350 key: ({ environmentId }) => environmentId,
1351 },1351 },
1352 }),1352 }),
1353 checkOtlpEndpoint: createEnvironmentRpcCommand(runtime, {
1354 label: "environment-data:server:check-otlp-endpoint",
1355 tag: WS_METHODS.serverCheckOtlpEndpoint,
1356 }),
1353 retryResourceTelemetry: createEnvironmentRpcCommand(runtime, {1357 retryResourceTelemetry: createEnvironmentRpcCommand(runtime, {
1354 label: "environment-data:server:retry-resource-telemetry",1358 label: "environment-data:server:retry-resource-telemetry",
1355 tag: WS_METHODS.serverRetryResourceTelemetry,1359 tag: WS_METHODS.serverRetryResourceTelemetry,
packages/contracts/src/clientRpcPermissions.ts+2
...@@ -11,6 +11,8 @@ import { WS_METHODS } from "./rpc.ts";...@@ -11,6 +11,8 @@ import { WS_METHODS } from "./rpc.ts";
11/** Incremental client enforcement; the server still authorizes every request. */11/** Incremental client enforcement; the server still authorizes every request. */
12export const CLIENT_GUARDED_RPC_SCOPES = {12export const CLIENT_GUARDED_RPC_SCOPES = {
13 [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope,13 [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope,
14 // Endpoint checks exist to configure exports, so they need the same grant as saving them.
15 [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope,
14 [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope,16 [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope,
15 [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope,17 [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope,
16 [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope,18 [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope,
packages/contracts/src/environment.ts+2
...@@ -126,6 +126,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -126,6 +126,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
126 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),126 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
127 storageCleanup: Schema.optionalKey(Schema.Boolean),127 storageCleanup: Schema.optionalKey(Schema.Boolean),
128 storageCleanupRun: Schema.optionalKey(Schema.Boolean),128 storageCleanupRun: Schema.optionalKey(Schema.Boolean),
129 /** Server checks OTLP endpoints for the telemetry export settings. */
130 otlpEndpointCheck: Schema.optionalKey(Schema.Boolean),
129 projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean),131 projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean),
130 /** Server honors the `worktreesDirectory` setting. */132 /** Server honors the `worktreesDirectory` setting. */
131 worktreesDirectory: Schema.optionalKey(Schema.Boolean),133 worktreesDirectory: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/rpc.ts+10
...@@ -287,6 +287,8 @@ import {} from "./previewAutomation.ts";...@@ -287,6 +287,8 @@ import {} from "./previewAutomation.ts";
287import {287import {
288 ServerConfigStreamEvent,288 ServerConfigStreamEvent,
289 DesktopUpdateCommitInput,289 DesktopUpdateCommitInput,
290 OtlpEndpointCheckInput,
291 OtlpEndpointCheckResult,
290 ServerConfig,292 ServerConfig,
291 ServerProviderUpdateError,293 ServerProviderUpdateError,
292 ServerProviderUpdateInput,294 ServerProviderUpdateInput,
...@@ -499,6 +501,7 @@ export const WS_METHODS = {...@@ -499,6 +501,7 @@ export const WS_METHODS = {
499 serverGetProcessResourceHistory: "server.getProcessResourceHistory",501 serverGetProcessResourceHistory: "server.getProcessResourceHistory",
500 serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory",502 serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory",
501 serverRetryResourceTelemetry: "server.retryResourceTelemetry",503 serverRetryResourceTelemetry: "server.retryResourceTelemetry",
504 serverCheckOtlpEndpoint: "server.checkOtlpEndpoint",
502 serverSignalProcess: "server.signalProcess",505 serverSignalProcess: "server.signalProcess",
503 serverReportClientActivity: "server.reportClientActivity",506 serverReportClientActivity: "server.reportClientActivity",
504 serverReportHostPowerState: "server.reportHostPowerState",507 serverReportHostPowerState: "server.reportHostPowerState",
...@@ -886,6 +889,12 @@ const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourc...@@ -886,6 +889,12 @@ const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourc
886 error: EnvironmentAuthorizationError,889 error: EnvironmentAuthorizationError,
887});890});
888891
892const WsServerCheckOtlpEndpointRpc = Rpc.make(WS_METHODS.serverCheckOtlpEndpoint, {
893 payload: OtlpEndpointCheckInput,
894 success: OtlpEndpointCheckResult,
895 error: EnvironmentAuthorizationError,
896});
897
889const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, {898const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, {
890 payload: UsageSummaryInput,899 payload: UsageSummaryInput,
891 success: UsageSummary,900 success: UsageSummary,
...@@ -1891,6 +1900,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1891,6 +1900,7 @@ export const WsRpcGroup = RpcGroup.make(
1891 WsServerGetProcessResourceHistoryRpc,1900 WsServerGetProcessResourceHistoryRpc,
1892 WsServerGetResourceTelemetryHistoryRpc,1901 WsServerGetResourceTelemetryHistoryRpc,
1893 WsServerRetryResourceTelemetryRpc,1902 WsServerRetryResourceTelemetryRpc,
1903 WsServerCheckOtlpEndpointRpc,
1894 WsServerGetUsageSummaryRpc,1904 WsServerGetUsageSummaryRpc,
1895 WsServerRefreshUsageRatesRpc,1905 WsServerRefreshUsageRatesRpc,
1896 WsServerSignalProcessRpc,1906 WsServerSignalProcessRpc,
packages/contracts/src/server.test.ts+14
...@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
33
4import { ExecutionEnvironmentDescriptor } from "./environment.ts";4import { ExecutionEnvironmentDescriptor } from "./environment.ts";
5import {5import {
6 OtlpEndpointCheckInput,
6 resolveEnvironmentMachineKind,7 resolveEnvironmentMachineKind,
7 ServerConfig,8 ServerConfig,
8 ServerObservability,9 ServerObservability,
...@@ -266,3 +267,16 @@ describe("resolveEnvironmentMachineKind", () => {...@@ -266,3 +267,16 @@ describe("resolveEnvironmentMachineKind", () => {
266 ).toBe("server");267 ).toBe("server");
267 });268 });
268});269});
270
271describe("OtlpEndpointCheckInput", () => {
272 const decode = Schema.decodeUnknownSync(OtlpEndpointCheckInput);
273
274 it("accepts HTTP receivers only", () => {
275 expect(decode({ signal: "traces", url: "https://collector.example.com/v1/traces" }).url).toBe(
276 "https://collector.example.com/v1/traces",
277 );
278 for (const url of ["data:text/plain,ok", "file:///etc/hosts", "collector:4318/v1/traces"]) {
279 expect(() => decode({ signal: "traces", url })).toThrow();
280 }
281 });
282});
packages/contracts/src/server.ts+21
...@@ -357,6 +357,27 @@ export const ServerObservability = Schema.Struct({...@@ -357,6 +357,27 @@ export const ServerObservability = Schema.Struct({
357});357});
358export type ServerObservability = typeof ServerObservability.Type;358export type ServerObservability = typeof ServerObservability.Type;
359359
360export const OtlpSignal = Schema.Literals(["traces", "metrics", "logs"]);
361export type OtlpSignal = typeof OtlpSignal.Type;
362
363export const OtlpEndpointCheckInput = Schema.Struct({
364 signal: OtlpSignal,
365 // Receivers are HTTP only; other schemes, such as data:, answer without contacting one.
366 url: TrimmedNonEmptyString.check(Schema.isPattern(/^https?:\/\/./i)),
367});
368export type OtlpEndpointCheckInput = typeof OtlpEndpointCheckInput.Type;
369
370/**
371 * What the server saw when it sent an empty export to an endpoint: accepted,
372 * answered with an error status, or never answered.
373 */
374export const OtlpEndpointCheckResult = Schema.Union([
375 Schema.TaggedStruct("Accepted", { latencyMs: Schema.Number }),
376 Schema.TaggedStruct("Rejected", { status: Schema.Number }),
377 Schema.TaggedStruct("Unreachable", { timedOut: Schema.Boolean }),
378]);
379export type OtlpEndpointCheckResult = typeof OtlpEndpointCheckResult.Type;
380
360export const ServerTraceDiagnosticsErrorKind = Schema.Literals([381export const ServerTraceDiagnosticsErrorKind = Schema.Literals([
361 "trace-file-not-found",382 "trace-file-not-found",
362 "trace-file-read-failed",383 "trace-file-read-failed",