Open History to see the last commit
1import { useAtomValue } from "@effect/atom-react";
2import type { OtlpEndpointCheckResult, OtlpSignal, ServerObservability } from "@t3tools/contracts";
3import { useEffect, useEffectEvent, useState } from "react";
4
5import { serverEnvironment } from "../../state/server";
6import { useAtomCommand } from "../../state/use-atom-command";
7import { Button } from "../ui/button";
8import { Input } from "../ui/input";
9import { Label } from "../ui/label";
10import { ConnectionStatusDot } from "../ConnectionStatusDot";
11import { useSettingsScope } from "./SettingsScopeContext";
12import { useScopedSettings } from "./useScopedSettings";
13import { SettingsSection } from "./settingsLayout";
14import { SettingsScopeNotice } from "./SettingsScopeNotice";
15
16const SIGNALS = [
17 {
18 key: "otlpTracesUrl",
19 sourceKey: "otlpTracesSource",
20 variableKey: "otlpTracesVariable",
21 label: "Traces",
22 signal: "traces",
23 },
24 {
25 key: "otlpMetricsUrl",
26 sourceKey: "otlpMetricsSource",
27 variableKey: "otlpMetricsVariable",
28 label: "Metrics",
29 signal: "metrics",
30 },
31 {
32 key: "otlpLogsUrl",
33 sourceKey: "otlpLogsSource",
34 variableKey: "otlpLogsVariable",
35 label: "Logs",
36 signal: "logs",
37 },
38] as const;
39
40type SignalDescriptor = (typeof SIGNALS)[number];
41type SignalKey = SignalDescriptor["key"];
42
43const OVERRIDE_LABELS = { environment: "Set by environment", bootstrap: "Set by desktop" } as const;
44
45function runningOverride(
46 running: ServerObservability | undefined,
47 { key, sourceKey, variableKey }: SignalDescriptor,
48) {
49 const url = running?.[key];
50 const source = running?.[sourceKey];
51 if (url === undefined || (source !== "environment" && source !== "bootstrap")) return undefined;
52 return { url, label: OVERRIDE_LABELS[source], variable: running?.[variableKey] };
53}
54
55type EndpointCheck = { readonly url: string; readonly result: OtlpEndpointCheckResult | null };
56
57const isHttpUrl = (url: string) => /^https?:\/\/./i.test(url);
58
59function endpointStatus(url: string, check: EndpointCheck | undefined) {
60 if (url === "") return { label: "Off", dot: "bg-muted-foreground/40" };
61 if (check?.url !== url) return { label: "Not checked", dot: "bg-muted-foreground/40" };
62 const { result } = check;
63 if (result === null) return { label: "Checking…", dot: "bg-warning" };
64 switch (result._tag) {
65 case "Accepted":
66 return { label: `Connected · ${Math.round(result.latencyMs)} ms`, dot: "bg-success" };
67 case "Rejected":
68 return { label: `Rejected · HTTP ${result.status}`, dot: "bg-destructive" };
69 case "Unreachable":
70 return { label: result.timedOut ? "Timed out" : "Unreachable", dot: "bg-destructive" };
71 }
72}
73
74export function TelemetryExportSettings() {
75 const { environment, scope } = useSettingsScope();
76 if (scope.kind !== "environment" || !environment) {
77 return (
78 <SettingsSection title="OpenTelemetry export" id="telemetry-export">
79 <SettingsScopeNotice target="environment">
80 Choose one environment to configure its telemetry exports.
81 </SettingsScopeNotice>
82 </SettingsSection>
83 );
84 }
85 return <TelemetryExportForm key={environment.environmentId} />;
86}
87
88function TelemetryExportForm() {
89 const { environment } = useSettingsScope();
90 const saved = useScopedSettings((settings) => settings.observability);
91 const [draft, setDraft] = useState<Partial<typeof saved>>({});
92 const [saving, setSaving] = useState(false);
93 const [message, setMessage] = useState<string | null>(null);
94 const environmentId = environment?.environmentId;
95 const canEdit = useAtomValue(
96 serverEnvironment.checkOtlpEndpoint.permissionAtom(environmentId ?? null),
97 );
98 const canCheck =
99 canEdit && environment?.serverConfig?.environment.capabilities.otlpEndpointCheck === true;
100 const running = environment?.serverConfig?.observability;
101 const [checks, setChecks] = useState<Partial<Record<SignalKey, EndpointCheck>>>(() =>
102 Object.fromEntries(
103 SIGNALS.flatMap((descriptor) => {
104 const url = runningOverride(running, descriptor)?.url ?? saved[descriptor.key];
105 return isHttpUrl(url) ? [[descriptor.key, { url, result: null }]] : [];
106 }),
107 ),
108 );
109 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: true });
110 const checkOtlpEndpoint = useAtomCommand(serverEnvironment.checkOtlpEndpoint);
111 const values = { ...saved, ...draft };
112 const changedSignals = SIGNALS.filter(({ key }) => values[key].trim() !== saved[key]);
113 const changed = changedSignals.length > 0;
114
115 const sendCheck = async (key: SignalKey, signal: OtlpSignal, url: string) => {
116 if (!environmentId) return;
117 const response = await checkOtlpEndpoint({ environmentId, input: { signal, url } });
118 setChecks((previous) => {
119 if (previous[key]?.url !== url || previous[key].result !== null) return previous;
120 const next = { ...previous };
121 if (response._tag === "Success") next[key] = { url, result: response.value };
122 else delete next[key];
123 return next;
124 });
125 };
126
127 const checkEndpoint = (key: SignalKey, signal: OtlpSignal, url: string) => {
128 if (!isHttpUrl(url)) return;
129 setChecks((previous) => ({ ...previous, [key]: { url, result: null } }));
130 void sendCheck(key, signal, url);
131 };
132
133 const sendPendingChecks = useEffectEvent(() => {
134 for (const { key, signal } of SIGNALS) {
135 const check = checks[key];
136 if (check?.result === null) void sendCheck(key, signal, check.url);
137 }
138 });
139 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- sends pending endpoint check requests once the environment can run them
140 useEffect(() => {
141 // oxlint-disable-next-line react/set-state-in-effect -- State changes only after the response arrives.
142 if (canCheck) sendPendingChecks();
143 }, [canCheck]);
144
145 return (
146 <SettingsSection title="OpenTelemetry export" id="telemetry-export">
147 <form
148 className="grid gap-4 px-4 py-4 sm:px-5"
149 onSubmit={async (event) => {
150 event.preventDefault();
151 if (!environment || saving) return;
152 setSaving(true);
153 setMessage(null);
154 const result = await updateSettings({
155 environmentId: environment.environmentId,
156 input: {
157 patch: {
158 observability: Object.fromEntries(
159 changedSignals.map(({ key }) => [key, values[key]]),
160 ),
161 },
162 },
163 });
164 setSaving(false);
165 if (result._tag === "Success") {
166 setDraft({});
167 setMessage("Saved. Restart the server to apply.");
168 }
169 }}
170 >
171 <p className="text-xs text-muted-foreground">
172 Send traces, metrics, and logs to an OTLP HTTP receiver. Restart the server to apply.
173 Environment variables override these settings.
174 </p>
175 {!canEdit && (
176 <p className="text-xs text-muted-foreground">
177 This connection lacks permission to change settings on{" "}
178 {environment?.label ?? "the selected environment"}.
179 </p>
180 )}
181 {SIGNALS.map((descriptor) => {
182 const { key, label, signal } = descriptor;
183 const override = runningOverride(running, descriptor);
184 const url = override?.url ?? values[key].trim();
185 const check = canCheck ? endpointStatus(url, checks[key]) : null;
186 const status = override
187 ? {
188 label: check ? `${override.label} · ${check.label}` : override.label,
189 dot: check?.dot ?? "bg-muted-foreground/40",
190 }
191 : check;
192 return (
193 <div key={key} className="grid gap-1.5">
194 <div className="flex items-center justify-between gap-3">
195 <div className="flex items-center gap-1.5">
196 {status && <ConnectionStatusDot dotClassName={status.dot} />}
197 <Label htmlFor={key}>{label} endpoint</Label>
198 </div>
199 {status && <span className="text-xs text-muted-foreground">{status.label}</span>}
200 </div>
201 <div className="flex gap-2">
202 <Input
203 id={key}
204 size="sm"
205 type="url"
206 pattern="[Hh][Tt][Tt][Pp][Ss]?://.*"
207 title="Enter an HTTP or HTTPS endpoint, or leave empty to disable export."
208 placeholder={`http://localhost:4318/v1/${signal}`}
209 value={values[key]}
210 disabled={saving || !canEdit}
211 onChange={(event) => {
212 const value = event.target.value;
213 // Clear restored drafts so later saves cannot overwrite another client's changes.
214 setDraft((previous) => {
215 const next = { ...previous, [key]: value };
216 if (value.trim() === saved[key]) delete next[key];
217 return next;
218 });
219 setMessage(null);
220 }}
221 />
222 {canCheck && (
223 <Button
224 type="button"
225 size="sm"
226 variant="outline"
227 disabled={!isHttpUrl(url) || checks[key]?.result === null}
228 onClick={() => void checkEndpoint(key, signal, url)}
229 >
230 Test
231 </Button>
232 )}
233 </div>
234 {(saved[key] !== (running?.[key] ?? "") || override?.variable) && (
235 <p className="break-all text-xs text-muted-foreground">
236 Running: {running?.[key] || "Disabled"}
237 {override?.variable && (
238 <>
239 {" "}
240 from <code className="font-mono">{override.variable}</code>
241 </>
242 )}
243 </p>
244 )}
245 </div>
246 );
247 })}
248 <div className="flex items-center justify-between gap-3">
249 <p role="status" className="text-xs text-muted-foreground">
250 {message}
251 </p>
252 <div className="flex shrink-0 gap-2">
253 <Button
254 type="button"
255 size="xs"
256 variant="outline"
257 disabled={!changed || saving || !canEdit}
258 onClick={() => {
259 setDraft({});
260 setMessage(null);
261 if (!canCheck) return;
262 for (const descriptor of SIGNALS) {
263 const { key, signal } = descriptor;
264 if (runningOverride(running, descriptor)) continue;
265 if (checks[key]?.url !== saved[key]) checkEndpoint(key, signal, saved[key]);
266 }
267 }}
268 >
269 Discard
270 </Button>
271 <Button type="submit" size="xs" disabled={!changed || saving || !canEdit}>
272 {saving ? "Saving…" : "Save"}
273 </Button>
274 </div>
275 </div>
276 </form>
277 </SettingsSection>
278 );
279}