| 1 | import * as Exit from "effect/Exit"; |
| 2 | import * as Layer from "effect/Layer"; |
| 3 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 4 | import * as Scope from "effect/Scope"; |
| 5 | import { HttpClient } from "effect/http"; |
| 6 | import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/observability"; |
| 7 | |
| 8 | import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 9 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 10 | import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; |
| 11 | import * as ClientTracer from "./clientTracer"; |
| 12 | import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; |
| 13 | import { isElectron } from "../env"; |
| 14 | import { APP_VERSION } from "~/branding"; |
| 15 | |
| 16 | const DEFAULT_EXPORT_INTERVAL_MS = 1_000; |
| 17 | const 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 | |
| 27 | const layerDelegateRuntime = Layer.mergeAll( |
| 28 | PrimaryEnvironmentHttpLayer.layer, |
| 29 | OtlpExporter.layerFlusher, |
| 30 | OtlpSerialization.layerJson, |
| 31 | Layer.succeed(HttpClient.TracerDisabledWhen, () => true), |
| 32 | ); |
| 33 | |
| 34 | let activeRuntime: ManagedRuntime.ManagedRuntime<never, never> | null = null; |
| 35 | let activeScope: Scope.Closeable | null = null; |
| 36 | let activeConfigKey: string | null = null; |
| 37 | let configurationGeneration = 0; |
| 38 | let pendingConfiguration = Promise.resolve(); |
| 39 | |
| 40 | export interface ClientTracingConfig { |
| 41 | readonly exportIntervalMs?: number; |
| 42 | } |
| 43 | |
| 44 | export 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 | |
| 52 | async 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 | |
| 114 | async 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 | } |