Skip to content
Maple Docs
Open app

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 dependenciesalchemy >=2.0.0-beta.80 <3, effect ^4.0.0
Sourcepackages/alchemy-maple

Quick start

Get a Worker’s telemetry into Maple in four steps.

1. Install

npm install @maple-dev/alchemy
pnpm add @maple-dev/alchemy
bun add @maple-dev/alchemy

alchemy 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_KEY is set on the Worker another way, call Telemetry({ serviceName: "api" }) without ingestKey. You then don’t need Maple.providers() or an API key at all.

Telemetry options

Telemetry binds these onto the Worker at deploy time:

OptionBound asNotes
ingestKeyMAPLE_INGEST_KEY (secret)A Maple.IngestKeys resource (its private key is used) or a Redacted key
endpointMAPLE_ENDPOINTDefaults to https://ingest.maple.dev. Use https://ingest.eu.maple.dev for EU orgs
environmentMAPLE_ENVIRONMENTBecomes deployment.environment.name, e.g. your Alchemy stage
localMAPLE_ENDPOINTA 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.

PropDefaultNotes
dataDir.alchemy/maple-local/<hash>/dataOne store per stack, stage and resource; it persists across alchemy dev runs
port4318An 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.

ResourceWhat it manages
Maple.DashboardDashboards, their widgets, sections and variables
Maple.AlertDestinationPagerDuty, webhook, Discord, Telegram and email channels
Maple.AlertRuleAlert rules on any signal, delivering to destinations
Maple.ApiKeyScoped API keys, with in-place rotation
Maple.IngestKeysYour 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:

VariableRequiredDefaultMeaning
MAPLE_API_KEYYesA maple_ak_… API key with the scopes your resources need
MAPLE_API_URLNohttps://api.maple.devAPI base URL. Use https://api.eu.maple.dev for EU orgs

The key needs these permissions, depending on which resources the stack declares:

ResourceScopeOrg admin key
Maple.IngestKeys (used by Telemetry)ingest_keys:readYes
Maple.Dashboarddashboards:writeNo
Maple.AlertRule, Maple.AlertDestinationalerts:writeYes
Maple.ApiKeyapi_keys:writeYes

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.

Next steps