| 1 | import * as NodePath from "@effect/platform-node/NodePath"; |
| 2 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | import * as NodeOS from "node:os"; |
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as Tracer from "effect/Tracer"; |
| 9 | import * as HttpClient from "effect/http/HttpClient"; |
| 10 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 11 | |
| 12 | import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; |
| 13 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; |
| 14 | |
| 15 | import * as ServerConfig from "./config.ts"; |
| 16 | import * as ServerLogger from "./serverLogger.ts"; |
| 17 | |
| 18 | interface ExportedRequest { |
| 19 | readonly url: string; |
| 20 | readonly headers: Readonly<Record<string, string>>; |
| 21 | readonly body: string; |
| 22 | } |
| 23 | |
| 24 | /** Answers every export with a 200 and keeps what was posted for assertions. */ |
| 25 | const layerCollector = (requests: Array<ExportedRequest>) => |
| 26 | Layer.succeed( |
| 27 | HttpClient.HttpClient, |
| 28 | HttpClient.make((request) => |
| 29 | Effect.sync(() => { |
| 30 | requests.push({ |
| 31 | url: request.url, |
| 32 | headers: request.headers, |
| 33 | body: |
| 34 | request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "", |
| 35 | }); |
| 36 | return HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })); |
| 37 | }), |
| 38 | ), |
| 39 | ); |
| 40 | |
| 41 | const layerConfig = (overrides: Partial<ServerConfig.ServerConfig["Service"]>) => |
| 42 | Layer.effect( |
| 43 | ServerConfig.ServerConfig, |
| 44 | Effect.gen(function* () { |
| 45 | const path = yield* Path.Path; |
| 46 | const baseDir = path.join(NodeOS.tmpdir(), "t3-server-logger-test"); |
| 47 | const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); |
| 48 | return ServerConfig.make({ |
| 49 | logLevel: "Info", |
| 50 | traceMinLevel: "Info", |
| 51 | traceTimingEnabled: false, |
| 52 | traceBatchWindowMs: 200, |
| 53 | traceMaxBytes: 1024, |
| 54 | traceMaxFiles: 1, |
| 55 | otlpTracesUrl: undefined, |
| 56 | otlpMetricsUrl: undefined, |
| 57 | otlpLogsUrl: undefined, |
| 58 | otlpTracesExport: DEFAULT_SIGNAL_EXPORT, |
| 59 | otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, |
| 60 | otlpLogsExport: DEFAULT_SIGNAL_EXPORT, |
| 61 | otelEnvironment: OtelEnvironment.none, |
| 62 | cwd: baseDir, |
| 63 | baseDir, |
| 64 | ...derivedPaths, |
| 65 | mode: "web", |
| 66 | autoBootstrapProjectFromCwd: false, |
| 67 | logWebSocketEvents: false, |
| 68 | tailscaleServeEnabled: false, |
| 69 | tailscaleServePort: 443, |
| 70 | port: 0, |
| 71 | host: undefined, |
| 72 | desktopBootstrapToken: undefined, |
| 73 | desktopTelemetryFd: undefined, |
| 74 | desktopTelemetryControlFd: undefined, |
| 75 | resourceMonitorPath: undefined, |
| 76 | staticDir: undefined, |
| 77 | devUrl: undefined, |
| 78 | devAllowedOrigins: [], |
| 79 | noBrowser: false, |
| 80 | startupPresentation: "browser", |
| 81 | ...overrides, |
| 82 | }); |
| 83 | }), |
| 84 | ).pipe(Layer.provide(NodePath.layer)); |
| 85 | |
| 86 | /** |
| 87 | * Logs once with the server's own logger set installed, then reports what the |
| 88 | * collector received. The export is asserted after the layer's scope closes, |
| 89 | * which is where the exporter flushes whatever the interval did not. |
| 90 | */ |
| 91 | const logThrough = (overrides: Partial<ServerConfig.ServerConfig["Service"]>) => |
| 92 | Effect.gen(function* () { |
| 93 | const requests: Array<ExportedRequest> = []; |
| 94 | yield* Effect.log("server logger under test").pipe( |
| 95 | Effect.provide( |
| 96 | ServerLogger.layer.pipe( |
| 97 | Layer.provide(layerConfig(overrides)), |
| 98 | Layer.provide(layerCollector(requests)), |
| 99 | ), |
| 100 | ), |
| 101 | ); |
| 102 | return requests; |
| 103 | }); |
| 104 | |
| 105 | /** |
| 106 | * Logs inside a span so the tracer logger has somewhere to attach an event, |
| 107 | * then reports both what the collector received and what landed on the span. |
| 108 | */ |
| 109 | const logInSpanThrough = (overrides: Partial<ServerConfig.ServerConfig["Service"]>) => |
| 110 | Effect.gen(function* () { |
| 111 | const requests: Array<ExportedRequest> = []; |
| 112 | const spans: Array<Tracer.NativeSpan> = []; |
| 113 | const layerTracer = Layer.succeed( |
| 114 | Tracer.Tracer, |
| 115 | Tracer.make({ |
| 116 | span: (spanOptions) => { |
| 117 | const span = new Tracer.NativeSpan(spanOptions); |
| 118 | spans.push(span); |
| 119 | return span; |
| 120 | }, |
| 121 | }), |
| 122 | ); |
| 123 | yield* Effect.log("server logger under test").pipe( |
| 124 | Effect.withSpan("server-logger-test"), |
| 125 | Effect.provide( |
| 126 | Layer.mergeAll( |
| 127 | ServerLogger.layer.pipe( |
| 128 | Layer.provide(layerConfig(overrides)), |
| 129 | Layer.provide(layerCollector(requests)), |
| 130 | ), |
| 131 | layerTracer, |
| 132 | ), |
| 133 | ), |
| 134 | ); |
| 135 | return { requests, spans }; |
| 136 | }); |
| 137 | |
| 138 | describe("ServerLoggerLive", () => { |
| 139 | it.effect("exports log records to the configured logs endpoint", () => |
| 140 | Effect.gen(function* () { |
| 141 | const requests = yield* logThrough({ |
| 142 | otlpLogsUrl: "https://collector.example.com/v1/logs", |
| 143 | }); |
| 144 | |
| 145 | assert.lengthOf(requests, 1); |
| 146 | const [request] = requests; |
| 147 | assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); |
| 148 | assert.include(request?.body ?? "", "server logger under test"); |
| 149 | assert.include(request?.body ?? "", "t3code-server"); |
| 150 | assert.include(request?.body ?? "", "service.runtime"); |
| 151 | }), |
| 152 | ); |
| 153 | |
| 154 | it.effect("keeps its service name while OTEL resource attributes add dimensions", () => |
| 155 | Effect.gen(function* () { |
| 156 | const requests = yield* logThrough({ |
| 157 | otlpLogsUrl: "https://collector.example.com/v1/logs", |
| 158 | }).pipe( |
| 159 | Effect.provide( |
| 160 | ConfigProvider.layer( |
| 161 | ConfigProvider.fromEnv({ |
| 162 | env: { |
| 163 | OTEL_SERVICE_NAME: "renamed", |
| 164 | OTEL_RESOURCE_ATTRIBUTES: |
| 165 | "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", |
| 166 | }, |
| 167 | }), |
| 168 | ), |
| 169 | ), |
| 170 | ); |
| 171 | |
| 172 | assert.lengthOf(requests, 1); |
| 173 | const body = requests[0]?.body ?? ""; |
| 174 | assert.include(body, '"stringValue":"t3code-server"'); |
| 175 | assert.include(body, "deployment.environment.name"); |
| 176 | assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); |
| 177 | assert.notInclude(body, "renamed"); |
| 178 | }), |
| 179 | ); |
| 180 | |
| 181 | it.effect("stays off the network when no logs endpoint is configured", () => |
| 182 | Effect.gen(function* () { |
| 183 | const requests = yield* logThrough({}); |
| 184 | |
| 185 | assert.lengthOf(requests, 0); |
| 186 | }), |
| 187 | ); |
| 188 | |
| 189 | it.effect("sends the headers and wire format the log signal asked for", () => |
| 190 | Effect.gen(function* () { |
| 191 | const requests = yield* logThrough({ |
| 192 | otlpLogsUrl: "https://collector.example.com/v1/logs", |
| 193 | otlpLogsExport: { |
| 194 | ...DEFAULT_SIGNAL_EXPORT, |
| 195 | protocol: "http/protobuf", |
| 196 | headers: { "x-scope": "logs" }, |
| 197 | }, |
| 198 | }); |
| 199 | |
| 200 | assert.lengthOf(requests, 1); |
| 201 | assert.strictEqual(requests[0]?.headers["x-scope"], "logs"); |
| 202 | assert.strictEqual(requests[0]?.headers["content-type"], "application/x-protobuf"); |
| 203 | }), |
| 204 | ); |
| 205 | |
| 206 | it.effect("attaches log messages to the active span when no logs endpoint is configured", () => |
| 207 | Effect.gen(function* () { |
| 208 | const { requests, spans } = yield* logInSpanThrough({}); |
| 209 | |
| 210 | assert.lengthOf(requests, 0); |
| 211 | assert.lengthOf(spans, 1); |
| 212 | assert.deepEqual( |
| 213 | spans[0]?.events.map(([name]) => name), |
| 214 | ["server logger under test"], |
| 215 | ); |
| 216 | }), |
| 217 | ); |
| 218 | |
| 219 | it.effect("stops duplicating messages onto the span once log records are exported", () => |
| 220 | Effect.gen(function* () { |
| 221 | const { requests, spans } = yield* logInSpanThrough({ |
| 222 | otlpLogsUrl: "https://collector.example.com/v1/logs", |
| 223 | }); |
| 224 | |
| 225 | assert.lengthOf(requests, 1); |
| 226 | assert.include(requests[0]?.body ?? "", "server logger under test"); |
| 227 | assert.lengthOf(spans, 1); |
| 228 | assert.lengthOf(spans[0]?.events ?? [], 0); |
| 229 | }), |
| 230 | ); |
| 231 | }); |