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 @@
1−import { 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+// ---------------------------------------------------------------------------
217
3−const sharedShape = {
4− NODE_ENV: Env.literalOr("NODE_ENV", "development", "development", "test", "production"),
5−};
6−
7−const 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−};
18+import { env } from "cloudflare:workers";
2719
2820 type SharedEnv = Readonly<{
2921 NODE_ENV: "development" | "test" | "production";
@@ -48,22 +40,50 @@ type ServerEnv = SharedEnv & · 18 unmodified lines
4840
4941 type WebEnv = Readonly<Record<string, never>>;
5042
51−export const shared = createEnv(sharedShape, {
52− runtimeEnv: process.env,
53− emptyStringAsUndefined: true,
54−}) as SharedEnv;
43+const SERVER_DEFAULTS: Record<keyof ServerEnv, string> = {
44+ NODE_ENV: "development",
45+ DATABASE_URL: "",
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+
60+const SHARED_DEFAULTS: Record<keyof SharedEnv, string> = {
61+ NODE_ENV: "development",
62+};
63+
64+const 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+
70+const 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+
76+const read = (key: string): string | undefined =>
77+ readFromWorkerEnv(key) ?? readFromProcessEnv(key);
5578
56−export const web = createEnv(
57− {},
58− {
59− prefix: "PUBLIC_",
60− runtimeEnv: process.env,
61− emptyStringAsUndefined: true,
62− },
63−) as WebEnv;
79+const makeEnvProxy = <T extends object>(defaults: Record<string, string>): T =>
80+ new Proxy({} as T, {
81+ get: (_target, key) => {
82+ if (typeof key !== "string") return undefined;
83+ return read(key) ?? defaults[key] ?? "";
84+ },
85+ });
6486
65−export const server = createEnv(serverShape, {
66− extends: [shared],
67− runtimeEnv: process.env,
68− emptyStringAsUndefined: true,
69−}) as ServerEnv;
87+export const shared: SharedEnv = makeEnvProxy<SharedEnv>(SHARED_DEFAULTS);
88+export const server: ServerEnv = makeEnvProxy<ServerEnv>(SERVER_DEFAULTS);
89+export const web: WebEnv = {} as WebEnv;
apps/cloud/src/services/telemetry.ts+28−17
@@ -14,7 +14,11 @@ · 13 unmodified lines
1414 // that breaks `this` binding on `WorkerTransport`'s stream primitives —
1515 // every MCP request 500s with "Illegal invocation"). The DO uses a
1616 // `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).
1822 // ---------------------------------------------------------------------------
1923
2024 // Subpath imports — the barrel `@effect/opentelemetry` re-exports `NodeSdk`,
@@ -25,9 +29,15 @@ · 4 unmodified lines
2529 import * as Resource from "@effect/opentelemetry/Resource";
2630 import * as OtelTracer from "@effect/opentelemetry/Tracer";
2731 import * as WebSdk from "@effect/opentelemetry/WebSdk";
28−import { 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.
38+import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http/build/esm/platform/browser/index.js";
2939 import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
30−import { Layer } from "effect";
40+import { Effect, Layer } from "effect";
3141
3242 import { server } from "../env";
3343
@@ -35,18 +45,19 @@ export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe( · 1 unmodified line
3545 Layer.provide(Resource.layer({ serviceName: "executor-cloud", serviceVersion: "1.0.0" })),
3646 );
3747
38−const makeDoOtelExporter = () =>
39− new OTLPTraceExporter({
40− url: server.AXIOM_TRACES_URL,
41− headers: {
42− Authorization: `Bearer ${server.AXIOM_TOKEN}`,
43− "X-Axiom-Dataset": server.AXIOM_DATASET,
44− },
45− });
46−
47−export const DoTelemetryLive: Layer.Layer<never> = server.AXIOM_TOKEN
48− ? WebSdk.layer(() => ({
48+export const DoTelemetryLive: Layer.Layer<never> = Layer.unwrapEffect(
49+ Effect.sync(() => {
50+ if (!server.AXIOM_TOKEN) return Layer.empty;
51+ const exporter = new OTLPTraceExporter({
52+ url: server.AXIOM_TRACES_URL,
53+ headers: {
54+ Authorization: `Bearer ${server.AXIOM_TOKEN}`,
55+ "X-Axiom-Dataset": server.AXIOM_DATASET,
56+ },
57+ });
58+ return WebSdk.layer(() => ({
4959 resource: { serviceName: "executor-cloud", serviceVersion: "1.0.0" },
50− spanProcessor: new SimpleSpanProcessor(makeDoOtelExporter()),
51− }))
52− : Layer.empty;
60+ spanProcessor: new SimpleSpanProcessor(exporter),
61+ }));
62+ }),
63+);