1import type {
2 OtlpEndpointCheckInput,
3 OtlpEndpointCheckResult,
4 OtlpSignal,
5} from "@t3tools/contracts";
6import { layerOtlpSerialization } from "@t3tools/shared/observability";
7import * as Clock from "effect/Clock";
8import * as Context from "effect/Context";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import { FetchHttpClient, HttpClient } from "effect/http";
13import { OtlpSerialization } from "effect/observability";
14
15import * as ServerConfig from "../config.ts";
16
17const CHECK_TIMEOUT = "5 seconds";
18
19export 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
32const 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
46const 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
98export const layer = Layer.effect(OtlpEndpointCheck, make);