Skip to content
Source
Sign inSign up

clientTracing.ts

148 lines5.1 KB

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 { releaseChannelOf, releaseResourceAttributes } from "@t3tools/shared/releaseResource";
12import * as ClientTracer from "./clientTracer";
13import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer";
14import { isElectron } from "../env";
15import { APP_VERSION } from "~/branding";
16​
17const DEFAULT_EXPORT_INTERVAL_MS = 1_000;
18interface NavigatorUserAgentData {
19 readonly platform: string;
20 readonly mobile: boolean;
21 readonly brands: ReadonlyArray<{ readonly brand: string; readonly version: string }>;
23​
24/**
25 * The `browser.*` and `user_agent.original` attributes the OpenTelemetry browser
26 * resource detector reports. `userAgentData` exists only in Chromium, which
27 * includes the desktop app.
28 */
29const browserResourceAttributes = (): Record<string, unknown> => {
30 if (typeof navigator === "undefined") return {};
31 const userAgentData = (navigator as Navigator & { userAgentData?: NavigatorUserAgentData })
32 .userAgentData;
33 return {
34 "user_agent.original": navigator.userAgent,
35 "browser.language": navigator.language,
36 ...(userAgentData && {
37 "browser.platform": userAgentData.platform,
38 "browser.mobile": userAgentData.mobile,
39 "browser.brands": userAgentData.brands.map(({ brand, version }) => `${brand} ${version}`),
40 }),
41 };
42};
43​
44// PR previews can't mark themselves in APP_VERSION without breaking the version-skew check against the server.
45const RELEASE_CHANNEL =
46 import.meta.env.DEV || import.meta.env.VITE_RELEASE_CHANNEL === "dev"
47 ? "dev"
48 : import.meta.env.VITE_RELEASE_CHANNEL === "preview"
49 ? "preview"
50 : releaseChannelOf(APP_VERSION);
51const CLIENT_TRACING_RESOURCE = {
52 serviceName: "t3code-web",
53 attributes: {
54 "service.namespace": "t3code",
55 "service.instance.id": ClientTracer.serviceInstanceId,
56 ...releaseResourceAttributes(RELEASE_CHANNEL),
57 "service.runtime": "t3-web",
58 "t3code.client.surface": isElectron ? "desktop" : "web",
59 "service.version": APP_VERSION,
60 ...browserResourceAttributes(),
61 },
62} as const;
63​
64const layerDelegateRuntime = Layer.mergeAll(
65 PrimaryEnvironmentHttpLayer.layer,
66 OtlpExporter.layerFlusher,
67 OtlpSerialization.layerJson,
68 Layer.succeed(HttpClient.TracerDisabledWhen, () => true),
69);
70​
71let activeRuntime: ManagedRuntime.ManagedRuntime<never, never> | null = null;
72let activeScope: Scope.Closeable | null = null;
73let activeConfigKey: string | null = null;
74let pendingConfiguration = Promise.resolve();
75​
76export function configureClientTracing(): Promise<void> {
77 if (activeConfigKey !== null) {
78 return pendingConfiguration;
79 }
80 pendingConfiguration = pendingConfiguration.finally(() => applyClientTracingConfig());
81 return pendingConfiguration;
83​
84async function applyClientTracingConfig(): Promise<void> {
85 const otlpTracesUrl = resolvePrimaryEnvironmentHttpUrl("/api/observability/v1/traces");
86 const exportIntervalMs = DEFAULT_EXPORT_INTERVAL_MS;
87 const nextConfigKey = `${otlpTracesUrl}|${exportIntervalMs}`;
88​
89 if (activeConfigKey === nextConfigKey && ClientTracer.hasDelegate()) {
90 return;
91 }
92​
93 activeConfigKey = nextConfigKey;
94​
95 const previousRuntime = activeRuntime;
96 const previousScope = activeScope;
97​
98 ClientTracer.setDelegate(null);
99 activeRuntime = null;
100 activeScope = null;
101​
102 await disposeTracerRuntime(previousRuntime, previousScope);
103​
104 const runtime = ManagedRuntime.make(layerDelegateRuntime);
105 const scope = runtime.runSync(Scope.make());
106​
107 const delegateResult = await settleAsyncResult(() =>
108 runtime.runPromiseExit(
109 Scope.provide(scope)(
110 OtlpTracer.make({
111 url: otlpTracesUrl,
112 exportInterval: `${exportIntervalMs} millis`,
113 resource: CLIENT_TRACING_RESOURCE,
114 }),
115 ),
116 ),
117 );
118 if (delegateResult._tag === "Failure") {
119 await disposeTracerRuntime(runtime, scope);
120​
121 const error = squashAtomCommandFailure(delegateResult);
122 const tracesUrl = new URL(otlpTracesUrl);
123 console.warn("Failed to configure client tracing exporter", {
124 scheme: tracesUrl.protocol.replace(/:$/, ""),
125 host: tracesUrl.hostname,
126 port: tracesUrl.port || undefined,
127 exportIntervalMs,
128 ...safeErrorLogAttributes(error),
129 });
130 return;
131 }
132​
133 ClientTracer.setDelegate(delegateResult.value);
134 activeRuntime = runtime;
135 activeScope = scope;
137​
138async function disposeTracerRuntime(
139 runtime: ManagedRuntime.ManagedRuntime<never, never> | null,
140 scope: Scope.Closeable | null,
141): Promise<void> {
142 if (runtime === null || scope === null) {
143 return;
144 }
145​
146 await settleAsyncResult(() => runtime.runPromiseExit(Scope.close(scope, Exit.void)));
147 runtime.dispose();