1import * as NodePath from "@effect/platform-node/NodePath";
2import { assert, describe, it } from "@effect/vitest";
3import * as NodeOS from "node:os";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as Tracer from "effect/Tracer";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientResponse from "effect/http/HttpClientResponse";
11
12import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
13import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
14
15import * as ServerConfig from "./config.ts";
16import * as ServerLogger from "./serverLogger.ts";
17
18interface 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. */
25const 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
41const 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 */
91const 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 */
109const 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
138describe("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});