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:...@@ -180,12 +180,46 @@ jobs:
180 clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }}180 clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }}
181 relay_url: ${{ steps.public_config.outputs.relay_url }}181 relay_url: ${{ steps.public_config.outputs.relay_url }}
182 env:182 env:
183 CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
184 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
183 RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }}185 RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }}
184 RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }}186 RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }}
185 CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}187 CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
186 CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }}188 CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }}
187 CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }}189 CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }}
188 steps:190 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
189 - id: public_config223 - id: public_config
190 name: Resolve production relay public config224 name: Resolve production relay public config
191 shell: bash225 shell: bash
...@@ -272,6 +306,20 @@ jobs:...@@ -272,6 +306,20 @@ jobs:
272 cache: true306 cache: true
273 run-install: true307 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
275 - name: Align package versions to release version323 - name: Align package versions to release version
276 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"324 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
277325
...@@ -508,6 +556,20 @@ jobs:...@@ -508,6 +556,20 @@ jobs:
508 - --filter=@t3tools/web...556 - --filter=@t3tools/web...
509 - --filter=@t3tools/scripts...557 - --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
511 - name: Align package versions to release version573 - name: Align package versions to release version
512 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"574 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
513575
...@@ -670,6 +732,20 @@ jobs:...@@ -670,6 +732,20 @@ jobs:
670 - --filter=@t3tools/scripts...732 - --filter=@t3tools/scripts...
671 - --filter=@t3tools/web...733 - --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
673 - name: Align package versions to release version749 - name: Align package versions to release version
674 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"750 run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"
675751
...@@ -716,6 +792,9 @@ jobs:...@@ -716,6 +792,9 @@ jobs:
716 --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \792 --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \
717 --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \793 --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \
718 --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \794 --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:-}" \
719 --build-env "VITE_HOSTED_APP_URL=$router_url" \798 --build-env "VITE_HOSTED_APP_URL=$router_url" \
720 --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"799 --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"
721 )"800 )"
apps/mobile/src/features/observability/mobileTracing.test.ts+2-1
...@@ -4,6 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,6 +4,7 @@ import * as Layer from "effect/Layer";
4import { vi } from "vite-plus/test";4import { vi } from "vite-plus/test";
55
6import { remoteHttpClientLayer } from "@t3tools/client-runtime";6import { remoteHttpClientLayer } from "@t3tools/client-runtime";
7import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
78
8import { makeMobileTracingLayer } from "./mobileTracing";9import { makeMobileTracingLayer } from "./mobileTracing";
910
...@@ -29,7 +30,7 @@ it.effect("exports spans through the scoped mobile OTLP layer", () => {...@@ -29,7 +30,7 @@ it.effect("exports spans through the scoped mobile OTLP layer", () => {
29 },30 },
30 ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn)));31 ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn)));
31 const tracedApplication = Layer.effectDiscard(32 const tracedApplication = Layer.effectDiscard(
32 Effect.void.pipe(Effect.withSpan("mobile.test.span")),33 Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing),
33 ).pipe(Layer.provide(tracingLayer));34 ).pipe(Layer.provide(tracingLayer));
3435
35 return Effect.gen(function* () {36 return Effect.gen(function* () {
apps/mobile/src/features/observability/mobileTracing.ts+8-24
...@@ -1,7 +1,5 @@...@@ -1,7 +1,5 @@
1import Constants from "expo-constants";1import Constants from "expo-constants";
2import * as Layer from "effect/Layer";2import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing";
3import type { HttpClient } from "effect/unstable/http";
4import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
53
6import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig";4import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig";
75
...@@ -28,27 +26,13 @@ export function resolveMobileTracingConfig(): MobileTracingConfig | null {...@@ -28,27 +26,13 @@ export function resolveMobileTracingConfig(): MobileTracingConfig | null {
28export function makeMobileTracingLayer(26export function makeMobileTracingLayer(
29 config: MobileTracingConfig | null,27 config: MobileTracingConfig | null,
30 resource: MobileTracingResource,28 resource: MobileTracingResource,
31): Layer.Layer<never, never, HttpClient.HttpClient> {29) {
32 if (config === null) {30 return makeRelayClientTracingLayer(config, {
33 return Layer.empty;31 serviceName: "t3-mobile-relay-client",
34 }32 serviceVersion: resource.serviceVersion,
3533 runtime: "react-native",
36 return OtlpTracer.layer({34 client: `mobile-${resource.appVariant}`,
37 url: config.tracesUrl,35 });
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));
52}36}
5337
54export const mobileTracingLayer = makeMobileTracingLayer(resolveMobileTracingConfig(), {38export const mobileTracingLayer = makeMobileTracingLayer(resolveMobileTracingConfig(), {
apps/server/src/cli/connect.ts+4
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { RelayOkResponse } from "@t3tools/contracts/relay";7import { RelayOkResponse } from "@t3tools/contracts/relay";
8import * as RelayClient from "@t3tools/shared/relayClient";8import * as RelayClient from "@t3tools/shared/relayClient";
9import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
9import * as Console from "effect/Console";10import * as Console from "effect/Console";
10import * as Duration from "effect/Duration";11import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
...@@ -29,6 +30,7 @@ import * as CliState from "../cloud/CliState.ts";...@@ -29,6 +30,7 @@ import * as CliState from "../cloud/CliState.ts";
29import * as CliTokenManager from "../cloud/CliTokenManager.ts";30import * as CliTokenManager from "../cloud/CliTokenManager.ts";
30import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts";31import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts";
31import { relayUrlConfig } from "../cloud/publicConfig.ts";32import { relayUrlConfig } from "../cloud/publicConfig.ts";
33import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
32import { ServerConfig } from "../config.ts";34import { ServerConfig } from "../config.ts";
33import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts";35import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts";
34import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";36import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
...@@ -228,6 +230,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f...@@ -228,6 +230,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f
228 httpClient.execute,230 httpClient.execute,
229 Effect.flatMap(HttpClientResponse.filterStatusOk),231 Effect.flatMap(HttpClientResponse.filterStatusOk),
230 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),232 Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)),
233 withRelayClientTracing,
231 );234 );
232 return response.ok235 return response.ok
233 ? ({ status: "revoked" } satisfies RelayUnlinkResult)236 ? ({ status: "revoked" } satisfies RelayUnlinkResult)
...@@ -299,6 +302,7 @@ const runCloudCommand = <A, E>(...@@ -299,6 +302,7 @@ const runCloudCommand = <A, E>(
299 RelayClient.layerCloudflared({ baseDir: config.baseDir }),302 RelayClient.layerCloudflared({ baseDir: config.baseDir }),
300 EnvironmentAuth.runtimeLayer,303 EnvironmentAuth.runtimeLayer,
301 ServerEnvironmentLive,304 ServerEnvironmentLive,
305 headlessRelayClientTracingLayer,
302 ).pipe(306 ).pipe(
303 Layer.provideMerge(FetchHttpClient.layer),307 Layer.provideMerge(FetchHttpClient.layer),
304 Layer.provideMerge(Layer.succeed(ServerConfig, config)),308 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";...@@ -3,13 +3,19 @@ import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import * as PlatformError from "effect/PlatformError";5import * 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";
8import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";10import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
9import * as ServerSecretStore from "../auth/ServerSecretStore.ts";11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
10import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";12import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
11import * as CliTokenManager from "./CliTokenManager.ts";13import * as CliTokenManager from "./CliTokenManager.ts";
12import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts";14import {
15 consumeCloudReplayGuards,
16 reconcileDesiredCloudLink,
17 traceRelayBrokerHandler,
18} from "./http.ts";
13import {19import {
14 CloudManagedEndpointRuntime,20 CloudManagedEndpointRuntime,
15 type CloudManagedEndpointRuntimeShape,21 type CloudManagedEndpointRuntimeShape,
...@@ -69,6 +75,38 @@ describe("consumeCloudReplayGuards", () => {...@@ -69,6 +75,38 @@ describe("consumeCloudReplayGuards", () => {
69 );75 );
70});76});
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
72describe("reconcileDesiredCloudLink", () => {110describe("reconcileDesiredCloudLink", () => {
73 it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () =>111 it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () =>
74 Effect.gen(function* () {112 Effect.gen(function* () {
apps/server/src/cloud/http.ts+17-2
...@@ -29,6 +29,7 @@ import {...@@ -29,6 +29,7 @@ import {
29 RelayLinkProofRequest,29 RelayLinkProofRequest,
30 RelayManagedEndpointOrigin,30 RelayManagedEndpointOrigin,
31} from "@t3tools/contracts/relay";31} from "@t3tools/contracts/relay";
32import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
32import {33import {
33 normalizeRelayIssuer,34 normalizeRelayIssuer,
34 RELAY_HEALTH_REQUEST_TYP,35 RELAY_HEALTH_REQUEST_TYP,
...@@ -47,7 +48,7 @@ import * as Effect from "effect/Effect";...@@ -47,7 +48,7 @@ import * as Effect from "effect/Effect";
47import * as Option from "effect/Option";48import * as Option from "effect/Option";
48import * as Schema from "effect/Schema";49import * as Schema from "effect/Schema";
49import * as HttpEffect from "effect/unstable/http/HttpEffect";50import * as HttpEffect from "effect/unstable/http/HttpEffect";
50import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";51import { HttpServerRequest, HttpServerResponse, HttpTraceContext } from "effect/unstable/http";
51import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";52import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
52import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";53import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
5354
...@@ -110,6 +111,19 @@ const requireRelayUrl = relayUrlConfig.pipe(...@@ -110,6 +111,19 @@ const requireRelayUrl = relayUrlConfig.pipe(
110 ),111 ),
111);112);
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
113function bytesToString(bytes: Uint8Array): string {127function bytesToString(bytes: Uint8Array): string {
114 return new TextDecoder().decode(bytes);128 return new TextDecoder().decode(bytes);
115}129}
...@@ -512,6 +526,7 @@ const relayClientRequest = <A>(...@@ -512,6 +526,7 @@ const relayClientRequest = <A>(
512 message: `T3 Connect relay request failed: ${String(cause)}`,526 message: `T3 Connect relay request failed: ${String(cause)}`,
513 }),527 }),
514 ),528 ),
529 withRelayClientTracing,
515 );530 );
516531
517const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")(532const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")(
...@@ -938,7 +953,7 @@ export const connectHttpApiLayer = HttpApiBuilder.group(...@@ -938,7 +953,7 @@ export const connectHttpApiLayer = HttpApiBuilder.group(
938 .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload))953 .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload))
939 .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload))954 .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload))
940 .handle("t3MintCredential", ({ payload }) =>955 .handle("t3MintCredential", ({ payload }) =>
941 cloudMintCredentialHandler(dependencies, payload),956 traceRelayBrokerHandler(cloudMintCredentialHandler(dependencies, payload)),
942 );957 );
943 }),958 }),
944);959);
apps/server/src/cloud/publicConfig.test.ts+41-1
...@@ -2,7 +2,11 @@ import { assert, it } from "@effect/vitest";...@@ -2,7 +2,11 @@ import { assert, it } from "@effect/vitest";
2import * as ConfigProvider from "effect/ConfigProvider";2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
44
5import { makeCloudCliOAuthConfig, makeRelayUrlConfig } from "./publicConfig.ts";5import {
6 makeCloudCliOAuthConfig,
7 makeRelayUrlConfig,
8 resolveRelayClientTracingConfig,
9} from "./publicConfig.ts";
610
7const provideEnv = (env: Readonly<Record<string, string>>) =>11const provideEnv = (env: Readonly<Record<string, string>>) =>
8 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })));12 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...@@ -83,3 +87,39 @@ it.effect("requires Clerk OAuth config when the server bundle has no injected va
83 clerkCliOAuthClientIdFallback: "",87 clerkCliOAuthClientIdFallback: "",
84 }).pipe(provideEnv({}), Effect.flip),88 }).pipe(provideEnv({}), Effect.flip),
85);89);
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";...@@ -9,6 +9,9 @@ import * as SchemaIssue from "effect/SchemaIssue";
9declare const __T3CODE_BUILD_RELAY_URL__: string | undefined;9declare const __T3CODE_BUILD_RELAY_URL__: string | undefined;
10declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;10declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
11declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined;11declare 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
13const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback";16const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback";
14const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const;17const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const;
...@@ -32,6 +35,15 @@ function readBuildTimeValue(value: string | undefined): string {...@@ -32,6 +35,15 @@ function readBuildTimeValue(value: string | undefined): string {
32 return typeof value === "undefined" ? "" : value.trim();35 return typeof value === "undefined" ? "" : value.trim();
33}36}
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
35export const buildTimeRelayUrl =47export const buildTimeRelayUrl =
36 typeof __T3CODE_BUILD_RELAY_URL__ === "undefined"48 typeof __T3CODE_BUILD_RELAY_URL__ === "undefined"
37 ? ""49 ? ""
...@@ -46,6 +58,37 @@ export const buildTimeClerkCliOAuthClientId = readBuildTimeValue(...@@ -46,6 +58,37 @@ export const buildTimeClerkCliOAuthClientId = readBuildTimeValue(
46 ? undefined58 ? undefined
47 : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__,59 : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__,
48);60);
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
50export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) {93export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) {
51 const runtimeConfig = Config.nonEmptyString("T3CODE_RELAY_URL");94 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 {...@@ -17,6 +17,7 @@ import type {
17 RelayAgentActivityState,17 RelayAgentActivityState,
18} from "@t3tools/contracts/relay";18} from "@t3tools/contracts/relay";
19import { CommandId, ProviderInstanceId } from "@t3tools/contracts";19import { CommandId, ProviderInstanceId } from "@t3tools/contracts";
20import { RelayClientTracer } from "@t3tools/shared/relayTracing";
20import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt";21import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt";
21import { describe, expect, it } from "@effect/vitest";22import { describe, expect, it } from "@effect/vitest";
22import * as Deferred from "effect/Deferred";23import * as Deferred from "effect/Deferred";
...@@ -25,6 +26,7 @@ import * as Layer from "effect/Layer";...@@ -25,6 +26,7 @@ import * as Layer from "effect/Layer";
25import * as Option from "effect/Option";26import * as Option from "effect/Option";
26import * as Queue from "effect/Queue";27import * as Queue from "effect/Queue";
27import * as Stream from "effect/Stream";28import * as Stream from "effect/Stream";
29import * as Tracer from "effect/Tracer";
2830
29import * as ServerSecretStore from "../auth/ServerSecretStore.ts";31import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
30import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";32import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
...@@ -522,6 +524,20 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -522,6 +524,20 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
522 const runFork = Effect.runForkWith(context);524 const runFork = Effect.runForkWith(context);
523 const events = yield* Queue.unbounded<OrchestrationEvent>();525 const events = yield* Queue.unbounded<OrchestrationEvent>();
524 const fetchSeen = yield* Deferred.make<URL>();526 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 });
525 const secrets = makeMemorySecretStore();541 const secrets = makeMemorySecretStore();
526 const now = "2026-05-25T00:00:00.000Z";542 const now = "2026-05-25T00:00:00.000Z";
527 const projectId = "project-1" as ProjectId;543 const projectId = "project-1" as ProjectId;
...@@ -648,6 +664,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -648,6 +664,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
648664
649 const url = yield* Deferred.await(fetchSeen).pipe(Effect.timeout("2 seconds"));665 const url = yield* Deferred.await(fetchSeen).pipe(Effect.timeout("2 seconds"));
650 expect(url.origin).toBe("https://transport.example.test");666 expect(url.origin).toBe("https://transport.example.test");
667 expect(productSpans).toContain("makePublishProof");
668 expect(userSpans).not.toContain("makePublishProof");
651 }).pipe(669 }).pipe(
652 Effect.provide(670 Effect.provide(
653 AgentAwarenessRelay.layer.pipe(671 AgentAwarenessRelay.layer.pipe(
...@@ -655,6 +673,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {...@@ -655,6 +673,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => {
655 Layer.provideMerge(NodeServices.layer),673 Layer.provideMerge(NodeServices.layer),
656 ),674 ),
657 ),675 ),
676 Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))),
677 Effect.withTracer(collectingTracer(userSpans)),
658 );678 );
659 }),679 }),
660 ),680 ),
apps/server/src/relay/AgentAwarenessRelay.ts+2
...@@ -12,6 +12,7 @@ import type {...@@ -12,6 +12,7 @@ import type {
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";13import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
14import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";14import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
15import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
15import {16import {
16 RELAY_ACTIVITY_PUBLISH_TYP,17 RELAY_ACTIVITY_PUBLISH_TYP,
17 signRelayJwt,18 signRelayJwt,
...@@ -409,6 +410,7 @@ const make = Effect.gen(function* () {...@@ -409,6 +410,7 @@ const make = Effect.gen(function* () {
409 });410 });
410 }),411 }),
411 Effect.withSpan("AgentAwarenessRelay.publishThread"),412 Effect.withSpan("AgentAwarenessRelay.publishThread"),
413 withRelayClientTracing,
412 );414 );
413415
414 const publishActiveThreadsUnsafe = Effect.gen(function* () {416 const publishActiveThreadsUnsafe = Effect.gen(function* () {
apps/server/src/server.ts+2
...@@ -74,6 +74,7 @@ import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http...@@ -74,6 +74,7 @@ import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http
74import * as ServerSecretStore from "./auth/ServerSecretStore.ts";74import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
75import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";75import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
76import { connectHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts";76import { connectHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts";
77import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts";
77import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";78import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
78import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";79import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
79import * as CloudCliState from "./cloud/CliState.ts";80import * as CloudCliState from "./cloud/CliState.ts";
...@@ -477,6 +478,7 @@ export const makeServerLayer = Layer.unwrap(...@@ -477,6 +478,7 @@ export const makeServerLayer = Layer.unwrap(
477478
478 return serverApplicationLayer.pipe(479 return serverApplicationLayer.pipe(
479 Layer.provideMerge(RuntimeServicesLive),480 Layer.provideMerge(RuntimeServicesLive),
481 Layer.provideMerge(serverRelayBrokerTracingLayer),
480 Layer.provideMerge(HttpServerLive),482 Layer.provideMerge(HttpServerLive),
481 Layer.provide(ObservabilityLive),483 Layer.provide(ObservabilityLive),
482 Layer.provideMerge(FetchHttpClient.layer),484 Layer.provideMerge(FetchHttpClient.layer),
apps/server/vite.config.ts+9
...@@ -49,6 +49,15 @@ export default mergeConfig(...@@ -49,6 +49,15 @@ export default mergeConfig(
49 __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: JSON.stringify(49 __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: JSON.stringify(
50 repoEnv.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() ?? "",50 repoEnv.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() ?? "",
51 ),51 ),
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 ),
52 },61 },
53 },62 },
54 test: {63 test: {
apps/web/src/cloud/linkEnvironment.test.ts+8
...@@ -318,6 +318,14 @@ describe("web cloud link environment client", () => {...@@ -318,6 +318,14 @@ describe("web cloud link environment client", () => {
318 method: "POST",318 method: "POST",
319 url: "https://managed.example.test/oauth/token",319 url: "https://managed.example.test/oauth/token",
320 });320 });
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 );
321 }),329 }),
322 );330 );
323331
apps/web/src/cloud/linkEnvironment.ts+11-2
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import * as Data from "effect/Data";1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import { HttpClient } from "effect/unstable/http";4import { HttpClient, HttpTraceContext, type Headers } from "effect/unstable/http";
5import {5import {
6 EnvironmentCloudEndpointUnavailableError,6 EnvironmentCloudEndpointUnavailableError,
7 type EnvironmentCloudLinkStateResult,7 type EnvironmentCloudLinkStateResult,
...@@ -29,6 +29,7 @@ import {...@@ -29,6 +29,7 @@ import {
29 ManagedRelayDpopSigner,29 ManagedRelayDpopSigner,
30 type WsRpcClient,30 type WsRpcClient,
31} from "@t3tools/client-runtime";31} from "@t3tools/client-runtime";
32import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
3233
33import { ensureLocalApi } from "../localApi";34import { ensureLocalApi } from "../localApi";
34import {35import {
...@@ -245,6 +246,7 @@ export interface CloudManagedConnection {...@@ -245,6 +246,7 @@ export interface CloudManagedConnection {
245 readonly wsBaseUrl: string;246 readonly wsBaseUrl: string;
246 readonly relayUrl: string;247 readonly relayUrl: string;
247 readonly accessToken: string;248 readonly accessToken: string;
249 readonly relayTraceHeaders: Headers.Headers;
248}250}
249251
250export function collectCloudLinkTargets(input: {252export function collectCloudLinkTargets(input: {
...@@ -437,8 +439,15 @@ export function connectManagedCloudEnvironment(input: {...@@ -437,8 +439,15 @@ export function connectManagedCloudEnvironment(input: {
437 wsBaseUrl: connected.endpoint.wsBaseUrl,439 wsBaseUrl: connected.endpoint.wsBaseUrl,
438 relayUrl: configuredRelayUrl,440 relayUrl: configuredRelayUrl,
439 accessToken: session.access_token,441 accessToken: session.access_token,
442 relayTraceHeaders: HttpTraceContext.toHeaders(yield* Effect.currentSpan.pipe(Effect.orDie)),
440 };443 };
441 });444 }).pipe(
445 Effect.withSpan("relay.environment.connect", {
446 root: true,
447 attributes: { "relay.environment_id": input.environment.environmentId },
448 }),
449 withRelayClientTracing,
450 );
442}451}
443452
444export function readPrimaryCloudLinkState(): Effect.Effect<453export function readPrimaryCloudLinkState(): Effect.Effect<
apps/web/src/cloud/publicConfig.ts+34
...@@ -5,12 +5,26 @@ export interface CloudPublicConfig {...@@ -5,12 +5,26 @@ export interface CloudPublicConfig {
5 readonly clerkPublishableKey: string | null;5 readonly clerkPublishableKey: string | null;
6 readonly clerkJwtTemplate: string | null;6 readonly clerkJwtTemplate: string | null;
7 readonly relayUrl: string | null;7 readonly relayUrl: string | null;
8 readonly relayTracing: {
9 readonly tracesUrl: string | null;
10 readonly tracesDataset: string | null;
11 readonly tracesToken: string | null;
12 };
8}13}
914
10function trimNonEmpty(value: string | undefined): string | null {15function trimNonEmpty(value: string | undefined): string | null {
11 return value?.trim() || null;16 return value?.trim() || null;
12}17}
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
14export function resolveCloudPublicConfig(): CloudPublicConfig {28export function resolveCloudPublicConfig(): CloudPublicConfig {
15 return {29 return {
16 clerkPublishableKey: trimNonEmpty(30 clerkPublishableKey: trimNonEmpty(
...@@ -20,9 +34,29 @@ export function resolveCloudPublicConfig(): CloudPublicConfig {...@@ -20,9 +34,29 @@ export function resolveCloudPublicConfig(): CloudPublicConfig {
20 relayUrl: normalizeSecureRelayUrl(34 relayUrl: normalizeSecureRelayUrl(
21 (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "",35 (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "",
22 ),36 ),
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 },
23 };46 };
24}47}
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
26export function hasCloudPublicConfig(): boolean {60export function hasCloudPublicConfig(): boolean {
27 const config = resolveCloudPublicConfig();61 const config = resolveCloudPublicConfig();
28 return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl);62 return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl);
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+44-1
...@@ -1,8 +1,13 @@...@@ -1,8 +1,13 @@
1import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts";1import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
5import * as Tracer from "effect/Tracer";
6import { Headers } from "effect/unstable/http";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";7import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
58
9import { RelayClientTracer } from "@t3tools/shared/relayTracing";
10
6const decodeEnvironmentAuthInvalidError = Schema.decodeUnknownSync(EnvironmentAuthInvalidError);11const decodeEnvironmentAuthInvalidError = Schema.decodeUnknownSync(EnvironmentAuthInvalidError);
712
8let mockSavedRecords: Array<Record<string, unknown>> = [];13let mockSavedRecords: Array<Record<string, unknown>> = [];
...@@ -12,8 +17,11 @@ const mockFetchRemoteEnvironmentDescriptor = vi.fn();...@@ -12,8 +17,11 @@ const mockFetchRemoteEnvironmentDescriptor = vi.fn();
12const mockBootstrapRemoteBearerSession = vi.fn();17const mockBootstrapRemoteBearerSession = vi.fn();
13const mockFetchRemoteSessionState = vi.fn();18const mockFetchRemoteSessionState = vi.fn();
14const mockFetchRemoteDpopSessionState = vi.fn();19const mockFetchRemoteDpopSessionState = vi.fn();
20const mockResolveRemoteDpopWebSocketConnectionUrl = vi.fn();
15const mockResolveRemoteWebSocketConnectionUrl = vi.fn();21const mockResolveRemoteWebSocketConnectionUrl = vi.fn();
22const mockWsTransportConnectors: Array<() => Promise<string>> = [];
16let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner;23let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner;
24let mockRelayClientTracer = Option.none<Tracer.Tracer>();
17const mockRemoteHttpRunPromise = vi.fn(<A, E>(effect: Effect.Effect<A, E>) =>25const mockRemoteHttpRunPromise = vi.fn(<A, E>(effect: Effect.Effect<A, E>) =>
18 Effect.runPromise(26 Effect.runPromise(
19 effect.pipe(27 effect.pipe(
...@@ -24,6 +32,7 @@ const mockRemoteHttpRunPromise = vi.fn(<A, E>(effect: Effect.Effect<A, E>) =>...@@ -24,6 +32,7 @@ const mockRemoteHttpRunPromise = vi.fn(<A, E>(effect: Effect.Effect<A, E>) =>
24 createProof: () => Effect.succeed("dpop-proof"),32 createProof: () => Effect.succeed("dpop-proof"),
25 }),33 }),
26 ),34 ),
35 Effect.provideService(RelayClientTracer, mockRelayClientTracer),
27 ),36 ),
28 ),37 ),
29);38);
...@@ -164,12 +173,16 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {...@@ -164,12 +173,16 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {
164 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,173 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,
165 fetchRemoteSessionState: mockFetchRemoteSessionState,174 fetchRemoteSessionState: mockFetchRemoteSessionState,
166 fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState,175 fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState,
176 resolveRemoteDpopWebSocketConnectionUrl: mockResolveRemoteDpopWebSocketConnectionUrl,
167 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,177 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
168 };178 };
169});179});
170180
171vi.mock("../../rpc/wsTransport", () => ({181vi.mock("../../rpc/wsTransport", () => ({
172 WsTransport: vi.fn(),182 WsTransport: vi.fn(function WsTransport(connect: () => Promise<string>) {
183 mockWsTransportConnectors.push(connect);
184 return {};
185 }),
173}));186}));
174187
175describe("addSavedEnvironment", () => {188describe("addSavedEnvironment", () => {
...@@ -181,6 +194,8 @@ describe("addSavedEnvironment", () => {...@@ -181,6 +194,8 @@ describe("addSavedEnvironment", () => {
181 vi.resetModules();194 vi.resetModules();
182 vi.clearAllMocks();195 vi.clearAllMocks();
183 mockSavedRecords = [];196 mockSavedRecords = [];
197 mockRelayClientTracer = Option.none();
198 mockWsTransportConnectors.length = 0;
184 vi.stubGlobal("window", {199 vi.stubGlobal("window", {
185 desktopBridge: {200 desktopBridge: {
186 ensureSshEnvironment: mockEnsureSshEnvironment,201 ensureSshEnvironment: mockEnsureSshEnvironment,
...@@ -237,6 +252,9 @@ describe("addSavedEnvironment", () => {...@@ -237,6 +252,9 @@ describe("addSavedEnvironment", () => {
237 mockResolveRemoteWebSocketConnectionUrl.mockReturnValue(252 mockResolveRemoteWebSocketConnectionUrl.mockReturnValue(
238 Effect.succeed("wss://remote.example.com/?wsTicket=remote-token"),253 Effect.succeed("wss://remote.example.com/?wsTicket=remote-token"),
239 );254 );
255 mockResolveRemoteDpopWebSocketConnectionUrl.mockReturnValue(
256 Effect.succeed("wss://remote.example.com/?wsTicket=remote-dpop-token"),
257 );
240 mockFetchSshEnvironmentDescriptor.mockResolvedValue({258 mockFetchSshEnvironmentDescriptor.mockResolvedValue({
241 environmentId: EnvironmentId.make("environment-1"),259 environmentId: EnvironmentId.make("environment-1"),
242 label: "Remote environment",260 label: "Remote environment",
...@@ -386,6 +404,7 @@ describe("addSavedEnvironment", () => {...@@ -386,6 +404,7 @@ describe("addSavedEnvironment", () => {
386 wsBaseUrl: "wss://managed.example.com/",404 wsBaseUrl: "wss://managed.example.com/",
387 relayUrl: "https://relay.example.com",405 relayUrl: "https://relay.example.com",
388 accessToken: "managed-access-token",406 accessToken: "managed-access-token",
407 relayTraceHeaders: Headers.empty,
389 });408 });
390409
391 expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith(410 expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith(
...@@ -406,6 +425,19 @@ describe("addSavedEnvironment", () => {...@@ -406,6 +425,19 @@ describe("addSavedEnvironment", () => {
406425
407 it("renews expired managed DPoP credentials through the relay", async () => {426 it("renews expired managed DPoP credentials through the relay", async () => {
408 const environmentId = EnvironmentId.make("environment-1");427 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 });
409 mockSavedRecords = [441 mockSavedRecords = [
410 {442 {
411 environmentId,443 environmentId,
...@@ -443,6 +475,7 @@ describe("addSavedEnvironment", () => {...@@ -443,6 +475,7 @@ describe("addSavedEnvironment", () => {
443 wsBaseUrl: "wss://managed.example.com/",475 wsBaseUrl: "wss://managed.example.com/",
444 relayUrl: "https://relay.example.com",476 relayUrl: "https://relay.example.com",
445 accessToken: "renewed-access-token",477 accessToken: "renewed-access-token",
478 relayTraceHeaders,
446 }),479 }),
447 );480 );
448481
...@@ -460,6 +493,16 @@ describe("addSavedEnvironment", () => {...@@ -460,6 +493,16 @@ describe("addSavedEnvironment", () => {
460 method: "dpop",493 method: "dpop",
461 accessToken: "renewed-access-token",494 accessToken: "renewed-access-token",
462 });495 });
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);
463 await resetEnvironmentServiceForTests();506 await resetEnvironmentServiceForTests();
464 });507 });
465508
apps/web/src/environments/runtime/service.ts+60-30
...@@ -26,7 +26,10 @@ import {...@@ -26,7 +26,10 @@ import {
26import { type QueryClient } from "@tanstack/react-query";26import { type QueryClient } from "@tanstack/react-query";
27import { Throttler } from "@tanstack/react-pacer";27import { Throttler } from "@tanstack/react-pacer";
28import * as Effect from "effect/Effect";28import * as Effect from "effect/Effect";
29import * as Option from "effect/Option";
29import * as Schema from "effect/Schema";30import * as Schema from "effect/Schema";
31import { Headers, HttpTraceContext } from "effect/unstable/http";
32import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
30import {33import {
31 createKnownEnvironment,34 createKnownEnvironment,
32 getKnownEnvironmentWsBaseUrl,35 getKnownEnvironmentWsBaseUrl,
...@@ -1173,6 +1176,7 @@ function createPrimaryEnvironmentClient(...@@ -1173,6 +1176,7 @@ function createPrimaryEnvironmentClient(
1173function createSavedEnvironmentClient(1176function createSavedEnvironmentClient(
1174 environmentId: EnvironmentId,1177 environmentId: EnvironmentId,
1175 credentialRef: { current: SavedEnvironmentCredential },1178 credentialRef: { current: SavedEnvironmentCredential },
1179 relayTraceHeadersRef: { current: Headers.Headers | null },
1176): WsRpcClient {1180): WsRpcClient {
1177 useSavedEnvironmentRuntimeStore.getState().ensure(environmentId);1181 useSavedEnvironmentRuntimeStore.getState().ensure(environmentId);
11781182
...@@ -1196,21 +1200,10 @@ function createSavedEnvironmentClient(...@@ -1196,21 +1200,10 @@ function createSavedEnvironmentClient(
1196 }1200 }
1197 if (credential.method === "dpop") {1201 if (credential.method === "dpop") {
1198 try {1202 try {
1203 const relayTraceHeaders = relayTraceHeadersRef.current;
1204 relayTraceHeadersRef.current = null;
1199 return await webRuntime.runPromise(1205 return await webRuntime.runPromise(
1200 createManagedRelayDpopProof({1206 resolveManagedRelayWebSocketUrl(record, credential, relayTraceHeaders),
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 ),
1214 );1207 );
1215 } catch (error) {1208 } catch (error) {
1216 if (!isEnvironmentAuthInvalidError(error)) {1209 if (!isEnvironmentAuthInvalidError(error)) {
...@@ -1223,19 +1216,10 @@ function createSavedEnvironmentClient(...@@ -1223,19 +1216,10 @@ function createSavedEnvironmentClient(
1223 const renewedCredential = renewed.credential;1216 const renewedCredential = renewed.credential;
1224 credentialRef.current = renewedCredential;1217 credentialRef.current = renewedCredential;
1225 return await webRuntime.runPromise(1218 return await webRuntime.runPromise(
1226 createManagedRelayDpopProof({1219 resolveManagedRelayWebSocketUrl(
1227 method: "POST",1220 renewed.record,
1228 url: new URL("/api/auth/websocket-ticket", renewed.record.httpBaseUrl).toString(),1221 renewedCredential,
1229 accessToken: renewedCredential.accessToken,1222 renewed.relayTraceHeaders,
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 ),
1239 ),1223 ),
1240 );1224 );
1241 }1225 }
...@@ -1339,9 +1323,44 @@ async function refreshSavedEnvironmentMetadata(...@@ -1339,9 +1323,44 @@ async function refreshSavedEnvironmentMetadata(
1339 .rename(record.environmentId, serverConfig.environment.label);1323 .rename(record.environmentId, serverConfig.environment.label);
1340}1324}
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
1342async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Promise<{1360async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Promise<{
1343 readonly record: SavedEnvironmentRecord;1361 readonly record: SavedEnvironmentRecord;
1344 readonly credential: SavedEnvironmentCredential;1362 readonly credential: SavedEnvironmentCredential;
1363 readonly relayTraceHeaders: Headers.Headers;
1345} | null> {1364} | null> {
1346 if (!record.relayManaged) {1365 if (!record.relayManaged) {
1347 return null;1366 return null;
...@@ -1382,7 +1401,7 @@ async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Prom...@@ -1382,7 +1401,7 @@ async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Prom
1382 throw new Error("Unable to persist refreshed managed environment credentials.");1401 throw new Error("Unable to persist refreshed managed environment credentials.");
1383 }1402 }
1384 useSavedEnvironmentRegistryStore.getState().upsert(nextRecord);1403 useSavedEnvironmentRegistryStore.getState().upsert(nextRecord);
1385 return { record: nextRecord, credential };1404 return { record: nextRecord, credential, relayTraceHeaders: connected.relayTraceHeaders };
1386}1405}
13871406
1388function registerConnection(connection: EnvironmentConnection): EnvironmentConnection {1407function registerConnection(connection: EnvironmentConnection): EnvironmentConnection {
...@@ -1466,6 +1485,7 @@ async function ensureSavedEnvironmentConnection(...@@ -1466,6 +1485,7 @@ async function ensureSavedEnvironmentConnection(
1466 readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null;1485 readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null;
1467 readonly serverConfig?: ServerConfig | null;1486 readonly serverConfig?: ServerConfig | null;
1468 readonly allowManagedRenewal?: boolean;1487 readonly allowManagedRenewal?: boolean;
1488 readonly relayTraceHeaders?: Headers.Headers;
1469 },1489 },
1470): Promise<EnvironmentConnection> {1490): Promise<EnvironmentConnection> {
1471 const existing = environmentConnections.get(record.environmentId);1491 const existing = environmentConnections.get(record.environmentId);
...@@ -1511,9 +1531,14 @@ async function ensureSavedEnvironmentConnection(...@@ -1511,9 +1531,14 @@ async function ensureSavedEnvironmentConnection(
1511 }1531 }
15121532
1513 const activeCredential = { current: credential };1533 const activeCredential = { current: credential };
1534 const relayTraceHeaders = { current: options?.relayTraceHeaders ?? null };
1514 const client =1535 const client =
1515 options?.client ??1536 options?.client ??
1516 createSavedEnvironmentClient(activeRecord.environmentId, activeCredential);1537 createSavedEnvironmentClient(
1538 activeRecord.environmentId,
1539 activeCredential,
1540 relayTraceHeaders,
1541 );
1517 const initialConfigSnapshot = createDeferredPromise<ServerConfig>();1542 const initialConfigSnapshot = createDeferredPromise<ServerConfig>();
1518 const knownEnvironment = createKnownEnvironment({1543 const knownEnvironment = createKnownEnvironment({
1519 id: activeRecord.environmentId,1544 id: activeRecord.environmentId,
...@@ -1589,6 +1614,7 @@ async function ensureSavedEnvironmentConnection(...@@ -1589,6 +1614,7 @@ async function ensureSavedEnvironmentConnection(
1589 scopes: scopeHint,1614 scopes: scopeHint,
1590 serverConfig: options?.serverConfig ?? null,1615 serverConfig: options?.serverConfig ?? null,
1591 allowManagedRenewal: false,1616 allowManagedRenewal: false,
1617 relayTraceHeaders: renewed.relayTraceHeaders,
1592 });1618 });
1593 }1619 }
1594 }1620 }
...@@ -1916,6 +1942,7 @@ export async function addManagedRelayEnvironment(input: {...@@ -1916,6 +1942,7 @@ export async function addManagedRelayEnvironment(input: {
1916 readonly wsBaseUrl: string;1942 readonly wsBaseUrl: string;
1917 readonly relayUrl: string;1943 readonly relayUrl: string;
1918 readonly accessToken: string;1944 readonly accessToken: string;
1945 readonly relayTraceHeaders: Headers.Headers;
1919}): Promise<SavedEnvironmentRecord> {1946}): Promise<SavedEnvironmentRecord> {
1920 const existingRecord = getSavedEnvironmentRecord(input.environmentId);1947 const existingRecord = getSavedEnvironmentRecord(input.environmentId);
1921 const record: SavedEnvironmentRecord = {1948 const record: SavedEnvironmentRecord = {
...@@ -1939,7 +1966,10 @@ export async function addManagedRelayEnvironment(input: {...@@ -1939,7 +1966,10 @@ export async function addManagedRelayEnvironment(input: {
1939 }1966 }
1940 useSavedEnvironmentRegistryStore.getState().upsert(record);1967 useSavedEnvironmentRegistryStore.getState().upsert(record);
1941 await removeConnection(record.environmentId).catch(() => false);1968 await removeConnection(record.environmentId).catch(() => false);
1942 await ensureSavedEnvironmentConnection(record, { credential });1969 await ensureSavedEnvironmentConnection(record, {
1970 credential,
1971 relayTraceHeaders: input.relayTraceHeaders,
1972 });
1943 return record;1973 return record;
1944}1974}
19451975
apps/web/src/lib/runtime.ts+9-1
...@@ -5,6 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http";...@@ -5,6 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http";
55
6import { remoteHttpClientLayer } from "@t3tools/client-runtime";6import { remoteHttpClientLayer } from "@t3tools/client-runtime";
7import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";7import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
8import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing";
8import {9import {
9 PrimaryEnvironmentHttpClient,10 PrimaryEnvironmentHttpClient,
10 primaryEnvironmentHttpClientLive,11 primaryEnvironmentHttpClientLive,
...@@ -13,13 +14,19 @@ import { primaryEnvironmentRequestInit } from "../environments/primary/requestIn...@@ -13,13 +14,19 @@ import { primaryEnvironmentRequestInit } from "../environments/primary/requestIn
1314
14import { browserCryptoLayer } from "../cloud/dpop";15import { browserCryptoLayer } from "../cloud/dpop";
15import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer";16import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer";
16import { resolveCloudPublicConfig } from "../cloud/publicConfig";17import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig";
1718
18function configuredRelayUrl(): string {19function configuredRelayUrl(): string {
19 return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid";20 return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid";
20}21}
2122
22const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch);23const 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
24export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer);31export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer);
2532
...@@ -57,6 +64,7 @@ export const webRuntime = ManagedRuntime.make(...@@ -57,6 +64,7 @@ export const webRuntime = ManagedRuntime.make(
57 browserCryptoLayer,64 browserCryptoLayer,
58 webManagedRelayClientLayer(configuredRelayUrl()).pipe(65 webManagedRelayClientLayer(configuredRelayUrl()).pipe(
59 Layer.provide(Layer.mergeAll(webHttpClientLayer, browserCryptoLayer)),66 Layer.provide(Layer.mergeAll(webHttpClientLayer, browserCryptoLayer)),
67 Layer.provideMerge(webRelayTracingLayer),
60 ),68 ),
61 ),69 ),
62);70);
apps/web/src/vite-env.d.ts+3
...@@ -9,6 +9,9 @@ interface ImportMetaEnv {...@@ -9,6 +9,9 @@ interface ImportMetaEnv {
9 readonly VITE_HOSTED_APP_CHANNEL: string;9 readonly VITE_HOSTED_APP_CHANNEL: string;
10 readonly VITE_CLERK_PUBLISHABLE_KEY: string;10 readonly VITE_CLERK_PUBLISHABLE_KEY: string;
11 readonly VITE_CLERK_JWT_TEMPLATE: string;11 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;
12 readonly APP_VERSION: string;15 readonly APP_VERSION: string;
13}16}
1417
apps/web/vite.config.ts+8
...@@ -19,6 +19,9 @@ const configuredWsUrl = process.env.VITE_WS_URL?.trim();...@@ -19,6 +19,9 @@ const configuredWsUrl = process.env.VITE_WS_URL?.trim();
19const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";19const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
20const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";20const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
21const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";21const 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() || "";
22const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";25const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
23const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;26const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
24const configuredHostedAppUrl = (() => {27const configuredHostedAppUrl = (() => {
...@@ -135,6 +138,11 @@ export default defineConfig(() => {...@@ -135,6 +138,11 @@ export default defineConfig(() => {
135 "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl),138 "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl),
136 "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey),139 "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey),
137 "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate),140 "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),
138 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),146 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
139 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),147 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),
140 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),148 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),
infra/relay/alchemy.run.ts+3
...@@ -42,6 +42,9 @@ export default Alchemy.Stack(...@@ -42,6 +42,9 @@ export default Alchemy.Stack(
42 mobileTracingUrl: observability.traces.otelTracesEndpoint,42 mobileTracingUrl: observability.traces.otelTracesEndpoint,
43 mobileTracingDataset: observability.traces.name,43 mobileTracingDataset: observability.traces.name,
44 mobileTracingToken: observability.mobileIngestToken.token,44 mobileTracingToken: observability.mobileIngestToken.token,
45 clientTracingUrl: observability.traces.otelTracesEndpoint,
46 clientTracingDataset: observability.traces.name,
47 clientTracingToken: observability.clientIngestToken.token,
45 };48 };
46 }),49 }),
47);50);
infra/relay/scripts/deploy.test.ts+89-1
...@@ -1,10 +1,16 @@...@@ -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
3import {7import {
4 hasDeployChanges,8 hasDeployChanges,
9 publicConfigFromOutput,
5 reconcileRootEnvPublicConfig,10 reconcileRootEnvPublicConfig,
6 reconcileRootEnvRelayUrl,11 reconcileRootEnvRelayUrl,
7 serializeGithubOutput,12 serializeGithubOutput,
13 serializeRelayClientTracingEnvironment,
8} from "./deploy.ts";14} from "./deploy.ts";
915
10describe("hasDeployChanges", () => {16describe("hasDeployChanges", () => {
...@@ -62,6 +68,9 @@ describe("reconcileRootEnvPublicConfig", () => {...@@ -62,6 +68,9 @@ describe("reconcileRootEnvPublicConfig", () => {
62 mobileTracingUrl: "https://api.axiom.co/v1/traces",68 mobileTracingUrl: "https://api.axiom.co/v1/traces",
63 mobileTracingDataset: "t3-code-mobile-traces-dev",69 mobileTracingDataset: "t3-code-mobile-traces-dev",
64 mobileTracingToken: "xaat-public-ingest",70 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",
65 } as const;74 } as const;
6675
67 it("adds the complete local client config", () => {76 it("adds the complete local client config", () => {
...@@ -71,6 +80,9 @@ describe("reconcileRootEnvPublicConfig", () => {...@@ -71,6 +80,9 @@ describe("reconcileRootEnvPublicConfig", () => {
71 "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",80 "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
72 "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev",81 "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev",
73 "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest",82 "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",
74 "",86 "",
75 ].join("\n"),87 ].join("\n"),
76 );88 );
...@@ -85,6 +97,9 @@ describe("reconcileRootEnvPublicConfig", () => {...@@ -85,6 +97,9 @@ describe("reconcileRootEnvPublicConfig", () => {
85 "T3CODE_MOBILE_OTLP_TRACES_URL=https://old.example.test/v1/traces",97 "T3CODE_MOBILE_OTLP_TRACES_URL=https://old.example.test/v1/traces",
86 "T3CODE_MOBILE_OTLP_TRACES_DATASET=old-dataset",98 "T3CODE_MOBILE_OTLP_TRACES_DATASET=old-dataset",
87 "T3CODE_MOBILE_OTLP_TRACES_TOKEN=old-token",99 "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",
88 "",103 "",
89 ].join("\n"),104 ].join("\n"),
90 config,105 config,
...@@ -96,6 +111,9 @@ describe("reconcileRootEnvPublicConfig", () => {...@@ -96,6 +111,9 @@ describe("reconcileRootEnvPublicConfig", () => {
96 "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",111 "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
97 "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev",112 "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev",
98 "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest",113 "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",
99 "",117 "",
100 ].join("\n"),118 ].join("\n"),
101 );119 );
...@@ -113,3 +131,73 @@ describe("serializeGithubOutput", () => {...@@ -113,3 +131,73 @@ describe("serializeGithubOutput", () => {
113 ).toBe("changed=false\nresult=noop\nrelay_url=https://relay.example.test\n");131 ).toBe("changed=false\nresult=noop\nrelay_url=https://relay.example.test\n");
114 });132 });
115});133});
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";...@@ -8,10 +8,12 @@ import { provideFreshArtifactStore } from "alchemy/Artifacts";
8import { AuthProviders } from "alchemy/Auth/AuthProvider";8import { AuthProviders } from "alchemy/Auth/AuthProvider";
9import { CredentialsStoreLive } from "alchemy/Auth/Credentials";9import { CredentialsStoreLive } from "alchemy/Auth/Credentials";
10import { ProfileLive } from "alchemy/Auth/Profile";10import { ProfileLive } from "alchemy/Auth/Profile";
11import * as Cloudflare from "alchemy/Cloudflare";
11import { Cli } from "alchemy/Cli/Cli";12import { Cli } from "alchemy/Cli/Cli";
12import { LoggingCli } from "alchemy/Cli/LoggingCli";13import { LoggingCli } from "alchemy/Cli/LoggingCli";
13import * as Plan from "alchemy/Plan";14import * as Plan from "alchemy/Plan";
14import * as Stage from "alchemy/Stage";15import * as Stage from "alchemy/Stage";
16import * as State from "alchemy/State/State";
15import { TelemetryLive } from "alchemy/Telemetry/Layer";17import { TelemetryLive } from "alchemy/Telemetry/Layer";
16import { PlatformServices } from "alchemy/Util/PlatformServices";18import { PlatformServices } from "alchemy/Util/PlatformServices";
17import * as Config from "effect/Config";19import * as Config from "effect/Config";
...@@ -41,6 +43,8 @@ export interface RelayDeployOptions {...@@ -41,6 +43,8 @@ export interface RelayDeployOptions {
41 readonly yes: boolean;43 readonly yes: boolean;
42 readonly adopt: boolean;44 readonly adopt: boolean;
43 readonly githubOutput: boolean;45 readonly githubOutput: boolean;
46 readonly githubEnvFile: Option.Option<string>;
47 readonly readState: boolean;
44}48}
4549
46export interface RelayPublicConfig {50export interface RelayPublicConfig {
...@@ -48,6 +52,9 @@ export interface RelayPublicConfig {...@@ -48,6 +52,9 @@ export interface RelayPublicConfig {
48 readonly mobileTracingUrl: string;52 readonly mobileTracingUrl: string;
49 readonly mobileTracingDataset: string;53 readonly mobileTracingDataset: string;
50 readonly mobileTracingToken: string;54 readonly mobileTracingToken: string;
55 readonly clientTracingUrl: string;
56 readonly clientTracingDataset: string;
57 readonly clientTracingToken: string;
51}58}
5259
53const publicConfigEnvEntries = (config: RelayPublicConfig) =>60const publicConfigEnvEntries = (config: RelayPublicConfig) =>
...@@ -56,6 +63,9 @@ const publicConfigEnvEntries = (config: RelayPublicConfig) =>...@@ -56,6 +63,9 @@ const publicConfigEnvEntries = (config: RelayPublicConfig) =>
56 T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileTracingUrl,63 T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileTracingUrl,
57 T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileTracingDataset,64 T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileTracingDataset,
58 T3CODE_MOBILE_OTLP_TRACES_TOKEN: config.mobileTracingToken,65 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,
59 }) as const;69 }) as const;
6070
61export function reconcileRootEnvPublicConfig(contents: string, config: RelayPublicConfig): string {71export function reconcileRootEnvPublicConfig(contents: string, config: RelayPublicConfig): string {
...@@ -82,9 +92,13 @@ export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): st...@@ -82,9 +92,13 @@ export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): st
82 mobileTracingUrl: "",92 mobileTracingUrl: "",
83 mobileTracingDataset: "",93 mobileTracingDataset: "",
84 mobileTracingToken: "",94 mobileTracingToken: "",
95 clientTracingUrl: "",
96 clientTracingDataset: "",
97 clientTracingToken: "",
85 })98 })
86 .split("\n")99 .split("\n")
87 .filter((line) => !line.startsWith("T3CODE_MOBILE_OTLP_TRACES_"))100 .filter((line) => !line.startsWith("T3CODE_MOBILE_OTLP_TRACES_"))
101 .filter((line) => !line.startsWith("T3CODE_RELAY_CLIENT_OTLP_TRACES_"))
88 .join("\n");102 .join("\n");
89}103}
90104
...@@ -98,7 +112,7 @@ export function hasDeployChanges(plan: Plan.Plan): boolean {...@@ -98,7 +112,7 @@ export function hasDeployChanges(plan: Plan.Plan): boolean {
98 );112 );
99}113}
100114
101export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled";115export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled" | "state";
102116
103export interface RelayDeployOutcome {117export interface RelayDeployOutcome {
104 readonly result: RelayDeployResult;118 readonly result: RelayDeployResult;
...@@ -112,6 +126,14 @@ export function serializeGithubOutput(entries: Readonly<Record<string, string |...@@ -112,6 +126,14 @@ export function serializeGithubOutput(entries: Readonly<Record<string, string |
112 .join("");126 .join("");
113}127}
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
115const relayRoot = Effect.service(Path.Path).pipe(137const relayRoot = Effect.service(Path.Path).pipe(
116 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),138 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
117);139);
...@@ -151,7 +173,7 @@ const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* (...@@ -151,7 +173,7 @@ const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* (
151 const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : "";173 const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : "";
152174
153 yield* fs.writeFileString(rootEnvPath, reconcileRootEnvPublicConfig(contents, config));175 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`);
155});177});
156178
157const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* (179const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* (
...@@ -165,14 +187,34 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function*...@@ -165,14 +187,34 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function*
165 changed: outcome.changed,187 changed: outcome.changed,
166 result: outcome.result,188 result: outcome.result,
167 ...(Option.isSome(outcome.publicConfig)189 ...(Option.isSome(outcome.publicConfig)
168 ? { relay_url: outcome.publicConfig.value.relayUrl }190 ? {
191 relay_url: outcome.publicConfig.value.relayUrl,
192 }
169 : {}),193 : {}),
170 }),194 }),
171 { flag: "a" },195 { flag: "a" },
172 );196 );
173});197});
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, {}),
176 Layer.provideMerge(AlchemyContextLive, PlatformServices),218 Layer.provideMerge(AlchemyContextLive, PlatformServices),
177 Layer.provide(ProfileLive, PlatformServices),219 Layer.provide(ProfileLive, PlatformServices),
178 Layer.provide(CredentialsStoreLive, PlatformServices),220 Layer.provide(CredentialsStoreLive, PlatformServices),
...@@ -180,6 +222,64 @@ const deployServices = Layer.mergeAll(...@@ -180,6 +222,64 @@ const deployServices = Layer.mergeAll(
180 TelemetryLive,222 TelemetryLive,
181 LoggingCli,223 LoggingCli,
182);224);
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
184const runRelayDeploy = Effect.fn("relay.deploy.run")(284const runRelayDeploy = Effect.fn("relay.deploy.run")(
185 function* (285 function* (
...@@ -188,7 +288,6 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(...@@ -188,7 +288,6 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(
188 _stage: string,288 _stage: string,
189 ) {289 ) {
190 const stack = yield* RelayStack;290 const stack = yield* RelayStack;
191
192 const cli = yield* Cli;291 const cli = yield* Cli;
193 const plan = yield* Plan.make(stack, { force: options.force }).pipe(292 const plan = yield* Plan.make(stack, { force: options.force }).pipe(
194 Effect.provide(stack.services),293 Effect.provide(stack.services),
...@@ -223,7 +322,10 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(...@@ -223,7 +322,10 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(
223 output.url === undefined ||322 output.url === undefined ||
224 output.mobileTracingUrl === undefined ||323 output.mobileTracingUrl === undefined ||
225 output.mobileTracingDataset === undefined ||324 output.mobileTracingDataset === undefined ||
226 output.mobileTracingToken === undefined325 output.mobileTracingToken === undefined ||
326 output.clientTracingUrl === undefined ||
327 output.clientTracingDataset === undefined ||
328 output.clientTracingToken === undefined
227 ) {329 ) {
228 return yield* new RelayDeployError({330 return yield* new RelayDeployError({
229 message: "Alchemy relay deploy output did not include complete public client config",331 message: "Alchemy relay deploy output did not include complete public client config",
...@@ -237,6 +339,9 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(...@@ -237,6 +339,9 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")(
237 mobileTracingUrl: output.mobileTracingUrl,339 mobileTracingUrl: output.mobileTracingUrl,
238 mobileTracingDataset: output.mobileTracingDataset,340 mobileTracingDataset: output.mobileTracingDataset,
239 mobileTracingToken: Redacted.value(output.mobileTracingToken),341 mobileTracingToken: Redacted.value(output.mobileTracingToken),
342 clientTracingUrl: output.clientTracingUrl,
343 clientTracingDataset: output.clientTracingDataset,
344 clientTracingToken: Redacted.value(output.clientTracingToken),
240 }),345 }),
241 } satisfies RelayDeployOutcome;346 } satisfies RelayDeployOutcome;
242 },347 },
...@@ -264,13 +369,18 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO...@@ -264,13 +369,18 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO
264 Effect.provide(ConfigProvider.layer(configProvider)),369 Effect.provide(ConfigProvider.layer(configProvider)),
265 );370 );
266 const stage = Option.getOrElse(options.stage, () => configuredStage);371 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);
268 if (Option.isSome(outcome.publicConfig)) {375 if (Option.isSome(outcome.publicConfig)) {
269 yield* reconcileRootEnv(outcome.publicConfig.value);376 yield* reconcileRootEnv(outcome.publicConfig.value);
270 }377 }
271 if (options.githubOutput) {378 if (options.githubOutput) {
272 yield* writeGithubOutput(outcome);379 yield* writeGithubOutput(outcome);
273 }380 }
381 if (Option.isSome(options.githubEnvFile)) {
382 yield* writeGithubEnvFile(outcome, options.githubEnvFile.value);
383 }
274});384});
275385
276export const relayDeployCommand = Command.make(386export const relayDeployCommand = Command.make(
...@@ -306,6 +416,16 @@ export const relayDeployCommand = Command.make(...@@ -306,6 +416,16 @@ export const relayDeployCommand = Command.make(
306 Flag.withDescription("Append relay deployment metadata to GITHUB_OUTPUT."),416 Flag.withDescription("Append relay deployment metadata to GITHUB_OUTPUT."),
307 Flag.withDefault(false),417 Flag.withDefault(false),
308 ),418 ),
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 ),
309 },429 },
310 deploy,430 deploy,
311).pipe(Command.withDescription("Deploy the T3 Code relay through Alchemy."));431).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* () {...@@ -50,6 +50,14 @@ export const RelayObservability = Effect.gen(function* () {
50 })),50 })),
51 });51 });
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
53 yield* Axiom.View("RelayRecentSpansView", {61 yield* Axiom.View("RelayRecentSpansView", {
54 name: relayResourceNameForStage("t3-code-relay-recent-spans", stage),62 name: relayResourceNameForStage("t3-code-relay-recent-spans", stage),
55 description: "Recent relay HTTP request spans.",63 description: "Recent relay HTTP request spans.",
...@@ -57,7 +65,7 @@ export const RelayObservability = Effect.gen(function* () {...@@ -57,7 +65,7 @@ export const RelayObservability = Effect.gen(function* () {
57 aplQuery: Output.map(traces.name, relayRecentSpansQuery),65 aplQuery: Output.map(traces.name, relayRecentSpansQuery),
58 });66 });
5967
60 return { traces, workerIngestToken, mobileIngestToken } as const;68 return { traces, workerIngestToken, mobileIngestToken, clientIngestToken } as const;
61});69});
6270
63export const withSpanAttributes =71export const withSpanAttributes =
packages/client-runtime/src/managedRelay.ts+11-5
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26 RelayUnregisterDeviceEndpoint,26 RelayUnregisterDeviceEndpoint,
27} from "@t3tools/contracts/relay";27} from "@t3tools/contracts/relay";
28import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";28import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";
29import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
29import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";30import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
30import * as Clock from "effect/Clock";31import * as Clock from "effect/Clock";
31import * as Context from "effect/Context";32import * as Context from "effect/Context";
...@@ -357,6 +358,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)...@@ -357,6 +358,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
357 relayClientError("Could not list relay-managed environments.", cause),358 relayClientError("Could not list relay-managed environments.", cause),
358 ),359 ),
359 timeoutRelayRequest("Relay environment listing timed out."),360 timeoutRelayRequest("Relay environment listing timed out."),
361 withRelayClientTracing,
360 ),362 ),
361 listDevices: (input) =>363 listDevices: (input) =>
362 client.client364 client.client
...@@ -369,6 +371,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)...@@ -369,6 +371,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
369 relayClientError("Could not list relay client devices.", cause),371 relayClientError("Could not list relay client devices.", cause),
370 ),372 ),
371 timeoutRelayRequest("Relay client device listing timed out."),373 timeoutRelayRequest("Relay client device listing timed out."),
374 withRelayClientTracing,
372 ),375 ),
373 createEnvironmentLinkChallenge: (input) =>376 createEnvironmentLinkChallenge: (input) =>
374 client.client377 client.client
...@@ -381,6 +384,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)...@@ -381,6 +384,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
381 relayClientError("Could not create relay environment link challenge.", cause),384 relayClientError("Could not create relay environment link challenge.", cause),
382 ),385 ),
383 timeoutRelayRequest("Relay environment link challenge timed out."),386 timeoutRelayRequest("Relay environment link challenge timed out."),
387 withRelayClientTracing,
384 ),388 ),
385 linkEnvironment: (input) =>389 linkEnvironment: (input) =>
386 client.client390 client.client
...@@ -393,6 +397,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)...@@ -393,6 +397,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
393 relayClientError("Could not link relay environment.", cause),397 relayClientError("Could not link relay environment.", cause),
394 ),398 ),
395 timeoutRelayRequest("Relay environment linking timed out."),399 timeoutRelayRequest("Relay environment linking timed out."),
400 withRelayClientTracing,
396 ),401 ),
397 unlinkEnvironment: (input) =>402 unlinkEnvironment: (input) =>
398 client.client403 client.client
...@@ -405,6 +410,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)...@@ -405,6 +410,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
405 relayClientError("Could not unlink relay environment.", cause),410 relayClientError("Could not unlink relay environment.", cause),
406 ),411 ),
407 timeoutRelayRequest("Relay environment unlinking timed out."),412 timeoutRelayRequest("Relay environment unlinking timed out."),
413 withRelayClientTracing,
408 ),414 ),
409 getEnvironmentStatus: (input) =>415 getEnvironmentStatus: (input) =>
410 Effect.gen(function* () {416 Effect.gen(function* () {
...@@ -424,7 +430,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)...@@ -424,7 +430,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
424 ),430 ),
425 timeoutRelayRequest("Relay environment status request timed out."),431 timeoutRelayRequest("Relay environment status request timed out."),
426 );432 );
427 }),433 }).pipe(withRelayClientTracing),
428 connectEnvironment: (input) =>434 connectEnvironment: (input) =>
429 Effect.gen(function* () {435 Effect.gen(function* () {
430 const authorization = yield* authorize({436 const authorization = yield* authorize({
...@@ -448,7 +454,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)...@@ -448,7 +454,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
448 ),454 ),
449 timeoutRelayRequest("Relay environment connection timed out."),455 timeoutRelayRequest("Relay environment connection timed out."),
450 );456 );
451 }),457 }).pipe(withRelayClientTracing),
452 registerDevice: (input) =>458 registerDevice: (input) =>
453 Effect.gen(function* () {459 Effect.gen(function* () {
454 const authorization = yield* authorizeMobileRegistration({460 const authorization = yield* authorizeMobileRegistration({
...@@ -466,7 +472,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)...@@ -466,7 +472,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
466 ),472 ),
467 timeoutRelayRequest("Relay mobile device registration timed out."),473 timeoutRelayRequest("Relay mobile device registration timed out."),
468 );474 );
469 }),475 }).pipe(withRelayClientTracing),
470 unregisterDevice: (input) =>476 unregisterDevice: (input) =>
471 Effect.gen(function* () {477 Effect.gen(function* () {
472 const authorization = yield* authorizeMobileRegistration({478 const authorization = yield* authorizeMobileRegistration({
...@@ -484,7 +490,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)...@@ -484,7 +490,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
484 ),490 ),
485 timeoutRelayRequest("Relay mobile device unregistration timed out."),491 timeoutRelayRequest("Relay mobile device unregistration timed out."),
486 );492 );
487 }),493 }).pipe(withRelayClientTracing),
488 registerLiveActivity: (input) =>494 registerLiveActivity: (input) =>
489 Effect.gen(function* () {495 Effect.gen(function* () {
490 const authorization = yield* authorizeMobileRegistration({496 const authorization = yield* authorizeMobileRegistration({
...@@ -502,7 +508,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)...@@ -502,7 +508,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions)
502 ),508 ),
503 timeoutRelayRequest("Relay Live Activity registration timed out."),509 timeoutRelayRequest("Relay Live Activity registration timed out."),
504 );510 );
505 }),511 }).pipe(withRelayClientTracing),
506 resetTokenCache: SynchronizedRef.set(cachedTokens, []),512 resetTokenCache: SynchronizedRef.set(cachedTokens, []),
507 });513 });
508 }),514 }),
packages/shared/package.json+4
...@@ -158,6 +158,10 @@...@@ -158,6 +158,10 @@
158 "./hostProcess": {158 "./hostProcess": {
159 "types": "./src/hostProcess.ts",159 "types": "./src/hostProcess.ts",
160 "import": "./src/hostProcess.ts"160 "import": "./src/hostProcess.ts"
161 },
162 "./relayTracing": {
163 "types": "./src/relayTracing.ts",
164 "import": "./src/relayTracing.ts"
161 }165 }
162 },166 },
163 "scripts": {167 "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", () => {...@@ -33,6 +33,12 @@ describe("loadRepoEnv", () => {
33 expect(env.EXPO_PUBLIC_OTLP_TRACES_URL).toBeUndefined();33 expect(env.EXPO_PUBLIC_OTLP_TRACES_URL).toBeUndefined();
34 expect(env.EXPO_PUBLIC_OTLP_TRACES_DATASET).toBeUndefined();34 expect(env.EXPO_PUBLIC_OTLP_TRACES_DATASET).toBeUndefined();
35 expect(env.EXPO_PUBLIC_OTLP_TRACES_TOKEN).toBeUndefined();35 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();
36 });42 });
3743
38 it("applies process, root local, and root precedence in that order", () => {44 it("applies process, root local, and root precedence in that order", () => {
...@@ -91,6 +97,29 @@ describe("loadRepoEnv", () => {...@@ -91,6 +97,29 @@ describe("loadRepoEnv", () => {
91 mobileOtlpTracesUrl: "https://api.axiom.co/v1/traces",97 mobileOtlpTracesUrl: "https://api.axiom.co/v1/traces",
92 mobileOtlpTracesDataset: "mobile-traces",98 mobileOtlpTracesDataset: "mobile-traces",
93 mobileOtlpTracesToken: "mobile-token",99 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",
94 });123 });
95 });124 });
96125
scripts/lib/public-config.ts+36
...@@ -12,6 +12,9 @@ export interface T3CodePublicConfig {...@@ -12,6 +12,9 @@ export interface T3CodePublicConfig {
12 readonly mobileOtlpTracesUrl: string | undefined;12 readonly mobileOtlpTracesUrl: string | undefined;
13 readonly mobileOtlpTracesDataset: string | undefined;13 readonly mobileOtlpTracesDataset: string | undefined;
14 readonly mobileOtlpTracesToken: string | undefined;14 readonly mobileOtlpTracesToken: string | undefined;
15 readonly relayClientOtlpTracesUrl: string | undefined;
16 readonly relayClientOtlpTracesDataset: string | undefined;
17 readonly relayClientOtlpTracesToken: string | undefined;
15}18}
1619
17type Environment = Readonly<Record<string, string | undefined>>;20type Environment = Readonly<Record<string, string | undefined>>;
...@@ -78,6 +81,24 @@ export function loadRepoEnv({...@@ -78,6 +81,24 @@ export function loadRepoEnv({
78 EXPO_PUBLIC_OTLP_TRACES_TOKEN: config.mobileOtlpTracesToken,81 EXPO_PUBLIC_OTLP_TRACES_TOKEN: config.mobileOtlpTracesToken,
79 }82 }
80 : {}),83 : {}),
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 : {}),
81 };102 };
82}103}
83104
...@@ -112,6 +133,21 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP...@@ -112,6 +133,21 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP
112 "T3CODE_MOBILE_OTLP_TRACES_TOKEN",133 "T3CODE_MOBILE_OTLP_TRACES_TOKEN",
113 "EXPO_PUBLIC_OTLP_TRACES_TOKEN",134 "EXPO_PUBLIC_OTLP_TRACES_TOKEN",
114 ),135 ),
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 ),
115 };151 };
116}152}
117153