1import * as Exit from "effect/Exit";
2import * as Layer from "effect/Layer";
3import * as ManagedRuntime from "effect/ManagedRuntime";
4import * as Scope from "effect/Scope";
5import { HttpClient } from "effect/http";
6import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/observability";
7
8import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
9import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
10import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary";
11import * as ClientTracer from "./clientTracer";
12import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer";
13import { isElectron } from "../env";
14import { APP_VERSION } from "~/branding";
15
16const DEFAULT_EXPORT_INTERVAL_MS = 1_000;
17const CLIENT_TRACING_RESOURCE = {
18 serviceName: "t3code-web",
19 attributes: {
20 "service.namespace": "t3code",
21 "service.runtime": "t3-web",
22 "service.mode": isElectron ? "electron" : "browser",
23 "service.version": APP_VERSION,
24 },
25} as const;
26
27const layerDelegateRuntime = Layer.mergeAll(
28 PrimaryEnvironmentHttpLayer.layer,
29 OtlpExporter.layerFlusher,
30 OtlpSerialization.layerJson,
31 Layer.succeed(HttpClient.TracerDisabledWhen, () => true),
32);
33
34let activeRuntime: ManagedRuntime.ManagedRuntime<never, never> | null = null;
35let activeScope: Scope.Closeable | null = null;
36let activeConfigKey: string | null = null;
37let configurationGeneration = 0;
38let pendingConfiguration = Promise.resolve();
39
40export interface ClientTracingConfig {
41 readonly exportIntervalMs?: number;
42}
43
44export function configureClientTracing(config: ClientTracingConfig = {}): Promise<void> {
45 if (config.exportIntervalMs === undefined && activeConfigKey !== null) {
46 return pendingConfiguration;
47 }
48 pendingConfiguration = pendingConfiguration.finally(() => applyClientTracingConfig(config));
49 return pendingConfiguration;
50}
51
52async function applyClientTracingConfig(config: ClientTracingConfig): Promise<void> {
53 const otlpTracesUrl = resolvePrimaryEnvironmentHttpUrl("/api/observability/v1/traces");
54 const exportIntervalMs = Math.max(10, config.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS);
55 const nextConfigKey = `${otlpTracesUrl}|${exportIntervalMs}`;
56
57 if (activeConfigKey === nextConfigKey && ClientTracer.hasDelegate()) {
58 return;
59 }
60
61 activeConfigKey = nextConfigKey;
62 const generation = ++configurationGeneration;
63
64 const previousRuntime = activeRuntime;
65 const previousScope = activeScope;
66
67 ClientTracer.setDelegate(null);
68 activeRuntime = null;
69 activeScope = null;
70
71 await disposeTracerRuntime(previousRuntime, previousScope);
72
73 const runtime = ManagedRuntime.make(layerDelegateRuntime);
74 const scope = runtime.runSync(Scope.make());
75
76 const delegateResult = await settleAsyncResult(() =>
77 runtime.runPromiseExit(
78 Scope.provide(scope)(
79 OtlpTracer.make({
80 url: otlpTracesUrl,
81 exportInterval: `${exportIntervalMs} millis`,
82 resource: CLIENT_TRACING_RESOURCE,
83 }),
84 ),
85 ),
86 );
87 if (delegateResult._tag === "Failure") {
88 await disposeTracerRuntime(runtime, scope);
89
90 if (generation === configurationGeneration) {
91 const error = squashAtomCommandFailure(delegateResult);
92 const tracesUrl = new URL(otlpTracesUrl);
93 console.warn("Failed to configure client tracing exporter", {
94 scheme: tracesUrl.protocol.replace(/:$/, ""),
95 host: tracesUrl.hostname,
96 port: tracesUrl.port || undefined,
97 exportIntervalMs,
98 ...safeErrorLogAttributes(error),
99 });
100 }
101 return;
102 }
103
104 if (generation !== configurationGeneration) {
105 await disposeTracerRuntime(runtime, scope);
106 return;
107 }
108
109 ClientTracer.setDelegate(delegateResult.value);
110 activeRuntime = runtime;
111 activeScope = scope;
112}
113
114async function disposeTracerRuntime(
115 runtime: ManagedRuntime.ManagedRuntime<never, never> | null,
116 scope: Scope.Closeable | null,
117): Promise<void> {
118 if (runtime === null || scope === null) {
119 return;
120 }
121
122 await settleAsyncResult(() => runtime.runPromiseExit(Scope.close(scope, Exit.void)));
123 runtime.dispose();
124}