# Contributing to Maple This guide covers local development for the full Maple stack: app services, Postgres, ClickHouse, and the OTel collector pipeline. For a single-binary experience without Docker, see [docs/local-mode.md](docs/local-mode.md) (`maple start`). ## Prerequisites Install the pinned toolchain (recommended via [mise](https://mise.en.dev)): | Tool | Used for | | ------ | ------------------------------------ | | Bun | JS/TS apps, scripts, tests | | Node | Some scripts (e.g. mobile) | | Rust | `apps/ingest` (OTLP gateway) | | Docker | Postgres, ClickHouse, OTel collector | First-time bootstrap: ```bash curl https://mise.run | sh echo 'eval "$(mise activate zsh)"' >> ~/.zshrc # or bash/fish equivalent mise trust mise run setup # installs tools, bun deps, copies .env.example → .env.local, portless CA ``` Without mise, the same steps manually: ```bash bun install cp .env.example .env.local npx portless trust # only needed if you use `bun dev` / portless HTTPS URLs ``` ## Architecture (local dev) ```text Browser → web (3471) → api (3472) → ClickHouse (8123) ↓ OTLP clients → ingest (3473/3474) → OTel collector (4318) → ClickHouse ↓ Postgres (5499) app state (issues, keys, dashboards, …) ``` The development Docker stack (`docker-compose.development.yml`) runs the data plane. Application processes run on the host (see [Running application services](#running-application-services)). ## Environment file All services read secrets and overrides from **`.env.local`** at the repo root (gitignored). Start from the template: ```bash cp .env.example .env.local ``` ### Generate required secrets Several services share these keys. Generate them once and paste into `.env.local`: ```bash # AES-256-GCM key for encrypting private ingest keys at rest (must be base64 of exactly 32 bytes) openssl rand -base64 32 # HMAC key for ingest-key lookup hashes (any non-empty secret; hex is convenient) openssl rand -hex 32 ``` Set the outputs as: ```dotenv MAPLE_INGEST_KEY_ENCRYPTION_KEY= MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY= ``` ### Recommended baseline for ClickHouse local dev These values align with `docker-compose.development.yml` and self-hosted auth: ```dotenv # Warehouse — route API queries to local ClickHouse instead of Tinybird Cloud CLICKHOUSE_URL=http://localhost:8123 CLICKHOUSE_PROVIDER=clickhouse CLICKHOUSE_USER=maple CLICKHOUSE_PASSWORD=maple CLICKHOUSE_DATABASE=default # App database: nothing to set. `alchemy dev` binds MAPLE_DB to the docker Postgres on 5499; # see db:migrate:local below. # Auth — self-hosted mode (no Clerk account required) MAPLE_AUTH_MODE=self_hosted MAPLE_ROOT_PASSWORD=change-me MAPLE_DEFAULT_ORG_ID=default # Ingest — forward OTLP to the local collector (which writes ClickHouse) INGEST_WRITE_MODE=forward INGEST_FORWARD_OTLP_ENDPOINT=http://127.0.0.1:4318 INGEST_PORT=3474 # Single-tenant ingest keys (no Postgres key store required) MAPLE_SELF_HOSTED_MODE=single_tenant MAPLE_ORG_ID_OVERRIDE=default # Internal service auth (must match across api and scraper) INTERNAL_SERVICE_TOKEN=dev-internal-service-token SD_INTERNAL_TOKEN=dev-internal-service-token # Web → API/ingest URLs when running raw ports (not portless) VITE_API_BASE_URL=http://localhost:3472 VITE_MAPLE_AUTH_MODE=self_hosted ``` `TINYBIRD_HOST` and `TINYBIRD_TOKEN` remain in `.env.example` because the API validates them at startup even when `CLICKHOUSE_URL` is set. Placeholder values from the template are fine for this stack. > **Clerk mode:** set `MAPLE_AUTH_MODE=clerk` and provide `CLERK_SECRET_KEY`, > `CLERK_PUBLISHABLE_KEY`, plus the matching `VITE_*` overrides. Test credentials for the > hosted dev org are documented in [CLAUDE.md](CLAUDE.md). ## Start infrastructure (Docker) From the repo root: ```bash docker compose -f docker-compose.development.yml up -d ``` This starts: | Service | Ports | Purpose | | ---------------- | ----------------------- | -------------------------------------------- | | `postgres` | `5499 → 5432` | App DB for `alchemy dev` (Hyperdrive origin) | | `clickhouse` | `8123`, `9000` | Telemetry warehouse | | `otel-collector` | `4317`, `4318`, `13133` | OTLP ingest → ClickHouse via mapleexporter | The collector reads `.env.local` and uses `MAPLE_CLICKHOUSE_PASSWORD=maple` from compose overrides. Ensure ClickHouse credentials in `.env.local` match (`maple` / `maple`). Wait for health checks, then apply schema and migrations: ```bash # Postgres (Drizzle migrations for app state) bun run db:migrate:local # ClickHouse (telemetry tables + materialized views) bun run --cwd packages/clickhouse-cli start apply \ --url=http://localhost:8123 \ --user=maple \ --password=maple \ --database=default ``` Stop infrastructure: ```bash docker compose -f docker-compose.development.yml down ``` Postgres data persists in the `postgres-data` volume; ClickHouse in `clickhouse-data`. Remove volumes with `down -v` for a clean slate. ## Running application services `bun dev` runs every app below as one `alchemy dev` stack behind `https://.localhost`, and `bun dev api web` runs a subset — see [All at once](#all-at-once). The per-app sections describe what each one needs and how to run it alone on its raw port. ### 1. Workers: API, alerting, electric-sync ```bash bun dev api alerting electric-sync ``` The three Cloudflare Workers are served by alchemy's local runtime from the same `alchemy.run.ts` that deploys them, reachable at `https://api.localhost`, `https://alerting.localhost` and `https://electric-sync.localhost` through the portless proxy (branch-prefixed in a linked worktree). Ports are sticky per app and never need writing down; `bun dev` prints them. The variables below are the API's: | Variable | Required | Notes | | ----------------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------- | | `MAPLE_INGEST_KEY_ENCRYPTION_KEY` | yes | `openssl rand -base64 32` | | `MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY` | yes | `openssl rand -hex 32` | | `MAPLE_AUTH_MODE` | yes | `self_hosted` or `clerk` | | `MAPLE_ROOT_PASSWORD` | yes\* | \*Required when `MAPLE_AUTH_MODE=self_hosted` | | `MAPLE_DEFAULT_ORG_ID` | yes | Default `default`; must match ingest org override | | `TINYBIRD_HOST` | yes | Placeholder OK when using `CLICKHOUSE_URL` | | `TINYBIRD_TOKEN` | yes | Placeholder OK when using `CLICKHOUSE_URL` | | `TINYBIRD_SIGNING_KEY` / `TINYBIRD_WORKSPACE_ID` | with Tinybird raw SQL | Explicit JWT signing configuration; never derived from `TINYBIRD_TOKEN` | | `TINYBIRD_RAW_SQL_JWT_RPS_LIMIT` | optional | Positive integer; Tinybird-enforced raw-SQL ceiling with a separate bucket per org | | `CLICKHOUSE_URL` | recommended | `http://localhost:8123` for local ClickHouse stack | | `CLICKHOUSE_PROVIDER` | optional | `tinybird` (default) or `clickhouse`; set `clickhouse` for env-level vanilla/self-managed ClickHouse | | `CLICKHOUSE_USER` / `CLICKHOUSE_PASSWORD` / `CLICKHOUSE_DATABASE` | with CH | Match docker-compose (`maple` / `maple` / `default`) | | `INTERNAL_SERVICE_TOKEN` | recommended | Internal MCP tool calls; shared with the scraper | | `SD_INTERNAL_TOKEN` | optional | Prometheus scraper internal API auth | | `CLERK_*` | clerk mode | See `.env.example` | Loads env via `alchemy dev --env-file .env.local`; a changed variable needs a restart. Requires docker Postgres running (`bun run db:migrate:local`). ### 2. Web (`apps/web`) ```bash bun dev web # in the stack: https://web.localhost bun --filter=@maple/web dev # alone, raw port ``` Default raw URL: `http://localhost:3471` | Variable | Required | Notes | | ----------------------- | ---------- | ------------------------------------------------------------- | | `VITE_API_BASE_URL` | yes | `http://localhost:3472` (or portless `https://api.localhost`) | | `VITE_MAPLE_AUTH_MODE` | yes | Mirror `MAPLE_AUTH_MODE` | | `VITE_INGEST_URL` | optional | Defaults derived; set if ingest port differs | | `VITE_CLERK_*` | clerk mode | Publishable key + sign-in URLs | | `VITE_MAPLE_INGEST_KEY` | optional | Browser self-telemetry via ingest | Sign in (self-hosted): use the org/user you create with `MAPLE_ROOT_PASSWORD`. Clerk dev login: see [CLAUDE.md](CLAUDE.md). ### 3. Ingest (`apps/ingest`) ```bash bun dev ingest # in the stack: https://ingest.localhost bun --filter=@maple/ingest dev # alone, raw port ``` Default port: `3473` (`INGEST_PORT` / `PORT` override). `.env.example` uses `3474`; pick one port and keep `VITE_*` / `MAPLE_INGEST_PUBLIC_URL` consistent. | Variable | Required | Notes | | ---------------------------------- | -------------- | ----------------------------------------------------------------------- | | `INGEST_FORWARD_OTLP_ENDPOINT` | yes | `http://127.0.0.1:4318` for local collector | | `INGEST_WRITE_MODE` | recommended | `forward` for ClickHouse stack (default `tinybird`) | | `MAPLE_INTERNAL_ORG_ID` | yes | Org the gateway files its OWN telemetry under; no default | | `MAPLE_INGEST_KEY_ENCRYPTION_KEY` | yes\* | \*Required for postgres key store / ClickHouse direct path | | `MAPLE_INGEST_KEY_DECRYPTION_KEYS` | no | Older keys kept for decryption during a rotation; same value as API | | `MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY` | yes | Same value as API | | `MAPLE_SELF_HOSTED_MODE` | recommended | `single_tenant` → static key store (no DB) | | `MAPLE_ORG_ID_OVERRIDE` | with static | Must match `MAPLE_DEFAULT_ORG_ID` | | `MAPLE_PG_URL` | postgres store | `postgres://maple:maple@localhost:5499/maple` if not using static store | | `TINYBIRD_HOST` / `TINYBIRD_TOKEN` | tinybird mode | When `INGEST_WRITE_MODE=tinybird` or `dual` | | `INGEST_PORT` | optional | Default from port / env | | `INGEST_REQUIRE_TLS` | optional | `false` locally | Sources `../../.env.local` automatically. Requires Rust toolchain. ### 4. Scraper (`apps/scraper`) Optional — polls the API for Prometheus targets and forwards metrics through ingest. ```bash cd apps/scraper && bun dev ``` Default health port: `3475` | Variable | Required | Notes | | --------------------- | -------- | ------------------------------------------------------------- | | `MAPLE_API_URL` | optional | Default `http://127.0.0.1:3472` | | `MAPLE_INGEST_URL` | optional | Default `http://127.0.0.1:3474` | | `SD_INTERNAL_TOKEN` | optional | Default `maple-sd-dev-token`; match API's `SD_INTERNAL_TOKEN` | | `SCRAPER_CONCURRENCY` | optional | Default `10` | | `PORT` | optional | Health endpoint, default `3475` | Loads `../../.env.local` via `bun --env-file`. ### 5. AI chat and triage (`apps/ai`) `/chat` and `/investigations/*` are forwarded by `apps/api` to the `apps/ai` Worker over a service binding: the `ChatSession` Durable Object there owns each transcript and runs the agent turn. Run both under `alchemy dev` (`bun dev api ai`), because a plain `bun` process has no Durable Object namespace. The agent runs on OpenRouter by default and needs `OPENROUTER_API_KEY`. Set `MAPLE_LLM_PROVIDER=workers-ai` to run it on the Workers AI `AI` binding instead, which needs a Cloudflare account with Workers AI through an alchemy profile (`bunx alchemy login`). The default model ids live in `apps/ai/src/platform/Llm.ts`. Override them per provider with `MAPLE_TRIAGE_MODEL_OPENROUTER` or `MAPLE_TRIAGE_MODEL_WORKERS_AI`. > **`MAPLE_ORG_ID_OVERRIDE` breaks the investigation chat.** It pins every API request to > one org, but the browser addresses the session as `:`. With the > override set to anything other than your Clerk organization's id, the page watches a > session the API never seeded, and the transcript stays empty forever. Unset it, > or set it to your Clerk org id, when working on chat. In Clerk mode the pin admits a user who is not a member of the pinned org only when `MAPLE_ORG_ID_OVERRIDE_ADMITS_NONMEMBERS=true` and `MAPLE_ENVIRONMENT=development`. `bun dev` sets the opt-in for you; set it yourself when you run the API outside the dev stack. When an autonomous investigation fails to start, the `investigations.error` column names it: | `error` value | Cause | | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `agent_unavailable: …` | No `ChatSession` binding: `apps/ai` isn't running under `alchemy dev` with the API | | `start_failed: a turn is already running for this investigation` | The session is busy; retry | | `diagnosis_timeout: …` | The turn ran but never called `submit_diagnosis` within 15 minutes | ### All at once ```bash bun dev # everything bun dev api web # a subset ``` One `alchemy dev` stack: the Workers (api, alerting, electric-sync) on alchemy's local runtime, everything else (web, landing, local-ui, ingest, scraper) as child processes of the same stack, each behind `https://.localhost` (branch-prefixed in a linked worktree). Ctrl-C stops all of it. Logs share the one terminal. See `docs/infra.md` for how the ports and routes are handed out. ## Verify the stack 1. Open `http://localhost:3471` (or `https://web.localhost` with portless). 2. Sign in (self-hosted root password or Clerk test user). 3. Send test OTLP to `http://localhost:3474/v1/traces` (or your ingest port) with a well-formed ingest key, or rely on static-key mode (any `maple_pk_*` key resolves to `MAPLE_ORG_ID_OVERRIDE`). 4. Confirm data in the traces/logs UI after the collector batch flushes (~seconds). Health checks: - OTel collector: `http://localhost:13133` - Scraper: `http://localhost:3475/health` - API: `http://localhost:3472/health` ## Tests and quality ```bash bun run test # Vitest via turbo, not `bun test` bun typecheck bun run format # oxfmt + oxlint --fix ``` Vitest uses embedded PGlite — no Docker Postgres required for unit tests. ## Troubleshooting | Symptom | Check | | ------------------------ | ------------------------------------------------------------------------------------------ | | API fails on startup | Missing `MAPLE_INGEST_KEY_*`, `MAPLE_ROOT_PASSWORD`, or `TINYBIRD_*` | | Ingest 401 on OTLP | Static key store: use `maple_pk_…` prefix; postgres store: seed keys via API | | Empty traces UI | ClickHouse schema applied? Collector running? `INGEST_WRITE_MODE=forward`? | | API DB errors | `docker compose … up -d postgres` + `bun run db:migrate:local` | | Ingest/API HMAC mismatch | `MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY` must be identical; compare startup fingerprints in logs | | Chat MCP tools fail | `INTERNAL_SERVICE_TOKEN` mismatch or API not running | ## Further reading - [docs/persistence.md](docs/persistence.md) — Postgres migrations - [docs/self-hosted-clickhouse.md](docs/self-hosted-clickhouse.md) — ClickHouse BYO + schema - [docs/local-mode.md](docs/local-mode.md) — single-binary local mode - [.env.example](.env.example): full env reference with optional integrations (GitHub App, Autumn, email)