| author | |
| committer | |
| log | e7db61f6799faf9d72a898567c52b1c954f2826e |
| tree | 5df89031465368bf7d33961a6614b0626a9eab1a |
| parent | c448e63f7095ed155e93de1bca4c79cea6917c31 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 229 | threadAutoSettlement: true, |
| 230 | 230 | storageCleanup: true, |
| 231 | 231 | storageCleanupRun: true, |
| 232 | otlpEndpointCheck: true, | |
| 232 | 233 | projectWorktreeCleanup: true, |
| 233 | 234 | worktreesDirectory: true, |
| 234 | 235 | threadRestartContinuation: true, |
apps/server/src/observability/OtlpEndpointCheck.test.ts created+249| ... | ... | @@ -0,0 +1,249 @@ |
| 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { assert, it } from "@effect/vitest"; | |
| 4 | import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; | |
| 5 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Tracer from "effect/Tracer"; | |
| 9 | import * as NetAddress from "effect/net/NetAddress"; | |
| 10 | import { | |
| 11 | FetchHttpClient, | |
| 12 | HttpClient, | |
| 13 | HttpClientError, | |
| 14 | HttpClientResponse, | |
| 15 | HttpServer, | |
| 16 | HttpServerRequest, | |
| 17 | HttpServerResponse, | |
| 18 | } from "effect/http"; | |
| 19 | import { describe } from "vite-plus/test"; | |
| 20 | ||
| 21 | import * as ServerConfig from "../config.ts"; | |
| 22 | import * as OtlpEndpointCheck from "./OtlpEndpointCheck.ts"; | |
| 23 | ||
| 24 | interface 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. */ | |
| 32 | const 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 | ||
| 68 | const 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 | ||
| 74 | describe("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 @@ |
| 1 | import type { | |
| 2 | OtlpEndpointCheckInput, | |
| 3 | OtlpEndpointCheckResult, | |
| 4 | OtlpSignal, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import { layerOtlpSerialization } from "@t3tools/shared/observability"; | |
| 7 | import * as Clock from "effect/Clock"; | |
| 8 | import * as Context from "effect/Context"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Layer from "effect/Layer"; | |
| 11 | import * as Option from "effect/Option"; | |
| 12 | import { FetchHttpClient, HttpClient } from "effect/http"; | |
| 13 | import { OtlpSerialization } from "effect/observability"; | |
| 14 | ||
| 15 | import * as ServerConfig from "../config.ts"; | |
| 16 | ||
| 17 | const CHECK_TIMEOUT = "5 seconds"; | |
| 18 | ||
| 19 | export 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 | ||
| 32 | const 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 | ||
| 46 | const 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 | ||
| 98 | export const layer = Layer.effect(OtlpEndpointCheck, make); |
apps/server/src/observability/RpcInstrumentation.ts+1| ... | ... | @@ -76,6 +76,7 @@ const RPC_AGGREGATES = { |
| 76 | 76 | [WS_METHODS.serverGetProcessResourceHistory]: "server", |
| 77 | 77 | [WS_METHODS.serverGetResourceTelemetryHistory]: "server", |
| 78 | 78 | [WS_METHODS.serverRetryResourceTelemetry]: "server", |
| 79 | [WS_METHODS.serverCheckOtlpEndpoint]: "server", | |
| 79 | 80 | [WS_METHODS.serverGetUsageSummary]: "server", |
| 80 | 81 | [WS_METHODS.serverRefreshUsageRates]: "server", |
| 81 | 82 | [WS_METHODS.serverSignalProcess]: "server", |
apps/server/src/ws.ts+4| ... | ... | @@ -229,6 +229,7 @@ import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts"; |
| 229 | 229 | import * as SqlClient from "effect/sql/SqlClient"; |
| 230 | 230 | import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; |
| 231 | 231 | import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; |
| 232 | import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts"; | |
| 232 | 233 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 233 | 234 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; |
| 234 | 235 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| ... | ... | @@ -1303,6 +1304,7 @@ const layerWsRpc = ( |
| 1303 | 1304 | ); |
| 1304 | 1305 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 1305 | 1306 | const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; |
| 1307 | const otlpEndpointCheck = yield* OtlpEndpointCheck.OtlpEndpointCheck; | |
| 1306 | 1308 | const automaticGitFetchInterval = serverSettings.getSettings.pipe( |
| 1307 | 1309 | Effect.map( |
| 1308 | 1310 | (settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval, |
| ... | ... | @@ -2416,6 +2418,7 @@ const layerWsRpc = ( |
| 2416 | 2418 | [WS_METHODS.serverGetUsageSummary]: (input) => usage.readSummary(input), |
| 2417 | 2419 | [WS_METHODS.serverRefreshUsageRates]: (_input) => usage.refreshRates, |
| 2418 | 2420 | [WS_METHODS.serverRetryResourceTelemetry]: (_input) => resourceTelemetry.retry, |
| 2421 | [WS_METHODS.serverCheckOtlpEndpoint]: (input) => otlpEndpointCheck.check(input), | |
| 2419 | 2422 | [WS_METHODS.serverSignalProcess]: (input) => processDiagnostics.signal(input), |
| 2420 | 2423 | [WS_METHODS.serverReportClientActivity]: (input, metadata) => |
| 2421 | 2424 | Ref.update(rpcClientIds, (clientIds) => { |
| ... | ... | @@ -3200,6 +3203,7 @@ export const layer = Layer.unwrap( |
| 3200 | 3203 | // One server-lifetime service means clients share the same PR caches, and a WS |
| 3201 | 3204 | // mutation invalidates the HTTP diff cache that every client reads from. |
| 3202 | 3205 | Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)), |
| 3206 | Layer.provide(OtlpEndpointCheck.layer), | |
| 3203 | 3207 | Layer.provide( |
| 3204 | 3208 | SourceControlDiscovery.layer.pipe( |
| 3205 | 3209 | Layer.provide( |
apps/web/src/components/settings/DiagnosticsSettings.tsx+17-1| ... | ... | @@ -38,6 +38,7 @@ import { ResourceTelemetryDiagnostics } from "./ResourceTelemetryDiagnostics"; |
| 38 | 38 | import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; |
| 39 | 39 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 40 | 40 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 41 | import { TelemetryExportSettings } from "./TelemetryExportSettings"; | |
| 41 | 42 | |
| 42 | 43 | const NUMBER_FORMAT = new Intl.NumberFormat(); |
| 43 | 44 | |
| ... | ... | @@ -811,6 +812,20 @@ export function DiagnosticsSettingsPanel() { |
| 811 | 812 | |
| 812 | 813 | const isInitialLoading = isPending && data === null; |
| 813 | 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 | 829 | const signalProcess = useCallback( |
| 815 | 830 | async (pid: number, signal: ServerProcessSignal) => { |
| 816 | 831 | const targetEnvironmentId = environmentIdRef.current; |
| ... | ... | @@ -1054,7 +1069,7 @@ export function DiagnosticsSettingsPanel() { |
| 1054 | 1069 | <ProcessResourceHistoryTable |
| 1055 | 1070 | processes={resourceData?.topProcesses ?? []} |
| 1056 | 1071 | emptyLabel={ |
| 1057 | isResourcePending && resourceData === null | |
| 1072 | isResourceInitialLoading | |
| 1058 | 1073 | ? "Collecting process resource samples..." |
| 1059 | 1074 | : "No process resource samples found for this window." |
| 1060 | 1075 | } |
| ... | ... | @@ -1330,6 +1345,7 @@ export function DiagnosticsSettingsPanel() { |
| 1330 | 1345 | <EmptyRows label={isInitialLoading ? "Loading span names..." : "No spans found."} /> |
| 1331 | 1346 | )} |
| 1332 | 1347 | </SettingsSection> |
| 1348 | {exportFormEnvironmentId === environmentId && <TelemetryExportSettings />} | |
| 1333 | 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 | 78 | Record<SettingsPath, ComponentType<{ className?: string }>> |
| 79 | 79 | > = { |
| 80 | 80 | "/settings/general": Settings2Icon, |
| 81 | "/settings/diagnostics": Settings2Icon, | |
| 81 | 82 | "/settings/appearance": PaletteIcon, |
| 82 | 83 | "/settings/projects": PanelsTopLeftIcon, |
| 83 | 84 | "/settings/keybindings": KeyboardIcon, |
| ... | ... | @@ -95,11 +96,13 @@ const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| 95 | 96 | label: string; |
| 96 | 97 | to: SettingsPath; |
| 97 | 98 | icon: ComponentType<{ className?: string }>; |
| 98 | }> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[]).map((to) => ({ | |
| 99 | to, | |
| 100 | label: SETTINGS_SECTION_LABELS[to], | |
| 101 | icon: SETTINGS_SECTION_ICONS[to], | |
| 102 | })); | |
| 99 | }> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[]) | |
| 100 | .filter((to) => to !== "/settings/diagnostics") | |
| 101 | .map((to) => ({ | |
| 102 | to, | |
| 103 | label: SETTINGS_SECTION_LABELS[to], | |
| 104 | icon: SETTINGS_SECTION_ICONS[to], | |
| 105 | })); | |
| 103 | 106 | |
| 104 | 107 | function SettingsSectionIcon({ to }: { to: SettingsPath }) { |
| 105 | 108 | const Icon = SETTINGS_SECTION_ICONS[to]; |
apps/web/src/components/settings/TelemetryExportSettings.test.tsx created+73| ... | ... | @@ -0,0 +1,73 @@ |
| 1 | import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; | |
| 2 | import { act, type ReactNode } from "react"; | |
| 3 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 4 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 5 | ||
| 6 | const { 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 | ||
| 11 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => true })); | |
| 12 | vi.mock("../../state/server", () => ({ | |
| 13 | serverEnvironment: { | |
| 14 | updateSettings: "updateSettings", | |
| 15 | checkOtlpEndpoint: { permissionAtom: () => null }, | |
| 16 | }, | |
| 17 | })); | |
| 18 | vi.mock("../../state/use-atom-command", () => ({ | |
| 19 | useAtomCommand: (command: unknown) => (command === "updateSettings" ? updateSettings : vi.fn()), | |
| 20 | })); | |
| 21 | vi.mock("./SettingsScopeContext", () => ({ | |
| 22 | useSettingsScope: () => ({ | |
| 23 | scope: { kind: "environment" }, | |
| 24 | environment: { | |
| 25 | environmentId: "env", | |
| 26 | label: "MAC1", | |
| 27 | serverConfig: { environment: { capabilities: {} } }, | |
| 28 | }, | |
| 29 | }), | |
| 30 | })); | |
| 31 | vi.mock("./useScopedSettings", () => ({ | |
| 32 | useScopedSettings: (select: (settings: typeof state) => unknown) => select(state), | |
| 33 | })); | |
| 34 | vi.mock("./settingsLayout", () => ({ | |
| 35 | SettingsSection: ({ children }: { children: ReactNode }) => <section>{children}</section>, | |
| 36 | })); | |
| 37 | ||
| 38 | import { TelemetryExportSettings } from "./TelemetryExportSettings"; | |
| 39 | ||
| 40 | let renderer: ReactTestRenderer | null = null; | |
| 41 | afterEach(async () => { | |
| 42 | await act(() => renderer?.unmount()); | |
| 43 | renderer = null; | |
| 44 | updateSettings.mockClear(); | |
| 45 | }); | |
| 46 | ||
| 47 | const type = (id: string, value: string) => | |
| 48 | act(() => renderer!.root.findByProps({ id }).props.onChange({ target: { value } })); | |
| 49 | ||
| 50 | describe("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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import type { OtlpEndpointCheckResult, OtlpSignal } from "@t3tools/contracts"; | |
| 3 | import { useEffect, useEffectEvent, useState } from "react"; | |
| 4 | ||
| 5 | import { serverEnvironment } from "../../state/server"; | |
| 6 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 7 | import { Button } from "../ui/button"; | |
| 8 | import { Input } from "../ui/input"; | |
| 9 | import { Label } from "../ui/label"; | |
| 10 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; | |
| 11 | import { useSettingsScope } from "./SettingsScopeContext"; | |
| 12 | import { useScopedSettings } from "./useScopedSettings"; | |
| 13 | import { SettingsSection } from "./settingsLayout"; | |
| 14 | import { SettingsScopeNotice } from "./SettingsScopeNotice"; | |
| 15 | ||
| 16 | const 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 | ||
| 22 | type SignalKey = (typeof SIGNALS)[number]["key"]; | |
| 23 | ||
| 24 | /** The latest check for a signal; it describes the field only while `url` is still its value. */ | |
| 25 | type EndpointCheck = { readonly url: string; readonly result: OtlpEndpointCheckResult | null }; | |
| 26 | ||
| 27 | const isHttpUrl = (url: string) => /^https?:\/\/./i.test(url); | |
| 28 | ||
| 29 | function 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 | ||
| 44 | export 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 | ||
| 59 | function 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 | 45 | ]; |
| 46 | 46 | |
| 47 | 47 | describe("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 | 61 | it.each(["send shortcut", "multiline", "new line"])("finds Send shortcut for %s", (query) => { |
| 49 | 62 | expect(searchSettings(query).map((item) => item.id)).toContain("send-shortcut"); |
| 50 | 63 | }); |
| ... | ... | @@ -397,6 +410,15 @@ describe("searchSettings", () => { |
| 397 | 410 | }); |
| 398 | 411 | |
| 399 | 412 | describe("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 | 422 | it.each([ |
| 401 | 423 | "auto-settle-inactive-threads", |
| 402 | 424 | "auto-settle-merged-threads", |
apps/web/src/components/settings/settingsSearch.ts+11| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | export type SettingsPath = |
| 17 | 17 | | "/settings/projects" |
| 18 | 18 | | "/settings/general" |
| 19 | | "/settings/diagnostics" | |
| 19 | 20 | | "/settings/appearance" |
| 20 | 21 | | "/settings/keybindings" |
| 21 | 22 | | "/settings/snap-shot" |
| ... | ... | @@ -92,6 +93,7 @@ export interface SettingsSearchAvailability { |
| 92 | 93 | * subtitles both render from this record, so each label exists once. |
| 93 | 94 | */ |
| 94 | 95 | export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { |
| 96 | "/settings/diagnostics": "Diagnostics", | |
| 95 | 97 | "/settings/projects": "Project", |
| 96 | 98 | "/settings/general": "General", |
| 97 | 99 | "/settings/appearance": "Appearance", |
| ... | ... | @@ -574,6 +576,14 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 574 | 576 | to: "/settings/general", |
| 575 | 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 | 588 | id: "diagnostics", |
| 579 | 589 | title: "Diagnostics", |
| ... | ... | @@ -979,6 +989,7 @@ export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"]; |
| 979 | 989 | const SEARCH_ITEMS_BY_ID = new Map(SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item] as const)); |
| 980 | 990 | |
| 981 | 991 | const SETTINGS_CATEGORY_SCOPES: Readonly<Record<SettingsPath, SettingsSearchScope | null>> = { |
| 992 | "/settings/diagnostics": "environment", | |
| 982 | 993 | "/settings/projects": "project", |
| 983 | 994 | "/settings/general": null, |
| 984 | 995 | "/settings/appearance": null, |
docs/operations/observability.md+4| ... | ... | @@ -172,6 +172,10 @@ Default Grafana login: |
| 172 | 172 | |
| 173 | 173 | #### 2. Export OTLP env vars |
| 174 | 174 | |
| 175 | For endpoint settings and startup precedence, see the | |
| 176 | [user telemetry guide](../user/telemetry.md#export-diagnostics-to-your-own-receiver). | |
| 177 | In a worktree, use `vp run dev` instead of `npx t3` to keep state isolated. | |
| 178 | ||
| 175 | 179 | ```bash |
| 176 | 180 | export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces |
| 177 | 181 | export 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 | 12 | |
| 13 | 13 | The desktop app reads the variable from your shell profile (for example `~/.zshrc`) on macOS and |
| 14 | 14 | Linux, 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 | ||
| 18 | To send traces, metrics, or logs to an OpenTelemetry receiver, open | |
| 19 | **Settings > General > Diagnostics**, select an environment, and enter each signal's OTLP HTTP | |
| 20 | endpoint under **OpenTelemetry export** (for example `http://localhost:4318/v1/traces`). Save, then | |
| 21 | restart 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; | |
| 23 | saved endpoints are checked when the section opens. Checks send authentication headers only to | |
| 24 | the signal's currently running endpoint and do not follow redirects. To check a new endpoint | |
| 25 | that requires authentication, save it and restart the server first. | |
| 26 | ||
| 27 | `T3CODE_OTLP_*_URL` and standard `OTEL_EXPORTER_OTLP_*` environment variables override the saved | |
| 28 | settings. For endpoints saved here or set with `T3CODE_OTLP_*_URL`, set `T3CODE_OTLP_HEADERS` for | |
| 29 | receivers that need authentication and `T3CODE_OTLP_PROTOCOL=http/protobuf` for receivers that do | |
| 30 | not 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 | 85 | ), |
| 86 | 86 | ); |
| 87 | 87 | |
| 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 | 109 | it.effect("uses the target grant for both availability and execution", () => |
| 89 | 110 | Effect.scoped( |
| 90 | 111 | Effect.gen(function* () { |
packages/client-runtime/src/state/server.ts+4| ... | ... | @@ -1350,6 +1350,10 @@ export function createServerEnvironmentAtoms<R, E>( |
| 1350 | 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 | 1357 | retryResourceTelemetry: createEnvironmentRpcCommand(runtime, { |
| 1354 | 1358 | label: "environment-data:server:retry-resource-telemetry", |
| 1355 | 1359 | tag: WS_METHODS.serverRetryResourceTelemetry, |
packages/contracts/src/clientRpcPermissions.ts+2| ... | ... | @@ -11,6 +11,8 @@ import { WS_METHODS } from "./rpc.ts"; |
| 11 | 11 | /** Incremental client enforcement; the server still authorizes every request. */ |
| 12 | 12 | export const CLIENT_GUARDED_RPC_SCOPES = { |
| 13 | 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 | 16 | [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, |
| 15 | 17 | [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, |
| 16 | 18 | [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, |
packages/contracts/src/environment.ts+2| ... | ... | @@ -126,6 +126,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 126 | 126 | threadAutoSettlement: Schema.optionalKey(Schema.Boolean), |
| 127 | 127 | storageCleanup: Schema.optionalKey(Schema.Boolean), |
| 128 | 128 | storageCleanupRun: Schema.optionalKey(Schema.Boolean), |
| 129 | /** Server checks OTLP endpoints for the telemetry export settings. */ | |
| 130 | otlpEndpointCheck: Schema.optionalKey(Schema.Boolean), | |
| 129 | 131 | projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), |
| 130 | 132 | /** Server honors the `worktreesDirectory` setting. */ |
| 131 | 133 | worktreesDirectory: Schema.optionalKey(Schema.Boolean), |
packages/contracts/src/rpc.ts+10| ... | ... | @@ -287,6 +287,8 @@ import {} from "./previewAutomation.ts"; |
| 287 | 287 | import { |
| 288 | 288 | ServerConfigStreamEvent, |
| 289 | 289 | DesktopUpdateCommitInput, |
| 290 | OtlpEndpointCheckInput, | |
| 291 | OtlpEndpointCheckResult, | |
| 290 | 292 | ServerConfig, |
| 291 | 293 | ServerProviderUpdateError, |
| 292 | 294 | ServerProviderUpdateInput, |
| ... | ... | @@ -499,6 +501,7 @@ export const WS_METHODS = { |
| 499 | 501 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", |
| 500 | 502 | serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", |
| 501 | 503 | serverRetryResourceTelemetry: "server.retryResourceTelemetry", |
| 504 | serverCheckOtlpEndpoint: "server.checkOtlpEndpoint", | |
| 502 | 505 | serverSignalProcess: "server.signalProcess", |
| 503 | 506 | serverReportClientActivity: "server.reportClientActivity", |
| 504 | 507 | serverReportHostPowerState: "server.reportHostPowerState", |
| ... | ... | @@ -886,6 +889,12 @@ const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourc |
| 886 | 889 | error: EnvironmentAuthorizationError, |
| 887 | 890 | }); |
| 888 | 891 | |
| 892 | const WsServerCheckOtlpEndpointRpc = Rpc.make(WS_METHODS.serverCheckOtlpEndpoint, { | |
| 893 | payload: OtlpEndpointCheckInput, | |
| 894 | success: OtlpEndpointCheckResult, | |
| 895 | error: EnvironmentAuthorizationError, | |
| 896 | }); | |
| 897 | ||
| 889 | 898 | const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { |
| 890 | 899 | payload: UsageSummaryInput, |
| 891 | 900 | success: UsageSummary, |
| ... | ... | @@ -1891,6 +1900,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1891 | 1900 | WsServerGetProcessResourceHistoryRpc, |
| 1892 | 1901 | WsServerGetResourceTelemetryHistoryRpc, |
| 1893 | 1902 | WsServerRetryResourceTelemetryRpc, |
| 1903 | WsServerCheckOtlpEndpointRpc, | |
| 1894 | 1904 | WsServerGetUsageSummaryRpc, |
| 1895 | 1905 | WsServerRefreshUsageRatesRpc, |
| 1896 | 1906 | WsServerSignalProcessRpc, |
packages/contracts/src/server.test.ts+14| ... | ... | @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | 4 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 5 | 5 | import { |
| 6 | OtlpEndpointCheckInput, | |
| 6 | 7 | resolveEnvironmentMachineKind, |
| 7 | 8 | ServerConfig, |
| 8 | 9 | ServerObservability, |
| ... | ... | @@ -266,3 +267,16 @@ describe("resolveEnvironmentMachineKind", () => { |
| 266 | 267 | ).toBe("server"); |
| 267 | 268 | }); |
| 268 | 269 | }); |
| 270 | ||
| 271 | describe("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 | 357 | }); |
| 358 | 358 | export type ServerObservability = typeof ServerObservability.Type; |
| 359 | 359 | |
| 360 | export const OtlpSignal = Schema.Literals(["traces", "metrics", "logs"]); | |
| 361 | export type OtlpSignal = typeof OtlpSignal.Type; | |
| 362 | ||
| 363 | export 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 | }); | |
| 368 | export 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 | */ | |
| 374 | export 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 | ]); | |
| 379 | export type OtlpEndpointCheckResult = typeof OtlpEndpointCheckResult.Type; | |
| 380 | ||
| 360 | 381 | export const ServerTraceDiagnosticsErrorKind = Schema.Literals([ |
| 361 | 382 | "trace-file-not-found", |
| 362 | 383 | "trace-file-read-failed", |