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