Alchemy
Send a Cloudflare Worker's traces, logs and metrics to Maple from your alchemy.run.ts, then declare alerts and dashboards next to the infrastructure they watch.
@maple-dev/alchemy connects Alchemy stacks to Maple. Add one layer to a Cloudflare Worker and every request is traced, with its logs and metrics, without touching the handler. When you want more, the same package declares Maple alert rules, dashboards and keys in your alchemy.run.ts, so they deploy with the service they observe.
| Package | @maple-dev/alchemy |
| Peer dependencies | alchemy >=2.0.0-beta.80 <, effect ^4.0.0 |
| Source | packages/alchemy-maple |
Quick start
Get a Worker’s telemetry into Maple in four steps.
1. Install
npm install @maple-dev/alchemypnpm add @maple-dev/alchemybun add @maple-dev/alchemyalchemy and effect are peer dependencies, which your stack already has.
2. Register the provider
Merge Maple.providers() into your stack’s providers. It lets the stack read your organization’s ingest key, so you never copy it by hand.
// alchemy.run.ts
import * as Alchemy from "alchemy"
import * as Cloudflare from "alchemy/Cloudflare"
import * as Maple from "@maple-dev/alchemy"
import { Effect, Layer } from "effect"
import Api from "./src/worker.ts"
export default Alchemy.Stack(
"my-app",
{ providers: Layer.mergeAll(Cloudflare.providers(), Maple.providers()) },
Effect.gen(function* () {
yield* Api
}),
)
3. Add telemetry to the Worker
Declare the ingest keys once, then provide Telemetry on the Worker’s init Effect:
// src/maple.ts
import * as Maple from "@maple-dev/alchemy"
export const Ingest = Maple.IngestKeys("ingest")
// src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare"
import { Telemetry } from "@maple-dev/alchemy/telemetry"
import { Effect } from "effect"
import { HttpServerResponse } from "effect/http"
import { Ingest } from "./maple.ts"
export default Cloudflare.Worker(
"api",
{ main: import.meta.url },
Effect.gen(function* () {
const fetch = Effect.succeed(HttpServerResponse.text("Hello from Maple"))
return { fetch }
}).pipe(Effect.provide(Telemetry({ serviceName: "api", ingestKey: Ingest }))),
)
That is the whole integration. Each request becomes a server span, Effect logs and spans inside the handler are attached to it, and the batch is sent after the response so it adds no latency.
4. Deploy
Create an API key in Settings → Keys. An org admin’s key with full access is simplest; see Authentication for the minimum scopes. Then deploy:
MAPLE_API_KEY=maple_ak_… alchemy deploy
Send a request to the Worker and open Traces in Maple. The api service appears within a few seconds.
Already have the ingest key in the Worker’s env? If
MAPLE_INGEST_KEYis set on the Worker another way, callTelemetry(without{ serviceName: "api" }) ingestKey. You then don’t needMaple.providers()or an API key at all.
Telemetry options
Telemetry binds these onto the Worker at deploy time:
| Option | Bound as | Notes |
|---|---|---|
ingestKey | MAPLE_INGEST_KEY (secret) | A Maple.IngestKeys resource (its private key is used) or a Redacted key |
endpoint | MAPLE_ENDPOINT | Defaults to https://ingest.maple.dev. Use https://ingest.eu.maple.dev for EU orgs |
environment | MAPLE_ENVIRONMENT | Becomes deployment.environment.name, e.g. your Alchemy stage |
local | MAPLE_ENDPOINT | A Local resource. Under alchemy dev only, replaces ingestKey and endpoint |
Every other option (serviceName, serviceNamespace, dropSpanNames, anticipatedErrorIdentifiers, …) is the Effect SDK’s and is forwarded unchanged. See Effect SDK on Cloudflare Workers for the full list.
Import Telemetry from the /telemetry subpath, not the package root, so the Maple API client stays out of the Worker bundle.
Local development
Under alchemy dev your Workers can report to Maple Local instead of your Maple organization: an embedded collector and store that runs inside alchemy dev, needs no account or keys, and keeps everything on your machine. Declare one Local resource, register localProviders(), and pass it to every Worker’s Telemetry:
// observability.ts
import { Local } from "@maple-dev/alchemy/local"
export const Observability = Local("observability")
// alchemy.run.ts
import { localProviders } from "@maple-dev/alchemy/local"
export default Alchemy.Stack(
"my-app",
{ providers: Layer.mergeAll(Cloudflare.providers(), localProviders()) },
Effect.gen(function* () {
const local = yield* Observability
const api = yield* Api
return { api: api.url, dashboard: local.dashboardUrl }
}),
)
// api.ts
}).pipe(Effect.provide(Telemetry({ serviceName: "api", local: Observability }))),
Run alchemy dev and open the dashboard output. Workers sharing one Local report into one store, so their traces join up across services. Outside alchemy dev, Local starts nothing and its outputs are undefined.
To use the same stack for hosted deploys, also pass ingestKey (or endpoint): Telemetry ignores both under alchemy dev and binds them on deploy. Maple.providers() reads MAPLE_API_KEY when the stack loads, so either keep that key set while developing or register Maple.providers() only when deploying.
| Prop | Default | Notes |
|---|---|---|
dataDir | . | One store per stack, stage and resource; it persists across alchemy dev runs |
port | 4318 | An occupied port fails startup |
onDirtyStore | "fail" | "wipe" discards a store left unclean by a crashed server and starts fresh |
The first run downloads the chDB library Maple pins (the same release the maple CLI ships), verifies its checksum and caches it in ~/.maple/cache/libchdb (about 330 MB, shared across projects). Set MAPLE_LIBCHDB to point at a copy you already have. Run alchemy dev with Bun, on macOS or Linux.
Maple Local closes its store on exits observed by the JavaScript runtime, including another handler calling process.exit() before Alchemy finishes teardown. A death the runtime cannot observe (SIGKILL of the sidecar itself, an OOM kill, or power loss) can still leave the store unclean. Reopening it could crash chDB, so it is refused; set onDirtyStore: "wipe" to start fresh. A sharing session that encounters a dirty store during takeover logs the refusal and recovery instructions once, then stops trying to take over.
Add alerts and dashboards
The same stack can declare what you watch the Worker with. Add a notification channel, an alert rule that delivers to it, and a dashboard:
// alchemy.run.ts
import * as Alchemy from "alchemy"
import * as Cloudflare from "alchemy/Cloudflare"
import * as Maple from "@maple-dev/alchemy"
import { Effect, Layer, Redacted } from "effect"
import Api from "./src/worker.ts"
export default Alchemy.Stack(
"my-app",
{ providers: Layer.mergeAll(Cloudflare.providers(), Maple.providers()) },
Effect.gen(function* () {
yield* Api
const oncall = yield* Maple.AlertDestination("oncall", {
type: "pagerduty",
name: "On-call PagerDuty",
integration_key: Redacted.make(process.env.PAGERDUTY_ROUTING_KEY!),
})
// Alchemy creates the destination first, because the rule uses its output.
yield* Maple.AlertRule("api-errors", {
name: "API error rate",
severity: "critical",
signal_type: "error_rate",
comparator: "gt",
threshold: 0.05, // error rates are 0-1 ratios
window_minutes: 5,
service_names: ["api"],
destination_ids: [oncall.destinationId],
})
yield* Maple.Dashboard("api-health", {
name: "API health",
tags: ["api"],
time_range: { type: "relative", value: "12h" },
})
}),
)
Change a threshold and redeploy, and the rule is updated in place. Remove it from the stack and the next deploy deletes it.
| Resource | What it manages |
|---|---|
Maple.Dashboard | Dashboards, their widgets, sections and variables |
Maple.AlertDestination | PagerDuty, webhook, Discord, Telegram and email channels |
Maple.AlertRule | Alert rules on any signal, delivering to destinations |
Maple.ApiKey | Scoped API keys, with in-place rotation |
Maple.IngestKeys | Your organization’s ingest keys, as outputs (read-only) |
Every prop and output is in Alchemy resources.
Authentication
Maple.providers() reads two environment variables at deploy time:
| Variable | Required | Default | Meaning |
|---|---|---|---|
MAPLE_API_KEY | Yes | A maple_ak_… API key with the scopes your resources need | |
MAPLE_API_URL | No | https://api.maple.dev | API base URL. Use https://api.eu.maple.dev for EU orgs |
The key needs these permissions, depending on which resources the stack declares:
| Resource | Scope | Org admin key |
|---|---|---|
Maple.IngestKeys (used by Telemetry) | ingest_keys:read | Yes |
Maple.Dashboard | dashboards:write | No |
Maple.AlertRule, Maple.AlertDestination | alerts:write | Yes |
Maple.ApiKey | api_keys:write | Yes |
A key with no scopes has full access. See API keys and scopes.
Custom configuration or transport
Maple.providers() reads the environment and uses the runtime’s global fetch. To source the key elsewhere, point at another deployment, or use your own HttpClient, build the providers with providersWithDependencies() and supply both services yourself:
import * as Maple from "@maple-dev/alchemy"
import { Layer, Redacted } from "effect"
import { HttpClient } from "effect/http"
const mapleProviders = Maple.providersWithDependencies().pipe(
Layer.provide(
Layer.succeed(Maple.MapleEnvironment, {
baseUrl: "https://api.eu.maple.dev",
apiKey: Redacted.make(mySecretStore.get("maple")),
}),
),
Layer.provide(Layer.succeed(HttpClient.HttpClient, myHttpClient)),
)
Maple.MapleApiFromHttpClient() gives you the same API client outside the Alchemy provider collection, for scripts that call the Maple API directly.