authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-16 00:11:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-16 00:11:29-07:00
log708d5383a9c7415c3795890ca4f664c7b00f9a47
tree5c6a42f522eb1fcc23858558ea998a72826c118b
parent5cd27441007aee0218f8145f8033de7b8a495f3d
signature Signed by PGP key B5690EEEBB952194

[codex] Trace first-party relay clients (#2995)

Co-authored-by: codex <codex@users.noreply.github.com>

31 files changed, 900 insertions(+), 78 deletions(-)

.github/workflows/release.yml+79
......@@ -180,12 +180,46 @@ jobs:
180180 clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }}
181181 relay_url: ${{ steps.public_config.outputs.relay_url }}
182182 env:
183 CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
184 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
183185 RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }}
184186 RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }}
185187 CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
186188 CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }}
187189 CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }}
188190 steps:
191 - name: Checkout
192 uses: actions/checkout@v6
193 with:
194 ref: ${{ needs.preflight.outputs.ref }}
195
196 - name: Setup Vite+
197 uses: voidzero-dev/setup-vp@v1
198 with:
199 node-version-file: package.json
200 cache: true
201 run-install: |
202 args:
203 - --filter=t3code-relay...
204
205 - id: relay_state
206 name: Read production relay tracing config
207 shell: bash
208 run: |
209 vp run --filter t3code-relay deploy \
210 --stage prod \
211 --read-state \
212 --github-output \
213 --github-env-file "$RUNNER_TEMP/relay-client-tracing.env"
214
215 - name: Upload relay client tracing config
216 uses: actions/upload-artifact@v7
217 with:
218 name: relay-client-tracing-config
219 path: ${{ runner.temp }}/relay-client-tracing.env
220 if-no-files-found: error
221 retention-days: 1
222
189223 - id: public_config
190224 name: Resolve production relay public config
191225 shell: bash
......@@ -272,6 +306,20 @@ jobs:
272306 cache: true
273307 run-install: true
274308
309 - name: Download relay client tracing config
310 uses: actions/download-artifact@v8
311 with:
312 name: relay-client-tracing-config
313 path: ${{ runner.temp }}/relay-client-tracing
314
315 - name: Load relay client tracing config
316 shell: bash
317 run: |
318 config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env"
319 tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")"
320 echo "::add-mask::$tracing_token"
321 cat "$config_path" >> "$GITHUB_ENV"
322
275323 - name: Align package versions to release version
276324 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
277325
......@@ -508,6 +556,20 @@ jobs:
508556 - --filter=@t3tools/web...
509557 - --filter=@t3tools/scripts...
510558
559 - name: Download relay client tracing config
560 uses: actions/download-artifact@v8
561 with:
562 name: relay-client-tracing-config
563 path: ${{ runner.temp }}/relay-client-tracing
564
565 - name: Load relay client tracing config
566 shell: bash
567 run: |
568 config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env"
569 tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")"
570 echo "::add-mask::$tracing_token"
571 cat "$config_path" >> "$GITHUB_ENV"
572
511573 - name: Align package versions to release version
512574 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
513575
......@@ -670,6 +732,20 @@ jobs:
670732 - --filter=@t3tools/scripts...
671733 - --filter=@t3tools/web...
672734
735 - name: Download relay client tracing config
736 uses: actions/download-artifact@v8
737 with:
738 name: relay-client-tracing-config
739 path: ${{ runner.temp }}/relay-client-tracing
740
741 - name: Load relay client tracing config
742 shell: bash
743 run: |
744 config_path="$RUNNER_TEMP/relay-client-tracing/relay-client-tracing.env"
745 tracing_token="$(sed -n 's/^T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=//p' "$config_path")"
746 echo "::add-mask::$tracing_token"
747 cat "$config_path" >> "$GITHUB_ENV"
748
673749 - name: Align package versions to release version
674750 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
675751
......@@ -716,6 +792,9 @@ jobs:
716792 --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \
717793 --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \
718794 --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \
795 --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=${T3CODE_RELAY_CLIENT_OTLP_TRACES_URL:-}" \
796 --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=${T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET:-}" \
797 --build-env "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=${T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN:-}" \
719798 --build-env "VITE_HOSTED_APP_URL=$router_url" \
720799 --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"
721800 )"
apps/mobile/src/features/observability/mobileTracing.test.ts+2-1
......@@ -4,6 +4,7 @@ import * as Layer from "effect/Layer";
44import { vi } from "vite-plus/test";
55
66import { remoteHttpClientLayer } from "@t3tools/client-runtime";
7import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
78
89import { makeMobileTracingLayer } from "./mobileTracing";
910
......@@ -29,7 +30,7 @@ it.effect("exports spans through the scoped mobile OTLP layer", () => {
2930 },
3031 ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn)));
3132 const tracedApplication = Layer.effectDiscard(
32 Effect.void.pipe(Effect.withSpan("mobile.test.span")),
33 Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing),
3334 ).pipe(Layer.provide(tracingLayer));
3435
3536 return Effect.gen(function* () {
apps/mobile/src/features/observability/mobileTracing.ts+8-24
......@@ -1,7 +1,5 @@
11import Constants from "expo-constants";
2import * as Layer from "effect/Layer";
3import type { HttpClient } from "effect/unstable/http";
4import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
2import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing";
53
64import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig";
75
......@@ -28,27 +26,13 @@ export function resolveMobileTracingConfig(): MobileTracingConfig | null {
2826export function makeMobileTracingLayer(
2927 config: MobileTracingConfig | null,
3028 resource: MobileTracingResource,
31): Layer.Layer<never, never, HttpClient.HttpClient> {
32 if (config === null) {
33 return Layer.empty;
34 }
35
36 return OtlpTracer.layer({
37 url: config.tracesUrl,
38 headers: {
39 Authorization: `Bearer ${config.tracesToken}`,
40 "X-Axiom-Dataset": config.tracesDataset,
41 },
42 resource: {
43 serviceName: "t3-mobile",
44 serviceVersion: resource.serviceVersion,
45 attributes: {
46 "service.runtime": "react-native",
47 "service.component": "mobile",
48 "deployment.environment.name": resource.appVariant,
49 },
50 },
51 }).pipe(Layer.provide(OtlpSerialization.layerJson));
29) {
30 return makeRelayClientTracingLayer(config, {
31 serviceName: "t3-mobile-relay-client",
32 serviceVersion: resource.serviceVersion,
33 runtime: "react-native",
34 client: `mobile-${resource.appVariant}`,
35 });
5236}
5337
5438export const mobileTracingLayer = makeMobileTracingLayer(resolveMobileTracingConfig(), {
apps/server/src/cli/connect.ts+4
......@@ -6,6 +6,7 @@ import {
66} from "@t3tools/contracts";
77import { RelayOkResponse } from "@t3tools/contracts/relay";
88import * as RelayClient from "@t3tools/shared/relayClient";
9import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
910import * as Console from "effect/Console";
1011import * as Duration from "effect/Duration";
1112import * as Effect from "effect/Effect";
......@@ -29,6 +30,7 @@ import * as CliState from "../cloud/CliState.ts";
2930import * as CliTokenManager from "../cloud/CliTokenManager.ts";
3031import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts";
3132import { relayUrlConfig } from "../cloud/publicConfig.ts";
33import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
3234import { ServerConfig } from "../config.ts";
3335import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts";
3436import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
......@@ -228,6 +230,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f
228230 httpClient.execute,
229231 Effect.flatMap(HttpClientResponse.filterStatusOk),
230232 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
233 withRelayClientTracing,
231234 );
232235 return response.ok
233236 ? ({ status: "revoked" } satisfies RelayUnlinkResult)
......@@ -299,6 +302,7 @@ const runCloudCommand = <A, E>(
299302 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
300303 EnvironmentAuth.runtimeLayer,
301304 ServerEnvironmentLive,
305 headlessRelayClientTracingLayer,
302306 ).pipe(
303307 Layer.provideMerge(FetchHttpClient.layer),
304308 Layer.provideMerge(Layer.succeed(ServerConfig, config)),
apps/server/src/cloud/http.test.ts+40-2
......@@ -3,13 +3,19 @@ import { describe, expect, it } from "@effect/vitest";
33import * as Effect from "effect/Effect";
44import * as Option from "effect/Option";
55import * as PlatformError from "effect/PlatformError";
6import { HttpClient } from "effect/unstable/http";
6import * as Tracer from "effect/Tracer";
7import { HttpClient, HttpServerRequest } from "effect/unstable/http";
78
9import { RelayClientTracer } from "@t3tools/shared/relayTracing";
810import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
911import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
1012import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
1113import * as CliTokenManager from "./CliTokenManager.ts";
12import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts";
14import {
15 consumeCloudReplayGuards,
16 reconcileDesiredCloudLink,
17 traceRelayBrokerHandler,
18} from "./http.ts";
1319import {
1420 CloudManagedEndpointRuntime,
1521 type CloudManagedEndpointRuntimeShape,
......@@ -69,6 +75,38 @@ describe("consumeCloudReplayGuards", () => {
6975 );
7076});
7177
78describe("traceRelayBrokerHandler", () => {
79 it.effect("continues the incoming relay trace with the product tracer", () =>
80 Effect.gen(function* () {
81 const spans: Array<Tracer.Span> = [];
82 const productTracer = Tracer.make({
83 span: (options) => {
84 const span = new Tracer.NativeSpan(options);
85 spans.push(span);
86 return span;
87 },
88 });
89 const request = HttpServerRequest.fromWeb(
90 new Request("https://environment.example.test/api/t3-cloud/mint-credential", {
91 headers: {
92 traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01",
93 },
94 }),
95 );
96
97 yield* traceRelayBrokerHandler(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe(
98 Effect.provideService(HttpServerRequest.HttpServerRequest, request),
99 Effect.provideService(RelayClientTracer, Option.some(productTracer)),
100 );
101
102 expect(spans).toHaveLength(1);
103 const span = spans[0]!;
104 expect(span.traceId).toBe("0123456789abcdef0123456789abcdef");
105 expect(Option.getOrUndefined(span.parent)?.spanId).toBe("0123456789abcdef");
106 }),
107 );
108});
109
72110describe("reconcileDesiredCloudLink", () => {
73111 it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () =>
74112 Effect.gen(function* () {
apps/server/src/cloud/http.ts+17-2
......@@ -29,6 +29,7 @@ import {
2929 RelayLinkProofRequest,
3030 RelayManagedEndpointOrigin,
3131} from "@t3tools/contracts/relay";
32import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
3233import {
3334 normalizeRelayIssuer,
3435 RELAY_HEALTH_REQUEST_TYP,
......@@ -47,7 +48,7 @@ import * as Effect from "effect/Effect";
4748import * as Option from "effect/Option";
4849import * as Schema from "effect/Schema";
4950import * as HttpEffect from "effect/unstable/http/HttpEffect";
50import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
51import { HttpServerRequest, HttpServerResponse, HttpTraceContext } from "effect/unstable/http";
5152import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
5253import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
5354
......@@ -110,6 +111,19 @@ const requireRelayUrl = relayUrlConfig.pipe(
110111 ),
111112);
112113
114export const traceRelayBrokerHandler = <A, E, R>(
115 effect: Effect.Effect<A, E, R>,
116): Effect.Effect<A, E, R | HttpServerRequest.HttpServerRequest> =>
117 HttpServerRequest.HttpServerRequest.pipe(
118 Effect.flatMap((request) =>
119 Option.match(HttpTraceContext.fromHeaders(request.headers), {
120 onNone: () => effect,
121 onSome: (parent) => effect.pipe(Effect.withParentSpan(parent)),
122 }),
123 ),
124 withRelayClientTracing,
125 );
126
113127function bytesToString(bytes: Uint8Array): string {
114128 return new TextDecoder().decode(bytes);
115129}
......@@ -512,6 +526,7 @@ const relayClientRequest = <A>(
512526 message: `T3 Connect relay request failed: ${String(cause)}`,
513527 }),
514528 ),
529 withRelayClientTracing,
515530 );
516531
517532const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")(
......@@ -938,7 +953,7 @@ export const connectHttpApiLayer = HttpApiBuilder.group(
938953 .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload))
939954 .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload))
940955 .handle("t3MintCredential", ({ payload }) =>
941 cloudMintCredentialHandler(dependencies, payload),
956 traceRelayBrokerHandler(cloudMintCredentialHandler(dependencies, payload)),
942957 );
943958 }),
944959);
apps/server/src/cloud/publicConfig.test.ts+41-1
......@@ -2,7 +2,11 @@ import { assert, it } from "@effect/vitest";
22import * as ConfigProvider from "effect/ConfigProvider";
33import * as Effect from "effect/Effect";
44
5import { makeCloudCliOAuthConfig, makeRelayUrlConfig } from "./publicConfig.ts";
5import {
6 makeCloudCliOAuthConfig,
7 makeRelayUrlConfig,
8 resolveRelayClientTracingConfig,
9} from "./publicConfig.ts";
610
711const provideEnv = (env: Readonly<Record<string, string>>) =>
812 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })));
......@@ -83,3 +87,39 @@ it.effect("requires Clerk OAuth config when the server bundle has no injected va
8387 clerkCliOAuthClientIdFallback: "",
8488 }).pipe(provideEnv({}), Effect.flip),
8589);
90
91it("resolves relay client tracing from runtime config with build-time fallback", () => {
92 const fallback = {
93 tracesUrl: "https://embedded.example.test/v1/traces",
94 tracesDataset: "embedded-dataset",
95 tracesToken: "embedded-token",
96 };
97
98 assert.deepEqual(resolveRelayClientTracingConfig({}, fallback), fallback);
99 assert.deepEqual(
100 resolveRelayClientTracingConfig(
101 {
102 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://runtime.example.test/v1/traces",
103 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset",
104 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token",
105 },
106 fallback,
107 ),
108 {
109 tracesUrl: "https://runtime.example.test/v1/traces",
110 tracesDataset: "runtime-dataset",
111 tracesToken: "runtime-token",
112 },
113 );
114 assert.equal(
115 resolveRelayClientTracingConfig(
116 {
117 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "http://insecure.example.test/v1/traces",
118 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "runtime-dataset",
119 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "runtime-token",
120 },
121 fallback,
122 ),
123 null,
124 );
125});
apps/server/src/cloud/publicConfig.ts+43
......@@ -9,6 +9,9 @@ import * as SchemaIssue from "effect/SchemaIssue";
99declare const __T3CODE_BUILD_RELAY_URL__: string | undefined;
1010declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
1111declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined;
12declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: string | undefined;
13declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefined;
14declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined;
1215
1316const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback";
1417const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const;
......@@ -32,6 +35,15 @@ function readBuildTimeValue(value: string | undefined): string {
3235 return typeof value === "undefined" ? "" : value.trim();
3336}
3437
38function normalizeSecureUrl(value: string): string | null {
39 try {
40 const url = new URL(value);
41 return url.protocol === "https:" ? url.toString() : null;
42 } catch {
43 return null;
44 }
45}
46
3547export const buildTimeRelayUrl =
3648 typeof __T3CODE_BUILD_RELAY_URL__ === "undefined"
3749 ? ""
......@@ -46,6 +58,37 @@ export const buildTimeClerkCliOAuthClientId = readBuildTimeValue(
4658 ? undefined
4759 : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__,
4860);
61export const buildTimeRelayClientTracing = {
62 tracesUrl: readBuildTimeValue(
63 typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__ === "undefined"
64 ? undefined
65 : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__,
66 ),
67 tracesDataset: readBuildTimeValue(
68 typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__ === "undefined"
69 ? undefined
70 : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__,
71 ),
72 tracesToken: readBuildTimeValue(
73 typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__ === "undefined"
74 ? undefined
75 : __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__,
76 ),
77} as const;
78
79export function resolveRelayClientTracingConfig(
80 env: Readonly<Record<string, string | undefined>> = process.env,
81 fallback = buildTimeRelayClientTracing,
82) {
83 const tracesUrl = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() || fallback.tracesUrl;
84 const tracesDataset =
85 env.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET?.trim() || fallback.tracesDataset;
86 const tracesToken = env.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN?.trim() || fallback.tracesToken;
87 const normalizedTracesUrl = normalizeSecureUrl(tracesUrl);
88 return normalizedTracesUrl && tracesDataset && tracesToken
89 ? { tracesUrl: normalizedTracesUrl, tracesDataset, tracesToken }
90 : null;
91}
4992
5093export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) {
5194 const runtimeConfig = Config.nonEmptyString("T3CODE_RELAY_URL");
apps/server/src/cloud/relayTracing.ts created+21
......@@ -0,0 +1,21 @@
1import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing";
2
3import { resolveRelayClientTracingConfig } from "./publicConfig.ts";
4
5const relayClientTracingConfig = resolveRelayClientTracingConfig();
6
7export const headlessRelayClientTracingLayer = makeRelayClientTracingLayer(
8 relayClientTracingConfig,
9 {
10 serviceName: "t3-headless-relay-client",
11 runtime: "node",
12 client: "headless-cli",
13 },
14);
15
16export const serverRelayBrokerTracingLayer = makeRelayClientTracingLayer(relayClientTracingConfig, {
17 serviceName: "t3-server",
18 runtime: "node",
19 client: "environment-server",
20 component: "relay-broker",
21});
apps/server/src/relay/AgentAwarenessRelay.test.ts+20
......@@ -17,6 +17,7 @@ import type {
1717 RelayAgentActivityState,
1818} from "@t3tools/contracts/relay";
1919import { CommandId, ProviderInstanceId } from "@t3tools/contracts";
20import { RelayClientTracer } from "@t3tools/shared/relayTracing";
2021import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt";
2122import { describe, expect, it } from "@effect/vitest";
2223import * as Deferred from "effect/Deferred";
......@@ -25,6 +26,7 @@ import * as Layer from "effect/Layer";
2526import * as Option from "effect/Option";
2627import * as Queue from "effect/Queue";
2728import * as Stream from "effect/Stream";
29import * as Tracer from "effect/Tracer";
2830
2931import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
3032import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
......@@ -522,6 +524,20 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
522524 const runFork = Effect.runForkWith(context);
523525 const events = yield* Queue.unbounded<OrchestrationEvent>();
524526 const fetchSeen = yield* Deferred.make<URL>();
527 const userSpans: Array<string> = [];
528 const productSpans: Array<string> = [];
529 const collectingTracer = (spans: Array<string>) =>
530 Tracer.make({
531 span: (options) => {
532 const span = new Tracer.NativeSpan(options);
533 const end = span.end.bind(span);
534 span.end = (endTime, exit) => {
535 end(endTime, exit);
536 spans.push(span.name);
537 };
538 return span;
539 },
540 });
525541 const secrets = makeMemorySecretStore();
526542 const now = "2026-05-25T00:00:00.000Z";
527543 const projectId = "project-1" as ProjectId;
......@@ -648,6 +664,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
648664
649665 const url = yield* Deferred.await(fetchSeen).pipe(Effect.timeout("2 seconds"));
650666 expect(url.origin).toBe("https://transport.example.test");
667 expect(productSpans).toContain("makePublishProof");
668 expect(userSpans).not.toContain("makePublishProof");
651669 }).pipe(
652670 Effect.provide(
653671 AgentAwarenessRelay.layer.pipe(
......@@ -655,6 +673,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
655673 Layer.provideMerge(NodeServices.layer),
656674 ),
657675 ),
676 Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))),
677 Effect.withTracer(collectingTracer(userSpans)),
658678 );
659679 }),
660680 ),
apps/server/src/relay/AgentAwarenessRelay.ts+2
......@@ -12,6 +12,7 @@ import type {
1212} from "@t3tools/contracts";
1313import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
1414import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
15import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
1516import {
1617 RELAY_ACTIVITY_PUBLISH_TYP,
1718 signRelayJwt,
......@@ -409,6 +410,7 @@ const make = Effect.gen(function* () {
409410 });
410411 }),
411412 Effect.withSpan("AgentAwarenessRelay.publishThread"),
413 withRelayClientTracing,
412414 );
413415
414416 const publishActiveThreadsUnsafe = Effect.gen(function* () {
apps/server/src/server.ts+2
......@@ -74,6 +74,7 @@ import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http
7474import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
7575import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
7676import { connectHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts";
77import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts";
7778import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
7879import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
7980import * as CloudCliState from "./cloud/CliState.ts";
......@@ -477,6 +478,7 @@ export const makeServerLayer = Layer.unwrap(
477478
478479 return serverApplicationLayer.pipe(
479480 Layer.provideMerge(RuntimeServicesLive),
481 Layer.provideMerge(serverRelayBrokerTracingLayer),
480482 Layer.provideMerge(HttpServerLive),
481483 Layer.provide(ObservabilityLive),
482484 Layer.provideMerge(FetchHttpClient.layer),
apps/server/vite.config.ts+9
......@@ -49,6 +49,15 @@ export default mergeConfig(
4949 __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: JSON.stringify(
5050 repoEnv.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() ?? "",
5151 ),
52 __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__: JSON.stringify(
53 repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL?.trim() ?? "",
54 ),
55 __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: JSON.stringify(
56 repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET?.trim() ?? "",
57 ),
58 __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: JSON.stringify(
59 repoEnv.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN?.trim() ?? "",
60 ),
5261 },
5362 },
5463 test: {
apps/web/src/cloud/linkEnvironment.test.ts+8
......@@ -318,6 +318,14 @@ describe("web cloud link environment client", () => {
318318 method: "POST",
319319 url: "https://managed.example.test/oauth/token",
320320 });
321 const traceparents = fetchMock.mock.calls.map(
322 (call) => call[1]?.headers.traceparent as string | undefined,
323 );
324 expect(traceparents.every((traceparent) => typeof traceparent === "string")).toBe(true);
325 expect(new Set(traceparents.map((traceparent) => traceparent?.split("-")[1])).size).toBe(1);
326 expect(connection.relayTraceHeaders.traceparent?.split("-")[1]).toBe(
327 traceparents[0]?.split("-")[1],
328 );
321329 }),
322330 );
323331
apps/web/src/cloud/linkEnvironment.ts+11-2
......@@ -1,7 +1,7 @@
11import * as Data from "effect/Data";
22import * as Effect from "effect/Effect";
33import * as Schema from "effect/Schema";
4import { HttpClient } from "effect/unstable/http";
4import { HttpClient, HttpTraceContext, type Headers } from "effect/unstable/http";
55import {
66 EnvironmentCloudEndpointUnavailableError,
77 type EnvironmentCloudLinkStateResult,
......@@ -29,6 +29,7 @@ import {
2929 ManagedRelayDpopSigner,
3030 type WsRpcClient,
3131} from "@t3tools/client-runtime";
32import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
3233
3334import { ensureLocalApi } from "../localApi";
3435import {
......@@ -245,6 +246,7 @@ export interface CloudManagedConnection {
245246 readonly wsBaseUrl: string;
246247 readonly relayUrl: string;
247248 readonly accessToken: string;
249 readonly relayTraceHeaders: Headers.Headers;
248250}
249251
250252export function collectCloudLinkTargets(input: {
......@@ -437,8 +439,15 @@ export function connectManagedCloudEnvironment(input: {
437439 wsBaseUrl: connected.endpoint.wsBaseUrl,
438440 relayUrl: configuredRelayUrl,
439441 accessToken: session.access_token,
442 relayTraceHeaders: HttpTraceContext.toHeaders(yield* Effect.currentSpan.pipe(Effect.orDie)),
440443 };
441 });
444 }).pipe(
445 Effect.withSpan("relay.environment.connect", {
446 root: true,
447 attributes: { "relay.environment_id": input.environment.environmentId },
448 }),
449 withRelayClientTracing,
450 );
442451}
443452
444453export function readPrimaryCloudLinkState(): Effect.Effect<
apps/web/src/cloud/publicConfig.ts+34
......@@ -5,12 +5,26 @@ export interface CloudPublicConfig {
55 readonly clerkPublishableKey: string | null;
66 readonly clerkJwtTemplate: string | null;
77 readonly relayUrl: string | null;
8 readonly relayTracing: {
9 readonly tracesUrl: string | null;
10 readonly tracesDataset: string | null;
11 readonly tracesToken: string | null;
12 };
813}
914
1015function trimNonEmpty(value: string | undefined): string | null {
1116 return value?.trim() || null;
1217}
1318
19function normalizeSecureUrl(value: string): string | null {
20 try {
21 const url = new URL(value);
22 return url.protocol === "https:" ? url.toString() : null;
23 } catch {
24 return null;
25 }
26}
27
1428export function resolveCloudPublicConfig(): CloudPublicConfig {
1529 return {
1630 clerkPublishableKey: trimNonEmpty(
......@@ -20,9 +34,29 @@ export function resolveCloudPublicConfig(): CloudPublicConfig {
2034 relayUrl: normalizeSecureRelayUrl(
2135 (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "",
2236 ),
37 relayTracing: {
38 tracesUrl: normalizeSecureUrl(
39 (import.meta.env.VITE_RELAY_OTLP_TRACES_URL as string | undefined) ?? "",
40 ),
41 tracesDataset: trimNonEmpty(
42 import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET as string | undefined,
43 ),
44 tracesToken: trimNonEmpty(import.meta.env.VITE_RELAY_OTLP_TRACES_TOKEN as string | undefined),
45 },
2346 };
2447}
2548
49export function resolveRelayTracingConfig() {
50 const { relayTracing } = resolveCloudPublicConfig();
51 return relayTracing.tracesUrl && relayTracing.tracesDataset && relayTracing.tracesToken
52 ? {
53 tracesUrl: relayTracing.tracesUrl,
54 tracesDataset: relayTracing.tracesDataset,
55 tracesToken: relayTracing.tracesToken,
56 }
57 : null;
58}
59
2660export function hasCloudPublicConfig(): boolean {
2761 const config = resolveCloudPublicConfig();
2862 return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl);
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+44-1
......@@ -1,8 +1,13 @@
11import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts";
22import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
34import * as Schema from "effect/Schema";
5import * as Tracer from "effect/Tracer";
6import { Headers } from "effect/unstable/http";
47import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
58
9import { RelayClientTracer } from "@t3tools/shared/relayTracing";
10
611const decodeEnvironmentAuthInvalidError = Schema.decodeUnknownSync(EnvironmentAuthInvalidError);
712
813let mockSavedRecords: Array<Record<string, unknown>> = [];
......@@ -12,8 +17,11 @@ const mockFetchRemoteEnvironmentDescriptor = vi.fn();
1217const mockBootstrapRemoteBearerSession = vi.fn();
1318const mockFetchRemoteSessionState = vi.fn();
1419const mockFetchRemoteDpopSessionState = vi.fn();
20const mockResolveRemoteDpopWebSocketConnectionUrl = vi.fn();
1521const mockResolveRemoteWebSocketConnectionUrl = vi.fn();
22const mockWsTransportConnectors: Array<() => Promise<string>> = [];
1623let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner;
24let mockRelayClientTracer = Option.none<Tracer.Tracer>();
1725const mockRemoteHttpRunPromise = vi.fn(<A, E>(effect: Effect.Effect<A, E>) =>
1826 Effect.runPromise(
1927 effect.pipe(
......@@ -24,6 +32,7 @@ const mockRemoteHttpRunPromise = vi.fn(<A, E>(effect: Effect.Effect<A, E>) =>
2432 createProof: () => Effect.succeed("dpop-proof"),
2533 }),
2634 ),
35 Effect.provideService(RelayClientTracer, mockRelayClientTracer),
2736 ),
2837 ),
2938);
......@@ -164,12 +173,16 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {
164173 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,
165174 fetchRemoteSessionState: mockFetchRemoteSessionState,
166175 fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState,
176 resolveRemoteDpopWebSocketConnectionUrl: mockResolveRemoteDpopWebSocketConnectionUrl,
167177 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
168178 };
169179});
170180
171181vi.mock("../../rpc/wsTransport", () => ({
172 WsTransport: vi.fn(),
182 WsTransport: vi.fn(function WsTransport(connect: () => Promise<string>) {
183 mockWsTransportConnectors.push(connect);
184 return {};
185 }),
173186}));
174187
175188describe("addSavedEnvironment", () => {
......@@ -181,6 +194,8 @@ describe("addSavedEnvironment", () => {
181194 vi.resetModules();
182195 vi.clearAllMocks();
183196 mockSavedRecords = [];
197 mockRelayClientTracer = Option.none();
198 mockWsTransportConnectors.length = 0;
184199 vi.stubGlobal("window", {
185200 desktopBridge: {
186201 ensureSshEnvironment: mockEnsureSshEnvironment,
......@@ -237,6 +252,9 @@ describe("addSavedEnvironment", () => {
237252 mockResolveRemoteWebSocketConnectionUrl.mockReturnValue(
238253 Effect.succeed("wss://remote.example.com/?wsTicket=remote-token"),
239254 );
255 mockResolveRemoteDpopWebSocketConnectionUrl.mockReturnValue(
256 Effect.succeed("wss://remote.example.com/?wsTicket=remote-dpop-token"),
257 );
240258 mockFetchSshEnvironmentDescriptor.mockResolvedValue({
241259 environmentId: EnvironmentId.make("environment-1"),
242260 label: "Remote environment",
......@@ -386,6 +404,7 @@ describe("addSavedEnvironment", () => {
386404 wsBaseUrl: "wss://managed.example.com/",
387405 relayUrl: "https://relay.example.com",
388406 accessToken: "managed-access-token",
407 relayTraceHeaders: Headers.empty,
389408 });
390409
391410 expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith(
......@@ -406,6 +425,19 @@ describe("addSavedEnvironment", () => {
406425
407426 it("renews expired managed DPoP credentials through the relay", async () => {
408427 const environmentId = EnvironmentId.make("environment-1");
428 const productSpans: Array<Tracer.Span> = [];
429 mockRelayClientTracer = Option.some(
430 Tracer.make({
431 span: (options) => {
432 const span = new Tracer.NativeSpan(options);
433 productSpans.push(span);
434 return span;
435 },
436 }),
437 );
438 const relayTraceHeaders = Headers.fromInput({
439 traceparent: "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01",
440 });
409441 mockSavedRecords = [
410442 {
411443 environmentId,
......@@ -443,6 +475,7 @@ describe("addSavedEnvironment", () => {
443475 wsBaseUrl: "wss://managed.example.com/",
444476 relayUrl: "https://relay.example.com",
445477 accessToken: "renewed-access-token",
478 relayTraceHeaders,
446479 }),
447480 );
448481
......@@ -460,6 +493,16 @@ describe("addSavedEnvironment", () => {
460493 method: "dpop",
461494 accessToken: "renewed-access-token",
462495 });
496 const renewedTransportConnector = mockWsTransportConnectors.at(-1);
497 expect(renewedTransportConnector).toBeDefined();
498 await renewedTransportConnector!();
499 expect(mockResolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({
500 wsBaseUrl: "wss://managed.example.com/",
501 httpBaseUrl: "https://managed.example.com/",
502 accessToken: "renewed-access-token",
503 dpopProof: "dpop-proof",
504 });
505 expect(productSpans.some((span) => span.name === "relay.environment.reconnect")).toBe(false);
463506 await resetEnvironmentServiceForTests();
464507 });
465508
apps/web/src/environments/runtime/service.ts+60-30
......@@ -26,7 +26,10 @@ import {
2626import { type QueryClient } from "@tanstack/react-query";
2727import { Throttler } from "@tanstack/react-pacer";
2828import * as Effect from "effect/Effect";
29import * as Option from "effect/Option";
2930import * as Schema from "effect/Schema";
31import { Headers, HttpTraceContext } from "effect/unstable/http";
32import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
3033import {
3134 createKnownEnvironment,
3235 getKnownEnvironmentWsBaseUrl,
......@@ -1173,6 +1176,7 @@ function createPrimaryEnvironmentClient(
11731176function createSavedEnvironmentClient(
11741177 environmentId: EnvironmentId,
11751178 credentialRef: { current: SavedEnvironmentCredential },
1179 relayTraceHeadersRef: { current: Headers.Headers | null },
11761180): WsRpcClient {
11771181 useSavedEnvironmentRuntimeStore.getState().ensure(environmentId);
11781182
......@@ -1196,21 +1200,10 @@ function createSavedEnvironmentClient(
11961200 }
11971201 if (credential.method === "dpop") {
11981202 try {
1203 const relayTraceHeaders = relayTraceHeadersRef.current;
1204 relayTraceHeadersRef.current = null;
11991205 return await webRuntime.runPromise(
1200 createManagedRelayDpopProof({
1201 method: "POST",
1202 url: new URL("/api/auth/websocket-ticket", record.httpBaseUrl).toString(),
1203 accessToken: credential.accessToken,
1204 }).pipe(
1205 Effect.flatMap((proof) =>
1206 resolveRemoteDpopWebSocketConnectionUrl({
1207 wsBaseUrl: record.wsBaseUrl,
1208 httpBaseUrl: record.httpBaseUrl,
1209 accessToken: credential.accessToken,
1210 dpopProof: proof,
1211 }),
1212 ),
1213 ),
1206 resolveManagedRelayWebSocketUrl(record, credential, relayTraceHeaders),
12141207 );
12151208 } catch (error) {
12161209 if (!isEnvironmentAuthInvalidError(error)) {
......@@ -1223,19 +1216,10 @@ function createSavedEnvironmentClient(
12231216 const renewedCredential = renewed.credential;
12241217 credentialRef.current = renewedCredential;
12251218 return await webRuntime.runPromise(
1226 createManagedRelayDpopProof({
1227 method: "POST",
1228 url: new URL("/api/auth/websocket-ticket", renewed.record.httpBaseUrl).toString(),
1229 accessToken: renewedCredential.accessToken,
1230 }).pipe(
1231 Effect.flatMap((proof) =>
1232 resolveRemoteDpopWebSocketConnectionUrl({
1233 wsBaseUrl: renewed.record.wsBaseUrl,
1234 httpBaseUrl: renewed.record.httpBaseUrl,
1235 accessToken: renewedCredential.accessToken,
1236 dpopProof: proof,
1237 }),
1238 ),
1219 resolveManagedRelayWebSocketUrl(
1220 renewed.record,
1221 renewedCredential,
1222 renewed.relayTraceHeaders,
12391223 ),
12401224 );
12411225 }
......@@ -1339,9 +1323,44 @@ async function refreshSavedEnvironmentMetadata(
13391323 .rename(record.environmentId, serverConfig.environment.label);
13401324}
13411325
1326const resolveManagedRelayWebSocketUrl = Effect.fn(
1327 "web.environment.resolveManagedRelayWebSocketUrl",
1328)(function* (
1329 record: SavedEnvironmentRecord,
1330 credential: Extract<SavedEnvironmentCredential, { readonly method: "dpop" }>,
1331 traceHeaders: Headers.Headers | null,
1332) {
1333 const request = createManagedRelayDpopProof({
1334 method: "POST",
1335 url: new URL("/api/auth/websocket-ticket", record.httpBaseUrl).toString(),
1336 accessToken: credential.accessToken,
1337 }).pipe(
1338 Effect.flatMap((proof) =>
1339 resolveRemoteDpopWebSocketConnectionUrl({
1340 wsBaseUrl: record.wsBaseUrl,
1341 httpBaseUrl: record.httpBaseUrl,
1342 accessToken: credential.accessToken,
1343 dpopProof: proof,
1344 }),
1345 ),
1346 );
1347 const parent = traceHeaders ? HttpTraceContext.fromHeaders(traceHeaders) : Option.none();
1348 return yield* (
1349 Option.isSome(parent)
1350 ? request.pipe(Effect.withParentSpan(parent.value))
1351 : request.pipe(
1352 Effect.withSpan("relay.environment.reconnect", {
1353 root: true,
1354 attributes: { "relay.environment_id": record.environmentId },
1355 }),
1356 )
1357 ).pipe(withRelayClientTracing);
1358});
1359
13421360async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Promise<{
13431361 readonly record: SavedEnvironmentRecord;
13441362 readonly credential: SavedEnvironmentCredential;
1363 readonly relayTraceHeaders: Headers.Headers;
13451364} | null> {
13461365 if (!record.relayManaged) {
13471366 return null;
......@@ -1382,7 +1401,7 @@ async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Prom
13821401 throw new Error("Unable to persist refreshed managed environment credentials.");
13831402 }
13841403 useSavedEnvironmentRegistryStore.getState().upsert(nextRecord);
1385 return { record: nextRecord, credential };
1404 return { record: nextRecord, credential, relayTraceHeaders: connected.relayTraceHeaders };
13861405}
13871406
13881407function registerConnection(connection: EnvironmentConnection): EnvironmentConnection {
......@@ -1466,6 +1485,7 @@ async function ensureSavedEnvironmentConnection(
14661485 readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null;
14671486 readonly serverConfig?: ServerConfig | null;
14681487 readonly allowManagedRenewal?: boolean;
1488 readonly relayTraceHeaders?: Headers.Headers;
14691489 },
14701490): Promise<EnvironmentConnection> {
14711491 const existing = environmentConnections.get(record.environmentId);
......@@ -1511,9 +1531,14 @@ async function ensureSavedEnvironmentConnection(
15111531 }
15121532
15131533 const activeCredential = { current: credential };
1534 const relayTraceHeaders = { current: options?.relayTraceHeaders ?? null };
15141535 const client =
15151536 options?.client ??
1516 createSavedEnvironmentClient(activeRecord.environmentId, activeCredential);
1537 createSavedEnvironmentClient(
1538 activeRecord.environmentId,
1539 activeCredential,
1540 relayTraceHeaders,
1541 );
15171542 const initialConfigSnapshot = createDeferredPromise<ServerConfig>();
15181543 const knownEnvironment = createKnownEnvironment({
15191544 id: activeRecord.environmentId,
......@@ -1589,6 +1614,7 @@ async function ensureSavedEnvironmentConnection(
15891614 scopes: scopeHint,
15901615 serverConfig: options?.serverConfig ?? null,
15911616 allowManagedRenewal: false,
1617 relayTraceHeaders: renewed.relayTraceHeaders,
15921618 });
15931619 }
15941620 }
......@@ -1916,6 +1942,7 @@ export async function addManagedRelayEnvironment(input: {
19161942 readonly wsBaseUrl: string;
19171943 readonly relayUrl: string;
19181944 readonly accessToken: string;
1945 readonly relayTraceHeaders: Headers.Headers;
19191946}): Promise<SavedEnvironmentRecord> {
19201947 const existingRecord = getSavedEnvironmentRecord(input.environmentId);
19211948 const record: SavedEnvironmentRecord = {
......@@ -1939,7 +1966,10 @@ export async function addManagedRelayEnvironment(input: {
19391966 }
19401967 useSavedEnvironmentRegistryStore.getState().upsert(record);
19411968 await removeConnection(record.environmentId).catch(() => false);
1942 await ensureSavedEnvironmentConnection(record, { credential });
1969 await ensureSavedEnvironmentConnection(record, {
1970 credential,
1971 relayTraceHeaders: input.relayTraceHeaders,
1972 });
19431973 return record;
19441974}
19451975
apps/web/src/lib/runtime.ts+9-1
......@@ -5,6 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http";
55
66import { remoteHttpClientLayer } from "@t3tools/client-runtime";
77import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
8import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing";
89import {
910 PrimaryEnvironmentHttpClient,
1011 primaryEnvironmentHttpClientLive,
......@@ -13,13 +14,19 @@ import { primaryEnvironmentRequestInit } from "../environments/primary/requestIn
1314
1415import { browserCryptoLayer } from "../cloud/dpop";
1516import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer";
16import { resolveCloudPublicConfig } from "../cloud/publicConfig";
17import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig";
1718
1819function configuredRelayUrl(): string {
1920 return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid";
2021}
2122
2223const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch);
24const webRelayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(), {
25 serviceName: "t3-web-relay-client",
26 serviceVersion: import.meta.env.APP_VERSION,
27 runtime: "browser",
28 client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web",
29}).pipe(Layer.provide(webHttpClientLayer));
2330
2431export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer);
2532
......@@ -57,6 +64,7 @@ export const webRuntime = ManagedRuntime.make(
5764 browserCryptoLayer,
5865 webManagedRelayClientLayer(configuredRelayUrl()).pipe(
5966 Layer.provide(Layer.mergeAll(webHttpClientLayer, browserCryptoLayer)),
67 Layer.provideMerge(webRelayTracingLayer),
6068 ),
6169 ),
6270);
apps/web/src/vite-env.d.ts+3
......@@ -9,6 +9,9 @@ interface ImportMetaEnv {
99 readonly VITE_HOSTED_APP_CHANNEL: string;
1010 readonly VITE_CLERK_PUBLISHABLE_KEY: string;
1111 readonly VITE_CLERK_JWT_TEMPLATE: string;
12 readonly VITE_RELAY_OTLP_TRACES_URL: string;
13 readonly VITE_RELAY_OTLP_TRACES_DATASET: string;
14 readonly VITE_RELAY_OTLP_TRACES_TOKEN: string;
1215 readonly APP_VERSION: string;
1316}
1417
apps/web/vite.config.ts+8
......@@ -19,6 +19,9 @@ const configuredWsUrl = process.env.VITE_WS_URL?.trim();
1919const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
2020const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
2121const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";
22const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || "";
23const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || "";
24const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || "";
2225const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
2326const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
2427const configuredHostedAppUrl = (() => {
......@@ -135,6 +138,11 @@ export default defineConfig(() => {
135138 "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl),
136139 "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey),
137140 "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate),
141 "import.meta.env.VITE_RELAY_OTLP_TRACES_URL": JSON.stringify(configuredRelayTracingUrl),
142 "import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET": JSON.stringify(
143 configuredRelayTracingDataset,
144 ),
145 "import.meta.env.VITE_RELAY_OTLP_TRACES_TOKEN": JSON.stringify(configuredRelayTracingToken),
138146 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
139147 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),
140148 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),
infra/relay/alchemy.run.ts+3
......@@ -42,6 +42,9 @@ export default Alchemy.Stack(
4242 mobileTracingUrl: observability.traces.otelTracesEndpoint,
4343 mobileTracingDataset: observability.traces.name,
4444 mobileTracingToken: observability.mobileIngestToken.token,
45 clientTracingUrl: observability.traces.otelTracesEndpoint,
46 clientTracingDataset: observability.traces.name,
47 clientTracingToken: observability.clientIngestToken.token,
4548 };
4649 }),
4750);
infra/relay/scripts/deploy.test.ts+89-1
......@@ -1,10 +1,16 @@
1import { describe, expect, it } from "vite-plus/test";
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
26
37import {
48 hasDeployChanges,
9 publicConfigFromOutput,
510 reconcileRootEnvPublicConfig,
611 reconcileRootEnvRelayUrl,
712 serializeGithubOutput,
13 serializeRelayClientTracingEnvironment,
814} from "./deploy.ts";
915
1016describe("hasDeployChanges", () => {
......@@ -62,6 +68,9 @@ describe("reconcileRootEnvPublicConfig", () => {
6268 mobileTracingUrl: "https://api.axiom.co/v1/traces",
6369 mobileTracingDataset: "t3-code-mobile-traces-dev",
6470 mobileTracingToken: "xaat-public-ingest",
71 clientTracingUrl: "https://api.axiom.co/v1/traces",
72 clientTracingDataset: "t3-code-relay-client-traces-dev",
73 clientTracingToken: "xaat-relay-client-ingest",
6574 } as const;
6675
6776 it("adds the complete local client config", () => {
......@@ -71,6 +80,9 @@ describe("reconcileRootEnvPublicConfig", () => {
7180 "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
7281 "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev",
7382 "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest",
83 "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
84 "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=t3-code-relay-client-traces-dev",
85 "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=xaat-relay-client-ingest",
7486 "",
7587 ].join("\n"),
7688 );
......@@ -85,6 +97,9 @@ describe("reconcileRootEnvPublicConfig", () => {
8597 "T3CODE_MOBILE_OTLP_TRACES_URL=https://old.example.test/v1/traces",
8698 "T3CODE_MOBILE_OTLP_TRACES_DATASET=old-dataset",
8799 "T3CODE_MOBILE_OTLP_TRACES_TOKEN=old-token",
100 "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://old.example.test/v1/traces",
101 "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=old-client-dataset",
102 "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=old-client-token",
88103 "",
89104 ].join("\n"),
90105 config,
......@@ -96,6 +111,9 @@ describe("reconcileRootEnvPublicConfig", () => {
96111 "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
97112 "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev",
98113 "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest",
114 "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
115 "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=t3-code-relay-client-traces-dev",
116 "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=xaat-relay-client-ingest",
99117 "",
100118 ].join("\n"),
101119 );
......@@ -113,3 +131,73 @@ describe("serializeGithubOutput", () => {
113131 ).toBe("changed=false\nresult=noop\nrelay_url=https://relay.example.test\n");
114132 });
115133});
134
135describe("serializeRelayClientTracingEnvironment", () => {
136 it("serializes tracing config for downstream GITHUB_ENV loading", () => {
137 expect(
138 serializeRelayClientTracingEnvironment({
139 relayUrl: "https://relay.example.test",
140 mobileTracingUrl: "https://api.axiom.co/v1/traces",
141 mobileTracingDataset: "mobile",
142 mobileTracingToken: "mobile-token",
143 clientTracingUrl: "https://api.axiom.co/v1/traces",
144 clientTracingDataset: "relay",
145 clientTracingToken: "client-token",
146 }),
147 ).toBe(
148 [
149 "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
150 "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=relay",
151 "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=client-token",
152 "",
153 ].join("\n"),
154 );
155 });
156});
157
158describe("release workflow tracing config propagation", () => {
159 it.effect("uses an artifact instead of a masked cross-job token output", () =>
160 Effect.gen(function* () {
161 const fileSystem = yield* FileSystem.FileSystem;
162 const path = yield* Path.Path;
163 const workflowPath = yield* path.fromFileUrl(
164 new URL("../../../.github/workflows/release.yml", import.meta.url),
165 );
166 const workflow = yield* fileSystem.readFileString(workflowPath);
167
168 expect(workflow).not.toContain("client_tracing_token:");
169 expect(workflow).not.toContain("needs.relay_public_config.outputs.client_tracing_token");
170 expect(workflow).toContain('--github-env-file "$RUNNER_TEMP/relay-client-tracing.env"');
171 expect(workflow).toContain("name: relay-client-tracing-config");
172 expect(workflow).toContain('cat "$config_path" >> "$GITHUB_ENV"');
173 }).pipe(Effect.provide(NodeServices.layer)),
174 );
175});
176
177describe("publicConfigFromOutput", () => {
178 it("reads the complete public tracing config from persisted Alchemy output", () => {
179 expect(
180 publicConfigFromOutput({
181 url: "https://relay.example.test",
182 mobileTracingUrl: "https://api.axiom.co/v1/traces",
183 mobileTracingDataset: "mobile",
184 mobileTracingToken: "mobile-token",
185 clientTracingUrl: "https://api.axiom.co/v1/traces",
186 clientTracingDataset: "relay",
187 clientTracingToken: "client-token",
188 }),
189 ).toEqual({
190 relayUrl: "https://relay.example.test",
191 mobileTracingUrl: "https://api.axiom.co/v1/traces",
192 mobileTracingDataset: "mobile",
193 mobileTracingToken: "mobile-token",
194 clientTracingUrl: "https://api.axiom.co/v1/traces",
195 clientTracingDataset: "relay",
196 clientTracingToken: "client-token",
197 });
198 });
199
200 it("rejects incomplete stack output", () => {
201 expect(publicConfigFromOutput({ url: "https://relay.example.test" })).toBeNull();
202 });
203});
infra/relay/scripts/deploy.ts+127-7
......@@ -8,10 +8,12 @@ import { provideFreshArtifactStore } from "alchemy/Artifacts";
88import { AuthProviders } from "alchemy/Auth/AuthProvider";
99import { CredentialsStoreLive } from "alchemy/Auth/Credentials";
1010import { ProfileLive } from "alchemy/Auth/Profile";
11import * as Cloudflare from "alchemy/Cloudflare";
1112import { Cli } from "alchemy/Cli/Cli";
1213import { LoggingCli } from "alchemy/Cli/LoggingCli";
1314import * as Plan from "alchemy/Plan";
1415import * as Stage from "alchemy/Stage";
16import * as State from "alchemy/State/State";
1517import { TelemetryLive } from "alchemy/Telemetry/Layer";
1618import { PlatformServices } from "alchemy/Util/PlatformServices";
1719import * as Config from "effect/Config";
......@@ -41,6 +43,8 @@ export interface RelayDeployOptions {
4143 readonly yes: boolean;
4244 readonly adopt: boolean;
4345 readonly githubOutput: boolean;
46 readonly githubEnvFile: Option.Option<string>;
47 readonly readState: boolean;
4448}
4549
4650export interface RelayPublicConfig {
......@@ -48,6 +52,9 @@ export interface RelayPublicConfig {
4852 readonly mobileTracingUrl: string;
4953 readonly mobileTracingDataset: string;
5054 readonly mobileTracingToken: string;
55 readonly clientTracingUrl: string;
56 readonly clientTracingDataset: string;
57 readonly clientTracingToken: string;
5158}
5259
5360const publicConfigEnvEntries = (config: RelayPublicConfig) =>
......@@ -56,6 +63,9 @@ const publicConfigEnvEntries = (config: RelayPublicConfig) =>
5663 T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileTracingUrl,
5764 T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileTracingDataset,
5865 T3CODE_MOBILE_OTLP_TRACES_TOKEN: config.mobileTracingToken,
66 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.clientTracingUrl,
67 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.clientTracingDataset,
68 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: config.clientTracingToken,
5969 }) as const;
6070
6171export function reconcileRootEnvPublicConfig(contents: string, config: RelayPublicConfig): string {
......@@ -82,9 +92,13 @@ export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): st
8292 mobileTracingUrl: "",
8393 mobileTracingDataset: "",
8494 mobileTracingToken: "",
95 clientTracingUrl: "",
96 clientTracingDataset: "",
97 clientTracingToken: "",
8598 })
8699 .split("\n")
87100 .filter((line) => !line.startsWith("T3CODE_MOBILE_OTLP_TRACES_"))
101 .filter((line) => !line.startsWith("T3CODE_RELAY_CLIENT_OTLP_TRACES_"))
88102 .join("\n");
89103}
90104
......@@ -98,7 +112,7 @@ export function hasDeployChanges(plan: Plan.Plan): boolean {
98112 );
99113}
100114
101export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled";
115export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled" | "state";
102116
103117export interface RelayDeployOutcome {
104118 readonly result: RelayDeployResult;
......@@ -112,6 +126,14 @@ export function serializeGithubOutput(entries: Readonly<Record<string, string |
112126 .join("");
113127}
114128
129export function serializeRelayClientTracingEnvironment(config: RelayPublicConfig): string {
130 return serializeGithubOutput({
131 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.clientTracingUrl,
132 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.clientTracingDataset,
133 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: config.clientTracingToken,
134 });
135}
136
115137const relayRoot = Effect.service(Path.Path).pipe(
116138 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
117139);
......@@ -151,7 +173,7 @@ const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* (
151173 const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : "";
152174
153175 yield* fs.writeFileString(rootEnvPath, reconcileRootEnvPublicConfig(contents, config));
154 yield* Console.log(`Updated ${rootEnvPath} with relay URL and mobile tracing configuration`);
176 yield* Console.log(`Updated ${rootEnvPath} with relay public client configuration`);
155177});
156178
157179const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* (
......@@ -165,14 +187,34 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function*
165187 changed: outcome.changed,
166188 result: outcome.result,
167189 ...(Option.isSome(outcome.publicConfig)
168 ? { relay_url: outcome.publicConfig.value.relayUrl }
190 ? {
191 relay_url: outcome.publicConfig.value.relayUrl,
192 }
169193 : {}),
170194 }),
171195 { flag: "a" },
172196 );
173197});
174198
175const deployServices = Layer.mergeAll(
199const writeGithubEnvFile = Effect.fn("relay.deploy.writeGithubEnvFile")(function* (
200 outcome: RelayDeployOutcome,
201 outputPath: string,
202) {
203 if (Option.isNone(outcome.publicConfig)) {
204 return yield* new RelayDeployError({
205 message: "Relay public client config is unavailable for the GitHub environment file",
206 });
207 }
208 const fs = yield* FileSystem.FileSystem;
209 yield* Console.log(`::add-mask::${outcome.publicConfig.value.clientTracingToken}`);
210 yield* fs.writeFileString(
211 outputPath,
212 serializeRelayClientTracingEnvironment(outcome.publicConfig.value),
213 );
214});
215
216const deployBaseServices = Layer.mergeAll(
217 Layer.succeed(AuthProviders, {}),
176218 Layer.provideMerge(AlchemyContextLive, PlatformServices),
177219 Layer.provide(ProfileLive, PlatformServices),
178220 Layer.provide(CredentialsStoreLive, PlatformServices),
......@@ -180,6 +222,64 @@ const deployServices = Layer.mergeAll(
180222 TelemetryLive,
181223 LoggingCli,
182224);
225const deployServices = deployBaseServices;
226
227export function publicConfigFromOutput(output: unknown): RelayPublicConfig | null {
228 if (typeof output !== "object" || output === null) {
229 return null;
230 }
231 const value = output as Record<string, unknown>;
232 const text = (name: string) => (typeof value[name] === "string" ? value[name] : undefined);
233 const secret = (name: string): string | undefined => {
234 const candidate = value[name];
235 if (!Redacted.isRedacted(candidate)) {
236 return text(name);
237 }
238 const redacted = Redacted.value(candidate);
239 return typeof redacted === "string" ? redacted : undefined;
240 };
241 const relayUrl = text("url");
242 const mobileTracingUrl = text("mobileTracingUrl");
243 const mobileTracingDataset = text("mobileTracingDataset");
244 const mobileTracingToken = secret("mobileTracingToken");
245 const clientTracingUrl = text("clientTracingUrl");
246 const clientTracingDataset = text("clientTracingDataset");
247 const clientTracingToken = secret("clientTracingToken");
248 return relayUrl &&
249 mobileTracingUrl &&
250 mobileTracingDataset &&
251 mobileTracingToken &&
252 clientTracingUrl &&
253 clientTracingDataset &&
254 clientTracingToken
255 ? {
256 relayUrl,
257 mobileTracingUrl,
258 mobileTracingDataset,
259 mobileTracingToken,
260 clientTracingUrl,
261 clientTracingDataset,
262 clientTracingToken,
263 }
264 : null;
265}
266
267const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (stage: string) {
268 const state = yield* State.State;
269 const service = yield* state;
270 const output = yield* service.getOutput({ stack: "T3CodeRelay", stage });
271 const publicConfig = publicConfigFromOutput(output);
272 if (publicConfig === null) {
273 return yield* new RelayDeployError({
274 message: `Alchemy relay state for stage ${stage} did not include complete public client config`,
275 });
276 }
277 return {
278 result: "state",
279 changed: false,
280 publicConfig: Option.some(publicConfig),
281 } satisfies RelayDeployOutcome;
282});
183283
184284const runRelayDeploy = Effect.fn("relay.deploy.run")(
185285 function* (
......@@ -188,7 +288,6 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(
188288 _stage: string,
189289 ) {
190290 const stack = yield* RelayStack;
191
192291 const cli = yield* Cli;
193292 const plan = yield* Plan.make(stack, { force: options.force }).pipe(
194293 Effect.provide(stack.services),
......@@ -223,7 +322,10 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(
223322 output.url === undefined ||
224323 output.mobileTracingUrl === undefined ||
225324 output.mobileTracingDataset === undefined ||
226 output.mobileTracingToken === undefined
325 output.mobileTracingToken === undefined ||
326 output.clientTracingUrl === undefined ||
327 output.clientTracingDataset === undefined ||
328 output.clientTracingToken === undefined
227329 ) {
228330 return yield* new RelayDeployError({
229331 message: "Alchemy relay deploy output did not include complete public client config",
......@@ -237,6 +339,9 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(
237339 mobileTracingUrl: output.mobileTracingUrl,
238340 mobileTracingDataset: output.mobileTracingDataset,
239341 mobileTracingToken: Redacted.value(output.mobileTracingToken),
342 clientTracingUrl: output.clientTracingUrl,
343 clientTracingDataset: output.clientTracingDataset,
344 clientTracingToken: Redacted.value(output.clientTracingToken),
240345 }),
241346 } satisfies RelayDeployOutcome;
242347 },
......@@ -264,13 +369,18 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO
264369 Effect.provide(ConfigProvider.layer(configProvider)),
265370 );
266371 const stage = Option.getOrElse(options.stage, () => configuredStage);
267 const outcome = yield* runRelayDeploy(options, configProvider, stage);
372 const outcome = options.readState
373 ? yield* readRelayPublicConfig(stage).pipe(Effect.provide(Cloudflare.state()))
374 : yield* runRelayDeploy(options, configProvider, stage);
268375 if (Option.isSome(outcome.publicConfig)) {
269376 yield* reconcileRootEnv(outcome.publicConfig.value);
270377 }
271378 if (options.githubOutput) {
272379 yield* writeGithubOutput(outcome);
273380 }
381 if (Option.isSome(options.githubEnvFile)) {
382 yield* writeGithubEnvFile(outcome, options.githubEnvFile.value);
383 }
274384});
275385
276386export const relayDeployCommand = Command.make(
......@@ -306,6 +416,16 @@ export const relayDeployCommand = Command.make(
306416 Flag.withDescription("Append relay deployment metadata to GITHUB_OUTPUT."),
307417 Flag.withDefault(false),
308418 ),
419 githubEnvFile: Flag.string("github-env-file").pipe(
420 Flag.withDescription(
421 "Write relay client tracing variables to a file suitable for GITHUB_ENV.",
422 ),
423 Flag.optional,
424 ),
425 readState: Flag.boolean("read-state").pipe(
426 Flag.withDescription("Read the deployed stack output without planning or applying changes."),
427 Flag.withDefault(false),
428 ),
309429 },
310430 deploy,
311431).pipe(Command.withDescription("Deploy the T3 Code relay through Alchemy."));
infra/relay/src/observability.ts+9-1
......@@ -50,6 +50,14 @@ export const RelayObservability = Effect.gen(function* () {
5050 })),
5151 });
5252
53 const clientIngestToken = yield* Axiom.ApiToken("RelayClientAxiomIngestToken", {
54 name: relayResourceNameForStage("t3-code-relay-client-otel-ingest", stage),
55 description: "Owned by Alchemy. Scoped OTLP ingest token for first-party relay client spans.",
56 datasetCapabilities: Output.map(traces.name, (dataset) => ({
57 [dataset]: { ingest: ["create" as const] },
58 })),
59 });
60
5361 yield* Axiom.View("RelayRecentSpansView", {
5462 name: relayResourceNameForStage("t3-code-relay-recent-spans", stage),
5563 description: "Recent relay HTTP request spans.",
......@@ -57,7 +65,7 @@ export const RelayObservability = Effect.gen(function* () {
5765 aplQuery: Output.map(traces.name, relayRecentSpansQuery),
5866 });
5967
60 return { traces, workerIngestToken, mobileIngestToken } as const;
68 return { traces, workerIngestToken, mobileIngestToken, clientIngestToken } as const;
6169});
6270
6371export const withSpanAttributes =
packages/client-runtime/src/managedRelay.ts+11-5
......@@ -26,6 +26,7 @@ import {
2626 RelayUnregisterDeviceEndpoint,
2727} from "@t3tools/contracts/relay";
2828import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";
29import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
2930import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
3031import * as Clock from "effect/Clock";
3132import * as Context from "effect/Context";
......@@ -357,6 +358,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
357358 relayClientError("Could not list relay-managed environments.", cause),
358359 ),
359360 timeoutRelayRequest("Relay environment listing timed out."),
361 withRelayClientTracing,
360362 ),
361363 listDevices: (input) =>
362364 client.client
......@@ -369,6 +371,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
369371 relayClientError("Could not list relay client devices.", cause),
370372 ),
371373 timeoutRelayRequest("Relay client device listing timed out."),
374 withRelayClientTracing,
372375 ),
373376 createEnvironmentLinkChallenge: (input) =>
374377 client.client
......@@ -381,6 +384,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
381384 relayClientError("Could not create relay environment link challenge.", cause),
382385 ),
383386 timeoutRelayRequest("Relay environment link challenge timed out."),
387 withRelayClientTracing,
384388 ),
385389 linkEnvironment: (input) =>
386390 client.client
......@@ -393,6 +397,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
393397 relayClientError("Could not link relay environment.", cause),
394398 ),
395399 timeoutRelayRequest("Relay environment linking timed out."),
400 withRelayClientTracing,
396401 ),
397402 unlinkEnvironment: (input) =>
398403 client.client
......@@ -405,6 +410,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
405410 relayClientError("Could not unlink relay environment.", cause),
406411 ),
407412 timeoutRelayRequest("Relay environment unlinking timed out."),
413 withRelayClientTracing,
408414 ),
409415 getEnvironmentStatus: (input) =>
410416 Effect.gen(function* () {
......@@ -424,7 +430,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
424430 ),
425431 timeoutRelayRequest("Relay environment status request timed out."),
426432 );
427 }),
433 }).pipe(withRelayClientTracing),
428434 connectEnvironment: (input) =>
429435 Effect.gen(function* () {
430436 const authorization = yield* authorize({
......@@ -448,7 +454,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
448454 ),
449455 timeoutRelayRequest("Relay environment connection timed out."),
450456 );
451 }),
457 }).pipe(withRelayClientTracing),
452458 registerDevice: (input) =>
453459 Effect.gen(function* () {
454460 const authorization = yield* authorizeMobileRegistration({
......@@ -466,7 +472,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
466472 ),
467473 timeoutRelayRequest("Relay mobile device registration timed out."),
468474 );
469 }),
475 }).pipe(withRelayClientTracing),
470476 unregisterDevice: (input) =>
471477 Effect.gen(function* () {
472478 const authorization = yield* authorizeMobileRegistration({
......@@ -484,7 +490,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
484490 ),
485491 timeoutRelayRequest("Relay mobile device unregistration timed out."),
486492 );
487 }),
493 }).pipe(withRelayClientTracing),
488494 registerLiveActivity: (input) =>
489495 Effect.gen(function* () {
490496 const authorization = yield* authorizeMobileRegistration({
......@@ -502,7 +508,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
502508 ),
503509 timeoutRelayRequest("Relay Live Activity registration timed out."),
504510 );
505 }),
511 }).pipe(withRelayClientTracing),
506512 resetTokenCache: SynchronizedRef.set(cachedTokens, []),
507513 });
508514 }),
packages/shared/package.json+4
......@@ -158,6 +158,10 @@
158158 "./hostProcess": {
159159 "types": "./src/hostProcess.ts",
160160 "import": "./src/hostProcess.ts"
161 },
162 "./relayTracing": {
163 "types": "./src/relayTracing.ts",
164 "import": "./src/relayTracing.ts"
161165 }
162166 },
163167 "scripts": {
packages/shared/src/relayTracing.test.ts created+57
......@@ -0,0 +1,57 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Tracer from "effect/Tracer";
5
6import { RelayClientTracer, withRelayClientTracing } from "./relayTracing.ts";
7
8function collectingTracer(spans: Array<string>): Tracer.Tracer {
9 return Tracer.make({
10 span: (options) => {
11 const span = new Tracer.NativeSpan(options);
12 const end = span.end.bind(span);
13 span.end = (endTime, exit) => {
14 end(endTime, exit);
15 spans.push(span.name);
16 };
17 return span;
18 },
19 });
20}
21
22describe("withRelayClientTracing", () => {
23 it.effect("uses the product tracer only for relay operations", () =>
24 Effect.gen(function* () {
25 const userSpans: Array<string> = [];
26 const productSpans: Array<string> = [];
27 const userTracer = collectingTracer(userSpans);
28 const productTracer = collectingTracer(productSpans);
29
30 yield* Effect.void.pipe(Effect.withSpan("user.operation"), Effect.withTracer(userTracer));
31 yield* Effect.void.pipe(
32 Effect.withSpan("relay.operation"),
33 withRelayClientTracing,
34 Effect.provideService(RelayClientTracer, Option.some(productTracer)),
35 Effect.withTracer(userTracer),
36 );
37
38 expect(userSpans).toEqual(["user.operation"]);
39 expect(productSpans).toEqual(["relay.operation"]);
40 }),
41 );
42
43 it.effect("preserves the active tracer when product tracing is disabled", () =>
44 Effect.gen(function* () {
45 const userSpans: Array<string> = [];
46 const userTracer = collectingTracer(userSpans);
47
48 yield* Effect.void.pipe(
49 Effect.withSpan("relay.operation"),
50 withRelayClientTracing,
51 Effect.withTracer(userTracer),
52 );
53
54 expect(userSpans).toEqual(["relay.operation"]);
55 }),
56 );
57});
packages/shared/src/relayTracing.ts created+70
......@@ -0,0 +1,70 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import * as Tracer from "effect/Tracer";
6import type { HttpClient } from "effect/unstable/http";
7import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
8
9export interface RelayClientTracingConfig {
10 readonly tracesUrl: string;
11 readonly tracesDataset: string;
12 readonly tracesToken: string;
13}
14
15export interface RelayClientTracingResource {
16 readonly serviceName: string;
17 readonly serviceVersion?: string;
18 readonly runtime: string;
19 readonly client: string;
20 readonly component?: string;
21}
22
23export class RelayClientTracer extends Context.Reference(
24 "@t3tools/shared/relayTracing/RelayClientTracer",
25 {
26 defaultValue: () => Option.none<Tracer.Tracer>(),
27 },
28) {}
29
30export const withRelayClientTracing = <A, E, R>(
31 effect: Effect.Effect<A, E, R>,
32): Effect.Effect<A, E, R> =>
33 RelayClientTracer.pipe(
34 Effect.flatMap(
35 Option.match({
36 onNone: () => effect,
37 onSome: (tracer) => effect.pipe(Effect.provideService(Tracer.Tracer, tracer)),
38 }),
39 ),
40 );
41
42export function makeRelayClientTracingLayer(
43 config: RelayClientTracingConfig | null,
44 resource: RelayClientTracingResource,
45): Layer.Layer<never, never, HttpClient.HttpClient> {
46 if (config === null) {
47 return Layer.succeed(RelayClientTracer, Option.none());
48 }
49
50 const tracerLayer = OtlpTracer.layer({
51 url: config.tracesUrl,
52 headers: {
53 Authorization: `Bearer ${config.tracesToken}`,
54 "X-Axiom-Dataset": config.tracesDataset,
55 },
56 resource: {
57 serviceName: resource.serviceName,
58 serviceVersion: resource.serviceVersion,
59 attributes: {
60 "service.runtime": resource.runtime,
61 "service.component": resource.component ?? "relay-client",
62 "t3.client.surface": resource.client,
63 },
64 },
65 }).pipe(Layer.provide(OtlpSerialization.layerJson));
66
67 return Layer.effect(RelayClientTracer, Tracer.Tracer.pipe(Effect.map(Option.some))).pipe(
68 Layer.provide(tracerLayer),
69 );
70}
scripts/lib/public-config.test.ts+29
......@@ -33,6 +33,12 @@ describe("loadRepoEnv", () => {
3333 expect(env.EXPO_PUBLIC_OTLP_TRACES_URL).toBeUndefined();
3434 expect(env.EXPO_PUBLIC_OTLP_TRACES_DATASET).toBeUndefined();
3535 expect(env.EXPO_PUBLIC_OTLP_TRACES_TOKEN).toBeUndefined();
36 expect(env.T3CODE_RELAY_CLIENT_OTLP_TRACES_URL).toBeUndefined();
37 expect(env.T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET).toBeUndefined();
38 expect(env.T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN).toBeUndefined();
39 expect(env.VITE_RELAY_OTLP_TRACES_URL).toBeUndefined();
40 expect(env.VITE_RELAY_OTLP_TRACES_DATASET).toBeUndefined();
41 expect(env.VITE_RELAY_OTLP_TRACES_TOKEN).toBeUndefined();
3642 });
3743
3844 it("applies process, root local, and root precedence in that order", () => {
......@@ -91,6 +97,29 @@ describe("loadRepoEnv", () => {
9197 mobileOtlpTracesUrl: "https://api.axiom.co/v1/traces",
9298 mobileOtlpTracesDataset: "mobile-traces",
9399 mobileOtlpTracesToken: "mobile-token",
100 relayClientOtlpTracesUrl: undefined,
101 relayClientOtlpTracesDataset: undefined,
102 relayClientOtlpTracesToken: undefined,
103 });
104 });
105
106 it("projects canonical relay client tracing values to web build aliases", () => {
107 expect(
108 loadRepoEnv({
109 baseEnv: {
110 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
111 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "relay-client-traces",
112 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "relay-client-token",
113 },
114 repoRoot: makeTemporaryDirectory(),
115 }),
116 ).toEqual({
117 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
118 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: "relay-client-traces",
119 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: "relay-client-token",
120 VITE_RELAY_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces",
121 VITE_RELAY_OTLP_TRACES_DATASET: "relay-client-traces",
122 VITE_RELAY_OTLP_TRACES_TOKEN: "relay-client-token",
94123 });
95124 });
96125
scripts/lib/public-config.ts+36
......@@ -12,6 +12,9 @@ export interface T3CodePublicConfig {
1212 readonly mobileOtlpTracesUrl: string | undefined;
1313 readonly mobileOtlpTracesDataset: string | undefined;
1414 readonly mobileOtlpTracesToken: string | undefined;
15 readonly relayClientOtlpTracesUrl: string | undefined;
16 readonly relayClientOtlpTracesDataset: string | undefined;
17 readonly relayClientOtlpTracesToken: string | undefined;
1518}
1619
1720type Environment = Readonly<Record<string, string | undefined>>;
......@@ -78,6 +81,24 @@ export function loadRepoEnv({
7881 EXPO_PUBLIC_OTLP_TRACES_TOKEN: config.mobileOtlpTracesToken,
7982 }
8083 : {}),
84 ...(config.relayClientOtlpTracesUrl
85 ? {
86 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.relayClientOtlpTracesUrl,
87 VITE_RELAY_OTLP_TRACES_URL: config.relayClientOtlpTracesUrl,
88 }
89 : {}),
90 ...(config.relayClientOtlpTracesDataset
91 ? {
92 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.relayClientOtlpTracesDataset,
93 VITE_RELAY_OTLP_TRACES_DATASET: config.relayClientOtlpTracesDataset,
94 }
95 : {}),
96 ...(config.relayClientOtlpTracesToken
97 ? {
98 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: config.relayClientOtlpTracesToken,
99 VITE_RELAY_OTLP_TRACES_TOKEN: config.relayClientOtlpTracesToken,
100 }
101 : {}),
81102 };
82103}
83104
......@@ -112,6 +133,21 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP
112133 "T3CODE_MOBILE_OTLP_TRACES_TOKEN",
113134 "EXPO_PUBLIC_OTLP_TRACES_TOKEN",
114135 ),
136 relayClientOtlpTracesUrl: firstNonEmpty(
137 sources,
138 "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL",
139 "VITE_RELAY_OTLP_TRACES_URL",
140 ),
141 relayClientOtlpTracesDataset: firstNonEmpty(
142 sources,
143 "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET",
144 "VITE_RELAY_OTLP_TRACES_DATASET",
145 ),
146 relayClientOtlpTracesToken: firstNonEmpty(
147 sources,
148 "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN",
149 "VITE_RELAY_OTLP_TRACES_TOKEN",
150 ),
115151 };
116152}
117153