Unified · Split
| @@ -1,3 +1,4 @@ | |||
| 1 | import * as Sentry from "@sentry/cloudflare"; | ||
| 1 | import { Data } from "effect"; | 2 | import { Data } from "effect"; |
| 2 | import { HttpServerResponse } from "@effect/platform"; | 3 | import { HttpServerResponse } from "@effect/platform"; |
| 3 | 4 | ||
| @@ -20,12 +21,16 @@ export const isServerError = (error: unknown): boolean => toHttpResponseError(er · 16 unmodified lines | |||
| 20 | 21 | ||
| 21 | export const toErrorResponse = (error: unknown): Response => { | 22 | export const toErrorResponse = (error: unknown): Response => { |
| 22 | const mapped = toHttpResponseError(error); | 23 | const mapped = toHttpResponseError(error); |
| 24 | if (mapped.status >= 500) Sentry.captureException(error); | ||
| 23 | return Response.json({ error: mapped.message, code: mapped.code }, { status: mapped.status }); | 25 | return Response.json({ error: mapped.message, code: mapped.code }, { status: mapped.status }); |
| 24 | }; | 26 | }; |
| 25 | 27 | ||
| 26 | export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => { | 28 | export const toErrorServerResponse = (error: unknown): HttpServerResponse.HttpServerResponse => { |
| 27 | console.error("[api] toErrorServerResponse error:", error instanceof Error ? error.stack : error); | ||
| 28 | const mapped = toHttpResponseError(error); | 29 | const mapped = toHttpResponseError(error); |
| 30 | if (mapped.status >= 500) { | ||
| 31 | console.error("[api] toErrorServerResponse error:", error instanceof Error ? error.stack : error); | ||
| 32 | Sentry.captureException(error); | ||
| 33 | } | ||
| 29 | return HttpServerResponse.unsafeJson( | 34 | return HttpServerResponse.unsafeJson( |
| 30 | { error: mapped.message, code: mapped.code }, | 35 | { error: mapped.message, code: mapped.code }, |
| 31 | { status: mapped.status }, | 36 | { status: mapped.status }, |
| @@ -30,11 +30,21 @@ const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) · 29 unmodified lines | |||
| 30 | const DbLive = DbService.Live; | 30 | const DbLive = DbService.Live; |
| 31 | const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); | 31 | const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); |
| 32 | 32 | ||
| 33 | /** | ||
| 34 | * Services that are independent of how the DB or tracer is provisioned — | ||
| 35 | * both the stateless HTTP path (per-request DB via Hyperdrive) and the MCP | ||
| 36 | * session DO (long-lived DB + isolate-local tracer SDK) merge this with | ||
| 37 | * their own `DbLive` + `UserStoreLive` + telemetry layer. | ||
| 38 | */ | ||
| 39 | export const CoreSharedServices = Layer.mergeAll( | ||
| 40 | WorkOSAuth.Default, | ||
| 41 | AutumnService.Default, | ||
| 42 | ); | ||
| 43 | |||
| 33 | export const SharedServices = Layer.mergeAll( | 44 | export const SharedServices = Layer.mergeAll( |
| 34 | DbLive, | 45 | DbLive, |
| 35 | UserStoreLive, | 46 | UserStoreLive, |
| 36 | WorkOSAuth.Default, | 47 | CoreSharedServices, |
| 37 | AutumnService.Default, | ||
| 38 | HttpServer.layerContext, | 48 | HttpServer.layerContext, |
| 39 | TelemetryLive, | 49 | TelemetryLive, |
| 40 | ); | 50 | ); |
| @@ -1,21 +1,15 @@ | |||
| 1 | import { env } from "cloudflare:workers"; | ||
| 2 | import { HttpApiBuilder, HttpApiSwagger, HttpServerRequest } from "@effect/platform"; | 1 | import { HttpApiBuilder, HttpApiSwagger, HttpServerRequest } from "@effect/platform"; |
| 3 | import { Effect, Layer } from "effect"; | 2 | import { Effect, Layer } from "effect"; |
| 4 | 3 | ||
| 5 | import { ExecutorService, ExecutionEngineService } from "@executor/api/server"; | 4 | import { ExecutorService, ExecutionEngineService } from "@executor/api/server"; |
| 6 | import { createExecutionEngine } from "@executor/execution"; | ||
| 7 | import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker"; | ||
| 8 | import { OpenApiExtensionService } from "@executor/plugin-openapi/api"; | 5 | import { OpenApiExtensionService } from "@executor/plugin-openapi/api"; |
| 9 | import { McpExtensionService } from "@executor/plugin-mcp/api"; | 6 | import { McpExtensionService } from "@executor/plugin-mcp/api"; |
| 10 | import { GraphqlExtensionService } from "@executor/plugin-graphql/api"; | 7 | import { GraphqlExtensionService } from "@executor/plugin-graphql/api"; |
| 11 | 8 | ||
| 12 | import { authorizeOrganization } from "../auth/authorize-organization"; | 9 | import { authorizeOrganization } from "../auth/authorize-organization"; |
| 13 | import { WorkOSAuth } from "../auth/workos"; | 10 | import { WorkOSAuth } from "../auth/workos"; |
| 14 | import { AutumnService } from "../services/autumn"; | 11 | import { makeExecutionStack } from "../services/execution-stack"; |
| 15 | import { createScopedExecutor } from "../services/executor"; | ||
| 16 | import { makeTrackExecutionUsage } from "./autumn"; | ||
| 17 | import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; | 12 | import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; |
| 18 | import { withExecutionUsageTracking } from "./execution-usage"; | ||
| 19 | import { ProtectedCloudApiLive, RouterConfig, SharedServices } from "./layers"; | 13 | import { ProtectedCloudApiLive, RouterConfig, SharedServices } from "./layers"; |
| 20 | 14 | ||
| 21 | const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => | 15 | const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => |
| @@ -38,14 +32,7 @@ const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => · 16 unmodified lines | |||
| 38 | 32 | ||
| 39 | const createProtectedApp = (organizationId: string, organizationName: string) => | 33 | const createProtectedApp = (organizationId: string, organizationName: string) => |
| 40 | Effect.gen(function* () { | 34 | Effect.gen(function* () { |
| 41 | const executor = yield* createScopedExecutor(organizationId, organizationName); | 35 | const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName); |
| 42 | const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER }); | ||
| 43 | const autumn = yield* AutumnService; | ||
| 44 | const engine = withExecutionUsageTracking( | ||
| 45 | organizationId, | ||
| 46 | createExecutionEngine({ executor, codeExecutor }), | ||
| 47 | makeTrackExecutionUsage(autumn), | ||
| 48 | ); | ||
| 49 | 36 | ||
| 50 | const requestServices = Layer.mergeAll( | 37 | const requestServices = Layer.mergeAll( |
| 51 | Layer.succeed(ExecutorService, executor), | 38 | Layer.succeed(ExecutorService, executor), |
| @@ -10,19 +10,15 @@ import { drizzle } from "drizzle-orm/postgres-js"; · 9 unmodified lines | |||
| 10 | import postgres from "postgres"; | 10 | import postgres from "postgres"; |
| 11 | 11 | ||
| 12 | import { createExecutorMcpServer } from "@executor/host-mcp"; | 12 | import { createExecutorMcpServer } from "@executor/host-mcp"; |
| 13 | import { createExecutionEngine } from "@executor/execution"; | ||
| 14 | import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker"; | ||
| 15 | import type { DrizzleDb, DbServiceShape } from "./services/db"; | 13 | import type { DrizzleDb, DbServiceShape } from "./services/db"; |
| 16 | 14 | ||
| 17 | import { makeTrackExecutionUsage } from "./api/autumn"; | 15 | import { CoreSharedServices } from "./api/layers"; |
| 18 | import { withExecutionUsageTracking } from "./api/execution-usage"; | ||
| 19 | import { UserStoreService } from "./auth/context"; | 16 | import { UserStoreService } from "./auth/context"; |
| 20 | import { resolveOrganization } from "./auth/resolve-organization"; | 17 | import { resolveOrganization } from "./auth/resolve-organization"; |
| 21 | import { WorkOSAuth } from "./auth/workos"; | ||
| 22 | import { server } from "./env"; | 18 | import { server } from "./env"; |
| 23 | import { AutumnService } from "./services/autumn"; | ||
| 24 | import { createScopedExecutor } from "./services/executor"; | ||
| 25 | import { DbService, combinedSchema } from "./services/db"; | 19 | import { DbService, combinedSchema } from "./services/db"; |
| 20 | import { makeExecutionStack } from "./services/execution-stack"; | ||
| 21 | import { DoTelemetryLive } from "./services/telemetry"; | ||
| 26 | 22 | ||
| 27 | // --------------------------------------------------------------------------- | 23 | // --------------------------------------------------------------------------- |
| 28 | // Types | 24 | // Types |
| @@ -105,47 +101,46 @@ export class McpSessionDO extends DurableObject { · 76 unmodified lines | |||
| 105 | async init(token: McpSessionInit): Promise<void> { | 101 | async init(token: McpSessionInit): Promise<void> { |
| 106 | if (this.initialized) return; | 102 | if (this.initialized) return; |
| 107 | 103 | ||
| 108 | // Create a long-lived DB connection for the DO's lifetime | ||
| 109 | this.dbHandle = makeLongLivedDb(); | 104 | this.dbHandle = makeLongLivedDb(); |
| 110 | 105 | try { | |
| 111 | const { sql, db } = this.dbHandle; | 106 | const { sql, db } = this.dbHandle; |
| 112 | const DbLive = Layer.succeed(DbService, { sql, db }); | 107 | const DbLive = Layer.succeed(DbService, { sql, db }); |
| 113 | const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); | 108 | const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); |
| 114 | const Services = Layer.mergeAll( | 109 | const Services = Layer.mergeAll( |
| 115 | DbLive, | 110 | DbLive, |
| 116 | UserStoreLive, | 111 | UserStoreLive, |
| 117 | WorkOSAuth.Default, | 112 | CoreSharedServices, |
| 118 | AutumnService.Default, | 113 | DoTelemetryLive, |
| 119 | ); | ||
| 120 | |||
| 121 | const program = Effect.gen(function* () { | ||
| 122 | const org = yield* resolveOrganization(token.organizationId); | ||
| 123 | if (!org) | ||
| 124 | return yield* new OrganizationNotFoundError({ organizationId: token.organizationId }); | ||
| 125 | |||
| 126 | const executor = yield* createScopedExecutor(org.id, org.name); | ||
| 127 | const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER }); | ||
| 128 | const autumn = yield* AutumnService; | ||
| 129 | const engine = withExecutionUsageTracking( | ||
| 130 | org.id, | ||
| 131 | createExecutionEngine({ executor, codeExecutor }), | ||
| 132 | makeTrackExecutionUsage(autumn), | ||
| 133 | ); | 114 | ); |
| 134 | return yield* Effect.promise(() => createExecutorMcpServer({ engine })); | ||
| 135 | }).pipe(Effect.provide(Services)); | ||
| 136 | 115 | ||
| 137 | this.mcpServer = await Effect.runPromise(program); | 116 | const program = Effect.gen(function* () { |
| 117 | const org = yield* resolveOrganization(token.organizationId); | ||
| 118 | if (!org) | ||
| 119 | return yield* new OrganizationNotFoundError({ organizationId: token.organizationId }); | ||
| 138 | 120 | ||
| 139 | this.transport = new WorkerTransport({ | 121 | const { engine } = yield* makeExecutionStack(org.id, org.name); |
| 140 | sessionIdGenerator: () => this.ctx.id.toString(), | 122 | return yield* Effect.promise(() => createExecutorMcpServer({ engine })); |
| 141 | storage: this.makeStorage(), | 123 | }).pipe(Effect.withSpan("McpSessionDO.init"), Effect.provide(Services)); |
| 142 | }); | ||
| 143 | 124 | ||
| 144 | await this.mcpServer.connect(this.transport); | 125 | this.mcpServer = await Effect.runPromise(program); |
| 145 | this.initialized = true; | ||
| 146 | this.lastActivityMs = Date.now(); | ||
| 147 | 126 | ||
| 148 | await this.ctx.storage.setAlarm(Date.now() + HEARTBEAT_MS); | 127 | this.transport = new WorkerTransport({ |
| 128 | sessionIdGenerator: () => this.ctx.id.toString(), | ||
| 129 | storage: this.makeStorage(), | ||
| 130 | }); | ||
| 131 | |||
| 132 | await this.mcpServer.connect(this.transport); | ||
| 133 | this.initialized = true; | ||
| 134 | this.lastActivityMs = Date.now(); | ||
| 135 | |||
| 136 | await this.ctx.storage.setAlarm(Date.now() + HEARTBEAT_MS); | ||
| 137 | } catch (err) { | ||
| 138 | // Partial init leaves dangling resources (DB socket, maybe mcpServer). | ||
| 139 | // Clean up before rethrowing so the DO isn't stuck in a half-built state. | ||
| 140 | console.error("[mcp-session] init failed:", err instanceof Error ? err.stack : err); | ||
| 141 | await this.cleanup(); | ||
| 142 | throw err; | ||
| 143 | } | ||
| 149 | } | 144 | } |
| 150 | 145 | ||
| 151 | async handleRequest(request: Request): Promise<Response> { | 146 | async handleRequest(request: Request): Promise<Response> { |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | |||
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | ||
| 5 | import { env } from "cloudflare:workers"; | 5 | import { env } from "cloudflare:workers"; |
| 6 | import * as Sentry from "@sentry/cloudflare"; | ||
| 6 | import { createRemoteJWKSet, jwtVerify } from "jose"; | 7 | import { createRemoteJWKSet, jwtVerify } from "jose"; |
| 7 | 8 | ||
| 8 | import { server } from "./env"; | 9 | import { server } from "./env"; |
| @@ -130,6 +131,7 @@ const handleMcpRequest_POST = async (request: Request, token: VerifiedToken): Pr · 121 unmodified lines | |||
| 130 | return await stub.handleRequest(request); | 131 | return await stub.handleRequest(request); |
| 131 | } catch (err) { | 132 | } catch (err) { |
| 132 | console.error("[mcp] POST handler error:", err instanceof Error ? err.stack : err); | 133 | console.error("[mcp] POST handler error:", err instanceof Error ? err.stack : err); |
| 134 | Sentry.captureException(err); | ||
| 133 | return jsonRpcError(500, -32603, "Internal server error"); | 135 | return jsonRpcError(500, -32603, "Internal server error"); |
| 134 | } | 136 | } |
| 135 | }; | 137 | }; |
| @@ -1,45 +1,68 @@ | |||
| 1 | import * as Sentry from "@sentry/cloudflare"; | 1 | import * as Sentry from "@sentry/cloudflare"; |
| 2 | import handler from "@tanstack/react-start/server-entry"; | 2 | import handler from "@tanstack/react-start/server-entry"; |
| 3 | import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers"; | 3 | import { instrument, type TraceConfig } from "@microlabs/otel-cf-workers"; |
| 4 | 4 | ||
| 5 | // Export Durable Objects as named exports | 5 | import { McpSessionDO as McpSessionDOBase } from "./mcp-session"; |
| 6 | export { McpSessionDO } from "./mcp-session"; | 6 | import { server } from "./env"; |
| 7 | 7 | ||
| 8 | // --------------------------------------------------------------------------- | 8 | // --------------------------------------------------------------------------- |
| 9 | // OTEL config — `otel-cf-workers` owns the global TracerProvider and flushes | 9 | // OTEL config for the main fetch handler — `otel-cf-workers` owns the global |
| 10 | // via `ctx.waitUntil` at the end of each request. `TelemetryLive` in | 10 | // TracerProvider and flushes via `ctx.waitUntil` at the end of each request. |
| 11 | // `services/telemetry.ts` plugs Effect's tracer into that same provider. | 11 | // The DO runs in a separate isolate and uses its own self-contained WebSdk |
| 12 | // (see `services/telemetry.ts#DoTelemetryLive`); `instrumentDO` from | ||
| 13 | // otel-cf-workers is NOT used because it breaks `this` binding on | ||
| 14 | // `WorkerTransport`'s stream primitives and crashes every MCP request with | ||
| 15 | // DOMException "Illegal invocation". | ||
| 12 | // --------------------------------------------------------------------------- | 16 | // --------------------------------------------------------------------------- |
| 13 | 17 | ||
| 14 | type OtelEnv = { | 18 | const otelConfig: TraceConfig = { |
| 15 | AXIOM_TOKEN?: string; | ||
| 16 | AXIOM_DATASET?: string; | ||
| 17 | }; | ||
| 18 | |||
| 19 | const resolveOtelConfig: ResolveConfigFn<OtelEnv> = (env) => ({ | ||
| 20 | service: { name: "executor-cloud", version: "1.0.0" }, | 19 | service: { name: "executor-cloud", version: "1.0.0" }, |
| 21 | exporter: { | 20 | exporter: { |
| 22 | url: "https://api.axiom.co/v1/traces", | 21 | url: "https://api.axiom.co/v1/traces", |
| 23 | headers: { | 22 | headers: { |
| 24 | Authorization: `Bearer ${env.AXIOM_TOKEN ?? ""}`, | 23 | Authorization: `Bearer ${server.AXIOM_TOKEN}`, |
| 25 | "X-Axiom-Dataset": env.AXIOM_DATASET ?? "executor-cloud", | 24 | "X-Axiom-Dataset": server.AXIOM_DATASET, |
| 26 | }, | 25 | }, |
| 27 | }, | 26 | }, |
| 27 | }; | ||
| 28 | |||
| 29 | // otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat shim | ||
| 30 | // registers a ProxyTracerProvider of its own, which prevents otel-cf-workers | ||
| 31 | // from finding its WorkerTracer and breaks the whole request path with | ||
| 32 | // "global tracer is not of type WorkerTracer". | ||
| 33 | // | ||
| 34 | // The `_env` parameter is unused — `server` from `./env` already gives us | ||
| 35 | // typed access to every secret. It's only in the signature so Sentry's | ||
| 36 | // generics infer the DO's `Env` type correctly. | ||
| 37 | const sentryOptions = (_env: Env) => ({ | ||
| 38 | dsn: server.SENTRY_DSN, | ||
| 39 | tracesSampleRate: 0, | ||
| 40 | enableLogs: true, | ||
| 41 | sendDefaultPii: true, | ||
| 42 | skipOpenTelemetrySetup: true, | ||
| 43 | // Our DO methods (init/handleRequest/alarm) live on the prototype, not on | ||
| 44 | // the instance. Sentry's default DO auto-wrap only visits own properties, | ||
| 45 | // which misses prototype methods — so errors thrown inside init() never | ||
| 46 | // reach Sentry. This flag opts into prototype-method instrumentation. | ||
| 47 | instrumentPrototypeMethods: true, | ||
| 28 | }); | 48 | }); |
| 29 | 49 | ||
| 30 | const instrumentedHandler = instrument({ fetch: handler.fetch }, resolveOtelConfig); | 50 | // --------------------------------------------------------------------------- |
| 31 | 51 | // Durable Object — wrapped with Sentry so DO errors land in Sentry (inits the | |
| 32 | export default Sentry.withSentry( | 52 | // client inside the DO isolate, which plain `Sentry.captureException` cannot |
| 33 | (env: Record<string, string>) => ({ | 53 | // do on its own). We deliberately do NOT wrap with otel-cf-workers' |
| 34 | dsn: env.SENTRY_DSN, | 54 | // `instrumentDO` (see note above). |
| 35 | tracesSampleRate: 0, | 55 | // --------------------------------------------------------------------------- |
| 36 | enableLogs: true, | 56 | |
| 37 | sendDefaultPii: true, | 57 | export const McpSessionDO = Sentry.instrumentDurableObjectWithSentry( |
| 38 | // otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat | 58 | sentryOptions, |
| 39 | // shim registers a ProxyTracerProvider of its own, which prevents | 59 | McpSessionDOBase, |
| 40 | // otel-cf-workers from finding its WorkerTracer and breaks the whole | ||
| 41 | // request path with "global tracer is not of type WorkerTracer". | ||
| 42 | skipOpenTelemetrySetup: true, | ||
| 43 | }), | ||
| 44 | instrumentedHandler, | ||
| 45 | ); | 60 | ); |
| 61 | |||
| 62 | // --------------------------------------------------------------------------- | ||
| 63 | // Worker fetch handler | ||
| 64 | // --------------------------------------------------------------------------- | ||
| 65 | |||
| 66 | const instrumentedHandler = instrument({ fetch: handler.fetch }, otelConfig); | ||
| 67 | |||
| 68 | export default Sentry.withSentry(sentryOptions, instrumentedHandler); | ||
| @@ -0,0 +1,29 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Shared execution stack — the wiring that turns an organization into a | ||
| 3 | // runnable executor + engine. Used by the protected HTTP API (per-request) | ||
| 4 | // and the MCP session DO (per-session) so changes to the stack flow to both. | ||
| 5 | // --------------------------------------------------------------------------- | ||
| 6 | |||
| 7 | import { env } from "cloudflare:workers"; | ||
| 8 | import { Effect } from "effect"; | ||
| 9 | |||
| 10 | import { createExecutionEngine } from "@executor/execution"; | ||
| 11 | import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker"; | ||
| 12 | |||
| 13 | import { makeTrackExecutionUsage } from "../api/autumn"; | ||
| 14 | import { withExecutionUsageTracking } from "../api/execution-usage"; | ||
| 15 | import { AutumnService } from "./autumn"; | ||
| 16 | import { createScopedExecutor } from "./executor"; | ||
| 17 | |||
| 18 | export const makeExecutionStack = (organizationId: string, organizationName: string) => | ||
| 19 | Effect.gen(function* () { | ||
| 20 | const executor = yield* createScopedExecutor(organizationId, organizationName); | ||
| 21 | const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER }); | ||
| 22 | const autumn = yield* AutumnService; | ||
| 23 | const engine = withExecutionUsageTracking( | ||
| 24 | organizationId, | ||
| 25 | createExecutionEngine({ executor, codeExecutor }), | ||
| 26 | makeTrackExecutionUsage(autumn), | ||
| 27 | ); | ||
| 28 | return { executor, engine }; | ||
| 29 | }); | ||
| @@ -1,15 +1,45 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Effect → global OTEL bridge | 2 | // Effect → OTEL → Axiom bridge |
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | // The global TracerProvider is owned by `@microlabs/otel-cf-workers` (see | 4 | // |
| 5 | // `server.ts`). This layer plugs Effect's tracer into that provider so every | 5 | // Two callers, two setups: |
| 6 | // `Effect.withSpan(...)` becomes a real OTLP span exported to Axiom, with | 6 | // |
| 7 | // flushing handled reliably by the instrument() wrapper via ctx.waitUntil. | 7 | // - `TelemetryLive` (fetch path): reads the global `TracerProvider` that |
| 8 | // `@microlabs/otel-cf-workers`' `instrument(...)` installs in `server.ts`. | ||
| 9 | // Flushing is handled by `instrument()` via `ctx.waitUntil` at request end. | ||
| 10 | // | ||
| 11 | // - `DoTelemetryLive` (Durable Object path): provisions its own | ||
| 12 | // `WebSdk`-backed tracer via `Effect`. The DO runs in a separate isolate | ||
| 13 | // and we deliberately avoid `instrumentDO` (it wraps DO methods in a way | ||
| 14 | // that breaks `this` binding on `WorkerTransport`'s stream primitives — | ||
| 15 | // every MCP request 500s with "Illegal invocation"). The DO uses a | ||
| 16 | // `SimpleSpanProcessor` so spans export immediately; there's no | ||
| 17 | // `ctx.waitUntil` to rely on for batching. | ||
| 8 | // --------------------------------------------------------------------------- | 18 | // --------------------------------------------------------------------------- |
| 9 | 19 | ||
| 10 | import { Resource, Tracer as OtelTracer } from "@effect/opentelemetry"; | 20 | import { Resource, Tracer as OtelTracer, WebSdk } from "@effect/opentelemetry"; |
| 21 | import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; | ||
| 22 | import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"; | ||
| 11 | import { Layer } from "effect"; | 23 | import { Layer } from "effect"; |
| 12 | 24 | ||
| 25 | import { server } from "../env"; | ||
| 26 | |||
| 13 | export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe( | 27 | export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe( |
| 14 | Layer.provide(Resource.layer({ serviceName: "executor-cloud", serviceVersion: "1.0.0" })), | 28 | Layer.provide(Resource.layer({ serviceName: "executor-cloud", serviceVersion: "1.0.0" })), |
| 15 | ); | 29 | ); |
| 30 | |||
| 31 | const makeDoOtelExporter = () => | ||
| 32 | new OTLPTraceExporter({ | ||
| 33 | url: "https://api.axiom.co/v1/traces", | ||
| 34 | headers: { | ||
| 35 | Authorization: `Bearer ${server.AXIOM_TOKEN}`, | ||
| 36 | "X-Axiom-Dataset": server.AXIOM_DATASET, | ||
| 37 | }, | ||
| 38 | }); | ||
| 39 | |||
| 40 | export const DoTelemetryLive: Layer.Layer<never> = server.AXIOM_TOKEN | ||
| 41 | ? WebSdk.layer(() => ({ | ||
| 42 | resource: { serviceName: "executor-cloud", serviceVersion: "1.0.0" }, | ||
| 43 | spanProcessor: new SimpleSpanProcessor(makeDoOtelExporter()), | ||
| 44 | })) | ||
| 45 | : Layer.empty; | ||