Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/deploy-prd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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(),
},
Expand Down
5 changes: 4 additions & 1 deletion apps/backend/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -96,6 +98,7 @@ export default Api.make(
OutboxDispatcherObjectLive,
DiscordGatewayObjectLive,
Cloudflare.Workers.CronEventSourceLive,
hazelTelemetry("api"),
),
),
),
Expand Down
12 changes: 7 additions & 5 deletions apps/bot-gateway/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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),
Expand All @@ -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 },
},
}
})
Expand Down Expand Up @@ -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)))),
)
9 changes: 7 additions & 2 deletions apps/electric-proxy/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -158,5 +163,5 @@ export default class ElectricProxy extends Cloudflare.Worker<ElectricProxy>()(
return HttpServerResponse.raw(response, { status: response.status })
}).pipe(Effect.orDie),
}
}),
}).pipe(Effect.provide(hazelTelemetry("electric-proxy"))),
) {}
7 changes: 7 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion infra/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
},
"alchemy": {
"@cloudflare/workers-types": "4.20260603.1",
"@maple-dev/alchemy": "0.2.0",
"alchemy": "2.0.0-beta.80"
}
}
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/infra/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -17,6 +18,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@maple-dev/alchemy": "catalog:alchemy",
"alchemy": "catalog:alchemy",
"effect": "catalog:effect"
},
Expand Down
31 changes: 31 additions & 0 deletions packages/infra/src/cloudflare/maple.ts
Original file line number Diff line number Diff line change
@@ -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,
})
6 changes: 3 additions & 3 deletions packages/infra/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,13 @@ export const plainWithDefault = (key: string, fallback: string): Config.Config<P
export const derived = (key: string, value: string): Config.Config<PlainEnv> =>
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<WorkerEnv> =>
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
Expand Down
Loading