| author | |
| committer | |
| log | 0e4a43519fe4fa85ce60aa8d6d53c067e56d2fca |
| tree | a164e1a74db2ffc995ea8751fd3a69908c107345 |
| parent | 3ea6adf17d5528d9c90ed53b6dead650d5931941 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>34 files changed, 810 insertions(+), 113 deletions(-)
.env.example+5| ... | @@ -10,3 +10,8 @@ | ... | @@ -10,3 +10,8 @@ |
| 10 | 10 | ||
| 11 | # Get this from your relay deployment. `infra/relay` deploys update it automatically. | 11 | # Get this from your relay deployment. `infra/relay` deploys update it automatically. |
| 12 | # T3CODE_RELAY_URL=https://relay.example.com | 12 | # T3CODE_RELAY_URL=https://relay.example.com |
| 13 | |||
| 14 | # Public, ingest-only mobile OpenTelemetry configuration. | ||
| 15 | # T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces | ||
| 16 | # T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev | ||
| 17 | # T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-... |
apps/mobile/app.config.ts+5| ... | @@ -161,6 +161,11 @@ const config: ExpoConfig = { | ... | @@ -161,6 +161,11 @@ const config: ExpoConfig = { |
| 161 | publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null, | 161 | publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null, |
| 162 | jwtTemplate: repoEnv.EXPO_PUBLIC_CLERK_JWT_TEMPLATE ?? null, | 162 | jwtTemplate: repoEnv.EXPO_PUBLIC_CLERK_JWT_TEMPLATE ?? null, |
| 163 | }, | 163 | }, |
| 164 | observability: { | ||
| 165 | tracesUrl: repoEnv.EXPO_PUBLIC_OTLP_TRACES_URL ?? "https://api.axiom.co/v1/traces", | ||
| 166 | tracesDataset: repoEnv.EXPO_PUBLIC_OTLP_TRACES_DATASET ?? null, | ||
| 167 | tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null, | ||
| 168 | }, | ||
| 164 | eas: { | 169 | eas: { |
| 165 | projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", | 170 | projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", |
| 166 | }, | 171 | }, |
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+1-1| ... | @@ -41,7 +41,7 @@ export function normalizeAgentAwarenessRelayBaseUrl( | ... | @@ -41,7 +41,7 @@ export function normalizeAgentAwarenessRelayBaseUrl( |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | function readRelayConfig(): { readonly url: string } | null { | 43 | function readRelayConfig(): { readonly url: string } | null { |
| 44 | const relayUrl = resolveCloudPublicConfig().relayUrl; | 44 | const relayUrl = resolveCloudPublicConfig().relay.url; |
| 45 | if (!relayUrl) { | 45 | if (!relayUrl) { |
| 46 | logRegistrationDebug("relay registration skipped; relay config missing"); | 46 | logRegistrationDebug("relay registration skipped; relay config missing"); |
| 47 | return null; | 47 | return null; |
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+3-1| ... | @@ -66,7 +66,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { | ... | @@ -66,7 +66,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { |
| 66 | } | 66 | } |
| 67 | 67 | ||
| 68 | export function CloudAuthProvider(props: { readonly children: ReactNode }) { | 68 | export function CloudAuthProvider(props: { readonly children: ReactNode }) { |
| 69 | const { clerkPublishableKey: publishableKey, relayUrl } = resolveCloudPublicConfig(); | 69 | const config = resolveCloudPublicConfig(); |
| 70 | const publishableKey = config.clerk.publishableKey; | ||
| 71 | const relayUrl = config.relay.url; | ||
| 70 | 72 | ||
| 71 | useEffect(() => { | 73 | useEffect(() => { |
| 72 | if (!publishableKey || !relayUrl) { | 74 | if (!publishableKey || !relayUrl) { |
apps/mobile/src/features/cloud/linkEnvironment.ts+1-1| ... | @@ -50,7 +50,7 @@ export function normalizeRelayBaseUrl(value: string | null | undefined): string | ... | @@ -50,7 +50,7 @@ export function normalizeRelayBaseUrl(value: string | null | undefined): string |
| 50 | } | 50 | } |
| 51 | 51 | ||
| 52 | function readRelayUrl(): string | null { | 52 | function readRelayUrl(): string | null { |
| 53 | return resolveCloudPublicConfig().relayUrl; | 53 | return resolveCloudPublicConfig().relay.url; |
| 54 | } | 54 | } |
| 55 | 55 | ||
| 56 | export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ | 56 | export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ |
apps/mobile/src/features/cloud/managedRelayState.ts+2-13| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 2 | import { |
| 3 | createManagedRelayQueryManager, | 3 | createManagedRelayQueryManager, |
| 4 | ManagedRelayClient, | ||
| 5 | managedRelaySessionAtom, | 4 | managedRelaySessionAtom, |
| 6 | readManagedRelaySnapshotState, | 5 | readManagedRelaySnapshotState, |
| 7 | } from "@t3tools/client-runtime"; | 6 | } from "@t3tools/client-runtime"; |
| ... | @@ -9,23 +8,13 @@ import type { | ... | @@ -9,23 +8,13 @@ import type { |
| 9 | RelayClientEnvironmentRecord, | 8 | RelayClientEnvironmentRecord, |
| 10 | RelayEnvironmentStatusResponse, | 9 | RelayEnvironmentStatusResponse, |
| 11 | } from "@t3tools/contracts/relay"; | 10 | } from "@t3tools/contracts/relay"; |
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 11 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 16 | import { useCallback } from "react"; | 12 | import { useCallback } from "react"; |
| 17 | 13 | ||
| 18 | import { mobileRuntime } from "../../lib/runtime"; | 14 | import { mobileRuntimeContextLayer } from "../../lib/runtime"; |
| 19 | import { appAtomRegistry } from "../../state/atom-registry"; | 15 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 20 | 16 | ||
| 21 | const managedRelayAtomRuntime = Atom.runtime( | 17 | const managedRelayAtomRuntime = Atom.runtime(mobileRuntimeContextLayer); |
| 22 | Layer.effect( | ||
| 23 | ManagedRelayClient, | ||
| 24 | mobileRuntime.contextEffect.pipe( | ||
| 25 | Effect.map((context) => Context.get(context, ManagedRelayClient)), | ||
| 26 | ), | ||
| 27 | ), | ||
| 28 | ); | ||
| 29 | 18 | ||
| 30 | export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); | 19 | export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); |
| 31 | 20 |
apps/mobile/src/features/cloud/publicConfig.test.ts+83-10| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { resolveCloudPublicConfig } from "./publicConfig"; | 3 | import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig"; |
| 4 | 4 | ||
| 5 | vi.mock("expo-constants", () => ({ | 5 | vi.mock("expo-constants", () => ({ |
| 6 | default: { | 6 | default: { |
| ... | @@ -13,9 +13,18 @@ vi.mock("expo-constants", () => ({ | ... | @@ -13,9 +13,18 @@ vi.mock("expo-constants", () => ({ |
| 13 | describe("resolveCloudPublicConfig", () => { | 13 | describe("resolveCloudPublicConfig", () => { |
| 14 | it("returns no cloud configuration for an unconfigured build", () => { | 14 | it("returns no cloud configuration for an unconfigured build", () => { |
| 15 | expect(resolveCloudPublicConfig({})).toEqual({ | 15 | expect(resolveCloudPublicConfig({})).toEqual({ |
| 16 | clerkPublishableKey: null, | 16 | clerk: { |
| 17 | clerkJwtTemplate: null, | 17 | publishableKey: null, |
| 18 | relayUrl: null, | 18 | jwtTemplate: null, |
| 19 | }, | ||
| 20 | relay: { | ||
| 21 | url: null, | ||
| 22 | }, | ||
| 23 | observability: { | ||
| 24 | tracesUrl: null, | ||
| 25 | tracesDataset: null, | ||
| 26 | tracesToken: null, | ||
| 27 | }, | ||
| 19 | }); | 28 | }); |
| 20 | }); | 29 | }); |
| 21 | 30 | ||
| ... | @@ -24,11 +33,25 @@ describe("resolveCloudPublicConfig", () => { | ... | @@ -24,11 +33,25 @@ describe("resolveCloudPublicConfig", () => { |
| 24 | resolveCloudPublicConfig({ | 33 | resolveCloudPublicConfig({ |
| 25 | clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " }, | 34 | clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " }, |
| 26 | relay: { url: " https://relay.example.test/// " }, | 35 | relay: { url: " https://relay.example.test/// " }, |
| 36 | observability: { | ||
| 37 | tracesUrl: " https://api.axiom.co/v1/traces ", | ||
| 38 | tracesDataset: " mobile-traces ", | ||
| 39 | tracesToken: " public-ingest-token ", | ||
| 40 | }, | ||
| 27 | }), | 41 | }), |
| 28 | ).toEqual({ | 42 | ).toEqual({ |
| 29 | clerkPublishableKey: "pk_test_example", | 43 | clerk: { |
| 30 | clerkJwtTemplate: "t3-relay", | 44 | publishableKey: "pk_test_example", |
| 31 | relayUrl: "https://relay.example.test", | 45 | jwtTemplate: "t3-relay", |
| 46 | }, | ||
| 47 | relay: { | ||
| 48 | url: "https://relay.example.test", | ||
| 49 | }, | ||
| 50 | observability: { | ||
| 51 | tracesUrl: "https://api.axiom.co/v1/traces", | ||
| 52 | tracesDataset: "mobile-traces", | ||
| 53 | tracesToken: "public-ingest-token", | ||
| 54 | }, | ||
| 32 | }); | 55 | }); |
| 33 | }); | 56 | }); |
| 34 | 57 | ||
| ... | @@ -39,9 +62,59 @@ describe("resolveCloudPublicConfig", () => { | ... | @@ -39,9 +62,59 @@ describe("resolveCloudPublicConfig", () => { |
| 39 | relay: { url: "http://relay.example.test" }, | 62 | relay: { url: "http://relay.example.test" }, |
| 40 | }), | 63 | }), |
| 41 | ).toEqual({ | 64 | ).toEqual({ |
| 42 | clerkPublishableKey: "pk_test_example", | 65 | clerk: { |
| 43 | clerkJwtTemplate: "t3-relay", | 66 | publishableKey: "pk_test_example", |
| 44 | relayUrl: null, | 67 | jwtTemplate: "t3-relay", |
| 68 | }, | ||
| 69 | relay: { | ||
| 70 | url: null, | ||
| 71 | }, | ||
| 72 | observability: { | ||
| 73 | tracesUrl: null, | ||
| 74 | tracesDataset: null, | ||
| 75 | tracesToken: null, | ||
| 76 | }, | ||
| 45 | }); | 77 | }); |
| 46 | }); | 78 | }); |
| 79 | |||
| 80 | it("rejects an insecure traces URL", () => { | ||
| 81 | expect( | ||
| 82 | resolveCloudPublicConfig({ | ||
| 83 | observability: { | ||
| 84 | tracesUrl: "http://api.axiom.co/v1/traces", | ||
| 85 | tracesDataset: "mobile-traces", | ||
| 86 | tracesToken: "public-ingest-token", | ||
| 87 | }, | ||
| 88 | }).observability, | ||
| 89 | ).toEqual({ | ||
| 90 | tracesUrl: null, | ||
| 91 | tracesDataset: "mobile-traces", | ||
| 92 | tracesToken: "public-ingest-token", | ||
| 93 | }); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("keeps tracing disabled unless every public tracing value is configured", () => { | ||
| 97 | expect(hasMobileTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false); | ||
| 98 | expect( | ||
| 99 | hasMobileTracingPublicConfig( | ||
| 100 | resolveCloudPublicConfig({ | ||
| 101 | observability: { | ||
| 102 | tracesUrl: "https://api.axiom.co/v1/traces", | ||
| 103 | tracesDataset: "mobile-traces", | ||
| 104 | }, | ||
| 105 | }), | ||
| 106 | ), | ||
| 107 | ).toBe(false); | ||
| 108 | expect( | ||
| 109 | hasMobileTracingPublicConfig( | ||
| 110 | resolveCloudPublicConfig({ | ||
| 111 | observability: { | ||
| 112 | tracesUrl: "https://api.axiom.co/v1/traces", | ||
| 113 | tracesDataset: "mobile-traces", | ||
| 114 | tracesToken: "public-ingest-token", | ||
| 115 | }, | ||
| 116 | }), | ||
| 117 | ), | ||
| 118 | ).toBe(true); | ||
| 119 | }); | ||
| 47 | }); | 120 | }); |
apps/mobile/src/features/cloud/publicConfig.ts+69-17| ... | @@ -2,40 +2,92 @@ import Constants from "expo-constants"; | ... | @@ -2,40 +2,92 @@ import Constants from "expo-constants"; |
| 2 | import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; | 2 | import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; |
| 3 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; | 3 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; |
| 4 | 4 | ||
| 5 | type ExpoExtra = Readonly<Record<string, unknown>> | undefined; | ||
| 6 | |||
| 7 | export interface CloudPublicConfig { | 5 | export interface CloudPublicConfig { |
| 8 | readonly clerkPublishableKey: string | null; | 6 | readonly clerk: { |
| 9 | readonly clerkJwtTemplate: string | null; | 7 | readonly publishableKey: string | null; |
| 10 | readonly relayUrl: string | null; | 8 | readonly jwtTemplate: string | null; |
| 9 | }; | ||
| 10 | readonly relay: { | ||
| 11 | readonly url: string | null; | ||
| 12 | }; | ||
| 13 | readonly observability: { | ||
| 14 | readonly tracesUrl: string | null; | ||
| 15 | readonly tracesDataset: string | null; | ||
| 16 | readonly tracesToken: string | null; | ||
| 17 | }; | ||
| 11 | } | 18 | } |
| 12 | 19 | ||
| 20 | type UntrustedSection<T> = { | ||
| 21 | readonly [Key in keyof T]?: unknown; | ||
| 22 | }; | ||
| 23 | |||
| 24 | type ExpoExtra = | ||
| 25 | | { | ||
| 26 | readonly [Section in keyof CloudPublicConfig]?: UntrustedSection<CloudPublicConfig[Section]>; | ||
| 27 | } | ||
| 28 | | undefined; | ||
| 29 | |||
| 13 | function trimNonEmpty(value: unknown): string | null { | 30 | function trimNonEmpty(value: unknown): string | null { |
| 14 | return typeof value === "string" && value.trim() ? value.trim() : null; | 31 | return typeof value === "string" && value.trim() ? value.trim() : null; |
| 15 | } | 32 | } |
| 16 | 33 | ||
| 17 | export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) { | 34 | function normalizeSecureUrl(value: unknown): string | null { |
| 18 | const clerk = extra?.clerk as | 35 | const raw = trimNonEmpty(value); |
| 19 | | { readonly publishableKey?: unknown; readonly jwtTemplate?: unknown } | 36 | if (raw === null) { |
| 20 | | undefined; | 37 | return null; |
| 21 | const relay = extra?.relay as { readonly url?: unknown } | undefined; | 38 | } |
| 39 | try { | ||
| 40 | const url = new URL(raw); | ||
| 41 | return url.protocol === "https:" ? url.toString() : null; | ||
| 42 | } catch { | ||
| 43 | return null; | ||
| 44 | } | ||
| 45 | } | ||
| 22 | 46 | ||
| 47 | export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) { | ||
| 23 | return { | 48 | return { |
| 24 | clerkPublishableKey: trimNonEmpty(clerk?.publishableKey), | 49 | clerk: { |
| 25 | clerkJwtTemplate: trimNonEmpty(clerk?.jwtTemplate), | 50 | publishableKey: trimNonEmpty(extra?.clerk?.publishableKey), |
| 26 | relayUrl: normalizeSecureRelayUrl(trimNonEmpty(relay?.url) ?? ""), | 51 | jwtTemplate: trimNonEmpty(extra?.clerk?.jwtTemplate), |
| 52 | }, | ||
| 53 | relay: { | ||
| 54 | url: normalizeSecureRelayUrl(trimNonEmpty(extra?.relay?.url) ?? ""), | ||
| 55 | }, | ||
| 56 | observability: { | ||
| 57 | tracesUrl: normalizeSecureUrl(extra?.observability?.tracesUrl), | ||
| 58 | tracesDataset: trimNonEmpty(extra?.observability?.tracesDataset), | ||
| 59 | tracesToken: trimNonEmpty(extra?.observability?.tracesToken), | ||
| 60 | }, | ||
| 27 | } satisfies CloudPublicConfig; | 61 | } satisfies CloudPublicConfig; |
| 28 | } | 62 | } |
| 29 | 63 | ||
| 30 | export function hasCloudPublicConfig(): boolean { | 64 | export function hasCloudPublicConfig(): boolean { |
| 31 | const config = resolveCloudPublicConfig(); | 65 | const config = resolveCloudPublicConfig(); |
| 32 | return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl); | 66 | return Boolean(config.clerk.publishableKey && config.clerk.jwtTemplate && config.relay.url); |
| 67 | } | ||
| 68 | |||
| 69 | type Configured<T> = { | ||
| 70 | readonly [Key in keyof T]: NonNullable<T[Key]>; | ||
| 71 | }; | ||
| 72 | |||
| 73 | type MobileTracingPublicConfig = Omit<CloudPublicConfig, "observability"> & { | ||
| 74 | readonly observability: Configured<CloudPublicConfig["observability"]>; | ||
| 75 | }; | ||
| 76 | |||
| 77 | export function hasMobileTracingPublicConfig( | ||
| 78 | config: CloudPublicConfig = resolveCloudPublicConfig(), | ||
| 79 | ): config is MobileTracingPublicConfig { | ||
| 80 | return Boolean( | ||
| 81 | config.observability.tracesUrl && | ||
| 82 | config.observability.tracesDataset && | ||
| 83 | config.observability.tracesToken, | ||
| 84 | ); | ||
| 33 | } | 85 | } |
| 34 | 86 | ||
| 35 | export function resolveRelayClerkTokenOptions() { | 87 | export function resolveRelayClerkTokenOptions() { |
| 36 | const { clerkJwtTemplate } = resolveCloudPublicConfig(); | 88 | const { jwtTemplate } = resolveCloudPublicConfig().clerk; |
| 37 | if (!clerkJwtTemplate) { | 89 | if (!jwtTemplate) { |
| 38 | throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured."); | 90 | throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured."); |
| 39 | } | 91 | } |
| 40 | return relayClerkTokenOptions(clerkJwtTemplate); | 92 | return relayClerkTokenOptions(jwtTemplate); |
| 41 | } | 93 | } |
apps/mobile/src/features/observability/mobileTracing.test.ts created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import { vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; | ||
| 7 | |||
| 8 | import { makeMobileTracingLayer } from "./mobileTracing"; | ||
| 9 | |||
| 10 | vi.mock("expo-constants", () => ({ | ||
| 11 | default: { | ||
| 12 | expoConfig: { | ||
| 13 | extra: {}, | ||
| 14 | }, | ||
| 15 | }, | ||
| 16 | })); | ||
| 17 | |||
| 18 | it.effect("exports spans through the scoped mobile OTLP layer", () => { | ||
| 19 | const fetchFn = vi.fn<typeof fetch>(async () => new Response(null, { status: 202 })); | ||
| 20 | const tracingLayer = makeMobileTracingLayer( | ||
| 21 | { | ||
| 22 | tracesUrl: "https://api.axiom.test/v1/traces", | ||
| 23 | tracesDataset: "mobile-traces", | ||
| 24 | tracesToken: "public-ingest-token", | ||
| 25 | }, | ||
| 26 | { | ||
| 27 | appVariant: "test", | ||
| 28 | serviceVersion: "1.2.3", | ||
| 29 | }, | ||
| 30 | ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); | ||
| 31 | const tracedApplication = Layer.effectDiscard( | ||
| 32 | Effect.void.pipe(Effect.withSpan("mobile.test.span")), | ||
| 33 | ).pipe(Layer.provide(tracingLayer)); | ||
| 34 | |||
| 35 | return Effect.gen(function* () { | ||
| 36 | yield* Layer.build(tracedApplication); | ||
| 37 | |||
| 38 | expect(fetchFn).not.toHaveBeenCalled(); | ||
| 39 | }).pipe( | ||
| 40 | Effect.scoped, | ||
| 41 | Effect.andThen( | ||
| 42 | Effect.sync(() => { | ||
| 43 | expect(fetchFn).toHaveBeenCalledOnce(); | ||
| 44 | const [url, init] = fetchFn.mock.calls[0]!; | ||
| 45 | expect(String(url)).toBe("https://api.axiom.test/v1/traces"); | ||
| 46 | expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token"); | ||
| 47 | expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces"); | ||
| 48 | expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span"); | ||
| 49 | }), | ||
| 50 | ), | ||
| 51 | ); | ||
| 52 | }); | ||
apps/mobile/src/features/observability/mobileTracing.ts created+60| ... | @@ -0,0 +1,60 @@ | ||
| 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"; | ||
| 5 | |||
| 6 | import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; | ||
| 7 | |||
| 8 | export interface MobileTracingConfig { | ||
| 9 | readonly tracesUrl: string; | ||
| 10 | readonly tracesDataset: string; | ||
| 11 | readonly tracesToken: string; | ||
| 12 | } | ||
| 13 | |||
| 14 | export interface MobileTracingResource { | ||
| 15 | readonly serviceVersion?: string; | ||
| 16 | readonly appVariant: string; | ||
| 17 | } | ||
| 18 | |||
| 19 | export function resolveMobileTracingConfig(): MobileTracingConfig | null { | ||
| 20 | const config = resolveCloudPublicConfig(); | ||
| 21 | if (!hasMobileTracingPublicConfig(config)) { | ||
| 22 | return null; | ||
| 23 | } | ||
| 24 | const { tracesUrl, tracesDataset, tracesToken } = config.observability; | ||
| 25 | return { tracesUrl, tracesDataset, tracesToken }; | ||
| 26 | } | ||
| 27 | |||
| 28 | export function makeMobileTracingLayer( | ||
| 29 | config: MobileTracingConfig | null, | ||
| 30 | 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)); | ||
| 52 | } | ||
| 53 | |||
| 54 | export const mobileTracingLayer = makeMobileTracingLayer(resolveMobileTracingConfig(), { | ||
| 55 | serviceVersion: Constants.expoConfig?.version, | ||
| 56 | appVariant: | ||
| 57 | typeof Constants.expoConfig?.extra?.appVariant === "string" | ||
| 58 | ? Constants.expoConfig.extra.appVariant | ||
| 59 | : "unknown", | ||
| 60 | }); | ||
apps/mobile/src/lib/runtime.ts+8-7| ... | @@ -6,19 +6,20 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime"; | ... | @@ -6,19 +6,20 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime"; |
| 6 | import { mobileCryptoLayer } from "../features/cloud/dpop"; | 6 | import { mobileCryptoLayer } from "../features/cloud/dpop"; |
| 7 | import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer"; | 7 | import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer"; |
| 8 | import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; | 8 | import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; |
| 9 | import { mobileTracingLayer } from "../features/observability/mobileTracing"; | ||
| 9 | 10 | ||
| 10 | function configuredRelayUrl(): string { | 11 | function configuredRelayUrl(): string { |
| 11 | return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; | 12 | return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; |
| 12 | } | 13 | } |
| 13 | 14 | ||
| 14 | const mobileHttpClientLayer = remoteHttpClientLayer(fetch); | 15 | const mobileHttpClientLayer = remoteHttpClientLayer(fetch); |
| 15 | 16 | ||
| 16 | export const mobileRuntime = ManagedRuntime.make( | 17 | export const mobileRuntime = ManagedRuntime.make( |
| 17 | Layer.mergeAll( | 18 | mobileManagedRelayClientLayer(configuredRelayUrl()).pipe( |
| 18 | mobileHttpClientLayer, | 19 | Layer.provideMerge(mobileCryptoLayer), |
| 19 | mobileCryptoLayer, | 20 | Layer.provideMerge(mobileHttpClientLayer), |
| 20 | mobileManagedRelayClientLayer(configuredRelayUrl()).pipe( | 21 | Layer.provideMerge(mobileTracingLayer.pipe(Layer.provide(mobileHttpClientLayer))), |
| 21 | Layer.provide(Layer.mergeAll(mobileHttpClientLayer, mobileCryptoLayer)), | ||
| 22 | ), | ||
| 23 | ), | 22 | ), |
| 24 | ); | 23 | ); |
| 24 | |||
| 25 | export const mobileRuntimeContextLayer = Layer.effectContext(mobileRuntime.contextEffect); |
apps/server/src/observability/Layers/Observability.ts+2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; | ||
| 1 | import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; | 2 | import { makeLocalFileTracer, makeTraceSink } from "@t3tools/shared/observability"; |
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| ... | @@ -18,6 +19,7 @@ export const ObservabilityLive = Layer.unwrap( | ... | @@ -18,6 +19,7 @@ export const ObservabilityLive = Layer.unwrap( |
| 18 | const traceReferencesLayer = Layer.mergeAll( | 19 | const traceReferencesLayer = Layer.mergeAll( |
| 19 | Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), | 20 | Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), |
| 20 | Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), | 21 | Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), |
| 22 | httpHeaderRedactionLayer, | ||
| 21 | ); | 23 | ); |
| 22 | 24 | ||
| 23 | const tracerLayer = Layer.unwrap( | 25 | const tracerLayer = Layer.unwrap( |
apps/web/src/lib/runtime.ts+2| ... | @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; |
| 4 | import { FetchHttpClient } from "effect/unstable/http"; | 4 | import { FetchHttpClient } from "effect/unstable/http"; |
| 5 | 5 | ||
| 6 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; | 6 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; |
| 7 | import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; | ||
| 7 | import { | 8 | import { |
| 8 | PrimaryEnvironmentHttpClient, | 9 | PrimaryEnvironmentHttpClient, |
| 9 | primaryEnvironmentHttpClientLive, | 10 | primaryEnvironmentHttpClientLive, |
| ... | @@ -28,6 +29,7 @@ const primaryHttpRuntime = ManagedRuntime.make( | ... | @@ -28,6 +29,7 @@ const primaryHttpRuntime = ManagedRuntime.make( |
| 28 | Layer.mergeAll( | 29 | Layer.mergeAll( |
| 29 | remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)), | 30 | remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)), |
| 30 | Layer.succeed(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit), | 31 | Layer.succeed(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit), |
| 32 | httpHeaderRedactionLayer, | ||
| 31 | ), | 33 | ), |
| 32 | ), | 34 | ), |
| 33 | ), | 35 | ), |
infra/relay/alchemy.run.ts+5| ... | @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; |
| 8 | import * as Planetscale from "alchemy/Planetscale"; | 8 | import * as Planetscale from "alchemy/Planetscale"; |
| 9 | 9 | ||
| 10 | import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts"; | 10 | import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts"; |
| 11 | import { RelayObservability } from "./src/observability.ts"; | ||
| 11 | import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts"; | 12 | import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts"; |
| 12 | import Api from "./src/worker.ts"; | 13 | import Api from "./src/worker.ts"; |
| 13 | 14 | ||
| ... | @@ -27,6 +28,7 @@ export default Alchemy.Stack( | ... | @@ -27,6 +28,7 @@ export default Alchemy.Stack( |
| 27 | const hyperdrive = yield* RelayHyperdrive; | 28 | const hyperdrive = yield* RelayHyperdrive; |
| 28 | const managedEndpointZone = yield* ManagedEndpointZone.pipe(Effect.orDie); | 29 | const managedEndpointZone = yield* ManagedEndpointZone.pipe(Effect.orDie); |
| 29 | const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie); | 30 | const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie); |
| 31 | const observability = yield* RelayObservability; | ||
| 30 | const api = yield* Api; | 32 | const api = yield* Api; |
| 31 | 33 | ||
| 32 | return { | 34 | return { |
| ... | @@ -37,6 +39,9 @@ export default Alchemy.Stack( | ... | @@ -37,6 +39,9 @@ export default Alchemy.Stack( |
| 37 | url: api.url, | 39 | url: api.url, |
| 38 | relayApiZoneId: relayApiZone.zoneId, | 40 | relayApiZoneId: relayApiZone.zoneId, |
| 39 | managedEndpointZoneId: managedEndpointZone.zoneId, | 41 | managedEndpointZoneId: managedEndpointZone.zoneId, |
| 42 | mobileTracingUrl: observability.traces.otelTracesEndpoint, | ||
| 43 | mobileTracingDataset: observability.traces.name, | ||
| 44 | mobileTracingToken: observability.mobileIngestToken.token, | ||
| 40 | }; | 45 | }; |
| 41 | }), | 46 | }), |
| 42 | ); | 47 | ); |
infra/relay/scripts/deploy.test.ts+52-1| ... | @@ -1,6 +1,11 @@ | ... | @@ -1,6 +1,11 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { hasDeployChanges, reconcileRootEnvRelayUrl, serializeGithubOutput } from "./deploy.ts"; | 3 | import { |
| 4 | hasDeployChanges, | ||
| 5 | reconcileRootEnvPublicConfig, | ||
| 6 | reconcileRootEnvRelayUrl, | ||
| 7 | serializeGithubOutput, | ||
| 8 | } from "./deploy.ts"; | ||
| 4 | 9 | ||
| 5 | describe("hasDeployChanges", () => { | 10 | describe("hasDeployChanges", () => { |
| 6 | it("detects resource, binding, and deletion changes", () => { | 11 | it("detects resource, binding, and deletion changes", () => { |
| ... | @@ -51,6 +56,52 @@ describe("reconcileRootEnvRelayUrl", () => { | ... | @@ -51,6 +56,52 @@ describe("reconcileRootEnvRelayUrl", () => { |
| 51 | }); | 56 | }); |
| 52 | }); | 57 | }); |
| 53 | 58 | ||
| 59 | describe("reconcileRootEnvPublicConfig", () => { | ||
| 60 | const config = { | ||
| 61 | relayUrl: "https://relay.example.test", | ||
| 62 | mobileTracingUrl: "https://api.axiom.co/v1/traces", | ||
| 63 | mobileTracingDataset: "t3-code-mobile-traces-dev", | ||
| 64 | mobileTracingToken: "xaat-public-ingest", | ||
| 65 | } as const; | ||
| 66 | |||
| 67 | it("adds the complete local client config", () => { | ||
| 68 | expect(reconcileRootEnvPublicConfig("", config)).toBe( | ||
| 69 | [ | ||
| 70 | "T3CODE_RELAY_URL=https://relay.example.test", | ||
| 71 | "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", | ||
| 72 | "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev", | ||
| 73 | "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest", | ||
| 74 | "", | ||
| 75 | ].join("\n"), | ||
| 76 | ); | ||
| 77 | }); | ||
| 78 | |||
| 79 | it("replaces stale values while preserving unrelated entries", () => { | ||
| 80 | expect( | ||
| 81 | reconcileRootEnvPublicConfig( | ||
| 82 | [ | ||
| 83 | "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example", | ||
| 84 | "T3CODE_RELAY_URL=https://old.example.test", | ||
| 85 | "T3CODE_MOBILE_OTLP_TRACES_URL=https://old.example.test/v1/traces", | ||
| 86 | "T3CODE_MOBILE_OTLP_TRACES_DATASET=old-dataset", | ||
| 87 | "T3CODE_MOBILE_OTLP_TRACES_TOKEN=old-token", | ||
| 88 | "", | ||
| 89 | ].join("\n"), | ||
| 90 | config, | ||
| 91 | ), | ||
| 92 | ).toBe( | ||
| 93 | [ | ||
| 94 | "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example", | ||
| 95 | "T3CODE_RELAY_URL=https://relay.example.test", | ||
| 96 | "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", | ||
| 97 | "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev", | ||
| 98 | "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest", | ||
| 99 | "", | ||
| 100 | ].join("\n"), | ||
| 101 | ); | ||
| 102 | }); | ||
| 103 | }); | ||
| 104 | |||
| 54 | describe("serializeGithubOutput", () => { | 105 | describe("serializeGithubOutput", () => { |
| 55 | it("serializes relay deploy metadata for GitHub Actions outputs", () => { | 106 | it("serializes relay deploy metadata for GitHub Actions outputs", () => { |
| 56 | expect( | 107 | expect( |
infra/relay/scripts/deploy.ts+69-20| ... | @@ -23,6 +23,7 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -23,6 +23,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 23 | import * as Layer from "effect/Layer"; | 23 | import * as Layer from "effect/Layer"; |
| 24 | import * as Option from "effect/Option"; | 24 | import * as Option from "effect/Option"; |
| 25 | import * as Path from "effect/Path"; | 25 | import * as Path from "effect/Path"; |
| 26 | import * as Redacted from "effect/Redacted"; | ||
| 26 | import { Command, Flag, Prompt } from "effect/unstable/cli"; | 27 | import { Command, Flag, Prompt } from "effect/unstable/cli"; |
| 27 | import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; | 28 | import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; |
| 28 | 29 | ||
| ... | @@ -42,15 +43,49 @@ export interface RelayDeployOptions { | ... | @@ -42,15 +43,49 @@ export interface RelayDeployOptions { |
| 42 | readonly githubOutput: boolean; | 43 | readonly githubOutput: boolean; |
| 43 | } | 44 | } |
| 44 | 45 | ||
| 45 | export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): string { | 46 | export interface RelayPublicConfig { |
| 46 | const entry = `T3CODE_RELAY_URL=${relayUrl}`; | 47 | readonly relayUrl: string; |
| 47 | if (/^T3CODE_RELAY_URL=.*$/mu.test(contents)) { | 48 | readonly mobileTracingUrl: string; |
| 48 | return contents.replace(/^T3CODE_RELAY_URL=.*$/mu, entry); | 49 | readonly mobileTracingDataset: string; |
| 49 | } | 50 | readonly mobileTracingToken: string; |
| 50 | if (!contents) { | 51 | } |
| 51 | return `${entry}\n`; | 52 | |
| 53 | const publicConfigEnvEntries = (config: RelayPublicConfig) => | ||
| 54 | ({ | ||
| 55 | T3CODE_RELAY_URL: config.relayUrl, | ||
| 56 | T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileTracingUrl, | ||
| 57 | T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileTracingDataset, | ||
| 58 | T3CODE_MOBILE_OTLP_TRACES_TOKEN: config.mobileTracingToken, | ||
| 59 | }) as const; | ||
| 60 | |||
| 61 | export function reconcileRootEnvPublicConfig(contents: string, config: RelayPublicConfig): string { | ||
| 62 | let next = contents; | ||
| 63 | for (const [name, value] of Object.entries(publicConfigEnvEntries(config))) { | ||
| 64 | const entry = `${name}=${value}`; | ||
| 65 | const pattern = new RegExp(`^${name}=.*$`, "mu"); | ||
| 66 | if (pattern.test(next)) { | ||
| 67 | next = next.replace(pattern, entry); | ||
| 68 | continue; | ||
| 69 | } | ||
| 70 | if (!next) { | ||
| 71 | next = `${entry}\n`; | ||
| 72 | continue; | ||
| 73 | } | ||
| 74 | next = `${next}${next.endsWith("\n") ? "" : "\n"}${entry}\n`; | ||
| 52 | } | 75 | } |
| 53 | return `${contents}${contents.endsWith("\n") ? "" : "\n"}${entry}\n`; | 76 | return next; |
| 77 | } | ||
| 78 | |||
| 79 | export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): string { | ||
| 80 | return reconcileRootEnvPublicConfig(contents, { | ||
| 81 | relayUrl, | ||
| 82 | mobileTracingUrl: "", | ||
| 83 | mobileTracingDataset: "", | ||
| 84 | mobileTracingToken: "", | ||
| 85 | }) | ||
| 86 | .split("\n") | ||
| 87 | .filter((line) => !line.startsWith("T3CODE_MOBILE_OTLP_TRACES_")) | ||
| 88 | .join("\n"); | ||
| 54 | } | 89 | } |
| 55 | 90 | ||
| 56 | export function hasDeployChanges(plan: Plan.Plan): boolean { | 91 | export function hasDeployChanges(plan: Plan.Plan): boolean { |
| ... | @@ -68,7 +103,7 @@ export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled"; | ... | @@ -68,7 +103,7 @@ export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled"; |
| 68 | export interface RelayDeployOutcome { | 103 | export interface RelayDeployOutcome { |
| 69 | readonly result: RelayDeployResult; | 104 | readonly result: RelayDeployResult; |
| 70 | readonly changed: boolean; | 105 | readonly changed: boolean; |
| 71 | readonly relayUrl: Option.Option<string>; | 106 | readonly publicConfig: Option.Option<RelayPublicConfig>; |
| 72 | } | 107 | } |
| 73 | 108 | ||
| 74 | export function serializeGithubOutput(entries: Readonly<Record<string, string | boolean>>): string { | 109 | export function serializeGithubOutput(entries: Readonly<Record<string, string | boolean>>): string { |
| ... | @@ -106,15 +141,17 @@ const relayDeployStage = Config.nonEmptyString("stage").pipe( | ... | @@ -106,15 +141,17 @@ const relayDeployStage = Config.nonEmptyString("stage").pipe( |
| 106 | ), | 141 | ), |
| 107 | ); | 142 | ); |
| 108 | 143 | ||
| 109 | const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* (relayUrl: string) { | 144 | const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* ( |
| 145 | config: RelayPublicConfig, | ||
| 146 | ) { | ||
| 110 | const fs = yield* FileSystem.FileSystem; | 147 | const fs = yield* FileSystem.FileSystem; |
| 111 | const path = yield* Path.Path; | 148 | const path = yield* Path.Path; |
| 112 | const root = yield* repoRoot; | 149 | const root = yield* repoRoot; |
| 113 | const rootEnvPath = path.join(root, ".env"); | 150 | const rootEnvPath = path.join(root, ".env"); |
| 114 | const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : ""; | 151 | const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : ""; |
| 115 | 152 | ||
| 116 | yield* fs.writeFileString(rootEnvPath, reconcileRootEnvRelayUrl(contents, relayUrl)); | 153 | yield* fs.writeFileString(rootEnvPath, reconcileRootEnvPublicConfig(contents, config)); |
| 117 | yield* Console.log(`Updated ${rootEnvPath} with T3CODE_RELAY_URL=${relayUrl}`); | 154 | yield* Console.log(`Updated ${rootEnvPath} with relay URL and mobile tracing configuration`); |
| 118 | }); | 155 | }); |
| 119 | 156 | ||
| 120 | const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* ( | 157 | const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* ( |
| ... | @@ -127,7 +164,9 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* | ... | @@ -127,7 +164,9 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* |
| 127 | serializeGithubOutput({ | 164 | serializeGithubOutput({ |
| 128 | changed: outcome.changed, | 165 | changed: outcome.changed, |
| 129 | result: outcome.result, | 166 | result: outcome.result, |
| 130 | ...(Option.isSome(outcome.relayUrl) ? { relay_url: outcome.relayUrl.value } : {}), | 167 | ...(Option.isSome(outcome.publicConfig) |
| 168 | ? { relay_url: outcome.publicConfig.value.relayUrl } | ||
| 169 | : {}), | ||
| 131 | }), | 170 | }), |
| 132 | { flag: "a" }, | 171 | { flag: "a" }, |
| 133 | ); | 172 | ); |
| ... | @@ -160,7 +199,7 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( | ... | @@ -160,7 +199,7 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( |
| 160 | return { | 199 | return { |
| 161 | result: "dry-run", | 200 | result: "dry-run", |
| 162 | changed, | 201 | changed, |
| 163 | relayUrl: Option.none<string>(), | 202 | publicConfig: Option.none<RelayPublicConfig>(), |
| 164 | } satisfies RelayDeployOutcome; | 203 | } satisfies RelayDeployOutcome; |
| 165 | } | 204 | } |
| 166 | if (!options.yes && changed) { | 205 | if (!options.yes && changed) { |
| ... | @@ -175,20 +214,30 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( | ... | @@ -175,20 +214,30 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( |
| 175 | return { | 214 | return { |
| 176 | result: "cancelled", | 215 | result: "cancelled", |
| 177 | changed, | 216 | changed, |
| 178 | relayUrl: Option.none<string>(), | 217 | publicConfig: Option.none<RelayPublicConfig>(), |
| 179 | } satisfies RelayDeployOutcome; | 218 | } satisfies RelayDeployOutcome; |
| 180 | } | 219 | } |
| 181 | } | 220 | } |
| 182 | const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services)); | 221 | const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services)); |
| 183 | if (output.url === undefined) { | 222 | if ( |
| 223 | output.url === undefined || | ||
| 224 | output.mobileTracingUrl === undefined || | ||
| 225 | output.mobileTracingDataset === undefined || | ||
| 226 | output.mobileTracingToken === undefined | ||
| 227 | ) { | ||
| 184 | return yield* new RelayDeployError({ | 228 | return yield* new RelayDeployError({ |
| 185 | message: "Alchemy relay deploy output did not include a URL", | 229 | message: "Alchemy relay deploy output did not include complete public client config", |
| 186 | }); | 230 | }); |
| 187 | } | 231 | } |
| 188 | return { | 232 | return { |
| 189 | result: changed ? "applied" : "noop", | 233 | result: changed ? "applied" : "noop", |
| 190 | changed, | 234 | changed, |
| 191 | relayUrl: Option.some(output.url), | 235 | publicConfig: Option.some({ |
| 236 | relayUrl: output.url, | ||
| 237 | mobileTracingUrl: output.mobileTracingUrl, | ||
| 238 | mobileTracingDataset: output.mobileTracingDataset, | ||
| 239 | mobileTracingToken: Redacted.value(output.mobileTracingToken), | ||
| 240 | }), | ||
| 192 | } satisfies RelayDeployOutcome; | 241 | } satisfies RelayDeployOutcome; |
| 193 | }, | 242 | }, |
| 194 | (effect, options, configProvider, stage) => | 243 | (effect, options, configProvider, stage) => |
| ... | @@ -216,8 +265,8 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO | ... | @@ -216,8 +265,8 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO |
| 216 | ); | 265 | ); |
| 217 | const stage = Option.getOrElse(options.stage, () => configuredStage); | 266 | const stage = Option.getOrElse(options.stage, () => configuredStage); |
| 218 | const outcome = yield* runRelayDeploy(options, configProvider, stage); | 267 | const outcome = yield* runRelayDeploy(options, configProvider, stage); |
| 219 | if (Option.isSome(outcome.relayUrl)) { | 268 | if (Option.isSome(outcome.publicConfig)) { |
| 220 | yield* reconcileRootEnv(outcome.relayUrl.value); | 269 | yield* reconcileRootEnv(outcome.publicConfig.value); |
| 221 | } | 270 | } |
| 222 | if (options.githubOutput) { | 271 | if (options.githubOutput) { |
| 223 | yield* writeGithubOutput(outcome); | 272 | yield* writeGithubOutput(outcome); |
infra/relay/src/agentActivity/AgentActivityPublisher.ts+7-2| ... | @@ -101,8 +101,13 @@ const make = Effect.gen(function* () { | ... | @@ -101,8 +101,13 @@ const make = Effect.gen(function* () { |
| 101 | "relay.mobile.device_id": input.deviceId, | 101 | "relay.mobile.device_id": input.deviceId, |
| 102 | "relay.operation": "replayForLiveActivityRegistration", | 102 | "relay.operation": "replayForLiveActivityRegistration", |
| 103 | }); | 103 | }); |
| 104 | const activeStates = yield* rows.listForUser({ userId: input.userId }); | 104 | const { activeStates, targets } = yield* Effect.all( |
| 105 | const targets = yield* liveActivities.listTargets({ userId: input.userId }); | 105 | { |
| 106 | activeStates: rows.listForUser({ userId: input.userId }), | ||
| 107 | targets: liveActivities.listTargets({ userId: input.userId }), | ||
| 108 | }, | ||
| 109 | { concurrency: 2 }, | ||
| 110 | ); | ||
| 106 | const target = targets.find((row) => row.device_id === input.deviceId) ?? null; | 111 | const target = targets.find((row) => row.device_id === input.deviceId) ?? null; |
| 107 | if (target === null) { | 112 | if (target === null) { |
| 108 | return null; | 113 | return null; |
infra/relay/src/db.ts+1-1| ... | @@ -63,6 +63,6 @@ export const RelayHyperdrive = Effect.gen(function* () { | ... | @@ -63,6 +63,6 @@ export const RelayHyperdrive = Effect.gen(function* () { |
| 63 | caching: { | 63 | caching: { |
| 64 | disabled: true, | 64 | disabled: true, |
| 65 | }, | 65 | }, |
| 66 | originConnectionLimit: 5, | 66 | originConnectionLimit: 20, |
| 67 | }); | 67 | }); |
| 68 | }); | 68 | }); |
infra/relay/src/environments/EnvironmentConnector.test.ts+54| ... | @@ -14,11 +14,13 @@ import { | ... | @@ -14,11 +14,13 @@ import { |
| 14 | import { describe, expect, it } from "@effect/vitest"; | 14 | import { describe, expect, it } from "@effect/vitest"; |
| 15 | import * as DateTime from "effect/DateTime"; | 15 | import * as DateTime from "effect/DateTime"; |
| 16 | import { RELAY_HEALTH_RESPONSE_TYP, RELAY_MINT_RESPONSE_TYP } from "@t3tools/shared/relayJwt"; | 16 | import { RELAY_HEALTH_RESPONSE_TYP, RELAY_MINT_RESPONSE_TYP } from "@t3tools/shared/relayJwt"; |
| 17 | import * as Deferred from "effect/Deferred"; | ||
| 17 | import * as Duration from "effect/Duration"; | 18 | import * as Duration from "effect/Duration"; |
| 18 | import * as Effect from "effect/Effect"; | 19 | import * as Effect from "effect/Effect"; |
| 19 | import * as Fiber from "effect/Fiber"; | 20 | import * as Fiber from "effect/Fiber"; |
| 20 | import * as Layer from "effect/Layer"; | 21 | import * as Layer from "effect/Layer"; |
| 21 | import * as Redacted from "effect/Redacted"; | 22 | import * as Redacted from "effect/Redacted"; |
| 23 | import * as Ref from "effect/Ref"; | ||
| 22 | import * as Result from "effect/Result"; | 24 | import * as Result from "effect/Result"; |
| 23 | import * as Schema from "effect/Schema"; | 25 | import * as Schema from "effect/Schema"; |
| 24 | import * as TestClock from "effect/testing/TestClock"; | 26 | import * as TestClock from "effect/testing/TestClock"; |
| ... | @@ -225,6 +227,58 @@ function makeLinks( | ... | @@ -225,6 +227,58 @@ function makeLinks( |
| 225 | } | 227 | } |
| 226 | 228 | ||
| 227 | describe("EnvironmentConnector", () => { | 229 | describe("EnvironmentConnector", () => { |
| 230 | it.effect("loads the environment link and managed allocation concurrently", () => | ||
| 231 | Effect.gen(function* () { | ||
| 232 | const started = yield* Ref.make(0); | ||
| 233 | const bothStarted = yield* Deferred.make<void>(); | ||
| 234 | const release = yield* Deferred.make<void>(); | ||
| 235 | const waitForPeer = Effect.gen(function* () { | ||
| 236 | const count = yield* Ref.updateAndGet(started, (value) => value + 1); | ||
| 237 | if (count === 2) { | ||
| 238 | yield* Deferred.succeed(bothStarted, undefined); | ||
| 239 | } | ||
| 240 | yield* Deferred.await(release); | ||
| 241 | }); | ||
| 242 | const links = makeLinks(); | ||
| 243 | const allocations = makeAllocations(); | ||
| 244 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 245 | Effect.sync(() => { | ||
| 246 | const healthRequest = decodeHealthRequestBody(requestBodyText(request)); | ||
| 247 | return HttpClientResponse.fromWeb( | ||
| 248 | request, | ||
| 249 | Response.json(signHealthResponse(healthRequest), { status: 200 }), | ||
| 250 | ); | ||
| 251 | }); | ||
| 252 | const status = Effect.gen(function* () { | ||
| 253 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 254 | return yield* connector.status({ | ||
| 255 | userId: "user_123", | ||
| 256 | environmentId: "env-connector-test" as never, | ||
| 257 | }); | ||
| 258 | }).pipe( | ||
| 259 | Effect.provide( | ||
| 260 | connectorTestLayer(execute, { | ||
| 261 | links: { | ||
| 262 | ...links, | ||
| 263 | getForUser: (input) => waitForPeer.pipe(Effect.andThen(links.getForUser(input))), | ||
| 264 | }, | ||
| 265 | allocations: { | ||
| 266 | ...allocations, | ||
| 267 | get: (input) => waitForPeer.pipe(Effect.andThen(allocations.get(input))), | ||
| 268 | }, | ||
| 269 | }), | ||
| 270 | ), | ||
| 271 | ); | ||
| 272 | |||
| 273 | const fiber = yield* Effect.forkChild(status); | ||
| 274 | yield* Deferred.await(bothStarted); | ||
| 275 | yield* Deferred.succeed(release, undefined); | ||
| 276 | yield* Fiber.join(fiber); | ||
| 277 | |||
| 278 | expect(yield* Ref.get(started)).toBe(2); | ||
| 279 | }), | ||
| 280 | ); | ||
| 281 | |||
| 228 | it.effect("checks linked environment health through the managed endpoint", () => { | 282 | it.effect("checks linked environment health through the managed endpoint", () => { |
| 229 | const seenUrls: Array<string> = []; | 283 | const seenUrls: Array<string> = []; |
| 230 | const seenProofs: Array<RelayCloudEnvironmentHealthProofPayload> = []; | 284 | const seenProofs: Array<RelayCloudEnvironmentHealthProofPayload> = []; |
infra/relay/src/environments/EnvironmentConnector.ts+29-19| ... | @@ -295,9 +295,9 @@ const make = Effect.gen(function* () { | ... | @@ -295,9 +295,9 @@ const make = Effect.gen(function* () { |
| 295 | ); | 295 | ); |
| 296 | const resolveManagedEndpoint = Effect.fn("relay.environment_connector.resolve_managed_endpoint")( | 296 | const resolveManagedEndpoint = Effect.fn("relay.environment_connector.resolve_managed_endpoint")( |
| 297 | function* (input: { | 297 | function* (input: { |
| 298 | readonly userId: string; | ||
| 299 | readonly operation: "connect" | "status"; | 298 | readonly operation: "connect" | "status"; |
| 300 | readonly link: EnvironmentLinks.RelayLinkedEnvironmentRecord; | 299 | readonly link: EnvironmentLinks.RelayLinkedEnvironmentRecord; |
| 300 | readonly allocation: ManagedEndpointAllocations.ManagedEndpointAllocation | null; | ||
| 301 | }) { | 301 | }) { |
| 302 | if (input.link.endpoint.providerKind !== "cloudflare_tunnel") { | 302 | if (input.link.endpoint.providerKind !== "cloudflare_tunnel") { |
| 303 | yield* Effect.annotateCurrentSpan({ | 303 | yield* Effect.annotateCurrentSpan({ |
| ... | @@ -309,11 +309,7 @@ const make = Effect.gen(function* () { | ... | @@ -309,11 +309,7 @@ const make = Effect.gen(function* () { |
| 309 | reason: "endpoint_provider_not_managed", | 309 | reason: "endpoint_provider_not_managed", |
| 310 | }); | 310 | }); |
| 311 | } | 311 | } |
| 312 | const allocation = yield* allocations.get({ | 312 | if (!input.allocation) { |
| 313 | userId: input.userId, | ||
| 314 | environmentId: input.link.environmentId, | ||
| 315 | }); | ||
| 316 | if (!allocation) { | ||
| 317 | return yield* new EnvironmentConnectNotAuthorized({ | 313 | return yield* new EnvironmentConnectNotAuthorized({ |
| 318 | environmentId: input.link.environmentId, | 314 | environmentId: input.link.environmentId, |
| 319 | operation: input.operation, | 315 | operation: input.operation, |
| ... | @@ -321,10 +317,10 @@ const make = Effect.gen(function* () { | ... | @@ -321,10 +317,10 @@ const make = Effect.gen(function* () { |
| 321 | }); | 317 | }); |
| 322 | } | 318 | } |
| 323 | const allocationAttributes = { | 319 | const allocationAttributes = { |
| 324 | "relay.authorization.allocation_hostname": allocation.hostname, | 320 | "relay.authorization.allocation_hostname": input.allocation.hostname, |
| 325 | "relay.authorization.allocation_has_ready_at": allocation.readyAt !== null, | 321 | "relay.authorization.allocation_has_ready_at": input.allocation.readyAt !== null, |
| 326 | "relay.authorization.allocation_has_tunnel_id": allocation.tunnelId !== null, | 322 | "relay.authorization.allocation_has_tunnel_id": input.allocation.tunnelId !== null, |
| 327 | "relay.authorization.allocation_has_dns_record_id": allocation.dnsRecordId !== null, | 323 | "relay.authorization.allocation_has_dns_record_id": input.allocation.dnsRecordId !== null, |
| 328 | } as const; | 324 | } as const; |
| 329 | if (!settings.managedEndpointBaseDomain) { | 325 | if (!settings.managedEndpointBaseDomain) { |
| 330 | yield* Effect.annotateCurrentSpan(allocationAttributes); | 326 | yield* Effect.annotateCurrentSpan(allocationAttributes); |
| ... | @@ -335,9 +331,9 @@ const make = Effect.gen(function* () { | ... | @@ -335,9 +331,9 @@ const make = Effect.gen(function* () { |
| 335 | }); | 331 | }); |
| 336 | } | 332 | } |
| 337 | if ( | 333 | if ( |
| 338 | allocation.readyAt === null || | 334 | input.allocation.readyAt === null || |
| 339 | allocation.tunnelId === null || | 335 | input.allocation.tunnelId === null || |
| 340 | allocation.dnsRecordId === null | 336 | input.allocation.dnsRecordId === null |
| 341 | ) { | 337 | ) { |
| 342 | yield* Effect.annotateCurrentSpan(allocationAttributes); | 338 | yield* Effect.annotateCurrentSpan(allocationAttributes); |
| 343 | return yield* new EnvironmentConnectNotAuthorized({ | 339 | return yield* new EnvironmentConnectNotAuthorized({ |
| ... | @@ -346,7 +342,9 @@ const make = Effect.gen(function* () { | ... | @@ -346,7 +342,9 @@ const make = Effect.gen(function* () { |
| 346 | reason: "managed_endpoint_allocation_not_ready", | 342 | reason: "managed_endpoint_allocation_not_ready", |
| 347 | }); | 343 | }); |
| 348 | } | 344 | } |
| 349 | if (!isManagedEndpointHostname(allocation.hostname, settings.managedEndpointBaseDomain)) { | 345 | if ( |
| 346 | !isManagedEndpointHostname(input.allocation.hostname, settings.managedEndpointBaseDomain) | ||
| 347 | ) { | ||
| 350 | yield* Effect.annotateCurrentSpan({ | 348 | yield* Effect.annotateCurrentSpan({ |
| 351 | ...allocationAttributes, | 349 | ...allocationAttributes, |
| 352 | "relay.authorization.managed_endpoint_base_domain": settings.managedEndpointBaseDomain, | 350 | "relay.authorization.managed_endpoint_base_domain": settings.managedEndpointBaseDomain, |
| ... | @@ -358,7 +356,7 @@ const make = Effect.gen(function* () { | ... | @@ -358,7 +356,7 @@ const make = Effect.gen(function* () { |
| 358 | }); | 356 | }); |
| 359 | } | 357 | } |
| 360 | const endpoint = ManagedEndpointAllocations.resolveReadyManagedEndpoint({ | 358 | const endpoint = ManagedEndpointAllocations.resolveReadyManagedEndpoint({ |
| 361 | allocation, | 359 | allocation: input.allocation, |
| 362 | baseDomain: settings.managedEndpointBaseDomain, | 360 | baseDomain: settings.managedEndpointBaseDomain, |
| 363 | }); | 361 | }); |
| 364 | if ( | 362 | if ( |
| ... | @@ -393,7 +391,13 @@ const make = Effect.gen(function* () { | ... | @@ -393,7 +391,13 @@ const make = Effect.gen(function* () { |
| 393 | "relay.environment_id": input.environmentId, | 391 | "relay.environment_id": input.environmentId, |
| 394 | "relay.operation": "status", | 392 | "relay.operation": "status", |
| 395 | }); | 393 | }); |
| 396 | const link = yield* links.getForUser(input); | 394 | const { link, allocation } = yield* Effect.all( |
| 395 | { | ||
| 396 | link: links.getForUser(input), | ||
| 397 | allocation: allocations.get(input), | ||
| 398 | }, | ||
| 399 | { concurrency: 2 }, | ||
| 400 | ); | ||
| 397 | if (!link) { | 401 | if (!link) { |
| 398 | return yield* new EnvironmentConnectNotAuthorized({ | 402 | return yield* new EnvironmentConnectNotAuthorized({ |
| 399 | environmentId: input.environmentId, | 403 | environmentId: input.environmentId, |
| ... | @@ -402,9 +406,9 @@ const make = Effect.gen(function* () { | ... | @@ -402,9 +406,9 @@ const make = Effect.gen(function* () { |
| 402 | }); | 406 | }); |
| 403 | } | 407 | } |
| 404 | const endpoint = yield* resolveManagedEndpoint({ | 408 | const endpoint = yield* resolveManagedEndpoint({ |
| 405 | userId: input.userId, | ||
| 406 | operation: "status", | 409 | operation: "status", |
| 407 | link, | 410 | link, |
| 411 | allocation, | ||
| 408 | }); | 412 | }); |
| 409 | const now = yield* DateTime.now; | 413 | const now = yield* DateTime.now; |
| 410 | const expiresAt = DateTime.add(now, { minutes: 2 }); | 414 | const expiresAt = DateTime.add(now, { minutes: 2 }); |
| ... | @@ -518,7 +522,13 @@ const make = Effect.gen(function* () { | ... | @@ -518,7 +522,13 @@ const make = Effect.gen(function* () { |
| 518 | reason: "client_proof_key_thumbprint_missing", | 522 | reason: "client_proof_key_thumbprint_missing", |
| 519 | }); | 523 | }); |
| 520 | } | 524 | } |
| 521 | const link = yield* links.getForUser(input); | 525 | const { link, allocation } = yield* Effect.all( |
| 526 | { | ||
| 527 | link: links.getForUser(input), | ||
| 528 | allocation: allocations.get(input), | ||
| 529 | }, | ||
| 530 | { concurrency: 2 }, | ||
| 531 | ); | ||
| 522 | if (!link) { | 532 | if (!link) { |
| 523 | return yield* new EnvironmentConnectNotAuthorized({ | 533 | return yield* new EnvironmentConnectNotAuthorized({ |
| 524 | environmentId: input.environmentId, | 534 | environmentId: input.environmentId, |
| ... | @@ -527,9 +537,9 @@ const make = Effect.gen(function* () { | ... | @@ -527,9 +537,9 @@ const make = Effect.gen(function* () { |
| 527 | }); | 537 | }); |
| 528 | } | 538 | } |
| 529 | const endpoint = yield* resolveManagedEndpoint({ | 539 | const endpoint = yield* resolveManagedEndpoint({ |
| 530 | userId: input.userId, | ||
| 531 | operation: "connect", | 540 | operation: "connect", |
| 532 | link, | 541 | link, |
| 542 | allocation, | ||
| 533 | }); | 543 | }); |
| 534 | const now = yield* DateTime.now; | 544 | const now = yield* DateTime.now; |
| 535 | const expiresAt = DateTime.add(now, { minutes: 2 }); | 545 | const expiresAt = DateTime.add(now, { minutes: 2 }); |
infra/relay/src/http/Api.test.ts+6| ... | @@ -181,6 +181,10 @@ describe("relay request tracing", () => { | ... | @@ -181,6 +181,10 @@ describe("relay request tracing", () => { |
| 181 | const request = HttpServerRequest.fromWeb( | 181 | const request = HttpServerRequest.fromWeb( |
| 182 | new Request("https://relay.test/v1/mobile/devices?client=mobile", { | 182 | new Request("https://relay.test/v1/mobile/devices?client=mobile", { |
| 183 | method: "POST", | 183 | method: "POST", |
| 184 | headers: { | ||
| 185 | authorization: "Bearer secret", | ||
| 186 | dpop: "signed-proof", | ||
| 187 | }, | ||
| 184 | }), | 188 | }), |
| 185 | ); | 189 | ); |
| 186 | 190 | ||
| ... | @@ -192,6 +196,8 @@ describe("relay request tracing", () => { | ... | @@ -192,6 +196,8 @@ describe("relay request tracing", () => { |
| 192 | expect(spans[0]?.kind).toBe("server"); | 196 | expect(spans[0]?.kind).toBe("server"); |
| 193 | expect(spans[0]?.attributes.get("url.path")).toBe("/v1/mobile/devices"); | 197 | expect(spans[0]?.attributes.get("url.path")).toBe("/v1/mobile/devices"); |
| 194 | expect(spans[0]?.attributes.get("http.response.status_code")).toBe(204); | 198 | expect(spans[0]?.attributes.get("http.response.status_code")).toBe(204); |
| 199 | expect(spans[0]?.attributes.get("http.request.header.authorization")).toBe("<redacted>"); | ||
| 200 | expect(spans[0]?.attributes.get("http.request.header.dpop")).toBe("<redacted>"); | ||
| 195 | expect(Option.isNone(spans[0]!.parent)).toBe(true); | 201 | expect(Option.isNone(spans[0]!.parent)).toBe(true); |
| 196 | expect(Option.getOrUndefined(spans[1]!.parent)?.spanId).toBe(spans[0]?.spanId); | 202 | expect(Option.getOrUndefined(spans[1]!.parent)?.spanId).toBe(spans[0]?.spanId); |
| 197 | }), | 203 | }), |
infra/relay/src/http/Api.ts+10-13| ... | @@ -3,6 +3,7 @@ import { sql as drizzleSql } from "drizzle-orm"; | ... | @@ -3,6 +3,7 @@ import { sql as drizzleSql } from "drizzle-orm"; |
| 3 | import * as Crypto from "effect/Crypto"; | 3 | import * as Crypto from "effect/Crypto"; |
| 4 | import * as Context from "effect/Context"; | 4 | import * as Context from "effect/Context"; |
| 5 | import * as DateTime from "effect/DateTime"; | 5 | import * as DateTime from "effect/DateTime"; |
| 6 | import * as Duration from "effect/Duration"; | ||
| 6 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 8 | import * as Record from "effect/Record"; | 9 | import * as Record from "effect/Record"; |
| ... | @@ -18,6 +19,7 @@ import * as HttpTraceContext from "effect/unstable/http/HttpTraceContext"; | ... | @@ -18,6 +19,7 @@ import * as HttpTraceContext from "effect/unstable/http/HttpTraceContext"; |
| 18 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | 19 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 19 | import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; | 20 | import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; |
| 20 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | 21 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| 22 | import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; | ||
| 21 | 23 | ||
| 22 | import { | 24 | import { |
| 23 | RelayApi, | 25 | RelayApi, |
| ... | @@ -67,6 +69,7 @@ import { withSpanAttributes } from "../observability.ts"; | ... | @@ -67,6 +69,7 @@ import { withSpanAttributes } from "../observability.ts"; |
| 67 | import { RelayDb } from "../db.ts"; | 69 | import { RelayDb } from "../db.ts"; |
| 68 | 70 | ||
| 69 | const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; | 71 | const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; |
| 72 | const RELAY_DPOP_ACCESS_TOKEN_TTL = "30 minutes"; | ||
| 70 | const relayCorsAllowedHeaders = [ | 73 | const relayCorsAllowedHeaders = [ |
| 71 | "authorization", | 74 | "authorization", |
| 72 | "b3", | 75 | "b3", |
| ... | @@ -74,11 +77,7 @@ const relayCorsAllowedHeaders = [ | ... | @@ -74,11 +77,7 @@ const relayCorsAllowedHeaders = [ |
| 74 | "content-type", | 77 | "content-type", |
| 75 | "dpop", | 78 | "dpop", |
| 76 | ] as const; | 79 | ] as const; |
| 77 | const relayCorsExposedHeaders = [ | 80 | const relayCorsExposedHeaders = ["traceparent", "www-authenticate"] as const; |
| 78 | "traceparent", | ||
| 79 | "x-t3-relay-auth-failure", | ||
| 80 | "www-authenticate", | ||
| 81 | ] as const; | ||
| 82 | 81 | ||
| 83 | const relayCorsHeaders = { | 82 | const relayCorsHeaders = { |
| 84 | "access-control-allow-origin": "*", | 83 | "access-control-allow-origin": "*", |
| ... | @@ -176,7 +175,10 @@ export const traceRelayHttpRequestWith = <E, R, LayerError, LayerRequirements>( | ... | @@ -176,7 +175,10 @@ export const traceRelayHttpRequestWith = <E, R, LayerError, LayerRequirements>( |
| 176 | HttpServerRequest.HttpServerRequest | R | 175 | HttpServerRequest.HttpServerRequest | R |
| 177 | >, | 176 | >, |
| 178 | tracerLayer: Layer.Layer<never, LayerError, LayerRequirements>, | 177 | tracerLayer: Layer.Layer<never, LayerError, LayerRequirements>, |
| 179 | ) => traceRelayHttpRequest(httpEffect).pipe(Effect.provide(tracerLayer)); | 178 | ) => |
| 179 | traceRelayHttpRequest(httpEffect).pipe( | ||
| 180 | Effect.provide(Layer.merge(tracerLayer, httpHeaderRedactionLayer)), | ||
| 181 | ); | ||
| 180 | 182 | ||
| 181 | export const withoutCapturedParentSpan = <A, E, R>( | 183 | export const withoutCapturedParentSpan = <A, E, R>( |
| 182 | effect: Effect.Effect<A, E, R>, | 184 | effect: Effect.Effect<A, E, R>, |
| ... | @@ -351,12 +353,7 @@ export const healthApi = HttpApiBuilder.group( | ... | @@ -351,12 +353,7 @@ export const healthApi = HttpApiBuilder.group( |
| 351 | "health", | 353 | "health", |
| 352 | Effect.fn("relay.api.health")( | 354 | Effect.fn("relay.api.health")( |
| 353 | function* () { | 355 | function* () { |
| 354 | const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis); | ||
| 355 | yield* db.execute(drizzleSql`SELECT 1`); | 356 | yield* db.execute(drizzleSql`SELECT 1`); |
| 356 | const completedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis); | ||
| 357 | yield* Effect.logInfo("relay health db probe completed", { | ||
| 358 | durationMs: completedAt - startedAt, | ||
| 359 | }); | ||
| 360 | return { ok: true, service: "relay" as const }; | 357 | return { ok: true, service: "relay" as const }; |
| 361 | }, | 358 | }, |
| 362 | Effect.catch(() => relayInternalErrorResponse("database_unavailable")), | 359 | Effect.catch(() => relayInternalErrorResponse("database_unavailable")), |
| ... | @@ -602,7 +599,7 @@ export const tokenApi = HttpApiBuilder.group( | ... | @@ -602,7 +599,7 @@ export const tokenApi = HttpApiBuilder.group( |
| 602 | Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs), | 599 | Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs), |
| 603 | ); | 600 | ); |
| 604 | const now = yield* DateTime.now; | 601 | const now = yield* DateTime.now; |
| 605 | const expiresAt = DateTime.add(now, { minutes: 5 }); | 602 | const expiresAt = DateTime.addDuration(now, RELAY_DPOP_ACCESS_TOKEN_TTL); |
| 606 | const jti = yield* crypto.randomUUIDv4.pipe( | 603 | const jti = yield* crypto.randomUUIDv4.pipe( |
| 607 | Effect.catch(() => relayInternalErrorResponse("internal_error")), | 604 | Effect.catch(() => relayInternalErrorResponse("internal_error")), |
| 608 | ); | 605 | ); |
| ... | @@ -620,7 +617,7 @@ export const tokenApi = HttpApiBuilder.group( | ... | @@ -620,7 +617,7 @@ export const tokenApi = HttpApiBuilder.group( |
| 620 | .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))), | 617 | .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))), |
| 621 | issued_token_type: RelayAccessTokenType, | 618 | issued_token_type: RelayAccessTokenType, |
| 622 | token_type: "DPoP" as const, | 619 | token_type: "DPoP" as const, |
| 623 | expires_in: 300, | 620 | expires_in: Duration.toSeconds(RELAY_DPOP_ACCESS_TOKEN_TTL), |
| 624 | scope: encodeOAuthScope(requestedScopes), | 621 | scope: encodeOAuthScope(requestedScopes), |
| 625 | }; | 622 | }; |
| 626 | }, mapRelayCommonApiErrors("invalid_dpop")), | 623 | }, mapRelayCommonApiErrors("invalid_dpop")), |
infra/relay/src/observability.ts+10-2| ... | @@ -34,7 +34,7 @@ export const RelayObservability = Effect.gen(function* () { | ... | @@ -34,7 +34,7 @@ export const RelayObservability = Effect.gen(function* () { |
| 34 | useRetentionPeriod: true, | 34 | useRetentionPeriod: true, |
| 35 | }); | 35 | }); |
| 36 | 36 | ||
| 37 | const ingestToken = yield* Axiom.ApiToken("RelayAxiomIngestToken", { | 37 | const workerIngestToken = yield* Axiom.ApiToken("RelayWorkerAxiomIngestToken", { |
| 38 | name: relayResourceNameForStage("t3-code-relay-otel-ingest", stage), | 38 | name: relayResourceNameForStage("t3-code-relay-otel-ingest", stage), |
| 39 | description: "Owned by Alchemy. Scoped OTLP ingest token for relay HTTP spans.", | 39 | description: "Owned by Alchemy. Scoped OTLP ingest token for relay HTTP spans.", |
| 40 | datasetCapabilities: Output.map(traces.name, (dataset) => ({ | 40 | datasetCapabilities: Output.map(traces.name, (dataset) => ({ |
| ... | @@ -42,6 +42,14 @@ export const RelayObservability = Effect.gen(function* () { | ... | @@ -42,6 +42,14 @@ export const RelayObservability = Effect.gen(function* () { |
| 42 | })), | 42 | })), |
| 43 | }); | 43 | }); |
| 44 | 44 | ||
| 45 | const mobileIngestToken = yield* Axiom.ApiToken("RelayMobileAxiomIngestToken", { | ||
| 46 | name: relayResourceNameForStage("t3-code-mobile-otel-ingest", stage), | ||
| 47 | description: "Owned by Alchemy. Scoped OTLP ingest token for T3 Code mobile spans.", | ||
| 48 | datasetCapabilities: Output.map(traces.name, (dataset) => ({ | ||
| 49 | [dataset]: { ingest: ["create" as const] }, | ||
| 50 | })), | ||
| 51 | }); | ||
| 52 | |||
| 45 | yield* Axiom.View("RelayRecentSpansView", { | 53 | yield* Axiom.View("RelayRecentSpansView", { |
| 46 | name: relayResourceNameForStage("t3-code-relay-recent-spans", stage), | 54 | name: relayResourceNameForStage("t3-code-relay-recent-spans", stage), |
| 47 | description: "Recent relay HTTP request spans.", | 55 | description: "Recent relay HTTP request spans.", |
| ... | @@ -49,7 +57,7 @@ export const RelayObservability = Effect.gen(function* () { | ... | @@ -49,7 +57,7 @@ export const RelayObservability = Effect.gen(function* () { |
| 49 | aplQuery: Output.map(traces.name, relayRecentSpansQuery), | 57 | aplQuery: Output.map(traces.name, relayRecentSpansQuery), |
| 50 | }); | 58 | }); |
| 51 | 59 | ||
| 52 | return { traces, ingestToken } as const; | 60 | return { traces, workerIngestToken, mobileIngestToken } as const; |
| 53 | }); | 61 | }); |
| 54 | 62 | ||
| 55 | export const withSpanAttributes = | 63 | export const withSpanAttributes = |
infra/relay/src/worker.ts+1-1| ... | @@ -129,7 +129,7 @@ export default class Api extends Cloudflare.Worker<Api>()( | ... | @@ -129,7 +129,7 @@ export default class Api extends Cloudflare.Worker<Api>()( |
| 129 | const apnsDeliveryQueueSender = yield* Cloudflare.QueueBinding.bind(apnsDeliveryQueue); | 129 | const apnsDeliveryQueueSender = yield* Cloudflare.QueueBinding.bind(apnsDeliveryQueue); |
| 130 | 130 | ||
| 131 | const axiomDatasetName = yield* observability.traces.name; | 131 | const axiomDatasetName = yield* observability.traces.name; |
| 132 | const axiomIngestToken = yield* observability.ingestToken.token; | 132 | const axiomIngestToken = yield* observability.workerIngestToken.token; |
| 133 | const axiomTracesEndpoint = yield* observability.traces.otelTracesEndpoint; | 133 | const axiomTracesEndpoint = yield* observability.traces.otelTracesEndpoint; |
| 134 | 134 | ||
| 135 | const clerkSecretKey = yield* Config.redacted("CLERK_SECRET_KEY"); | 135 | const clerkSecretKey = yield* Config.redacted("CLERK_SECRET_KEY"); |
oxlint-plugin-t3code/index.ts+2| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { definePlugin } from "@oxlint/plugins"; | 1 | import { definePlugin } from "@oxlint/plugins"; |
| 2 | 2 | ||
| 3 | import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; | 3 | import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts"; |
| 4 | import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts"; | ||
| 4 | 5 | ||
| 5 | export default definePlugin({ | 6 | export default definePlugin({ |
| 6 | meta: { | 7 | meta: { |
| ... | @@ -8,5 +9,6 @@ export default definePlugin({ | ... | @@ -8,5 +9,6 @@ export default definePlugin({ |
| 8 | }, | 9 | }, |
| 9 | rules: { | 10 | rules: { |
| 10 | "no-inline-schema-compile": noInlineSchemaCompile, | 11 | "no-inline-schema-compile": noInlineSchemaCompile, |
| 12 | "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests, | ||
| 11 | }, | 13 | }, |
| 12 | }); | 14 | }); |
oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts created+73| ... | @@ -0,0 +1,73 @@ | ||
| 1 | import { assert, describe } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { createOxlintRuleHarness } from "../test/utils.ts"; | ||
| 4 | |||
| 5 | const rule = createOxlintRuleHarness("t3code/no-manual-effect-runtime-in-tests", { | ||
| 6 | filename: "fixture.test.ts", | ||
| 7 | }); | ||
| 8 | |||
| 9 | describe("t3code/no-manual-effect-runtime-in-tests", () => { | ||
| 10 | rule.valid( | ||
| 11 | "allows @effect/vitest effect tests", | ||
| 12 | ` | ||
| 13 | import { it } from "@effect/vitest"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | |||
| 16 | it.effect("runs an Effect", () => Effect.succeed("ok")); | ||
| 17 | `, | ||
| 18 | ); | ||
| 19 | |||
| 20 | const runtimeMethods = [ | ||
| 21 | "runCallback", | ||
| 22 | "runCallbackWith", | ||
| 23 | "runFork", | ||
| 24 | "runForkWith", | ||
| 25 | "runPromise", | ||
| 26 | "runPromiseExit", | ||
| 27 | "runPromiseExitWith", | ||
| 28 | "runPromiseWith", | ||
| 29 | "runSync", | ||
| 30 | "runSyncExit", | ||
| 31 | "runSyncExitWith", | ||
| 32 | "runSyncWith", | ||
| 33 | ] as const; | ||
| 34 | |||
| 35 | for (const method of runtimeMethods) { | ||
| 36 | rule.invalid( | ||
| 37 | `reports Effect.${method}`, | ||
| 38 | ` | ||
| 39 | import * as Effect from "effect/Effect"; | ||
| 40 | |||
| 41 | test("runs an Effect", () => { | ||
| 42 | Effect.${method}(Effect.succeed("ok")); | ||
| 43 | }); | ||
| 44 | `, | ||
| 45 | (output) => { | ||
| 46 | assert.match(output, /Use @effect\/vitest with it\.effect/); | ||
| 47 | }, | ||
| 48 | ); | ||
| 49 | } | ||
| 50 | |||
| 51 | rule.invalid( | ||
| 52 | "reports ManagedRuntime.make", | ||
| 53 | ` | ||
| 54 | import * as Layer from "effect/Layer"; | ||
| 55 | import * as ManagedRuntime from "effect/ManagedRuntime"; | ||
| 56 | |||
| 57 | test("makes a runtime", () => { | ||
| 58 | ManagedRuntime.make(Layer.empty); | ||
| 59 | }); | ||
| 60 | `, | ||
| 61 | ); | ||
| 62 | }); | ||
| 63 | |||
| 64 | const productionRule = createOxlintRuleHarness("t3code/no-manual-effect-runtime-in-tests"); | ||
| 65 | |||
| 66 | productionRule.valid( | ||
| 67 | "allows production runtime boundaries", | ||
| 68 | ` | ||
| 69 | import * as Effect from "effect/Effect"; | ||
| 70 | |||
| 71 | export const main = () => Effect.runPromise(Effect.void); | ||
| 72 | `, | ||
| 73 | ); | ||
oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts created+113| ... | @@ -0,0 +1,113 @@ | ||
| 1 | import { defineRule } from "@oxlint/plugins"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | |||
| 4 | import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts"; | ||
| 5 | |||
| 6 | const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u; | ||
| 7 | const EFFECT_RUNTIME_METHODS = new Set([ | ||
| 8 | "runCallback", | ||
| 9 | "runCallbackWith", | ||
| 10 | "runFork", | ||
| 11 | "runForkWith", | ||
| 12 | "runPromise", | ||
| 13 | "runPromiseExit", | ||
| 14 | "runPromiseExitWith", | ||
| 15 | "runPromiseWith", | ||
| 16 | "runSync", | ||
| 17 | "runSyncExit", | ||
| 18 | "runSyncExitWith", | ||
| 19 | "runSyncWith", | ||
| 20 | ]); | ||
| 21 | |||
| 22 | // Existing manual runners are tracked as debt. The rule permits no net-new | ||
| 23 | // occurrences in these files, while unlisted test files must have zero. | ||
| 24 | const LEGACY_BASELINE = new Map<string, number>([ | ||
| 25 | ["apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts", 1], | ||
| 26 | ["apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts", 2], | ||
| 27 | ["apps/mobile/src/state/use-remote-environment-registry.test.ts", 2], | ||
| 28 | ["apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts", 5], | ||
| 29 | ["apps/server/src/orchestration/commandInvariants.test.ts", 6], | ||
| 30 | ["apps/server/src/orchestration/Layers/CheckpointReactor.test.ts", 42], | ||
| 31 | ["apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts", 5], | ||
| 32 | ["apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts", 4], | ||
| 33 | ["apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts", 70], | ||
| 34 | ["apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts", 31], | ||
| 35 | ["apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts", 2], | ||
| 36 | ["apps/server/src/orchestration/projector.test.ts", 20], | ||
| 37 | ["apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts", 4], | ||
| 38 | ["apps/server/src/provider/acp/CursorAcpSupport.test.ts", 1], | ||
| 39 | ["apps/server/src/provider/Layers/ClaudeAdapter.test.ts", 2], | ||
| 40 | ["apps/server/src/provider/Layers/CodexAdapter.test.ts", 1], | ||
| 41 | ["apps/server/src/provider/Layers/CodexSessionRuntime.test.ts", 5], | ||
| 42 | ["apps/server/src/provider/Layers/CursorAdapter.test.ts", 1], | ||
| 43 | ["apps/server/src/provider/Layers/CursorProvider.test.ts", 4], | ||
| 44 | ["apps/server/src/provider/Layers/ProviderService.test.ts", 2], | ||
| 45 | ["apps/server/src/provider/Layers/ProviderSessionReaper.test.ts", 21], | ||
| 46 | ["apps/server/src/relay/AgentAwarenessRelay.test.ts", 4], | ||
| 47 | ["apps/server/src/server.test.ts", 1], | ||
| 48 | ["apps/web/src/cloud/dpop.test.ts", 2], | ||
| 49 | ["apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts", 1], | ||
| 50 | ["oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts", 7], | ||
| 51 | ["packages/client-runtime/src/managedRelayState.test.ts", 1], | ||
| 52 | ["packages/client-runtime/src/wsTransport.test.ts", 2], | ||
| 53 | ]); | ||
| 54 | |||
| 55 | const baselineFor = (filename: string): number => { | ||
| 56 | const normalized = filename.replaceAll("\\", "/"); | ||
| 57 | for (const [suffix, count] of LEGACY_BASELINE) { | ||
| 58 | if (normalized.endsWith(suffix)) return count; | ||
| 59 | } | ||
| 60 | return 0; | ||
| 61 | }; | ||
| 62 | |||
| 63 | const manualRunnerName = (callee: unknown): Option.Option<string> => { | ||
| 64 | const expression = unwrapExpression(callee); | ||
| 65 | if (Option.isNone(expression) || expression.value.type !== "MemberExpression") { | ||
| 66 | return Option.none(); | ||
| 67 | } | ||
| 68 | |||
| 69 | const object = unwrapExpression(expression.value.object); | ||
| 70 | const property = getPropertyName(expression.value.property); | ||
| 71 | if (Option.isNone(property)) return Option.none(); | ||
| 72 | |||
| 73 | if (isIdentifier(object, "Effect") && EFFECT_RUNTIME_METHODS.has(property.value)) { | ||
| 74 | return Option.some(`Effect.${property.value}`); | ||
| 75 | } | ||
| 76 | |||
| 77 | if (isIdentifier(object, "ManagedRuntime") && property.value === "make") { | ||
| 78 | return Option.some("ManagedRuntime.make"); | ||
| 79 | } | ||
| 80 | |||
| 81 | return Option.none(); | ||
| 82 | }; | ||
| 83 | |||
| 84 | export default defineRule({ | ||
| 85 | meta: { | ||
| 86 | type: "problem", | ||
| 87 | docs: { | ||
| 88 | description: | ||
| 89 | "Disallow manually creating or running Effect runtimes in tests; use @effect/vitest.", | ||
| 90 | }, | ||
| 91 | }, | ||
| 92 | create(context) { | ||
| 93 | if (!TEST_FILE_PATTERN.test(context.filename)) return {}; | ||
| 94 | |||
| 95 | const allowedCount = baselineFor(context.filename); | ||
| 96 | let occurrenceCount = 0; | ||
| 97 | |||
| 98 | return { | ||
| 99 | CallExpression(node) { | ||
| 100 | const runner = manualRunnerName(node.callee); | ||
| 101 | if (Option.isNone(runner)) return; | ||
| 102 | |||
| 103 | occurrenceCount++; | ||
| 104 | if (occurrenceCount <= allowedCount) return; | ||
| 105 | |||
| 106 | context.report({ | ||
| 107 | node: node.callee, | ||
| 108 | message: `Do not use ${runner.value} in tests. Use @effect/vitest with it.effect(...) and test layers instead.`, | ||
| 109 | }); | ||
| 110 | }, | ||
| 111 | }; | ||
| 112 | }, | ||
| 113 | }); | ||
oxlint-plugin-t3code/test/utils.ts+9-2| ... | @@ -48,6 +48,10 @@ interface RuleHarness { | ... | @@ -48,6 +48,10 @@ interface RuleHarness { |
| 48 | readonly invalid: (name: string, source: string, assertion?: (output: string) => void) => void; | 48 | readonly invalid: (name: string, source: string, assertion?: (output: string) => void) => void; |
| 49 | } | 49 | } |
| 50 | 50 | ||
| 51 | interface RuleHarnessOptions { | ||
| 52 | readonly filename?: string; | ||
| 53 | } | ||
| 54 | |||
| 51 | const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> => | 55 | const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> => |
| 52 | stream.pipe( | 56 | stream.pipe( |
| 53 | Stream.decodeText(), | 57 | Stream.decodeText(), |
| ... | @@ -73,7 +77,10 @@ const spawnAndCollectOutput = Effect.fnUntraced(function* (command: ChildProcess | ... | @@ -73,7 +77,10 @@ const spawnAndCollectOutput = Effect.fnUntraced(function* (command: ChildProcess |
| 73 | return { exitCode, stdout, stderr }; | 77 | return { exitCode, stdout, stderr }; |
| 74 | }, Effect.scoped); | 78 | }, Effect.scoped); |
| 75 | 79 | ||
| 76 | export const createOxlintRuleHarness = (ruleName: string): RuleHarness => { | 80 | export const createOxlintRuleHarness = ( |
| 81 | ruleName: string, | ||
| 82 | options: RuleHarnessOptions = {}, | ||
| 83 | ): RuleHarness => { | ||
| 77 | const [pluginName, shortRuleName] = ruleName.split("/"); | 84 | const [pluginName, shortRuleName] = ruleName.split("/"); |
| 78 | const diagnosticRuleName = | 85 | const diagnosticRuleName = |
| 79 | pluginName && shortRuleName ? `${pluginName}\\(${shortRuleName}\\)` : ruleName; | 86 | pluginName && shortRuleName ? `${pluginName}\\(${shortRuleName}\\)` : ruleName; |
| ... | @@ -84,7 +91,7 @@ export const createOxlintRuleHarness = (ruleName: string): RuleHarness => { | ... | @@ -84,7 +91,7 @@ export const createOxlintRuleHarness = (ruleName: string): RuleHarness => { |
| 84 | const path = yield* Path.Path; | 91 | const path = yield* Path.Path; |
| 85 | const fixtureDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-oxlint-" }); | 92 | const fixtureDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-oxlint-" }); |
| 86 | const configPath = path.join(fixtureDir, ".oxlintrc.json"); | 93 | const configPath = path.join(fixtureDir, ".oxlintrc.json"); |
| 87 | const sourcePath = path.join(fixtureDir, "fixture.ts"); | 94 | const sourcePath = path.join(fixtureDir, options.filename ?? "fixture.ts"); |
| 88 | const repoRoot = path.join(import.meta.dirname, "..", ".."); | 95 | const repoRoot = path.join(import.meta.dirname, "..", ".."); |
| 89 | const oxlintBin = path.join( | 96 | const oxlintBin = path.join( |
| 90 | repoRoot, | 97 | repoRoot, |
packages/client-runtime/src/remote.ts+5-1| ... | @@ -15,6 +15,7 @@ import type { | ... | @@ -15,6 +15,7 @@ import type { |
| 15 | EnvironmentScopeRequiredError, | 15 | EnvironmentScopeRequiredError, |
| 16 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 17 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | 17 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| 18 | import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; | ||
| 18 | import * as Data from "effect/Data"; | 19 | import * as Data from "effect/Data"; |
| 19 | import * as Duration from "effect/Duration"; | 20 | import * as Duration from "effect/Duration"; |
| 20 | import * as Effect from "effect/Effect"; | 21 | import * as Effect from "effect/Effect"; |
| ... | @@ -111,7 +112,10 @@ export type RemoteEnvironmentAuthError = | ... | @@ -111,7 +112,10 @@ export type RemoteEnvironmentAuthError = |
| 111 | export const remoteHttpClientLayer = ( | 112 | export const remoteHttpClientLayer = ( |
| 112 | fetchFn: typeof globalThis.fetch, | 113 | fetchFn: typeof globalThis.fetch, |
| 113 | ): Layer.Layer<HttpClient.HttpClient> => | 114 | ): Layer.Layer<HttpClient.HttpClient> => |
| 114 | FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))); | 115 | Layer.merge( |
| 116 | FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))), | ||
| 117 | httpHeaderRedactionLayer, | ||
| 118 | ); | ||
| 115 | 119 | ||
| 116 | const failRemoteRequest = ( | 120 | const failRemoteRequest = ( |
| 117 | requestUrl: string, | 121 | requestUrl: string, |
packages/shared/package.json+4| ... | @@ -31,6 +31,10 @@ | ... | @@ -31,6 +31,10 @@ |
| 31 | "types": "./src/observability.ts", | 31 | "types": "./src/observability.ts", |
| 32 | "import": "./src/observability.ts" | 32 | "import": "./src/observability.ts" |
| 33 | }, | 33 | }, |
| 34 | "./httpObservability": { | ||
| 35 | "types": "./src/httpObservability.ts", | ||
| 36 | "import": "./src/httpObservability.ts" | ||
| 37 | }, | ||
| 34 | "./shell": { | 38 | "./shell": { |
| 35 | "types": "./src/shell.ts", | 39 | "types": "./src/shell.ts", |
| 36 | "import": "./src/shell.ts" | 40 | "import": "./src/shell.ts" |
packages/shared/src/httpObservability.ts created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Layer from "effect/Layer"; | ||
| 3 | import * as Headers from "effect/unstable/http/Headers"; | ||
| 4 | |||
| 5 | export const httpHeaderRedactionLayer = Layer.effect( | ||
| 6 | Headers.CurrentRedactedNames, | ||
| 7 | Effect.map(Headers.CurrentRedactedNames, (names) => [...names, "dpop"]), | ||
| 8 | ); | ||
scripts/lib/public-config.test.ts+22-1| ... | @@ -27,6 +27,12 @@ describe("loadRepoEnv", () => { | ... | @@ -27,6 +27,12 @@ describe("loadRepoEnv", () => { |
| 27 | expect(env.EXPO_PUBLIC_CLERK_JWT_TEMPLATE).toBeUndefined(); | 27 | expect(env.EXPO_PUBLIC_CLERK_JWT_TEMPLATE).toBeUndefined(); |
| 28 | expect(env.T3CODE_RELAY_URL).toBeUndefined(); | 28 | expect(env.T3CODE_RELAY_URL).toBeUndefined(); |
| 29 | expect(env.VITE_T3CODE_RELAY_URL).toBeUndefined(); | 29 | expect(env.VITE_T3CODE_RELAY_URL).toBeUndefined(); |
| 30 | expect(env.T3CODE_MOBILE_OTLP_TRACES_URL).toBeUndefined(); | ||
| 31 | expect(env.T3CODE_MOBILE_OTLP_TRACES_DATASET).toBeUndefined(); | ||
| 32 | expect(env.T3CODE_MOBILE_OTLP_TRACES_TOKEN).toBeUndefined(); | ||
| 33 | expect(env.EXPO_PUBLIC_OTLP_TRACES_URL).toBeUndefined(); | ||
| 34 | expect(env.EXPO_PUBLIC_OTLP_TRACES_DATASET).toBeUndefined(); | ||
| 35 | expect(env.EXPO_PUBLIC_OTLP_TRACES_TOKEN).toBeUndefined(); | ||
| 30 | }); | 36 | }); |
| 31 | 37 | ||
| 32 | it("applies process, root local, and root precedence in that order", () => { | 38 | it("applies process, root local, and root precedence in that order", () => { |
| ... | @@ -73,26 +79,41 @@ describe("loadRepoEnv", () => { | ... | @@ -73,26 +79,41 @@ describe("loadRepoEnv", () => { |
| 73 | VITE_CLERK_JWT_TEMPLATE: "template_legacy", | 79 | VITE_CLERK_JWT_TEMPLATE: "template_legacy", |
| 74 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_canonical", | 80 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_canonical", |
| 75 | VITE_T3CODE_RELAY_URL: "https://legacy.example.test", | 81 | VITE_T3CODE_RELAY_URL: "https://legacy.example.test", |
| 82 | EXPO_PUBLIC_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", | ||
| 83 | EXPO_PUBLIC_OTLP_TRACES_DATASET: "mobile-traces", | ||
| 84 | EXPO_PUBLIC_OTLP_TRACES_TOKEN: "mobile-token", | ||
| 76 | }), | 85 | }), |
| 77 | ).toEqual({ | 86 | ).toEqual({ |
| 78 | clerkPublishableKey: "pk_legacy", | 87 | clerkPublishableKey: "pk_legacy", |
| 79 | clerkJwtTemplate: "template_legacy", | 88 | clerkJwtTemplate: "template_legacy", |
| 80 | clerkCliOAuthClientId: "oauth_canonical", | 89 | clerkCliOAuthClientId: "oauth_canonical", |
| 81 | relayUrl: "https://legacy.example.test", | 90 | relayUrl: "https://legacy.example.test", |
| 91 | mobileOtlpTracesUrl: "https://api.axiom.co/v1/traces", | ||
| 92 | mobileOtlpTracesDataset: "mobile-traces", | ||
| 93 | mobileOtlpTracesToken: "mobile-token", | ||
| 82 | }); | 94 | }); |
| 83 | }); | 95 | }); |
| 84 | 96 | ||
| 85 | it("projects only the configured aliases", () => { | 97 | it("projects canonical mobile tracing values to Expo public aliases", () => { |
| 86 | expect( | 98 | expect( |
| 87 | loadRepoEnv({ | 99 | loadRepoEnv({ |
| 88 | baseEnv: { | 100 | baseEnv: { |
| 89 | T3CODE_RELAY_URL: "https://relay.example.test", | 101 | T3CODE_RELAY_URL: "https://relay.example.test", |
| 102 | T3CODE_MOBILE_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", | ||
| 103 | T3CODE_MOBILE_OTLP_TRACES_DATASET: "mobile-traces", | ||
| 104 | T3CODE_MOBILE_OTLP_TRACES_TOKEN: "mobile-token", | ||
| 90 | }, | 105 | }, |
| 91 | repoRoot: makeTemporaryDirectory(), | 106 | repoRoot: makeTemporaryDirectory(), |
| 92 | }), | 107 | }), |
| 93 | ).toEqual({ | 108 | ).toEqual({ |
| 94 | T3CODE_RELAY_URL: "https://relay.example.test", | 109 | T3CODE_RELAY_URL: "https://relay.example.test", |
| 95 | VITE_T3CODE_RELAY_URL: "https://relay.example.test", | 110 | VITE_T3CODE_RELAY_URL: "https://relay.example.test", |
| 111 | T3CODE_MOBILE_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", | ||
| 112 | T3CODE_MOBILE_OTLP_TRACES_DATASET: "mobile-traces", | ||
| 113 | T3CODE_MOBILE_OTLP_TRACES_TOKEN: "mobile-token", | ||
| 114 | EXPO_PUBLIC_OTLP_TRACES_URL: "https://api.axiom.co/v1/traces", | ||
| 115 | EXPO_PUBLIC_OTLP_TRACES_DATASET: "mobile-traces", | ||
| 116 | EXPO_PUBLIC_OTLP_TRACES_TOKEN: "mobile-token", | ||
| 96 | }); | 117 | }); |
| 97 | }); | 118 | }); |
| 98 | }); | 119 | }); |
scripts/lib/public-config.ts+36| ... | @@ -9,6 +9,9 @@ export interface T3CodePublicConfig { | ... | @@ -9,6 +9,9 @@ export interface T3CodePublicConfig { |
| 9 | readonly clerkJwtTemplate: string | undefined; | 9 | readonly clerkJwtTemplate: string | undefined; |
| 10 | readonly clerkCliOAuthClientId: string | undefined; | 10 | readonly clerkCliOAuthClientId: string | undefined; |
| 11 | readonly relayUrl: string | undefined; | 11 | readonly relayUrl: string | undefined; |
| 12 | readonly mobileOtlpTracesUrl: string | undefined; | ||
| 13 | readonly mobileOtlpTracesDataset: string | undefined; | ||
| 14 | readonly mobileOtlpTracesToken: string | undefined; | ||
| 12 | } | 15 | } |
| 13 | 16 | ||
| 14 | type Environment = Readonly<Record<string, string | undefined>>; | 17 | type Environment = Readonly<Record<string, string | undefined>>; |
| ... | @@ -57,6 +60,24 @@ export function loadRepoEnv({ | ... | @@ -57,6 +60,24 @@ export function loadRepoEnv({ |
| 57 | VITE_T3CODE_RELAY_URL: config.relayUrl, | 60 | VITE_T3CODE_RELAY_URL: config.relayUrl, |
| 58 | } | 61 | } |
| 59 | : {}), | 62 | : {}), |
| 63 | ...(config.mobileOtlpTracesUrl | ||
| 64 | ? { | ||
| 65 | T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileOtlpTracesUrl, | ||
| 66 | EXPO_PUBLIC_OTLP_TRACES_URL: config.mobileOtlpTracesUrl, | ||
| 67 | } | ||
| 68 | : {}), | ||
| 69 | ...(config.mobileOtlpTracesDataset | ||
| 70 | ? { | ||
| 71 | T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileOtlpTracesDataset, | ||
| 72 | EXPO_PUBLIC_OTLP_TRACES_DATASET: config.mobileOtlpTracesDataset, | ||
| 73 | } | ||
| 74 | : {}), | ||
| 75 | ...(config.mobileOtlpTracesToken | ||
| 76 | ? { | ||
| 77 | T3CODE_MOBILE_OTLP_TRACES_TOKEN: config.mobileOtlpTracesToken, | ||
| 78 | EXPO_PUBLIC_OTLP_TRACES_TOKEN: config.mobileOtlpTracesToken, | ||
| 79 | } | ||
| 80 | : {}), | ||
| 60 | }; | 81 | }; |
| 61 | } | 82 | } |
| 62 | 83 | ||
| ... | @@ -76,6 +97,21 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP | ... | @@ -76,6 +97,21 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP |
| 76 | ), | 97 | ), |
| 77 | clerkCliOAuthClientId: firstNonEmpty(sources, "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID"), | 98 | clerkCliOAuthClientId: firstNonEmpty(sources, "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID"), |
| 78 | relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"), | 99 | relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"), |
| 100 | mobileOtlpTracesUrl: firstNonEmpty( | ||
| 101 | sources, | ||
| 102 | "T3CODE_MOBILE_OTLP_TRACES_URL", | ||
| 103 | "EXPO_PUBLIC_OTLP_TRACES_URL", | ||
| 104 | ), | ||
| 105 | mobileOtlpTracesDataset: firstNonEmpty( | ||
| 106 | sources, | ||
| 107 | "T3CODE_MOBILE_OTLP_TRACES_DATASET", | ||
| 108 | "EXPO_PUBLIC_OTLP_TRACES_DATASET", | ||
| 109 | ), | ||
| 110 | mobileOtlpTracesToken: firstNonEmpty( | ||
| 111 | sources, | ||
| 112 | "T3CODE_MOBILE_OTLP_TRACES_TOKEN", | ||
| 113 | "EXPO_PUBLIC_OTLP_TRACES_TOKEN", | ||
| 114 | ), | ||
| 79 | }; | 115 | }; |
| 80 | } | 116 | } |
| 81 | 117 |
vite.config.ts+1| ... | @@ -91,6 +91,7 @@ export default defineConfig({ | ... | @@ -91,6 +91,7 @@ export default defineConfig({ |
| 91 | "typescript/restrict-template-expressions": "off", | 91 | "typescript/restrict-template-expressions": "off", |
| 92 | "typescript/unbound-method": "off", | 92 | "typescript/unbound-method": "off", |
| 93 | "t3code/no-inline-schema-compile": "warn", | 93 | "t3code/no-inline-schema-compile": "warn", |
| 94 | "t3code/no-manual-effect-runtime-in-tests": "error", | ||
| 94 | }, | 95 | }, |
| 95 | options: { | 96 | options: { |
| 96 | // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics. | 97 | // Revisit once Oxlint's tsgolint path can integrate with @effect/tsgo diagnostics. |