Skip to content

feat(local): maple start — standalone local observability binary#65

Open
Makisuo wants to merge 27 commits into
mainfrom
local-maple
Open

feat(local): maple start — standalone local observability binary#65
Makisuo wants to merge 27 commits into
mainfrom
local-maple

Conversation

@Makisuo
Copy link
Copy Markdown
Owner

@Makisuo Makisuo commented May 29, 2026

Overview

Adds maple start: a single, distributable binary that runs a local OTLP ingest endpoint + embedded storage + a purpose-built telemetry UI — a Jaeger/Grafana-style local tool that reuses Maple's real ingest pipeline, real ClickHouse schema, real SQL query layer, and real visualization components, without shipping the heavy multi-tenant SaaS frontend (no Clerk, billing, agents, or Cloudflare).

This PR is the full feature across the planned phases; the three commits build on each other.

What's in it

Backend — apps/ingest (maple bin, local feature)

  • New WriteMode::Local + chdb module owning a single embedded chDB session (in-process ClickHouse — no server, no Docker). All bootstrap/insert/query calls serialized through one writer.
  • Reuses the existing OTLP decode + encode_traces/logs/metrics row-building (zero mapping divergence from the Tinybird path), batched per datasource.
  • Bootstraps the real generated ClickHouse DDL (local-schema.sql) on startup — base tables and materialized views, so chDB derives the rest on INSERT.
  • POST /local/query runs SQL through chDB; GET /* serves the embedded SPA via rust-embed. Single pinned tenant (OrgId = "local").

Shared components — packages/ui

  • Extracted the trace-timeline waterfall + supporting types/utils/icons from apps/web so both apps share them. span-tree.ts exposes buildTraceDetail/buildSpanTree with branded TraceId/SpanId.

Frontend — apps/local-ui (new minimal React + Vite SPA)

  • Hash-routed trace list → trace detail (waterfall) → logs.
  • Data layer builds ClickHouse SQL via @maple/query-engine (CH.compile) pinned to the local org and posts to /local/query. useInfiniteQuery + @tanstack/react-virtual for logs.
  • No router/Clerk/Cloudflare deps.

Build & distribution

  • scripts/build-local-binary.sh: builds the SPA → syncs into apps/ingest/ui-dist/ (rust-embed bakes it in) → cargo build --features local --bin maple → bundles libchdb.so beside the binary and rewrites the dynamic-load path (install_name_tool @rpath/@loader_path on macOS, patchelf $ORIGIN on Linux) → relocatable 2-file bundle.
  • .github/workflows/local-binary-release.yml: builds on native runners per triple (aarch64/x86_64 × darwin/linux), ad-hoc codesigns macOS, tars maple + libchdb.so with a sha256, publishes to a GitHub release on v* tags.

Why native runners (no cross-compilation)

chdb-rust's build.rs downloads a host-specific prebuilt libchdb.so and links it. Cross-compiling would link the wrong architecture's engine, so each target is built on a matching native runner.

Verification

  • Phase 4 verified end-to-end against real chDB data in the browser: trace list (method/service badges), trace-detail waterfall (parent→child hierarchy, duration bars, status, span detail panel), logs (severity colors, newest-first, virtualized). No console errors.
  • Relocatable bundle confirmed to run standalone on macOS arm64 with no DYLD_LIBRARY_PATH.

Reviewer notes

  • macOS notarization is deferred — release bundles are only ad-hoc signed (documented in the workflow + release notes). Production Developer ID + notarization needs APPLE_* secrets not configured here.
  • The release workflow can't be validated locally — it's written-then-pushed; first CI run will confirm the Linux-arm64 libchdb download path in particular.
  • apps/ingest/ui-dist/ is now a build artifact: only .gitkeep is tracked (rust-embed needs the folder to exist); the generated SPA output is gitignored. The previously-tracked stale ui-dist/index.html is removed.

🤖 Generated with Claude Code

@pullfrog
Copy link
Copy Markdown

pullfrog Bot commented May 29, 2026

This run croaked 😵

The workflow encountered an error before any progress could be reported. Please check the link below for details.

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog𝕏

Adds `maple start`, a standalone single-binary local mode: OTLP/HTTP
ingest into an embedded in-process ClickHouse (chDB), reusing the
production OTLP→NDJSON encoders and the generated ClickHouse schema so
local rows are shaped identically to cloud. Single-tenant (OrgId="local").

- chdb module: one dedicated writer thread owns the chDB session; all
  bootstrap/insert/query is funneled through it (chDB is single-owner).
- new `maple` bin gated behind the `local` cargo feature so the
  production maple-ingest build never links libchdb; clap CLI, Axum
  routes, rust-embed SPA fallback.
- telemetry::encode_local_{traces,logs,metrics} wrap the private encoders
  for zero row-mapping divergence with the Tinybird path.
- schema codegen: emit local-schema.sql + local-inserts.json from the
  Tinybird manifest, wired into the clickhouse:schema task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Makisuo and others added 2 commits May 29, 2026 16:09
Pre-existing regression on `main` (introduced by ac723de "feat: fix some
react stuff", which rewrote this provider to use createElement). main's CI is
red on the same error; this branch inherits it via rebase onto main.

AutocompleteValuesProvider's `children` prop is required, so React 19's
createElement overload requires it in the props object — passing it as the
variadic 3rd arg leaves the required prop unsatisfied (TS2769). Move children
into the props object. Functionally identical; unblocks @maple/web typecheck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 29, 2026

Ingest Rust Test + Benchmark Results

Commit: c2f1ddab47b3ed2f68fbeb5c14f248dcbc60d1a6

Load Benchmark — tinybird mode, median of 3 run(s) vs main

Metric main (median) PR (median) Delta
Requests/sec 2412.92 2268.52 -6.0% worse
Rows/sec 24129.23 22685.17 -6.0% worse
p50 latency 25.83 ms 27.66 ms +7.1% worse
p95 latency 28.34 ms 30.43 ms +7.4% worse
p99 latency 30.40 ms 32.74 ms +7.7% worse
Export catch-up 0.026 s 0.026 s -0.6% better
Max RSS 97.58 MiB 96.01 MiB -1.6% better
Failures 0 0 same

Same code path on both sides (same LOAD_TEST_INGEST_MODE), so the delta column is meaningful. Numbers come from ubuntu-latest, which is noisy — treat single-digit-percent deltas as noise.

PR load benchmark JSON (per-iteration)
[
  {
    "ingest_mode": "tinybird",
    "requests": 2000,
    "successes": 2000,
    "failures": 0,
    "rows_sent": 20000,
    "rows_exported": 20000,
    "imports": 24,
    "duration_seconds": 0.897704576,
    "export_catchup_seconds": 0.025471513,
    "request_rps": 2227.904428104419,
    "row_rps": 22279.044281044193,
    "p50_ms": 27.729,
    "p95_ms": 31.516,
    "p99_ms": 34.113,
    "max_rss_mb": 96.75390625,
    "max_cpu_percent": 75.0,
    "avg_cpu_percent": 54.15
  },
  {
    "ingest_mode": "tinybird",
    "requests": 2000,
    "successes": 2000,
    "failures": 0,
    "rows_sent": 20000,
    "rows_exported": 20000,
    "imports": 25,
    "duration_seconds": 0.881633053,
    "export_catchup_seconds": 0.026095632,
    "request_rps": 2268.5174894412676,
    "row_rps": 22685.174894412674,
    "p50_ms": 27.665,
    "p95_ms": 30.425,
    "p99_ms": 32.744,
    "max_rss_mb": 95.90625,
    "max_cpu_percent": 75.0,
    "avg_cpu_percent": 45.8
  },
  {
    "ingest_mode": "tinybird",
    "requests": 2000,
    "successes": 2000,
    "failures": 0,
    "rows_sent": 20000,
    "rows_exported": 20000,
    "imports": 24,
    "duration_seconds": 0.816166996,
    "export_catchup_seconds": 0.026171473,
    "request_rps": 2450.478896845763,
    "row_rps": 24504.788968457626,
    "p50_ms": 25.621,
    "p95_ms": 27.202,
    "p99_ms": 28.461,
    "max_rss_mb": 96.0078125,
    "max_cpu_percent": 80.3,
    "avg_cpu_percent": 56.8
  }
]
main load benchmark JSON (per-iteration)
[
  {
    "ingest_mode": "tinybird",
    "requests": 2000,
    "successes": 2000,
    "failures": 0,
    "rows_sent": 20000,
    "rows_exported": 20000,
    "imports": 25,
    "duration_seconds": 0.895887909,
    "export_catchup_seconds": 0.026263043,
    "request_rps": 2232.4221366403103,
    "row_rps": 22324.221366403104,
    "p50_ms": 28.031,
    "p95_ms": 31.086,
    "p99_ms": 32.759,
    "max_rss_mb": 97.1875,
    "max_cpu_percent": 75.0,
    "avg_cpu_percent": 45.8
  },
  {
    "ingest_mode": "tinybird",
    "requests": 2000,
    "successes": 2000,
    "failures": 0,
    "rows_sent": 20000,
    "rows_exported": 20000,
    "imports": 25,
    "duration_seconds": 0.828870368,
    "export_catchup_seconds": 0.025845742,
    "request_rps": 2412.9225476184474,
    "row_rps": 24129.225476184474,
    "p50_ms": 25.832,
    "p95_ms": 28.341,
    "p99_ms": 30.403,
    "max_rss_mb": 99.15234375,
    "max_cpu_percent": 80.3,
    "avg_cpu_percent": 60.15
  },
  {
    "ingest_mode": "tinybird",
    "requests": 2000,
    "successes": 2000,
    "failures": 0,
    "rows_sent": 20000,
    "rows_exported": 20000,
    "imports": 23,
    "duration_seconds": 0.814727958,
    "export_catchup_seconds": 0.026269159,
    "request_rps": 2454.807129621051,
    "row_rps": 24548.07129621051,
    "p50_ms": 25.498,
    "p95_ms": 27.858,
    "p99_ms": 28.268,
    "max_rss_mb": 97.578125,
    "max_cpu_percent": 80.7,
    "avg_cpu_percent": 57.0
  }
]

WAL-acked microbench (cargo bench --bench ingest_bench)

   Compiling maple-ingest v0.1.0 (/home/runner/work/maple/maple/apps/ingest)
    Finished `bench` profile [optimized] target(s) in 32.24s
     Running benches/ingest_bench.rs (target/release/deps/ingest_bench-56e0fe315b7f3811)
Gnuplot not found, using plotters backend
test ingest_accept/logs_10_rows_wal_ack ... bench:      467413 ns/iter (+/- 7714)
test ingest_accept/traces_10_spans_wal_ack ... bench:      503311 ns/iter (+/- 23826)

cargo test

    Updating crates.io index
   Compiling maple-ingest v0.1.0 (/home/runner/work/maple/maple/apps/ingest)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 6.73s
     Running unittests src/lib.rs (target/debug/deps/maple_ingest-8b9e9fc61a910385)

running 22 tests
test telemetry::tests::apply_attribute_mappings_rewrites_span_attributes ... ok
test otel::tests::build_resource_sets_runtime_and_sdk_type ... ok
test telemetry::tests::hex_empty_for_zero_ids ... ok
test telemetry::tests::log_encoder_matches_tinybird_row_shape ... ok
test telemetry::tests::logs_severity_text_falls_back_to_mapped_number ... ok
test telemetry::tests::logs_emit_exactly_the_jsonpaths_declared_in_datasources_ts ... ok
test telemetry::tests::custom_datasource_names_propagate_to_frames ... ok
test telemetry::tests::logs_use_observed_time_when_time_unix_nano_is_zero ... ok
test telemetry::tests::metrics_emit_exactly_the_jsonpaths_declared_in_datasources_ts ... ok
test telemetry::tests::metric_encoder_matches_all_tinybird_datasource_shapes ... ok
test telemetry::tests::metrics_summary_data_points_are_dropped ... ok
test telemetry::tests::sampling_keeps_errors_even_when_ratio_low ... ok
test telemetry::tests::timestamp_has_nano_precision ... ok
test telemetry::tests::timestamps_match_clickhouse_datetime64_nine_format ... ok
test telemetry::tests::trace_encoder_matches_tinybird_row_shape ... ok
test telemetry::tests::traces_emit_exactly_the_jsonpaths_declared_in_datasources_ts ... ok
test telemetry::tests::wal_partial_drain_advances_cursor_without_truncating ... ok
test telemetry::tests::wal_round_trips_frame ... ok
test telemetry::tests::wal_truncates_after_full_drain_allowing_further_appends ... ok
test telemetry::tests::pipeline_e2e_exports_gzip_ndjson_to_fake_tinybird ... ok
test telemetry::tests::pipeline_e2e_exports_metrics_to_fake_tinybird ... ok
test telemetry::tests::pipeline_e2e_exports_traces_to_fake_tinybird ... ok

test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s

     Running unittests src/bin/load_test.rs (target/debug/deps/load_test-3ae74910c06cd17d)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/maple_ingest-c2f428c94e6b99e8)

running 18 tests
test tests::cloudflare_validation_payload_is_detected ... ok
test tests::cloudflare_log_record_maps_body_severity_and_attributes ... ok
test tests::cloudflare_timestamps_support_rfc3339_unix_and_unix_nano ... ok
test tests::d1_response_parses_empty_results_as_no_match ... ok
test tests::d1_response_parses_failure_with_errors ... ok
test tests::d1_truthy_accepts_int_and_bool_self_managed ... ok
test tests::enrichment_overwrites_tenant_fields ... ok
test tests::d1_response_parses_success_with_rows ... ok
test tests::cloudflare_ndjson_payload_parses_multiple_records ... ok
test tests::non_self_managed_goes_to_shared_pool ... ok
test tests::hash_is_deterministic ... ok
test tests::extract_ingest_key_returns_sentinel_literal_unchanged ... ok
test tests::self_managed_degrades_to_shared_when_endpoint_unset ... ok
test tests::resolve_ingest_key_returns_self_managed_true_when_active_settings_row ... ok
test tests::resolve_ingest_key_returns_none_when_hash_missing ... ok
test tests::self_managed_goes_to_self_managed_pool_when_configured ... ok
test tests::sentinel_token_matches_only_exact_literal ... ok
test tests::resolve_ingest_key_returns_self_managed_false_when_no_settings_row ... ok

test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

   Doc-tests maple_ingest

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Adds the frontend and distribution half of `maple start` (the standalone
local Maple binary). The Rust ingest binary + embedded chDB (earlier on this
branch) now has a UI to drive it and a reproducible way to ship it.

apps/local-ui — minimal React + Vite SPA (no Clerk/router/Cloudflare):
  - hash-routed trace list / trace detail / logs views
  - data layer hits the binary's POST /local/query, builds ClickHouse SQL
    via @maple/query-engine (CH.compile) pinned to the `local` org
  - reuses @maple/ui components (trace waterfall, severity badges, tables)
  - its dist/ is synced into apps/ingest/ui-dist/ and baked in via rust-embed

packages/ui/src/lib/span-tree.ts — shared buildTraceDetail/buildSpanTree
  (branded TraceId/SpanId) consumed by the local trace-detail view.

scripts/build-local-binary.sh — builds the SPA, syncs ui-dist, compiles
  `maple` with the `local` feature, then bundles libchdb.so beside the binary
  and rewrites the dynamic-load path (install_name_tool @rpath/@loader_path on
  macOS, patchelf $ORIGIN on Linux) so the 2-file bundle is relocatable.

.github/workflows/local-binary-release.yml — builds the bundle on native
  runners per target triple (aarch64/x86_64 darwin + linux; native because
  chdb-rust downloads a host-specific libchdb), ad-hoc codesigns macOS, tars
  maple + libchdb.so with a sha256, and publishes to a GitHub release on v*.

ui-dist is now a build artifact: only .gitkeep is tracked (rust-embed needs
the folder to exist); the generated SPA output is gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Orphan scaffolding never imported anywhere; ToolbarSearch already has its
own inline debounce (with external-sync for "Clear all", which this hook
lacks). Knip's files rule is error-severity, so the unused file failed CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…actor

TinybirdConfig consolidated the flat datasource_traces/logs/metrics_* fields
into a single `datasources: DatasourceNames`, but benches/ingest_bench.rs still
constructed the old shape — breaking `cargo bench --bench ingest_bench` (E0560)
and failing the Cargo test CI job. Use DatasourceNames::defaults(), matching the
telemetry tests and main.rs construction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds scripts/install.sh: detects OS/arch, downloads the matching bundle from the
latest GitHub release, verifies the sha256, installs the 3-file bundle into
~/.maple/bin (kept co-located for the rpath + maple→maple-cli forwarding),
clears the macOS quarantine, and symlinks `maple` onto PATH. Documents the
one-liner in docs/local-mode.md and the release notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
apps/landing copies scripts/install.sh → public/cli/install at build/dev time
(single source via the `sync:install` script; the copy is gitignored). Astro's
static build emits it to dist/cli/install, so the canonical installer URL is:

  curl -fsSL https://maple.dev/cli/install | sh

Updates the script header, docs/local-mode.md, and release notes to the nice URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…unners

Replace the single aggregated `release` job (which waited for all 4 platforms)
with a per-platform "Publish to release" step at the end of each build job.
Each platform creates/uploads to the release as soon as it finishes. A queued
macos-13 runner can no longer block arm64 mac and Linux bundles from publishing.
`gh release create ... || true` handles the creation race between concurrent jobs.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…essage

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Use printf to write release notes to a file — heredocs inside YAML literal
block scalars break when the terminator/content have less indentation than
the block, causing instant workflow failures on every branch push.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
maple-cli (the Bun/TypeScript query CLI) is now baked into the maple binary
at build time via rust-embed (#[folder = "cli-dist/"]), the same pattern
used for the SPA. The build script compiles it before cargo so rust-embed
picks it up. At runtime, the first query subcommand (services, traces, etc.)
extracts the embedded CLI to ~/.maple/maple-cli-<hash> and execs into it;
subsequent invocations skip re-extraction.

Distribution shrinks from 3 files (maple + libchdb.so + maple-cli) to 2
(maple + libchdb.so). The install script, CI, and docs are updated to match.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- `maple start` writes ~/.maple/maple.pid and refuses to start a second
  instance with a clear "already running (PID N) — run maple stop" message.
  Stale PID files from crashes are cleaned up automatically.
- `maple stop` sends SIGTERM and waits up to 5 s, printing progress dots,
  then reports "maple stopped." or suggests kill -9.
- Graceful shutdown wires SIGTERM + SIGINT into axum's with_graceful_shutdown
  and removes the PID file on clean exit.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
executor.ts was refactored to export makeLocalWarehouseExecutor(baseUrl)
and DEFAULT_LOCAL_URL instead of the resolved LocalWarehouseExecutorLive.
Update bin.ts to match.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- auth.ts: Effect.catchAll removed in Effect v4; replace with Effect.catch
- warehouse.ts: executor.query pipe param is typed as WarehouseQueryName union;
  the passthrough wrapper casts string→WarehouseQueryName

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Knip has no entry for apps/local-cli so it can't trace the import chain
from src/bin.ts and marks auth.ts + warehouse.ts as unused files (error).
Add the workspace with src/bin.ts as the entry point.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@Makisuo Makisuo deployed to pr-preview May 29, 2026 23:30 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant