| author | |
| committer | |
| log | fd996d15e220144ac6e66670169aa87d074ce846 |
| tree | be082e09f80c6dea04fb64ae3b9479ee73dd049e |
| parent | a4f6078bebcb3283e53e1cea247257136ce82cc8 |
| signature | Signed by PGP key B5690EEEBB952194 |
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>5 files changed, 693 insertions(+), 22 deletions(-)
apps/server/src/cli/config.test.ts+142| ... | ... | @@ -999,4 +999,146 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 999 | 999 | expect(resolved.otlpLogsUrl).toBe("http://collector.internal:4318/v1/logs"); |
| 1000 | 1000 | }), |
| 1001 | 1001 | ); |
| 1002 | ||
| 1003 | const minimalWebFlags = (baseDir: string) => ({ | |
| 1004 | mode: Option.some("web" as const), | |
| 1005 | port: Option.some(3773), | |
| 1006 | host: Option.none<string>(), | |
| 1007 | baseDir: Option.some(baseDir), | |
| 1008 | cwd: Option.none<string>(), | |
| 1009 | devUrl: Option.none<URL>(), | |
| 1010 | noBrowser: Option.none<boolean>(), | |
| 1011 | bootstrapFd: Option.none<number>(), | |
| 1012 | autoBootstrapProjectFromCwd: Option.none<boolean>(), | |
| 1013 | logWebSocketEvents: Option.none<boolean>(), | |
| 1014 | tailscaleServeEnabled: Option.none<boolean>(), | |
| 1015 | tailscaleServePort: Option.none<number>(), | |
| 1016 | }); | |
| 1017 | ||
| 1018 | it.effect( | |
| 1019 | "resolves each signal's endpoint through T3CODE_OTLP_*_URL, an OTEL endpoint, the bootstrap envelope, and persisted Settings, in that order", | |
| 1020 | () => | |
| 1021 | Effect.gen(function* () { | |
| 1022 | const fs = yield* FileSystem.FileSystem; | |
| 1023 | const path = yield* Path.Path; | |
| 1024 | const baseDir = yield* fs.makeTempDirectoryScoped({ | |
| 1025 | prefix: "t3-cli-config-otel-precedence-", | |
| 1026 | }); | |
| 1027 | const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); | |
| 1028 | yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); | |
| 1029 | yield* fs.writeFileString( | |
| 1030 | derivedPaths.settingsPath, | |
| 1031 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 1032 | `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, | |
| 1033 | ); | |
| 1034 | ||
| 1035 | const fd = yield* openBootstrapFd( | |
| 1036 | makeDesktopBootstrap({ | |
| 1037 | otlpMetricsUrl: "http://bootstrap:4318/v1/metrics", | |
| 1038 | // Blank, not an endpoint: it must not stand in front of Settings. | |
| 1039 | otlpLogsUrl: "", | |
| 1040 | }), | |
| 1041 | ); | |
| 1042 | ||
| 1043 | const resolved = yield* resolveServerConfig( | |
| 1044 | { | |
| 1045 | ...minimalWebFlags(baseDir), | |
| 1046 | mode: Option.some("desktop"), | |
| 1047 | port: Option.some(4888), | |
| 1048 | bootstrapFd: Option.some(fd), | |
| 1049 | }, | |
| 1050 | Option.none(), | |
| 1051 | ).pipe( | |
| 1052 | Effect.provide( | |
| 1053 | Layer.mergeAll( | |
| 1054 | ConfigProvider.layer( | |
| 1055 | ConfigProvider.fromEnv({ | |
| 1056 | env: { | |
| 1057 | T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces", | |
| 1058 | T3CODE_OTLP_HEADERS: "x-key=secret", | |
| 1059 | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://otel-traces:4318/custom", | |
| 1060 | OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "http://otel-metrics:4318/custom", | |
| 1061 | OTEL_EXPORTER_OTLP_HEADERS: "x-key=otel", | |
| 1062 | }, | |
| 1063 | }), | |
| 1064 | ), | |
| 1065 | NetService.layer, | |
| 1066 | ), | |
| 1067 | ), | |
| 1068 | ); | |
| 1069 | ||
| 1070 | // T3CODE_OTLP_TRACES_URL wins over the OTEL variable for the same | |
| 1071 | // signal, and keeps T3 Code's own headers since T3 Code still owns it. | |
| 1072 | expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces"); | |
| 1073 | expect(resolved.otlpTracesExport.headers).toEqual({ "x-key": "secret" }); | |
| 1074 | // Metrics named no T3CODE_OTLP_METRICS_URL, so the OTEL endpoint wins | |
| 1075 | // over the bootstrap envelope and brings the OTEL headers and protocol. | |
| 1076 | expect(resolved.otlpMetricsUrl).toBe("http://otel-metrics:4318/custom"); | |
| 1077 | expect(resolved.otlpMetricsExport).toEqual({ | |
| 1078 | ...DEFAULT_SIGNAL_EXPORT, | |
| 1079 | protocol: "http/protobuf", | |
| 1080 | headers: { "x-key": "otel" }, | |
| 1081 | }); | |
| 1082 | // Logs named no T3 or OTEL endpoint and a blank bootstrap value, so | |
| 1083 | // Settings answers, and logs keep the shared headers since no OTEL | |
| 1084 | // endpoint claimed them. | |
| 1085 | expect(resolved.otlpLogsUrl).toBe("http://settings:4318/v1/logs"); | |
| 1086 | expect(resolved.otlpLogsExport.headers).toEqual({ "x-key": "secret" }); | |
| 1087 | }), | |
| 1088 | ); | |
| 1089 | ||
| 1090 | it.effect( | |
| 1091 | "exports nothing for a signal an OTEL endpoint claimed with a protocol or headers that do not read", | |
| 1092 | () => | |
| 1093 | Effect.gen(function* () { | |
| 1094 | const fs = yield* FileSystem.FileSystem; | |
| 1095 | const path = yield* Path.Path; | |
| 1096 | const baseDir = yield* fs.makeTempDirectoryScoped({ | |
| 1097 | prefix: "t3-cli-config-otel-off-", | |
| 1098 | }); | |
| 1099 | const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); | |
| 1100 | yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); | |
| 1101 | yield* fs.writeFileString( | |
| 1102 | derivedPaths.settingsPath, | |
| 1103 | // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 1104 | `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, | |
| 1105 | ); | |
| 1106 | ||
| 1107 | const fd = yield* openBootstrapFd( | |
| 1108 | makeDesktopBootstrap({ otlpMetricsUrl: "http://bootstrap:4318/v1/metrics" }), | |
| 1109 | ); | |
| 1110 | ||
| 1111 | const resolved = yield* resolveServerConfig( | |
| 1112 | { | |
| 1113 | ...minimalWebFlags(baseDir), | |
| 1114 | mode: Option.some("desktop"), | |
| 1115 | port: Option.some(4888), | |
| 1116 | bootstrapFd: Option.some(fd), | |
| 1117 | }, | |
| 1118 | Option.none(), | |
| 1119 | ).pipe( | |
| 1120 | Effect.provide( | |
| 1121 | Layer.mergeAll( | |
| 1122 | ConfigProvider.layer( | |
| 1123 | ConfigProvider.fromEnv({ | |
| 1124 | env: { | |
| 1125 | T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces", | |
| 1126 | OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel:4318", | |
| 1127 | OTEL_EXPORTER_OTLP_HEADERS: "x-key=%zz", | |
| 1128 | }, | |
| 1129 | }), | |
| 1130 | ), | |
| 1131 | NetService.layer, | |
| 1132 | ), | |
| 1133 | ), | |
| 1134 | ); | |
| 1135 | ||
| 1136 | // T3CODE_OTLP_TRACES_URL still wins outright. | |
| 1137 | expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces"); | |
| 1138 | // The OTEL endpoint claimed metrics and logs, so neither the bootstrap | |
| 1139 | // envelope nor Settings receives them with T3 Code's headers. | |
| 1140 | expect(resolved.otlpMetricsUrl).toBeUndefined(); | |
| 1141 | expect(resolved.otlpLogsUrl).toBeUndefined(); | |
| 1142 | }), | |
| 1143 | ); | |
| 1002 | 1144 | }); |
apps/server/src/cli/config.ts+27-16| ... | ... | @@ -396,6 +396,27 @@ export const resolveServerConfig = ( |
| 396 | 396 | headers: env.otlpHeaders, |
| 397 | 397 | exportIntervalMs: env.otlpExportIntervalMs, |
| 398 | 398 | }; |
| 399 | const traces = OtelEnvironment.resolveSignalEndpoint( | |
| 400 | otel, | |
| 401 | "traces", | |
| 402 | { url: env.otlpTracesUrl, export: signalExport }, | |
| 403 | bootstrap?.otlpTracesUrl, | |
| 404 | persistedObservabilitySettings.otlpTracesUrl, | |
| 405 | ); | |
| 406 | const metrics = OtelEnvironment.resolveSignalEndpoint( | |
| 407 | otel, | |
| 408 | "metrics", | |
| 409 | { url: env.otlpMetricsUrl, export: signalExport }, | |
| 410 | bootstrap?.otlpMetricsUrl, | |
| 411 | persistedObservabilitySettings.otlpMetricsUrl, | |
| 412 | ); | |
| 413 | const logs = OtelEnvironment.resolveSignalEndpoint( | |
| 414 | otel, | |
| 415 | "logs", | |
| 416 | { url: env.otlpLogsUrl, export: signalExport }, | |
| 417 | bootstrap?.otlpLogsUrl, | |
| 418 | persistedObservabilitySettings.otlpLogsUrl, | |
| 419 | ); | |
| 399 | 420 | |
| 400 | 421 | const config: ServerConfig.ServerConfig["Service"] = { |
| 401 | 422 | logLevel, |
| ... | ... | @@ -404,22 +425,12 @@ export const resolveServerConfig = ( |
| 404 | 425 | traceBatchWindowMs: env.traceBatchWindowMs, |
| 405 | 426 | traceMaxBytes: env.traceMaxBytes, |
| 406 | 427 | traceMaxFiles: env.traceMaxFiles, |
| 407 | otlpTracesUrl: otel.disabled | |
| 408 | ? undefined | |
| 409 | : (env.otlpTracesUrl ?? | |
| 410 | bootstrap?.otlpTracesUrl ?? | |
| 411 | persistedObservabilitySettings.otlpTracesUrl), | |
| 412 | otlpMetricsUrl: otel.disabled | |
| 413 | ? undefined | |
| 414 | : (env.otlpMetricsUrl ?? | |
| 415 | bootstrap?.otlpMetricsUrl ?? | |
| 416 | persistedObservabilitySettings.otlpMetricsUrl), | |
| 417 | otlpLogsUrl: otel.disabled | |
| 418 | ? undefined | |
| 419 | : (env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl), | |
| 420 | otlpTracesExport: signalExport, | |
| 421 | otlpMetricsExport: signalExport, | |
| 422 | otlpLogsExport: signalExport, | |
| 428 | otlpTracesUrl: traces?.url, | |
| 429 | otlpMetricsUrl: metrics?.url, | |
| 430 | otlpLogsUrl: logs?.url, | |
| 431 | otlpTracesExport: traces?.export ?? signalExport, | |
| 432 | otlpMetricsExport: metrics?.export ?? signalExport, | |
| 433 | otlpLogsExport: logs?.export ?? signalExport, | |
| 423 | 434 | otlpServiceName: env.otlpServiceName, |
| 424 | 435 | otelEnvironment: otel, |
| 425 | 436 | mode, |
docs/operations/observability.md+12| ... | ... | @@ -552,6 +552,18 @@ OTLP export: |
| 552 | 552 | `OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values. |
| 553 | 553 | - `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf` |
| 554 | 554 | |
| 555 | The server also reads the standard `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT` and generic | |
| 556 | `OTEL_EXPORTER_OTLP_ENDPOINT` (with `/v1/traces`, `/v1/metrics`, or `/v1/logs` appended), for a | |
| 557 | collector expecting those instead. A non-blank `T3CODE_OTLP_*_URL` wins over either, and a | |
| 558 | per-signal endpoint wins over the generic one for its signal. A blank value counts as unset. A | |
| 559 | signal with an OTEL endpoint takes its headers from `OTEL_EXPORTER_OTLP_HEADERS` and its protocol | |
| 560 | from `OTEL_EXPORTER_OTLP_PROTOCOL` (default `http/protobuf`, read case-insensitively), and a | |
| 561 | per-signal `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_HEADERS` or `_PROTOCOL` wins over the generic | |
| 562 | one for its signal. `T3CODE_OTLP_HEADERS` and `T3CODE_OTLP_PROTOCOL` never apply to it. An endpoint | |
| 563 | that is not an `http` or `https` URL, a protocol other than `http/protobuf` or `http/json` such as | |
| 564 | `grpc`, or headers that are not `key=value` pairs with percent-encoded values turn that signal's | |
| 565 | export off with a startup warning, rather than sending it to the Settings endpoint. | |
| 566 | ||
| 555 | 567 | If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay |
| 556 | 568 | on stdout only. |
| 557 | 569 |
packages/shared/src/otelEnvironment.test.ts+290| ... | ... | @@ -106,6 +106,296 @@ describe("OtelEnvironment", () => { |
| 106 | 106 | }), |
| 107 | 107 | ); |
| 108 | 108 | |
| 109 | describe("endpoints", () => { | |
| 110 | const urlOf = (signal: OtelEnvironment.OtelSignal) => | |
| 111 | OtelEnvironment.OtelSignal.$match(signal, { | |
| 112 | Export: ({ url }) => url, | |
| 113 | Off: () => "Off", | |
| 114 | Unset: () => "Unset", | |
| 115 | }); | |
| 116 | it.effect.each([ | |
| 117 | { | |
| 118 | name: "nothing set", | |
| 119 | env: {}, | |
| 120 | traces: "Unset", | |
| 121 | metrics: "Unset", | |
| 122 | logs: "Unset", | |
| 123 | warnings: [], | |
| 124 | }, | |
| 125 | { | |
| 126 | name: "generic endpoint appends each signal's path, keeping the query", | |
| 127 | env: { OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base?api_key=secret" }, | |
| 128 | traces: "https://collector:4318/base/v1/traces?api_key=secret", | |
| 129 | metrics: "https://collector:4318/base/v1/metrics?api_key=secret", | |
| 130 | logs: "https://collector:4318/base/v1/logs?api_key=secret", | |
| 131 | warnings: [], | |
| 132 | }, | |
| 133 | { | |
| 134 | name: "a per-signal endpoint is used verbatim", | |
| 135 | env: { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://tempo:4318/custom" }, | |
| 136 | traces: "https://tempo:4318/custom", | |
| 137 | metrics: "Unset", | |
| 138 | logs: "Unset", | |
| 139 | warnings: [], | |
| 140 | }, | |
| 141 | { | |
| 142 | name: "a per-signal endpoint beats the generic one", | |
| 143 | env: { | |
| 144 | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://tempo:4318/custom", | |
| 145 | OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base", | |
| 146 | }, | |
| 147 | traces: "https://tempo:4318/custom", | |
| 148 | metrics: "https://collector:4318/base/v1/metrics", | |
| 149 | logs: "https://collector:4318/base/v1/logs", | |
| 150 | warnings: [], | |
| 151 | }, | |
| 152 | { | |
| 153 | name: "a blank per-signal endpoint falls through to the generic one", | |
| 154 | env: { | |
| 155 | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: " ", | |
| 156 | OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base", | |
| 157 | }, | |
| 158 | traces: "https://collector:4318/base/v1/traces", | |
| 159 | metrics: "https://collector:4318/base/v1/metrics", | |
| 160 | logs: "https://collector:4318/base/v1/logs", | |
| 161 | warnings: [], | |
| 162 | }, | |
| 163 | { | |
| 164 | name: "an invalid per-signal endpoint warns and does not fall through", | |
| 165 | env: { | |
| 166 | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "not-a-url", | |
| 167 | OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base", | |
| 168 | }, | |
| 169 | traces: "Off", | |
| 170 | metrics: "https://collector:4318/base/v1/metrics", | |
| 171 | logs: "https://collector:4318/base/v1/logs", | |
| 172 | warnings: [ | |
| 173 | "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is not an http or https URL, so the signals it configures are not exported", | |
| 174 | ], | |
| 175 | }, | |
| 176 | { | |
| 177 | name: "an invalid generic endpoint warns without leaking its query", | |
| 178 | env: { OTEL_EXPORTER_OTLP_ENDPOINT: "not-a-url?api_key=secret" }, | |
| 179 | traces: "Off", | |
| 180 | metrics: "Off", | |
| 181 | logs: "Off", | |
| 182 | warnings: [ | |
| 183 | "OTEL_EXPORTER_OTLP_ENDPOINT is not an http or https URL, so the signals it configures are not exported", | |
| 184 | ], | |
| 185 | }, | |
| 186 | { | |
| 187 | name: "an endpoint without a scheme is not an http URL", | |
| 188 | env: { OTEL_EXPORTER_OTLP_ENDPOINT: "localhost:4318" }, | |
| 189 | traces: "Off", | |
| 190 | metrics: "Off", | |
| 191 | logs: "Off", | |
| 192 | warnings: [ | |
| 193 | "OTEL_EXPORTER_OTLP_ENDPOINT is not an http or https URL, so the signals it configures are not exported", | |
| 194 | ], | |
| 195 | }, | |
| 196 | { | |
| 197 | name: "the kill switch wins outright over a valid endpoint", | |
| 198 | env: { | |
| 199 | T3CODE_OTEL_SDK_DISABLED: "true", | |
| 200 | OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318/base", | |
| 201 | }, | |
| 202 | traces: "Unset", | |
| 203 | metrics: "Unset", | |
| 204 | logs: "Unset", | |
| 205 | warnings: [T3_OFF], | |
| 206 | }, | |
| 207 | ])("$name", ({ env, traces, metrics, logs, warnings }) => | |
| 208 | Effect.gen(function* () { | |
| 209 | const resolved = yield* load(env); | |
| 210 | assert.strictEqual(urlOf(resolved.traces), traces); | |
| 211 | assert.strictEqual(urlOf(resolved.metrics), metrics); | |
| 212 | assert.strictEqual(urlOf(resolved.logs), logs); | |
| 213 | assert.deepStrictEqual(resolved.warnings, warnings); | |
| 214 | }), | |
| 215 | ); | |
| 216 | ||
| 217 | const ENDPOINT = { OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector:4318" }; | |
| 218 | const exportOf = (signal: OtelEnvironment.OtelSignal): unknown => | |
| 219 | OtelEnvironment.OtelSignal.$match(signal, { | |
| 220 | Export: ({ protocol, headers }) => ({ protocol, headers }), | |
| 221 | Off: () => "Off", | |
| 222 | Unset: () => "Unset", | |
| 223 | }); | |
| 224 | it.effect.each([ | |
| 225 | { | |
| 226 | name: "nothing else set takes the specification's default protocol", | |
| 227 | env: ENDPOINT, | |
| 228 | traces: { protocol: "http/protobuf", headers: undefined }, | |
| 229 | logs: { protocol: "http/protobuf", headers: undefined }, | |
| 230 | warnings: [], | |
| 231 | }, | |
| 232 | { | |
| 233 | name: "headers are comma-separated pairs with percent-encoded values", | |
| 234 | env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS: "api-key=a%20b,tenant=t3" }, | |
| 235 | traces: { protocol: "http/protobuf", headers: { "api-key": "a b", tenant: "t3" } }, | |
| 236 | logs: { protocol: "http/protobuf", headers: { "api-key": "a b", tenant: "t3" } }, | |
| 237 | warnings: [], | |
| 238 | }, | |
| 239 | { | |
| 240 | name: "a per-signal protocol and headers beat the generic ones", | |
| 241 | env: { | |
| 242 | ...ENDPOINT, | |
| 243 | OTEL_EXPORTER_OTLP_PROTOCOL: "http/json", | |
| 244 | OTEL_EXPORTER_OTLP_HEADERS: "api-key=shared", | |
| 245 | OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "http/protobuf", | |
| 246 | OTEL_EXPORTER_OTLP_TRACES_HEADERS: "api-key=traces%20only", | |
| 247 | }, | |
| 248 | traces: { protocol: "http/protobuf", headers: { "api-key": "traces only" } }, | |
| 249 | logs: { protocol: "http/json", headers: { "api-key": "shared" } }, | |
| 250 | warnings: [], | |
| 251 | }, | |
| 252 | { | |
| 253 | name: "an unsupported protocol turns off the signals it configures", | |
| 254 | env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc" }, | |
| 255 | traces: { protocol: "http/protobuf", headers: undefined }, | |
| 256 | logs: "Off", | |
| 257 | warnings: [ | |
| 258 | "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL is not http/protobuf or http/json, so the signals it configures are not exported", | |
| 259 | ], | |
| 260 | }, | |
| 261 | { | |
| 262 | name: "a protocol reads case-insensitively", | |
| 263 | env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL: "HTTP/JSON" }, | |
| 264 | traces: { protocol: "http/json", headers: undefined }, | |
| 265 | logs: { protocol: "http/json", headers: undefined }, | |
| 266 | warnings: [], | |
| 267 | }, | |
| 268 | { | |
| 269 | name: "undecodable headers turn off every signal once, without leaking them", | |
| 270 | env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz" }, | |
| 271 | traces: "Off", | |
| 272 | logs: "Off", | |
| 273 | warnings: [ | |
| 274 | "OTEL_EXPORTER_OTLP_HEADERS is not a list of key=value pairs with percent-encoded values, so the signals it configures are not exported", | |
| 275 | ], | |
| 276 | }, | |
| 277 | { | |
| 278 | name: "a header without a value separator turns its signal off", | |
| 279 | env: { ...ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_HEADERS: "Authorization" }, | |
| 280 | traces: { protocol: "http/protobuf", headers: undefined }, | |
| 281 | logs: "Off", | |
| 282 | warnings: [ | |
| 283 | "OTEL_EXPORTER_OTLP_LOGS_HEADERS is not a list of key=value pairs with percent-encoded values, so the signals it configures are not exported", | |
| 284 | ], | |
| 285 | }, | |
| 286 | { | |
| 287 | name: "blank per-signal headers leave the generic ones in charge", | |
| 288 | env: { | |
| 289 | ...ENDPOINT, | |
| 290 | OTEL_EXPORTER_OTLP_HEADERS: "api-key=shared", | |
| 291 | OTEL_EXPORTER_OTLP_LOGS_HEADERS: " ", | |
| 292 | }, | |
| 293 | traces: { protocol: "http/protobuf", headers: { "api-key": "shared" } }, | |
| 294 | logs: { protocol: "http/protobuf", headers: { "api-key": "shared" } }, | |
| 295 | warnings: [], | |
| 296 | }, | |
| 297 | { | |
| 298 | name: "generic headers every signal overrides say nothing", | |
| 299 | env: { | |
| 300 | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "https://tempo:4318/v1/traces", | |
| 301 | OTEL_EXPORTER_OTLP_TRACES_HEADERS: "api-key=traces", | |
| 302 | OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz", | |
| 303 | }, | |
| 304 | traces: { protocol: "http/protobuf", headers: { "api-key": "traces" } }, | |
| 305 | logs: "Unset", | |
| 306 | warnings: [], | |
| 307 | }, | |
| 308 | { | |
| 309 | name: "protocol and headers say nothing for a signal no endpoint names", | |
| 310 | env: { OTEL_EXPORTER_OTLP_PROTOCOL: "grpc", OTEL_EXPORTER_OTLP_HEADERS: "api-key=%zz" }, | |
| 311 | traces: "Unset", | |
| 312 | logs: "Unset", | |
| 313 | warnings: [], | |
| 314 | }, | |
| 315 | ])("$name", ({ env, traces, logs, warnings }) => | |
| 316 | Effect.gen(function* () { | |
| 317 | const resolved = yield* load(env); | |
| 318 | assert.deepStrictEqual(exportOf(resolved.traces), traces); | |
| 319 | assert.deepStrictEqual(exportOf(resolved.logs), logs); | |
| 320 | assert.deepStrictEqual(resolved.warnings, warnings); | |
| 321 | }), | |
| 322 | ); | |
| 323 | }); | |
| 324 | ||
| 325 | describe("resolveSignalEndpoint", () => { | |
| 326 | const t3Export = { | |
| 327 | protocol: "http/json", | |
| 328 | headers: { "x-key": "t3" }, | |
| 329 | exportIntervalMs: 5_000, | |
| 330 | } as const; | |
| 331 | const withLogs = (logs: OtelEnvironment.OtelSignal, disabled = false) => ({ | |
| 332 | ...OtelEnvironment.none, | |
| 333 | disabled, | |
| 334 | logs, | |
| 335 | }); | |
| 336 | const otelExport = OtelEnvironment.OtelSignal.Export({ | |
| 337 | url: "http://otel:4318/v1/logs", | |
| 338 | protocol: "http/protobuf", | |
| 339 | headers: { "x-key": "otel" }, | |
| 340 | }); | |
| 341 | it.each([ | |
| 342 | { | |
| 343 | name: "T3CODE_OTLP_*_URL wins over an OTEL endpoint", | |
| 344 | otel: withLogs(otelExport), | |
| 345 | t3Url: "http://t3:4318/v1/logs", | |
| 346 | expected: { url: "http://t3:4318/v1/logs", export: t3Export }, | |
| 347 | }, | |
| 348 | { | |
| 349 | name: "T3CODE_OTLP_*_URL wins over a signal the OTEL variables turned off", | |
| 350 | otel: withLogs(OtelEnvironment.OtelSignal.Off()), | |
| 351 | t3Url: "http://t3:4318/v1/logs", | |
| 352 | expected: { url: "http://t3:4318/v1/logs", export: t3Export }, | |
| 353 | }, | |
| 354 | { | |
| 355 | name: "an OTEL endpoint brings its headers and protocol over the fallback", | |
| 356 | otel: withLogs(otelExport), | |
| 357 | t3Url: " ", | |
| 358 | expected: { | |
| 359 | url: "http://otel:4318/v1/logs", | |
| 360 | export: { | |
| 361 | protocol: "http/protobuf" as const, | |
| 362 | headers: { "x-key": "otel" }, | |
| 363 | exportIntervalMs: 5_000, | |
| 364 | }, | |
| 365 | }, | |
| 366 | }, | |
| 367 | { | |
| 368 | name: "a signal the OTEL variables turned off does not fall through", | |
| 369 | otel: withLogs(OtelEnvironment.OtelSignal.Off()), | |
| 370 | t3Url: undefined, | |
| 371 | expected: undefined, | |
| 372 | }, | |
| 373 | { | |
| 374 | name: "an unset signal takes the first non-blank fallback", | |
| 375 | otel: withLogs(OtelEnvironment.OtelSignal.Unset()), | |
| 376 | t3Url: undefined, | |
| 377 | expected: { url: "http://settings:4318/v1/logs", export: t3Export }, | |
| 378 | }, | |
| 379 | { | |
| 380 | name: "the kill switch wins over everything", | |
| 381 | otel: withLogs(otelExport, true), | |
| 382 | t3Url: "http://t3:4318/v1/logs", | |
| 383 | expected: undefined, | |
| 384 | }, | |
| 385 | ])("$name", ({ otel, t3Url, expected }) => { | |
| 386 | assert.deepStrictEqual( | |
| 387 | OtelEnvironment.resolveSignalEndpoint( | |
| 388 | otel, | |
| 389 | "logs", | |
| 390 | { url: t3Url, export: t3Export }, | |
| 391 | "", | |
| 392 | "http://settings:4318/v1/logs", | |
| 393 | ), | |
| 394 | expected, | |
| 395 | ); | |
| 396 | }); | |
| 397 | }); | |
| 398 | ||
| 109 | 399 | describe("layerResourceAttributes", () => { |
| 110 | 400 | it.effect.each([ |
| 111 | 401 | { name: "a list that does not decode", raw: "team=%zz", attributes: [] }, |
packages/shared/src/otelEnvironment.ts+222-6| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | /** |
| 2 | * otelEnvironment: the OpenTelemetry kill switch, shared by the server and the | |
| 3 | * desktop main process so both agree on what turns export off. | |
| 2 | * otelEnvironment: the OpenTelemetry kill switch and endpoint variables, | |
| 3 | * shared by the server and the desktop main process so both agree on what | |
| 4 | * turns export off and where it goes. | |
| 4 | 5 | * |
| 5 | 6 | * `T3CODE_OTEL_SDK_DISABLED` is read first, so a machine that sets |
| 6 | 7 | * `OTEL_SDK_DISABLED` for everything else can still opt T3 Code back in. |
| ... | ... | @@ -9,10 +10,33 @@ |
| 9 | 10 | */ |
| 10 | 11 | import * as Config from "effect/Config"; |
| 11 | 12 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 13 | import * as Data from "effect/Data"; | |
| 12 | 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as Option from "effect/Option"; | |
| 13 | 16 | import * as Schema from "effect/Schema"; |
| 14 | 17 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 15 | 18 | |
| 19 | import { OtlpHeadersFromString, OtlpProtocol, type SignalExport } from "./observability.ts"; | |
| 20 | ||
| 21 | /** The signals T3 Code exports, spelled as the variable names spell them. */ | |
| 22 | type OtlpSignalName = "TRACES" | "METRICS" | "LOGS"; | |
| 23 | ||
| 24 | /** | |
| 25 | * What the OTEL variables say about one signal. `Off` is a signal they | |
| 26 | * claimed with an endpoint, protocol, or headers that do not read, so it is | |
| 27 | * exported nowhere rather than to the bootstrap or Settings collector. | |
| 28 | */ | |
| 29 | export type OtelSignal = Data.TaggedEnum<{ | |
| 30 | Unset: {}; | |
| 31 | Off: {}; | |
| 32 | Export: { | |
| 33 | readonly url: string; | |
| 34 | readonly protocol: OtlpProtocol; | |
| 35 | readonly headers: Readonly<Record<string, string>> | undefined; | |
| 36 | }; | |
| 37 | }>; | |
| 38 | export const OtelSignal = Data.taggedEnum<OtelSignal>(); | |
| 39 | ||
| 16 | 40 | export interface OtelEnvironment { |
| 17 | 41 | /** Whether OTLP export is off, whatever endpoint is configured. */ |
| 18 | 42 | readonly disabled: boolean; |
| ... | ... | @@ -20,8 +44,17 @@ export interface OtelEnvironment { |
| 20 | 44 | readonly warnings: ReadonlyArray<string>; |
| 21 | 45 | /** `OTEL_RESOURCE_ATTRIBUTES`, or nothing when it could not be read. */ |
| 22 | 46 | readonly resourceAttributes: Readonly<Record<string, string>>; |
| 47 | readonly traces: OtelSignal; | |
| 48 | readonly metrics: OtelSignal; | |
| 49 | readonly logs: OtelSignal; | |
| 23 | 50 | } |
| 24 | 51 | |
| 52 | /** A set but blank value reads as unset, so the source under it can answer. */ | |
| 53 | const blankAsUnset = (value: string | undefined): string | undefined => { | |
| 54 | const trimmed = value?.trim(); | |
| 55 | return trimmed === undefined || trimmed === "" ? undefined : trimmed; | |
| 56 | }; | |
| 57 | ||
| 25 | 58 | interface Flag { |
| 26 | 59 | /** `undefined` when the variable is unset, blank, or unreadable. */ |
| 27 | 60 | readonly value: boolean | undefined; |
| ... | ... | @@ -93,6 +126,121 @@ const resourceAttributes = Config.Record( |
| 93 | 126 | Config.withDefault<ResourceAttributes>({ value: {} }), |
| 94 | 127 | ); |
| 95 | 128 | |
| 129 | interface Setting<A> { | |
| 130 | readonly value: A | undefined; | |
| 131 | readonly warning?: string; | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * Reads one variable. Blank reads as unset, and a value `parse` rejects warns | |
| 136 | * without echoing it, since these variables carry credentials. | |
| 137 | */ | |
| 138 | const readOrWarn = <A>( | |
| 139 | name: string, | |
| 140 | parse: (raw: string) => Option.Option<A>, | |
| 141 | warning: string, | |
| 142 | ): Config.Config<Setting<A>> => | |
| 143 | Config.String(name).pipe( | |
| 144 | Config.option, | |
| 145 | Config.map((option): Setting<A> => { | |
| 146 | const raw = blankAsUnset(Option.getOrUndefined(option)); | |
| 147 | if (raw === undefined) { | |
| 148 | return { value: undefined }; | |
| 149 | } | |
| 150 | return Option.match(parse(raw), { | |
| 151 | onNone: () => ({ value: undefined, warning }), | |
| 152 | onSome: (value) => ({ value }), | |
| 153 | }); | |
| 154 | }), | |
| 155 | ); | |
| 156 | ||
| 157 | const parseHttpUrl = (raw: string) => | |
| 158 | Option.liftThrowable((value: string) => new URL(value))(raw).pipe( | |
| 159 | Option.filter((url) => url.protocol === "http:" || url.protocol === "https:"), | |
| 160 | ); | |
| 161 | ||
| 162 | const NOT_EXPORTED = "so the signals it configures are not exported"; | |
| 163 | ||
| 164 | const endpoint = (name: string) => | |
| 165 | readOrWarn(name, parseHttpUrl, `${name} is not an http or https URL, ${NOT_EXPORTED}`); | |
| 166 | ||
| 167 | // The specification reads enum values case-insensitively. | |
| 168 | const protocol = (name: string) => | |
| 169 | readOrWarn( | |
| 170 | name, | |
| 171 | (raw) => Schema.decodeUnknownOption(OtlpProtocol)(raw.toLowerCase()), | |
| 172 | `${name} is not http/protobuf or http/json, ${NOT_EXPORTED}`, | |
| 173 | ); | |
| 174 | ||
| 175 | const headers = (name: string) => | |
| 176 | readOrWarn( | |
| 177 | name, | |
| 178 | Schema.decodeUnknownOption(OtlpHeadersFromString), | |
| 179 | `${name} is not a list of key=value pairs with percent-encoded values, ${NOT_EXPORTED}`, | |
| 180 | ); | |
| 181 | ||
| 182 | interface Settings { | |
| 183 | readonly endpoint: Setting<URL>; | |
| 184 | readonly protocol: Setting<OtlpProtocol>; | |
| 185 | readonly headers: Setting<Readonly<Record<string, string>>>; | |
| 186 | } | |
| 187 | ||
| 188 | const settings = (prefix: string): Config.Config<Settings> => | |
| 189 | Config.all({ | |
| 190 | endpoint: endpoint(`${prefix}ENDPOINT`), | |
| 191 | protocol: protocol(`${prefix}PROTOCOL`), | |
| 192 | headers: headers(`${prefix}HEADERS`), | |
| 193 | }); | |
| 194 | ||
| 195 | /** The signal's own variable claims the signal once set, valid or not. */ | |
| 196 | const isClaimed = (setting: Setting<unknown>) => | |
| 197 | setting.value !== undefined || setting.warning !== undefined; | |
| 198 | ||
| 199 | const claimed = <A>(own: Setting<A>, generic: Setting<A>) => (isClaimed(own) ? own : generic); | |
| 200 | ||
| 201 | /** Appends the signal's path, keeping the query an intake may take its API key in. */ | |
| 202 | const withSignalPath = (signal: OtlpSignalName, base: URL) => { | |
| 203 | const url = new URL(base); | |
| 204 | const slash = url.pathname.endsWith("/") ? "" : "/"; | |
| 205 | url.pathname += `${slash}v1/${signal.toLowerCase()}`; | |
| 206 | return url; | |
| 207 | }; | |
| 208 | ||
| 209 | interface ResolvedSignal { | |
| 210 | readonly signal: OtelSignal; | |
| 211 | /** The settings this signal read, whose warnings are the signal's to report. */ | |
| 212 | readonly used: ReadonlyArray<Setting<unknown>>; | |
| 213 | } | |
| 214 | ||
| 215 | /** | |
| 216 | * A signal whose endpoint, protocol, or headers do not read is not exported | |
| 217 | * rather than sent somewhere, in a format, or without the credentials its | |
| 218 | * collector expects. | |
| 219 | */ | |
| 220 | const signal = (name: OtlpSignalName, own: Settings, generic: Settings): ResolvedSignal => { | |
| 221 | const ownEndpoint = isClaimed(own.endpoint); | |
| 222 | const endpoint = ownEndpoint ? own.endpoint : generic.endpoint; | |
| 223 | if (endpoint.value === undefined) { | |
| 224 | const signal = endpoint.warning === undefined ? OtelSignal.Unset() : OtelSignal.Off(); | |
| 225 | return { signal, used: [endpoint] }; | |
| 226 | } | |
| 227 | const protocol = claimed(own.protocol, generic.protocol); | |
| 228 | const headers = claimed(own.headers, generic.headers); | |
| 229 | const used = [endpoint, protocol, headers]; | |
| 230 | if (protocol.warning !== undefined || headers.warning !== undefined) { | |
| 231 | return { signal: OtelSignal.Off(), used }; | |
| 232 | } | |
| 233 | const url = ownEndpoint ? endpoint.value : withSignalPath(name, endpoint.value); | |
| 234 | return { | |
| 235 | signal: OtelSignal.Export({ | |
| 236 | url: url.toString(), | |
| 237 | protocol: protocol.value ?? "http/protobuf", | |
| 238 | headers: headers.value, | |
| 239 | }), | |
| 240 | used, | |
| 241 | }; | |
| 242 | }; | |
| 243 | ||
| 96 | 244 | export const load: Effect.Effect<OtelEnvironment> = Config.all({ |
| 97 | 245 | t3: flag( |
| 98 | 246 | "T3CODE_OTEL_SDK_DISABLED", |
| ... | ... | @@ -111,12 +259,31 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({ |
| 111 | 259 | `OTEL_SDK_DISABLED=${value} was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way`, |
| 112 | 260 | ), |
| 113 | 261 | resource: resourceAttributes, |
| 262 | generic: settings("OTEL_EXPORTER_OTLP_"), | |
| 263 | traces: settings("OTEL_EXPORTER_OTLP_TRACES_"), | |
| 264 | metrics: settings("OTEL_EXPORTER_OTLP_METRICS_"), | |
| 265 | logs: settings("OTEL_EXPORTER_OTLP_LOGS_"), | |
| 114 | 266 | }).pipe( |
| 115 | Effect.map(({ t3, spec, resource }) => { | |
| 267 | Effect.map(({ t3, spec, resource, generic, ...own }) => { | |
| 116 | 268 | const disabled = t3.value ?? spec.value ?? false; |
| 117 | const warnings = [t3.warning, spec.warning, resource.warning].filter( | |
| 118 | (warning) => warning !== undefined, | |
| 269 | // The kill switch wins outright, so the signals say nothing once it is set. | |
| 270 | const signals = disabled | |
| 271 | ? undefined | |
| 272 | : { | |
| 273 | traces: signal("TRACES", own.traces, generic), | |
| 274 | metrics: signal("METRICS", own.metrics, generic), | |
| 275 | logs: signal("LOGS", own.logs, generic), | |
| 276 | }; | |
| 277 | // A generic variable read by several signals warns once. | |
| 278 | const used = new Set( | |
| 279 | signals === undefined ? [] : Object.values(signals).flatMap((resolved) => resolved.used), | |
| 119 | 280 | ); |
| 281 | const warnings = [ | |
| 282 | t3.warning, | |
| 283 | spec.warning, | |
| 284 | resource.warning, | |
| 285 | ...Array.from(used, (setting) => setting.warning), | |
| 286 | ].filter((warning) => warning !== undefined); | |
| 120 | 287 | if (disabled) { |
| 121 | 288 | warnings.push( |
| 122 | 289 | t3.value |
| ... | ... | @@ -124,12 +291,58 @@ export const load: Effect.Effect<OtelEnvironment> = Config.all({ |
| 124 | 291 | : "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway", |
| 125 | 292 | ); |
| 126 | 293 | } |
| 127 | return { disabled, warnings, resourceAttributes: resource.value }; | |
| 294 | return { | |
| 295 | disabled, | |
| 296 | warnings, | |
| 297 | resourceAttributes: resource.value, | |
| 298 | traces: signals?.traces.signal ?? OtelSignal.Unset(), | |
| 299 | metrics: signals?.metrics.signal ?? OtelSignal.Unset(), | |
| 300 | logs: signals?.logs.signal ?? OtelSignal.Unset(), | |
| 301 | }; | |
| 128 | 302 | }), |
| 129 | 303 | // Every read above falls back instead of failing, so this cannot happen. |
| 130 | 304 | Effect.orDie, |
| 131 | 305 | ); |
| 132 | 306 | |
| 307 | export type SignalName = "traces" | "metrics" | "logs"; | |
| 308 | ||
| 309 | export interface SignalEndpoint { | |
| 310 | readonly url: string; | |
| 311 | readonly export: SignalExport; | |
| 312 | } | |
| 313 | ||
| 314 | /** | |
| 315 | * Where one signal exports and how. `T3CODE_OTLP_*_URL` wins outright with | |
| 316 | * T3 Code's own export, then an OTEL endpoint with its own headers and | |
| 317 | * protocol, since `T3CODE_OTLP_HEADERS` was written for a different | |
| 318 | * collector, then the first of `fallbackUrls` with T3 Code's own export. | |
| 319 | */ | |
| 320 | export const resolveSignalEndpoint = ( | |
| 321 | otel: OtelEnvironment, | |
| 322 | signal: SignalName, | |
| 323 | t3: { readonly url: string | undefined; readonly export: SignalExport }, | |
| 324 | ...fallbackUrls: ReadonlyArray<string | undefined> | |
| 325 | ): SignalEndpoint | undefined => { | |
| 326 | if (otel.disabled) { | |
| 327 | return undefined; | |
| 328 | } | |
| 329 | const t3Url = blankAsUnset(t3.url); | |
| 330 | if (t3Url !== undefined) { | |
| 331 | return { url: t3Url, export: t3.export }; | |
| 332 | } | |
| 333 | return OtelSignal.$match(otel[signal], { | |
| 334 | Export: ({ url, protocol, headers }): SignalEndpoint => ({ | |
| 335 | url, | |
| 336 | export: { protocol, headers, exportIntervalMs: t3.export.exportIntervalMs }, | |
| 337 | }), | |
| 338 | Off: () => undefined, | |
| 339 | Unset: () => { | |
| 340 | const url = fallbackUrls.map(blankAsUnset).find((candidate) => candidate !== undefined); | |
| 341 | return url === undefined ? undefined : { url, export: t3.export }; | |
| 342 | }, | |
| 343 | }); | |
| 344 | }; | |
| 345 | ||
| 133 | 346 | /** |
| 134 | 347 | * Provide this around Effect's OTLP exporters, which read |
| 135 | 348 | * `OTEL_RESOURCE_ATTRIBUTES` for themselves and die when it does not decode, |
| ... | ... | @@ -154,4 +367,7 @@ export const none: OtelEnvironment = { |
| 154 | 367 | disabled: false, |
| 155 | 368 | warnings: [], |
| 156 | 369 | resourceAttributes: {}, |
| 370 | traces: OtelSignal.Unset(), | |
| 371 | metrics: OtelSignal.Unset(), | |
| 372 | logs: OtelSignal.Unset(), | |
| 157 | 373 | }; |