| 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,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 @@ | ||
| 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,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"; |
| 229 | import * as SqlClient from "effect/sql/SqlClient"; | 229 | import * as SqlClient from "effect/sql/SqlClient"; |
| 230 | import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; | 230 | import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; |
| 231 | import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; | 231 | import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; |
| 232 | import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts"; | ||
| 232 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | 233 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 233 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; | 234 | import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; |
| 234 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; | 235 | import * 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 WS | 3203 | // 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"; |
| 38 | import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; | 38 | import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; |
| 39 | import { useAtomCommand } from "../../state/use-atom-command"; | 39 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 40 | import { useSettingsScope } from "./SettingsScopeContext"; | 40 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 41 | import { TelemetryExportSettings } from "./TelemetryExportSettings"; | ||
| 41 | 42 | ||
| 42 | const NUMBER_FORMAT = new Intl.NumberFormat(); | 43 | const NUMBER_FORMAT = new Intl.NumberFormat(); |
| 43 | 44 | ||
| ... | @@ -811,6 +812,20 @@ export function DiagnosticsSettingsPanel() { | ... | @@ -811,6 +812,20 @@ export function DiagnosticsSettingsPanel() { |
| 811 | 812 | ||
| 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 | <ProcessResourceHistoryTable | 1069 | <ProcessResourceHistoryTable |
| 1055 | processes={resourceData?.topProcesses ?? []} | 1070 | processes={resourceData?.topProcesses ?? []} |
| 1056 | emptyLabel={ | 1071 | emptyLabel={ |
| 1057 | isResourcePending && resourceData === null | 1072 | 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 | })); | ||
| 103 | 106 | ||
| 104 | function SettingsSectionIcon({ to }: { to: SettingsPath }) { | 107 | function 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 @@ | ||
| 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,6 +45,19 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [ |
| 45 | ]; | 45 | ]; |
| 46 | 46 | ||
| 47 | describe("searchSettings", () => { | 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 | 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 | }); |
| 398 | 411 | ||
| 399 | describe("settings search targets", () => { | 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 | 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 { |
| 16 | export type SettingsPath = | 16 | export 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 | */ |
| 94 | export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { | 95 | export 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"]; |
| 979 | const SEARCH_ITEMS_BY_ID = new Map(SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item] as const)); | 989 | const SEARCH_ITEMS_BY_ID = new Map(SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item] as const)); |
| 980 | 990 | ||
| 981 | const SETTINGS_CATEGORY_SCOPES: Readonly<Record<SettingsPath, SettingsSearchScope | null>> = { | 991 | const 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: |
| 172 | 172 | ||
| 173 | #### 2. Export OTLP env vars | 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 | ```bash | 179 | ```bash |
| 176 | export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces | 180 | export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces |
| 177 | export T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics | 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,3 +12,20 @@ starting it. This stops product events from being recorded or sent. |
| 12 | 12 | ||
| 13 | The desktop app reads the variable from your shell profile (for example `~/.zshrc`) on macOS and | 13 | The desktop app reads the variable from your shell profile (for example `~/.zshrc`) on macOS and |
| 14 | Linux, so export it there and restart the app. On Windows, set it as a user environment variable. | 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,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 | 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. */ |
| 12 | export const CLIENT_GUARDED_RPC_SCOPES = { | 12 | export 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"; |
| 287 | import { | 287 | import { |
| 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 | }); |
| 888 | 891 | ||
| 892 | const WsServerCheckOtlpEndpointRpc = Rpc.make(WS_METHODS.serverCheckOtlpEndpoint, { | ||
| 893 | payload: OtlpEndpointCheckInput, | ||
| 894 | success: OtlpEndpointCheckResult, | ||
| 895 | error: EnvironmentAuthorizationError, | ||
| 896 | }); | ||
| 897 | |||
| 889 | const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { | 898 | const 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"; |
| 3 | 3 | ||
| 4 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | 4 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; |
| 5 | import { | 5 | import { |
| 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 | |||
| 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,6 +357,27 @@ export const ServerObservability = Schema.Struct({ |
| 357 | }); | 357 | }); |
| 358 | export type ServerObservability = typeof ServerObservability.Type; | 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 | export const ServerTraceDiagnosticsErrorKind = Schema.Literals([ | 381 | export const ServerTraceDiagnosticsErrorKind = Schema.Literals([ |
| 361 | "trace-file-not-found", | 382 | "trace-file-not-found", |
| 362 | "trace-file-read-failed", | 383 | "trace-file-read-failed", |