diff --git a/.github/workflows/deploy-prd.yml b/.github/workflows/deploy-prd.yml index 34dec8611..f155d7486 100644 --- a/.github/workflows/deploy-prd.yml +++ b/.github/workflows/deploy-prd.yml @@ -70,8 +70,8 @@ jobs: KLIPY_API_KEY: ${{ secrets.KLIPY_API_KEY }} # electric-proxy + self-hosted Electric (DATABASE_URL comes from the stack's replication role) ELECTRIC_SECRET: ${{ secrets.ELECTRIC_SECRET }} - OTEL_BASE_URL: ${{ vars.OTEL_BASE_URL }} - MAPLE_INGEST_KEY: ${{ secrets.MAPLE_INGEST_KEY }} + # Maple: an org-admin API key with `ingest_keys:read`; the deploy binds the ingest key onto the Workers + MAPLE_API_KEY: ${{ secrets.MAPLE_API_KEY }} steps: - name: Checkout uses: actions/checkout@v4 diff --git a/alchemy.run.ts b/alchemy.run.ts index 913057e9b..19cbbf1a6 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -2,6 +2,7 @@ // builds the deploy context (`HazelStack`) and yields them in dependency order. // Plan: infra/cloudflare-migration-plan.md import { appendFileSync } from "node:fs" +import * as Maple from "@maple-dev/alchemy" import * as Alchemy from "alchemy" import * as Cloudflare from "alchemy/Cloudflare" import * as Planetscale from "alchemy/Planetscale" @@ -18,6 +19,7 @@ import { resolveHazelDomains, } from "@hazel/infra/cloudflare" import { plainWithDefault } from "@hazel/infra/env" +import { isMapleDeploy } from "@hazel/infra/maple" import Actors from "./apps/actors/alchemy.run.ts" import ApiLive, { Api } from "./apps/backend/src/worker.ts" import BotGateway from "./apps/bot-gateway/alchemy.run.ts" @@ -80,9 +82,13 @@ export default Alchemy.Stack( "hazel", { // PlanetScale's credential lookup runs when the layer is built; `alchemy dev` never needs it. - providers: isDevServer - ? Cloudflare.providers() - : Cloudflare.providers().pipe(Layer.provideMerge(Planetscale.providers())), + // Maple (Worker telemetry's ingest key) only with `MAPLE_API_KEY`: see `@hazel/infra/maple`. + providers: Layer.mergeAll( + isDevServer + ? Cloudflare.providers() + : Cloudflare.providers().pipe(Layer.provideMerge(Planetscale.providers())), + isMapleDeploy() ? Maple.providers() : Layer.empty, + ), // ALCHEMY_LOCAL_STATE=1 uses .alchemy/ file state instead of the account-wide store. state: process.env.ALCHEMY_LOCAL_STATE ? Alchemy.localState() : Cloudflare.state(), }, diff --git a/apps/backend/src/worker.ts b/apps/backend/src/worker.ts index 68925e239..7933a28a1 100644 --- a/apps/backend/src/worker.ts +++ b/apps/backend/src/worker.ts @@ -6,6 +6,7 @@ import { bindBotGateways } from "@hazel/bot-gateway/object" import { HazelStack, hazelWorkerProps, stageProps } from "@hazel/infra/cloudflare" import { cachedRecoverable } from "@hazel/infra/cached-recoverable" +import { hazelTelemetry } from "@hazel/infra/maple" import { isolateContext } from "@hazel/infra/worker-http" import * as Cloudflare from "alchemy/Cloudflare" import { Effect, Layer } from "effect" @@ -43,7 +44,8 @@ const props = Effect.gen(function* () { observability: { enabled: true, logs: { enabled: true, invocationLogs: true, destinations: ["maple-logs"] }, - traces: { enabled: true, destinations: ["maple-traces"] }, + // Traces reach Maple through the SDK (`hazelTelemetry`), not the Cloudflare destination. + traces: { enabled: true }, }, env: { HAZEL_DB: stack.db.hyperdrive, @@ -96,6 +98,7 @@ export default Api.make( OutboxDispatcherObjectLive, DiscordGatewayObjectLive, Cloudflare.Workers.CronEventSourceLive, + hazelTelemetry("api"), ), ), ), diff --git a/apps/bot-gateway/src/worker.ts b/apps/bot-gateway/src/worker.ts index 6c5fd0ef3..ae2b385fa 100644 --- a/apps/bot-gateway/src/worker.ts +++ b/apps/bot-gateway/src/worker.ts @@ -8,9 +8,10 @@ * bot's object (`gateway/relay.ts`). The wire protocol is the Bun gateway's, unchanged. */ import { HAZEL_DB_BINDING, HazelStack, hazelWorkerProps, readHazelDbBinding } from "@hazel/infra/cloudflare" -import { merge, optionalPlain } from "@hazel/infra/env" +import { merge, optionalPlain, telemetryEnv } from "@hazel/infra/env" +import { hazelTelemetry } from "@hazel/infra/maple" import * as Cloudflare from "alchemy/Cloudflare" -import { Effect, Option } from "effect" +import { Effect, Layer, Option } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/http" import * as HttpBody from "effect/http/HttpBody" import { authenticateBotToken } from "./gateway/auth.ts" @@ -33,7 +34,7 @@ const props = Effect.gen(function* () { if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } const stack = yield* HazelStack // The Bun gateway's tuning knobs; unset keeps its defaults (gateway/settings.ts). - const env = yield* merge(...GATEWAY_ENV_KEYS.map((key) => optionalPlain(key))) + const env = yield* merge(telemetryEnv(stack.stage), ...GATEWAY_ENV_KEYS.map((key) => optionalPlain(key))) return { main: import.meta.url, ...hazelWorkerProps(BOT_GATEWAY_APP, stack), @@ -50,7 +51,8 @@ const props = Effect.gen(function* () { invocationLogs: true, destinations: ["maple-logs"], }, - traces: { enabled: true, persist: true, headSamplingRate: 1, destinations: ["maple-traces"] }, + // Traces reach Maple through the SDK (`hazelTelemetry`), not the Cloudflare destination. + traces: { enabled: true, persist: true, headSamplingRate: 1 }, }, } }) @@ -115,5 +117,5 @@ export default BotGatewayWorker.make( return HttpServerResponse.text("Not found", { status: 404 }) }), } - }).pipe(Effect.provide(BotGatewayObjectLive)), + }).pipe(Effect.provide(Layer.mergeAll(BotGatewayObjectLive, hazelTelemetry(BOT_GATEWAY_APP)))), ) diff --git a/apps/electric-proxy/src/worker.ts b/apps/electric-proxy/src/worker.ts index 4f4174459..11856ece6 100644 --- a/apps/electric-proxy/src/worker.ts +++ b/apps/electric-proxy/src/worker.ts @@ -9,6 +9,7 @@ import { layerKvResultPersistence } from "@hazel/effect-cloudflare/KvPersistence import { cachedRecoverable } from "@hazel/infra/cached-recoverable" import { HAZEL_DB_BINDING, HazelStack, hazelWorkerProps, readHazelDbBinding } from "@hazel/infra/cloudflare" import { merge, requireSecretEntry, telemetryEnv } from "@hazel/infra/env" +import { hazelTelemetry } from "@hazel/infra/maple" import { forIsolate, isolateContext } from "@hazel/infra/worker-http" import { workerEnvLayer } from "@hazel/infra/worker-runtime" import type { KVNamespace } from "@cloudflare/workers-types" @@ -36,7 +37,11 @@ const props = Effect.gen(function* () { workersDev: stage.kind !== "prd", // Same hostname as the Railway deployment, so the web app's VITE_ELECTRIC_URL is unchanged. domain: domains.electric, - observability: mapleObservability, + // Traces reach Maple through the SDK (`hazelTelemetry`), not the Cloudflare destination. + observability: { + ...mapleObservability, + traces: { enabled: true, persist: true, headSamplingRate: 1 }, + }, env: { [HAZEL_DB_BINDING]: stack.db.hyperdrive, [PROXY_CACHE_BINDING]: yield* ProxyCache, @@ -158,5 +163,5 @@ export default class ElectricProxy extends Cloudflare.Worker()( return HttpServerResponse.raw(response, { status: response.status }) }).pipe(Effect.orDie), } - }), + }).pipe(Effect.provide(hazelTelemetry("electric-proxy"))), ) {} diff --git a/bun.lock b/bun.lock index 7539135be..7c6bf8377 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@cloudflare/workers-types": "catalog:alchemy", "@effect/vitest": "catalog:effect", "@hazel/infra": "workspace:*", + "@maple-dev/alchemy": "catalog:alchemy", "@rolldown/plugin-babel": "^0.2.1", "@types/node": "^24", "@vitest/coverage-v8": "^4.1.0", @@ -595,6 +596,7 @@ "name": "@hazel/infra", "version": "0.0.0", "dependencies": { + "@maple-dev/alchemy": "catalog:alchemy", "alchemy": "catalog:alchemy", "effect": "catalog:effect", }, @@ -697,6 +699,7 @@ "catalogs": { "alchemy": { "@cloudflare/workers-types": "4.20260603.1", + "@maple-dev/alchemy": "0.2.0", "alchemy": "2.0.0-beta.80", }, "effect": { @@ -1400,6 +1403,10 @@ "@linear/sdk": ["@linear/sdk@73.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0" } }, "sha512-+7bMcDsDafS+468dd0JE0a0tbiBJtL253wKdKUIjGqkG6zm6Mr+WW+cvjo2Bl7T8Lldldq2cIA2tUT9ZjzJDkQ=="], + "@maple-dev/alchemy": ["@maple-dev/alchemy@0.2.0", "", { "dependencies": { "@maple-dev/effect-sdk": "0.10.0" }, "peerDependencies": { "alchemy": ">=2.0.0-beta.80 <3", "effect": "^4.0.0" } }, "sha512-lGjw1u0eAfNBKzp+IfcZHOJyqn2N3ivT0uGvrj8alK5vJsYwP1lI3y9A7rgTIyrgIsEswodTyUFduaZa9YjGkw=="], + + "@maple-dev/effect-sdk": ["@maple-dev/effect-sdk@0.10.0", "", { "dependencies": { "std-env": "^4.0.0" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-3MWyBqWPyiizAZItDk7Uk+vi69csziIJxCcaqW10aowVV1Tlcwao1EW6xLccAzvfkz43Y8WAiLzhf/Kr4oRkdQ=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@metascraper/helpers": ["@metascraper/helpers@5.49.5", "", { "dependencies": { "audio-extensions": "0.0.0", "chrono-node": "2.9.0", "condense-whitespace": "~2.0.0", "data-uri-utils": "~1.0.9", "debug-logfmt": "~1.4.6", "entities": "~6.0.1", "file-extension": "~4.0.5", "has-values": "~2.0.1", "image-extensions": "~1.1.0", "is-relative-url": "~3.0.0", "is-uri": "~1.2.11", "iso-639-3": "~2.2.0", "isostring": "0.0.1", "jsdom": "~27.0.0", "lodash": "~4.17.21", "memoize-one": "~6.0.0", "microsoft-capitalize": "~1.0.5", "mime": "3", "normalize-url": "~6.1.0", "re2": "~1.22.1", "smartquotes": "~2.3.2", "tldts": "~7.0.17", "url-regex-safe": "~4.0.0", "video-extensions": "1" } }, "sha512-VbXquXB+U9g84XaazoTHF3MMVqnxBDnEAUfnpRnj7w2GV4imlqxjLgWlfixWtkcuR859mUEszniuxJ/L0NBXyw=="], diff --git a/infra/README.md b/infra/README.md index 19625e86a..32d35abc9 100644 --- a/infra/README.md +++ b/infra/README.md @@ -41,6 +41,11 @@ Alchemy reads its own credentials from the environment (or an `alchemy profile`) - `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` (the "Maki Account", `189f0e30…`) - `PLANETSCALE_API_TOKEN_ID`, `PLANETSCALE_API_TOKEN`, `PLANETSCALE_ORGANIZATION` (prd only) +- `MAPLE_API_KEY` (optional): an org-admin Maple API key with `ingest_keys:read`. With it, the + stack reads the org's ingest keys (`@maple-dev/alchemy`) and binds the private one onto the + Effect Workers (api, electric-proxy, bot-gateway), which export traces, logs and metrics through + the Maple SDK (`packages/infra/src/cloudflare/maple.ts`). Without it, and under `alchemy dev`, + the SDK is a no-op. Locally, `bunx alchemy profile refresh --profile default --provider Cloudflare` re-authenticates the default profile. @@ -80,4 +85,4 @@ Worker env (secrets are uploaded as Worker secrets, plain values as vars) is lis - Moved to Cloudflare: `api.hazel.sh` (`hazel-api`), `electric.hazel.sh` (`hazel-electric-proxy`), `bot-gateway.hazel.sh`. - Electric itself runs on Railway (service `electric`, volume at `/app/persistent`, PlanetScale role `electric-railway` with replication); `ELECTRIC_URL` = `electric-production-0d89.up.railway.app`. Electric runs with `ELECTRIC_MANUAL_TABLE_PUBLISHING=true` and `ELECTRIC_DB_POOL_SIZE=4` (PS-5 allows 50 connections): the synced tables plus `channel_access` were added to `electric_publication_default` by hand (owned by `postgres`; Electric's role cannot alter it), so a newly synced table must be added there too. The Cloudflare Container was removed: always-on it cost several times more and lost its disk on every restart. `app.hazel.sh` serves `apps/web-foldkit`. - Rollback DNS (all unproxied CNAMEs, detach the Worker custom domain first): `api` → `j0tqzlof.up.railway.app`, `electric` → `2hg74iuk.up.railway.app`, `bot-gateway` → `s3t62x1p.up.railway.app` (was proxied). -- Not done yet: Discord gateway flip (Worker `false`, Railway still runs it), Worker traces to Maple (`OTEL_BASE_URL`, `MAPLE_INGEST_KEY`), docs.hazel.sh stays on Vercel, GitHub `production` environment secrets for CI deploys. Link previews return `INVALID_URL` for every URL (already broken on the June build). +- Not done yet: Discord gateway flip (Worker `false`, Railway still runs it), `MAPLE_API_KEY` for Worker telemetry to Maple, docs.hazel.sh stays on Vercel, GitHub `production` environment secrets for CI deploys. Link previews return `INVALID_URL` for every URL (already broken on the June build). diff --git a/package.json b/package.json index 62264cc6c..fc654af01 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "alchemy": { "@cloudflare/workers-types": "4.20260603.1", + "@maple-dev/alchemy": "0.2.0", "alchemy": "2.0.0-beta.80" } } @@ -54,6 +55,7 @@ "@cloudflare/workers-types": "catalog:alchemy", "@effect/vitest": "catalog:effect", "@hazel/infra": "workspace:*", + "@maple-dev/alchemy": "catalog:alchemy", "@rolldown/plugin-babel": "^0.2.1", "@types/node": "^24", "@vitest/coverage-v8": "^4.1.0", diff --git a/packages/infra/package.json b/packages/infra/package.json index 7e850e5be..53f0aad4f 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -8,6 +8,7 @@ ".": "./src/index.ts", "./cloudflare": "./src/cloudflare/index.ts", "./env": "./src/env.ts", + "./maple": "./src/cloudflare/maple.ts", "./worker-http": "./src/cloudflare/worker-http.ts", "./worker-runtime": "./src/cloudflare/worker-runtime.ts", "./cached-recoverable": "./src/cloudflare/cached-recoverable.ts" @@ -17,6 +18,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@maple-dev/alchemy": "catalog:alchemy", "alchemy": "catalog:alchemy", "effect": "catalog:effect" }, diff --git a/packages/infra/src/cloudflare/maple.ts b/packages/infra/src/cloudflare/maple.ts new file mode 100644 index 000000000..a26ea13a5 --- /dev/null +++ b/packages/infra/src/cloudflare/maple.ts @@ -0,0 +1,31 @@ +/** + * Worker telemetry to Maple through `@maple-dev/alchemy`. The stack merges `Maple.providers()` + * when `MAPLE_API_KEY` is set, and each Effect Worker provides {@link hazelTelemetry} on its init: + * the deploy binds the org's private ingest key onto the Worker, so no ingest key is copied into + * CI or Infisical. Environment and revision reach the SDK as `MAPLE_ENVIRONMENT` / `COMMIT_SHA` + * (`telemetryEnv` in `../env.ts`). + */ +import * as Maple from "@maple-dev/alchemy" +import { Telemetry } from "@maple-dev/alchemy/telemetry" + +/** Every Hazel service reports under this `service.namespace`. */ +export const MAPLE_SERVICE_NAMESPACE = "hazel" + +/** + * Whether this deploy talks to the Maple API. Not under `alchemy dev` (Workers then run without + * an ingest key and the SDK is a no-op), nor without `MAPLE_API_KEY`. Plan-time only. + */ +export const isMapleDeploy = (): boolean => + process.env.ALCHEMY_DEV !== "true" && Boolean(process.env.MAPLE_API_KEY?.trim()) + +/** The org's ingest keys: a read-only singleton whose private key the Workers export with. */ +export const MapleIngest = Maple.IngestKeys("ingest") + +/** Traces, logs and metrics of an Effect Worker to Maple. Provide it on the Worker's init. */ +export const hazelTelemetry = (serviceName: string) => + Telemetry({ + serviceName, + serviceNamespace: MAPLE_SERVICE_NAMESPACE, + // `__ALCHEMY_RUNTIME__` folds to `true` in the bundle: the deployed Worker reads the bound key. + ingestKey: !globalThis.__ALCHEMY_RUNTIME__ && isMapleDeploy() ? MapleIngest : undefined, + }) diff --git a/packages/infra/src/env.ts b/packages/infra/src/env.ts index f9f55a0b8..a653495c4 100644 --- a/packages/infra/src/env.ts +++ b/packages/infra/src/env.ts @@ -79,13 +79,13 @@ export const plainWithDefault = (key: string, fallback: string): Config.Config

=> Config.succeed({ [key]: value }) -/** OTLP export + environment stamping shared by every Effect Worker. */ +/** Environment and revision stamping shared by every Effect Worker. */ export const telemetryEnv = (stage: HazelStage): Config.Config => merge( derived("OTEL_ENVIRONMENT", resolveDeploymentEnvironment(stage)), + // Read by the Maple SDK (`@hazel/infra/maple`), which binds the ingest key itself. + derived("MAPLE_ENVIRONMENT", resolveDeploymentEnvironment(stage)), derived("NODE_ENV", stage.kind === "dev" ? "development" : "production"), - optionalPlain("OTEL_BASE_URL"), - optionalSecret("MAPLE_INGEST_KEY"), merge(optionalPlain("COMMIT_SHA"), optionalPlain("GITHUB_SHA")).pipe( Config.map((record): PlainEnv => { const sha = record.COMMIT_SHA ?? record.GITHUB_SHA