History

Merge pull request #291 from RhysSullivan/rs/do-telemetry-fix

Rhys Sullivan committed 7 months ago

Commit 71860092 parents 2f5bcd8 5cfe0272 files · +90 −59Browse files

Unified · Split

2 files
  1. apps/cloud/src/env.ts +62 −42
  2. apps/cloud/src/services/telemetry.ts +28 −17
apps/cloud/src/env.ts+62−42
@@ -1,29 +1,21 @@
1import { createEnv, Env } from "@executor/env";1// ---------------------------------------------------------------------------
2// Env accessors for apps/cloud.
3// ---------------------------------------------------------------------------
4//
5// `server` / `shared` are lazy Proxies over `cloudflare:workers` `env`, with
6// a `process.env` fallback for the node-pool tests (which stub
7// `cloudflare:workers` to an empty `env` and rely on vitest to populate
8// `process.env`). Reads happen at the call site, not at module load, so
9// secrets land correctly in both the edge isolate and the Durable Object
10// isolate — the latter previously captured empty strings at env.ts
11// module-load time and silently broke `DoTelemetryLive`, `AutumnService`
12// billing writes, and likely other DO-reachable secret readers.
13//
14// Types below mirror the prior `createEnv(...)`-derived shapes so every
15// caller (`server.X`) keeps the same typed surface.
16// ---------------------------------------------------------------------------
2 17
3const sharedShape = {18import { env } from "cloudflare:workers";
4 NODE_ENV: Env.literalOr("NODE_ENV", "development", "development", "test", "production"),
5};
6
7const serverShape = {
8 DATABASE_URL: Env.stringOr("DATABASE_URL", ""),
9 MCP_SESSION_REQUEST_SCOPED_RUNTIME: Env.literalOr(
10 "MCP_SESSION_REQUEST_SCOPED_RUNTIME",
11 "false",
12 "false",
13 "true",
14 ),
15 WORKOS_API_KEY: Env.string("WORKOS_API_KEY"),
16 WORKOS_CLIENT_ID: Env.string("WORKOS_CLIENT_ID"),
17 WORKOS_COOKIE_PASSWORD: Env.string("WORKOS_COOKIE_PASSWORD"),
18 VITE_PUBLIC_SITE_URL: Env.stringOr("VITE_PUBLIC_SITE_URL", ""),
19 MCP_AUTHKIT_DOMAIN: Env.stringOr("MCP_AUTHKIT_DOMAIN", "https://signin.executor.sh"),
20 MCP_RESOURCE_ORIGIN: Env.stringOr("MCP_RESOURCE_ORIGIN", "https://executor.sh"),
21 AUTUMN_SECRET_KEY: Env.stringOr("AUTUMN_SECRET_KEY", ""),
22 SENTRY_DSN: Env.stringOr("SENTRY_DSN", ""),
23 AXIOM_TOKEN: Env.stringOr("AXIOM_TOKEN", ""),
24 AXIOM_DATASET: Env.stringOr("AXIOM_DATASET", "executor-cloud"),
25 AXIOM_TRACES_URL: Env.stringOr("AXIOM_TRACES_URL", "https://api.axiom.co/v1/traces"),
26};
27 19
28type SharedEnv = Readonly<{20type SharedEnv = Readonly<{
29 NODE_ENV: "development" | "test" | "production";21 NODE_ENV: "development" | "test" | "production";
@@ -48,22 +40,50 @@ type ServerEnv = SharedEnv & · 18 unmodified lines
48 40
49type WebEnv = Readonly<Record<string, never>>;41type WebEnv = Readonly<Record<string, never>>;
50 42
51export const shared = createEnv(sharedShape, {43const SERVER_DEFAULTS: Record<keyof ServerEnv, string> = {
52 runtimeEnv: process.env,44 NODE_ENV: "development",
53 emptyStringAsUndefined: true,45 DATABASE_URL: "",
54}) as SharedEnv;46 MCP_SESSION_REQUEST_SCOPED_RUNTIME: "false",
47 WORKOS_API_KEY: "",
48 WORKOS_CLIENT_ID: "",
49 WORKOS_COOKIE_PASSWORD: "",
50 VITE_PUBLIC_SITE_URL: "",
51 MCP_AUTHKIT_DOMAIN: "https://signin.executor.sh",
52 MCP_RESOURCE_ORIGIN: "https://executor.sh",
53 AUTUMN_SECRET_KEY: "",
54 SENTRY_DSN: "",
55 AXIOM_TOKEN: "",
56 AXIOM_DATASET: "executor-cloud",
57 AXIOM_TRACES_URL: "https://api.axiom.co/v1/traces",
58};
59
60const SHARED_DEFAULTS: Record<keyof SharedEnv, string> = {
61 NODE_ENV: "development",
62};
63
64const readFromWorkerEnv = (key: string): string | undefined => {
65 const bag = env as unknown as Record<string, unknown>;
66 const v = bag[key];
67 return typeof v === "string" && v.length > 0 ? v : undefined;
68};
69
70const readFromProcessEnv = (key: string): string | undefined => {
71 if (typeof process === "undefined" || !process.env) return undefined;
72 const v = process.env[key];
73 return typeof v === "string" && v.length > 0 ? v : undefined;
74};
75
76const read = (key: string): string | undefined =>
77 readFromWorkerEnv(key) ?? readFromProcessEnv(key);
55 78
56export const web = createEnv(79const makeEnvProxy = <T extends object>(defaults: Record<string, string>): T =>
57 {},80 new Proxy({} as T, {
58 {81 get: (_target, key) => {
59 prefix: "PUBLIC_",82 if (typeof key !== "string") return undefined;
60 runtimeEnv: process.env,83 return read(key) ?? defaults[key] ?? "";
61 emptyStringAsUndefined: true,84 },
62 },85 });
63) as WebEnv;
64 86
65export const server = createEnv(serverShape, {87export const shared: SharedEnv = makeEnvProxy<SharedEnv>(SHARED_DEFAULTS);
66 extends: [shared],88export const server: ServerEnv = makeEnvProxy<ServerEnv>(SERVER_DEFAULTS);
67 runtimeEnv: process.env,89export const web: WebEnv = {} as WebEnv;
68 emptyStringAsUndefined: true,
69}) as ServerEnv;
apps/cloud/src/services/telemetry.ts+28−17
@@ -14,7 +14,11 @@ · 13 unmodified lines
14// that breaks `this` binding on `WorkerTransport`'s stream primitives —14// that breaks `this` binding on `WorkerTransport`'s stream primitives —
15// every MCP request 500s with "Illegal invocation"). The DO uses a15// every MCP request 500s with "Illegal invocation"). The DO uses a
16// `SimpleSpanProcessor` so spans export immediately; there's no16// `SimpleSpanProcessor` so spans export immediately; there's no
17// `ctx.waitUntil` to rely on for batching.17// `ctx.waitUntil` to rely on for batching. Gated inside a
18// `Layer.unwrapEffect` so the `AXIOM_TOKEN` check happens at
19// layer-provide time — `server` is now a lazy Proxy, so this is just
20// defensive against the token genuinely being unset (dev / missing
21// secret).
18// ---------------------------------------------------------------------------22// ---------------------------------------------------------------------------
19 23
20// Subpath imports — the barrel `@effect/opentelemetry` re-exports `NodeSdk`,24// Subpath imports — the barrel `@effect/opentelemetry` re-exports `NodeSdk`,
@@ -25,9 +29,15 @@ · 4 unmodified lines
25import * as Resource from "@effect/opentelemetry/Resource";29import * as Resource from "@effect/opentelemetry/Resource";
26import * as OtelTracer from "@effect/opentelemetry/Tracer";30import * as OtelTracer from "@effect/opentelemetry/Tracer";
27import * as WebSdk from "@effect/opentelemetry/WebSdk";31import * as WebSdk from "@effect/opentelemetry/WebSdk";
28import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";32// Force the browser platform entry — the package's conditional export would
33// otherwise resolve to the Node build, which uses `https.request` / `node:http`.
34// Under workerd + unenv's nodejs_compat, `https.request` isn't implemented
35// (surfaces as `[unenv] https.request is not implemented yet!` at export
36// time) and every DO span fails to ship. The browser build uses `fetch()`,
37// which workerd does support.
38import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http/build/esm/platform/browser/index.js";
29import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";39import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
30import { Layer } from "effect";40import { Effect, Layer } from "effect";
31 41
32import { server } from "../env";42import { server } from "../env";
33 43
@@ -35,18 +45,19 @@ export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe( · 1 unmodified line
35 Layer.provide(Resource.layer({ serviceName: "executor-cloud", serviceVersion: "1.0.0" })),45 Layer.provide(Resource.layer({ serviceName: "executor-cloud", serviceVersion: "1.0.0" })),
36);46);
37 47
38const makeDoOtelExporter = () =>48export const DoTelemetryLive: Layer.Layer<never> = Layer.unwrapEffect(
39 new OTLPTraceExporter({49 Effect.sync(() => {
40 url: server.AXIOM_TRACES_URL,50 if (!server.AXIOM_TOKEN) return Layer.empty;
41 headers: {51 const exporter = new OTLPTraceExporter({
42 Authorization: `Bearer ${server.AXIOM_TOKEN}`,52 url: server.AXIOM_TRACES_URL,
43 "X-Axiom-Dataset": server.AXIOM_DATASET,53 headers: {
44 },54 Authorization: `Bearer ${server.AXIOM_TOKEN}`,
45 });55 "X-Axiom-Dataset": server.AXIOM_DATASET,
46 56 },
47export const DoTelemetryLive: Layer.Layer<never> = server.AXIOM_TOKEN57 });
48 ? WebSdk.layer(() => ({58 return WebSdk.layer(() => ({
49 resource: { serviceName: "executor-cloud", serviceVersion: "1.0.0" },59 resource: { serviceName: "executor-cloud", serviceVersion: "1.0.0" },
50 spanProcessor: new SimpleSpanProcessor(makeDoOtelExporter()),60 spanProcessor: new SimpleSpanProcessor(exporter),
51 }))61 }));
52 : Layer.empty;62 }),
63);