diff --git a/alchemy.run.ts b/alchemy.run.ts index 6b828e812..da76af802 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -1,6 +1,7 @@ import { appendFileSync } from "node:fs" import * as Alchemy from "alchemy" import * as Cloudflare from "alchemy/Cloudflare" +import * as RemovalPolicy from "alchemy/RemovalPolicy" import * as Effect from "effect/Effect" import { formatMapleStage, parseMapleStage, resolveMapleDomains } from "@maple/infra/cloudflare" import { createAlertingWorker } from "./apps/alerting/alchemy.run.ts" @@ -27,6 +28,52 @@ if (!process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_DEFAULT_ACCOUNT const resolveUrl = (domain: string | undefined, envKey: string, fallback = ""): string => domain ? `https://${domain}` : process.env[envKey]?.trim() || fallback +const requireEnv = (key: string): string => { + const value = process.env[key]?.trim() + if (!value) { + throw new Error(`Missing required deployment env: ${key}`) + } + return value +} + +const createProductionSharedResources = (stage: ReturnType) => + Effect.gen(function* () { + // Bootstrap these account/zone-wide resources in production first. Other + // stages keep their existing bindings until the production state has been + // deployed, after which they can safely resolve these logical IDs via ref(). + if (stage.kind !== "prd") { + return {} + } + + const ingestEndpoint = (process.env.MAPLE_ENDPOINT?.trim() || "https://ingest.maple.dev").replace( + /\/+$/, + "", + ) + const headers = { authorization: `Bearer ${requireEnv("MAPLE_OTEL_INGEST_KEY")}` } + const tracesDestination = yield* Cloudflare.Workers.ObservabilityDestination( + "workers-observability-traces", + { + name: "maple-workers-traces", + url: `${ingestEndpoint}/v1/traces`, + headers, + logpushDataset: "opentelemetry-traces", + enabled: true, + }, + ).pipe(RemovalPolicy.retain()) + const logsDestination = yield* Cloudflare.Workers.ObservabilityDestination( + "workers-observability-logs", + { + name: "maple-workers-logs", + url: `${ingestEndpoint}/v1/logs`, + headers, + logpushDataset: "opentelemetry-logs", + enabled: true, + }, + ).pipe(RemovalPolicy.retain()) + + return { logsDestination, tracesDestination } + }) + export default Alchemy.Stack( "maple", { @@ -40,6 +87,7 @@ export default Alchemy.Stack( Effect.gen(function* () { const stage = parseMapleStage(yield* Alchemy.Stage) const domains = resolveMapleDomains(stage) + const shared = yield* createProductionSharedResources(stage) const apiUrl = resolveUrl(domains.api, "MAPLE_API_BASE_URL") const chatUrl = resolveUrl(domains.chat, "MAPLE_CHAT_BASE_URL") @@ -48,11 +96,23 @@ export default Alchemy.Stack( // back to a caller-supplied env var or the public Maple ingest endpoint. const ingestUrl = resolveUrl(domains.ingest, "VITE_INGEST_URL", "https://ingest.maple.dev") - // chat-flue deploys before api so api can service-bind the real worker (the - // v1 WorkerStub cycle-breaker is gone — chat-flue's api URL is now static). - const chatFlue = yield* createChatFlueWorker({ stage, domains, mapleApiUrl: apiUrl }) - - const { worker: api, db: mapleDb } = yield* createMapleApi({ stage, domains, chatFlue }) + // Deploy the API RPC surface before switching chat-flue to it. The reverse + // CHAT_FLUE binding is attached after chat deploys, which breaks the resource + // cycle without an HTTP fallback or a placeholder Worker. + const { worker: api, db: mapleDb } = yield* createMapleApi({ + stage, + domains, + }) + const chatFlue = yield* createChatFlueWorker({ + stage, + domains, + mapleApiRpc: api, + logsDestination: shared.logsDestination, + tracesDestination: shared.tracesDestination, + }) + yield* api.bind("CHAT_FLUE", { + bindings: [{ type: "service", name: "CHAT_FLUE", service: chatFlue.workerName }], + }) // Standalone ElectricSQL shape-proxy worker (DB-free); its public origin is // baked into the web build (VITE_ELECTRIC_SYNC_URL). @@ -71,7 +131,11 @@ export default Alchemy.Stack( const localUi = yield* createLocalUiWorker({ stage, domains }) - const alerting = yield* createAlertingWorker({ stage, domains, mapleDb }) + const alerting = yield* createAlertingWorker({ + stage, + domains, + mapleDb, + }) const summary = { stage: formatMapleStage(stage), diff --git a/apps/api/alchemy.run.ts b/apps/api/alchemy.run.ts index 8d1214670..0e0bc7246 100644 --- a/apps/api/alchemy.run.ts +++ b/apps/api/alchemy.run.ts @@ -1,7 +1,9 @@ import path from "node:path" import * as Cloudflare from "alchemy/Cloudflare" +import type { Rpc } from "alchemy/Rpc" import * as Effect from "effect/Effect" import * as Redacted from "effect/Redacted" +import type { MapleApiRpcShape } from "@maple/domain/internal-rpc" import type { MapleDomains, MapleStage } from "@maple/infra/cloudflare" import { CLOUDFLARE_WORKER_PLACEMENT, @@ -32,11 +34,12 @@ const optionalSecret = (key: string): Record> export interface CreateMapleApiOptions { stage: MapleStage domains: MapleDomains - /** The chat-flue worker, service-bound as CHAT_FLUE (hosts the Flue `triage` workflow). */ - chatFlue: Cloudflare.Worker } -export const createMapleApi = ({ stage, domains, chatFlue }: CreateMapleApiOptions) => +/** Alchemy resource type carried across the chat-flue service binding. */ +export type MapleApiWorker = Cloudflare.Worker & Rpc + +export const createMapleApi = ({ stage, domains }: CreateMapleApiOptions) => Effect.gen(function* () { // MAPLE_DB Hyperdrive comes in two flavors: // @@ -115,7 +118,7 @@ export const createMapleApi = ({ stage, domains, chatFlue }: CreateMapleApiOptio name: resolveWorkerName("vcs-sync", stage), }) - const worker = yield* Cloudflare.Worker("api", { + const worker = (yield* Cloudflare.Worker("api", { name: resolveWorkerName("api", stage), main: path.join(import.meta.dirname, "src", "worker.ts"), compatibility: { date: "2026-04-08", flags: ["nodejs_compat"] }, @@ -134,7 +137,6 @@ export const createMapleApi = ({ stage, domains, chatFlue }: CreateMapleApiOptio VCS_SYNC_QUEUE: vcsSyncQueue, CLICKHOUSE_SCHEMA_APPLY_WORKFLOW: schemaApplyWorkflow, AI_TRIAGE_WORKFLOW: aiTriageWorkflow, - CHAT_FLUE: chatFlue, EMAIL: Cloudflare.Email.SendEmail("email", { allowedSenderAddresses: ["notifications@noreply.maple.dev"], }), @@ -198,7 +200,7 @@ export const createMapleApi = ({ stage, domains, chatFlue }: CreateMapleApiOptio ...optionalPlain("PLANETSCALE_OAUTH_TOKEN_INFO_URL"), ...optionalPlain("MAPLE_PLANETSCALE_API_BASE_URL"), }, - }) + })) as MapleApiWorker if (hyperdriveRefId) { // v1 `HyperdriveRef` equivalent: bind the dashboard-managed config by ID diff --git a/apps/api/package.json b/apps/api/package.json index 35a0f7c9b..9d6eb822a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,6 +20,7 @@ "tinybird:build": "tinybird build", "tinybird:deploy": "tinybird deploy", "bench:fetch": "bun run scripts/bench-queries.ts fetch", + "bench:suite": "bun run scripts/bench-queries.ts suite", "bench:run": "bun run scripts/bench-queries.ts run", "bench:inspect": "bun run scripts/bench-queries.ts inspect", "bench:compare": "bun run scripts/bench-queries.ts compare", diff --git a/apps/api/scripts/BENCH.md b/apps/api/scripts/BENCH.md index cae7d8a58..d1601b1eb 100644 --- a/apps/api/scripts/BENCH.md +++ b/apps/api/scripts/BENCH.md @@ -15,18 +15,65 @@ bun bench:fetch [--context name] [--profile name] [--since 24h] [--top 20] [--out path] [--org id] # mine recent db.query.text spans from prod traces → JSON +bun bench:suite [--org id] [--since 24h] [--trace-id id] [--service name] + [--search phrase] [--attr-key k] [--attr-value v] + [--attr-scope span] [--table-map from=to,...] [--out path] + # compile the committed DSL benchmark suite → JSON (same + # shape as `fetch`, so `run`/`inspect`/`compare` replay it) + bun bench:run [--runs 5] [--warmup 1] [--out path] # replay each query N times and report aggregated stats bun bench:inspect # run EXPLAIN and EXPLAIN PIPELINE for each query -bun bench:compare - # diff two run outputs (p95 wall, read bytes, memory) +bun bench:compare [--max-regression-pct N] + # diff two run outputs (p95 wall, read bytes, memory); + # exits non-zero if any case's p95 regresses beyond N% ``` Output JSONs land in `apps/api/scripts/.bench/` by default (gitignored). +## Two ways to get a query set + +- **`fetch`** mines whatever prod actually ran for a context label — great for + chasing a specific slow query, but not repeatable (the fingerprints drift as + traffic changes). +- **`suite`** compiles a **committed, repeatable** set of ~13 representative + cases (needle-in-haystack body search, trace point lookup, attribute + equality/contains filters, list scans, facets, autocomplete, plus canaries) + straight from the `@maple/query-engine` DSL, so the benchmark runs the exact + SQL production emits. Definitions live in + [scripts/bench/suite.ts](bench/suite.ts). Pass `--trace-id` / `--service` / + `--attr-key` / `--attr-value` that exist in the target window for + representative timing — the two lookup cases fall back to zero-row + placeholders (with a warning) otherwise. + +## A/B: schema-change spike (e.g. sort-key experiment) + +`suite --table-map` rewrites `FROM`/`JOIN` table names in the compiled SQL so +you can benchmark the same queries against shadow tables without recompiling. +Rules are whole-identifier and longest-first, so `traces=traces_t2` never +touches `trace_detail_spans` or `traces_aggregates_hourly`. + +``` +# control vs candidate tables, same window/org +bun bench:suite --org org_x --trace-id --service \ + --out .bench/suiteA.json +bun bench:suite --org org_x --trace-id --service \ + --table-map "traces=traces_t2,logs=logs_l2" --out .bench/suiteB.json + +bun bench:run .bench/suiteA.json --out .bench/resA.json # CLICKHOUSE_URL = dedicated CH +bun bench:run .bench/suiteB.json --out .bench/resB.json +bun bench:compare .bench/resA.json .bench/resB.json --max-regression-pct 25 +``` + +Run A/B against a **dedicated ClickHouse** (docker/staging), not a Tinybird +branch — Tinybird branches share prod compute, so wall-time is noisy (lean on +`read rows`/`read bytes`, which are deterministic). The `inspect` command's +`EXPLAIN PIPELINE` tells you whether `optimize_read_in_order` actually engaged +for the candidate sort key. + ## Implementation Built on Effect v4 end-to-end: diff --git a/apps/api/scripts/bench-queries.ts b/apps/api/scripts/bench-queries.ts index 76ac76eba..180d459a3 100644 --- a/apps/api/scripts/bench-queries.ts +++ b/apps/api/scripts/bench-queries.ts @@ -39,6 +39,8 @@ import { import { Argument, Command, Flag } from "effect/unstable/cli" import { BunRuntime, BunServices } from "@effect/platform-bun" import { CH } from "@maple/query-engine" +import { buildSuite } from "./bench/suite" +import { parseTableMap, remapTables } from "./bench/table-map" // --------------------------------------------------------------------------- // Errors @@ -86,6 +88,13 @@ class InvalidDurationError extends Schema.TaggedErrorClass }, ) {} +class RegressionThresholdError extends Schema.TaggedErrorClass()( + "@maple/api/scripts/bench-queries/RegressionThresholdError", + { + message: Schema.String, + }, +) {} + // --------------------------------------------------------------------------- // Internal data shapes (typed JSON; not branded — local dev tool) // --------------------------------------------------------------------------- @@ -598,7 +607,7 @@ const fetchHandler = Effect.fn("bench.fetch")(function* (config: FetchConfig) { () => `apps/api/scripts/.bench/queries-${timestampSlug()}.json`, ) const output: FetchOutput = { - fetchedAt: now.toISOString(), + fetchedAt: new Date(nowMs).toISOString(), source: host, criteria: { orgId, @@ -638,6 +647,108 @@ const fetchHandler = Effect.fn("bench.fetch")(function* (config: FetchConfig) { yield* Console.log(`\nWrote ${outputPath}`) }) +// Non-empty sentinels so the point-lookup and service-scoped cases keep their +// query SHAPE (right table + filter) even when the user doesn't pass real +// values. They match zero rows, so timing for those two cases is only +// representative once real values are supplied — the handler warns when unset. +const SUITE_PLACEHOLDER_TRACE_ID = "REPLACE_WITH_REAL_TRACE_ID" +const SUITE_PLACEHOLDER_SERVICE = "REPLACE_WITH_REAL_SERVICE" + +interface SuiteConfig { + readonly org: Option.Option + readonly since: string + readonly traceId: string + readonly service: string + readonly search: string + readonly attrKey: string + readonly attrValue: string + readonly attrScope: string + readonly tableMap: Option.Option + readonly out: Option.Option +} + +// Compile the committed suite into a `fetch`-shaped JSON file so the existing +// `run` / `inspect` / `compare` commands replay it unchanged. For the sort-key +// A/B spike, run this twice with different `--table-map` targets and diff the +// two `run` outputs. +const suiteHandler = Effect.fn("bench.suite")(function* (config: SuiteConfig) { + const sinceMs = yield* parseRelativeDuration(config.since) + const nowMs = yield* Clock.currentTimeMillis + const startTime = formatCHDateTime(new Date(nowMs - sinceMs)) + const endTime = formatCHDateTime(new Date(nowMs)) + const orgId = Option.getOrElse(config.org, () => "internal") + + const tableMap = yield* Effect.try({ + try: () => + parseTableMap(Option.match(config.tableMap, { onNone: () => [], onSome: (s) => s.split(",") })), + catch: (cause) => new MissingConfigError({ what: "--table-map", message: String(cause) }), + }) + + const cases = buildSuite({ + traceId: config.traceId, + serviceName: config.service, + searchTerm: config.search, + attrKey: config.attrKey, + attrValue: config.attrValue, + attrScope: config.attrScope, + }) + + const samples: ReadonlyArray = cases.map((c) => ({ + fingerprint: c.name, + context: c.name, + profile: c.profile, + sampleSql: remapTables(c.compile({ orgId, startTime, endTime }), tableMap), + sampleCount: 0, + p50DurationMs: 0, + p95DurationMs: 0, + p99DurationMs: 0, + maxDurationMs: 0, + })) + + const remapNote = Option.match(config.tableMap, { + onNone: () => "", + onSome: (s) => ` table-map: ${s}`, + }) + const source = `suite (org=${orgId}, window=${config.since})${remapNote}` + const outputPath = Option.getOrElse( + config.out, + () => `apps/api/scripts/.bench/suite-${timestampSlug()}.json`, + ) + const output: FetchOutput = { + fetchedAt: new Date(nowMs).toISOString(), + source, + criteria: { orgId, startTime, endTime, topN: cases.length }, + samples, + } + yield* writeJsonFile(outputPath, output) + + yield* Console.log( + renderTable( + `Benchmark suite (${cases.length} cases)`, + [ + { header: "case", width: 28 }, + { header: "profile", width: 12 }, + { header: "description", width: 56 }, + ], + cases.map((c) => [c.name, c.profile, c.description]), + ), + ) + yield* Console.log(` org: ${orgId} window: ${startTime} → ${endTime} (${config.since})${remapNote}`) + + const placeholders: string[] = [] + if (config.traceId === SUITE_PLACEHOLDER_TRACE_ID) placeholders.push("--trace-id (trace_point_lookup)") + if (config.service === SUITE_PLACEHOLDER_SERVICE) placeholders.push("--service (service_span_search)") + if (placeholders.length > 0) { + yield* Console.log( + ` ⚠ using placeholder ${placeholders.join(" and ")} — those cases match 0 rows; ` + + "pass real values for representative timing.", + ) + } + + yield* Console.log(`\nWrote ${outputPath}`) + yield* Console.log(` Replay: bun run scripts/bench-queries.ts run ${outputPath}`) +}) + const stripTrailingSemi = (sql: string) => sql.replace(/;\s*$/, "") const failedResult = (sample: Sample, message: string): SampleResult => ({ @@ -871,6 +982,7 @@ const inspectHandler = Effect.fn("bench.inspect")(function* (config: { readonly const compareHandler = Effect.fn("bench.compare")(function* (config: { readonly baseline: string readonly candidate: string + readonly maxRegressionPct: Option.Option }) { const a = yield* readJsonFile(config.baseline) const b = yield* readJsonFile(config.candidate) @@ -915,6 +1027,33 @@ const compareHandler = Effect.fn("bench.compare")(function* (config: { rows, ), ) + + // Optional CI gate: fail if any case's candidate p95 regresses beyond the + // threshold vs the baseline. Cases missing from either side are ignored. + if (Option.isSome(config.maxRegressionPct)) { + const limit = config.maxRegressionPct.value + const regressions = a.results.flatMap((aResult) => { + const bResult = bByFp.get(aResult.fingerprint) + if (!bResult) return [] + const base = aResult.aggregates.p95WallMs + const cand = bResult.aggregates.p95WallMs + if (!Number.isFinite(base) || !Number.isFinite(cand) || base <= 0) return [] + const pct = ((cand - base) / base) * 100 + return pct > limit ? [{ name: aResult.context || aResult.fingerprint, pct }] : [] + }) + if (regressions.length > 0) { + const detail = regressions + .map((r) => ` ${r.name}: +${r.pct.toFixed(1)}% p95 (limit +${limit}%)`) + .join("\n") + yield* Console.log(`\n${regressions.length} case(s) regressed beyond +${limit}%:\n${detail}`) + return yield* Effect.fail( + new RegressionThresholdError({ + message: `${regressions.length} case(s) exceeded the +${limit}% p95 regression threshold`, + }), + ) + } + yield* Console.log(`\nNo case regressed beyond +${limit}% p95.`) + } }) // --------------------------------------------------------------------------- @@ -968,13 +1107,62 @@ const compareCommand = Command.make( { baseline: Argument.string("baseline").pipe(Argument.withDescription("Baseline results JSON")), candidate: Argument.string("candidate").pipe(Argument.withDescription("Candidate results JSON")), + maxRegressionPct: Flag.integer("max-regression-pct").pipe( + Flag.withDescription("Exit non-zero if any case's p95 regresses beyond this percent"), + Flag.optional, + ), }, compareHandler, ).pipe(Command.withDescription("Diff two run outputs (p95 wall, read bytes, memory)")) +const suiteCommand = Command.make( + "suite", + { + org: Flag.string("org").pipe(Flag.withDescription("Org id to scope the queries (default: internal)"), Flag.optional), + since: Flag.string("since").pipe( + Flag.withDescription("Time window the queries scan, e.g. 24h or 7d"), + Flag.withDefault("24h"), + ), + traceId: Flag.string("trace-id").pipe( + Flag.withDescription("Existing trace id for the point-lookup case (pass a real one for meaningful timing)"), + Flag.withDefault(SUITE_PLACEHOLDER_TRACE_ID), + ), + service: Flag.string("service").pipe( + Flag.withDescription("Service name for the service-scoped canary (pass a real one for meaningful timing)"), + Flag.withDefault(SUITE_PLACEHOLDER_SERVICE), + ), + search: Flag.string("search").pipe( + Flag.withDescription("Body-search phrase (an interior token engages the pre-filter, e.g. 'user login failed')"), + Flag.withDefault("user login failed"), + ), + attrKey: Flag.string("attr-key").pipe( + Flag.withDescription("Attribute key for the equality/contains filter cases"), + Flag.withDefault("http.request.method"), + ), + attrValue: Flag.string("attr-value").pipe( + Flag.withDescription("Attribute value for the equality/contains filter cases"), + Flag.withDefault("GET"), + ), + attrScope: Flag.string("attr-scope").pipe( + Flag.withDescription("Attribute scope for the autocomplete case (span|resource|log)"), + Flag.withDefault("span"), + ), + tableMap: Flag.string("table-map").pipe( + Flag.withDescription("Comma-separated FROM/JOIN rewrites for A/B, e.g. traces=traces_t2,logs=logs_l2"), + Flag.optional, + ), + out: Flag.string("out").pipe(Flag.withDescription("Output JSON path"), Flag.optional), + }, + suiteHandler, +).pipe( + Command.withDescription( + "Compile the committed DSL benchmark suite into a fetch-shaped JSON for `run`/`compare`", + ), +) + const rootCommand = Command.make("bench-queries").pipe( Command.withDescription("Measure ClickHouse query performance"), - Command.withSubcommands([fetchCommand, runCommand, inspectCommand, compareCommand]), + Command.withSubcommands([fetchCommand, suiteCommand, runCommand, inspectCommand, compareCommand]), ) const BenchLive = Layer.mergeAll(ClickHouse.layer, Tinybird.layer) diff --git a/apps/api/scripts/bench/suite.test.ts b/apps/api/scripts/bench/suite.test.ts new file mode 100644 index 000000000..be0973901 --- /dev/null +++ b/apps/api/scripts/bench/suite.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest" +import { buildSuite, type SuiteInputs, type SuiteParams } from "./suite" + +const inputs: SuiteInputs = { + traceId: "trace_abc123", + serviceName: "maple-api", + searchTerm: "user login failed", + attrKey: "http.request.method", + attrValue: "GET", + attrScope: "span", +} + +const params: SuiteParams = { + orgId: "org_test", + startTime: "2024-01-01 00:00:00", + endTime: "2024-01-02 00:00:00", +} + +describe("buildSuite", () => { + const cases = buildSuite(inputs) + + it("every case compiles to non-empty SQL scoped to the org", () => { + for (const c of cases) { + const sql = c.compile(params) + expect(sql.length, `${c.name} produced empty SQL`).toBeGreaterThan(0) + // Every query must be OrgId-scoped (the warehouse executor enforces this). + expect(sql, `${c.name} missing OrgId scope`).toContain("OrgId = 'org_test'") + } + }) + + it("has unique case names", () => { + const names = cases.map((c) => c.name) + expect(new Set(names).size).toBe(names.length) + }) + + it("the body-search case exercises the token index and the ILIKE tail", () => { + const sql = cases.find((c) => c.name === "logs_body_search")!.compile(params) + // "user login failed" → only the interior token "login" is index-safe. + expect(sql).toContain("hasToken(lower(Body), 'login')") + expect(sql).toContain("Body ILIKE '%user login failed%'") + }) + + it("the attribute-equality case exercises the items index", () => { + const sql = cases.find((c) => c.name === "traces_list_attr_equals")!.compile(params) + expect(sql).toContain("has(arrayMap((k, v) -> concat(k, '=', v), mapKeys(SpanAttributes)") + expect(sql).toContain("'http.request.method=GET'") + }) + + it("the point-lookup case reads the trace-detail projection", () => { + const sql = cases.find((c) => c.name === "trace_point_lookup")!.compile(params) + expect(sql).toContain("FROM trace_detail_spans") + expect(sql).toContain("TraceId = 'trace_abc123'") + }) +}) diff --git a/apps/api/scripts/bench/suite.ts b/apps/api/scripts/bench/suite.ts new file mode 100644 index 000000000..3007d7be0 --- /dev/null +++ b/apps/api/scripts/bench/suite.ts @@ -0,0 +1,153 @@ +// --------------------------------------------------------------------------- +// bench/suite.ts — committed, repeatable query benchmark suite +// +// Each case compiles a query from the SAME `@maple/query-engine` DSL that +// production runs, so the benchmark measures the exact SQL the app emits (not a +// hand-written approximation). Cases mirror ClickStack's representative +// patterns: needle-in-haystack body search, trace point lookup, attribute +// equality/contains filters, top-N list scans, facets, and autocomplete, plus a +// couple of service-scoped canaries to catch regressions from schema changes. +// +// The suite compiles to plain SQL strings, which the `bench` CLI writes into the +// same JSON shape `fetch` produces — so `run` / `inspect` / `compare` replay it +// unchanged. Keep this pure (no IO) so it stays unit-testable. +// --------------------------------------------------------------------------- + +import { CH } from "@maple/query-engine" + +/** Params substituted at compile time (the `param.*` placeholders every query shares). */ +export interface SuiteParams { + readonly orgId: string + readonly startTime: string + readonly endTime: string +} + +/** Literal inputs baked into query construction (a real trace id, a real attribute, …). */ +export interface SuiteInputs { + /** Existing trace id for the point-lookup case (bloom + trace_detail_spans path). */ + readonly traceId: string + /** A service present in the window, for the service-scoped canary. */ + readonly serviceName: string + /** Multi-word phrase so the body-search token pre-filter actually engages. */ + readonly searchTerm: string + /** Attribute key/value that occurs in the window (drives the items-index case). */ + readonly attrKey: string + readonly attrValue: string + /** Attribute scope for the autocomplete case ("span" | "resource" | "log"). */ + readonly attrScope: string +} + +export interface SuiteCase { + readonly name: string + readonly profile: string + readonly description: string + /** Compile this case's SQL for the given window/org. */ + readonly compile: (params: SuiteParams) => string +} + +/** + * Build the benchmark suite. `inputs` supplies the literal values (trace id, + * attribute, search phrase) that must exist in the target window for the query + * to touch real data — the CLI resolves sensible defaults or takes them from + * flags. + */ +export function buildSuite(inputs: SuiteInputs): ReadonlyArray { + const q = (query: Parameters[0], p: SuiteParams): string => CH.compile(query, p).sql + const u = (union: Parameters[0], p: SuiteParams): string => + CH.compileUnion(union, p).sql + + return [ + { + name: "logs_body_search", + profile: "list", + description: "Needle-in-haystack body search — exercises idx_body_tokens + ILIKE tail", + compile: (p) => q(CH.logsListQuery({ search: inputs.searchTerm, limit: 50 }), p), + }, + { + name: "logs_count_search", + profile: "list", + description: "count(*) over a body search — pure granule-pruning win from the token index", + compile: (p) => q(CH.logsCountQuery({ search: inputs.searchTerm }), p), + }, + { + name: "trace_point_lookup", + profile: "list", + description: "All spans of one trace — trace_detail_spans (OrgId,TraceId,SpanId) sort key", + compile: (p) => q(CH.spanSearchQuery({ traceId: inputs.traceId, limit: 100 }), p), + }, + { + name: "traces_list", + profile: "list", + description: "Traces list ORDER BY Timestamp DESC LIMIT N (two-stage cutoff)", + compile: (p) => q(CH.tracesListQuery({ limit: 50 }), p), + }, + { + name: "traces_list_attr_equals", + profile: "list", + description: "Traces list filtered by attribute equality — exercises idx_span_attr_items", + compile: (p) => + q( + CH.tracesListQuery({ + attributeFilters: [{ key: inputs.attrKey, value: inputs.attrValue, mode: "equals" }], + limit: 50, + }), + p, + ), + }, + { + name: "traces_list_attr_contains", + profile: "list", + description: "Traces list filtered by attribute substring — no items index (positionCaseInsensitive)", + compile: (p) => + q( + CH.tracesListQuery({ + attributeFilters: [{ key: inputs.attrKey, value: inputs.attrValue, mode: "contains" }], + limit: 50, + }), + p, + ), + }, + { + name: "logs_list", + profile: "list", + description: "Logs list ORDER BY Timestamp DESC LIMIT N (two-stage cutoff)", + compile: (p) => q(CH.logsListQuery({ limit: 50 }), p), + }, + { + name: "traces_facets", + profile: "unbounded", + description: "Trace facet breakdown (multi-branch UNION ALL)", + compile: (p) => u(CH.tracesFacetsQuery({}), p), + }, + { + name: "services_facets", + profile: "unbounded", + description: "Service facet breakdown (4-way UNION ALL over service_overview_spans)", + compile: (p) => u(CH.servicesFacetsQuery(), p), + }, + { + name: "logs_facets", + profile: "unbounded", + description: "Log facet breakdown (UNION over logs_aggregates_hourly)", + compile: (p) => u(CH.logsFacetsQuery({}), p), + }, + { + name: "attribute_keys_autocomplete", + profile: "discovery", + description: "Attribute-key autocomplete over the attribute_keys_hourly MV", + compile: (p) => q(CH.attributeKeysQuery({ scope: inputs.attrScope, limit: 200 }), p), + }, + { + name: "top_operations", + profile: "aggregation", + description: "Top operations by count — aggregation canary (should stay flat)", + compile: (p) => q(CH.topOperationsQuery({ metric: "count", limit: 20 }), p), + }, + { + name: "service_span_search", + profile: "list", + description: "Service-scoped raw span scan — sort-key-locality canary for the PK spike", + compile: (p) => q(CH.spanSearchQuery({ serviceName: inputs.serviceName, limit: 50 }), p), + }, + ] +} diff --git a/apps/api/scripts/bench/table-map.test.ts b/apps/api/scripts/bench/table-map.test.ts new file mode 100644 index 000000000..193870885 --- /dev/null +++ b/apps/api/scripts/bench/table-map.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest" +import { parseTableMap, remapTables } from "./table-map" + +describe("parseTableMap", () => { + it("parses from=to entries and skips blanks", () => { + const map = parseTableMap(["traces=traces_t2", " logs = logs_l2 ", ""]) + expect(map.get("traces")).toBe("traces_t2") + expect(map.get("logs")).toBe("logs_l2") + expect(map.size).toBe(2) + }) + + it("rejects malformed entries", () => { + expect(() => parseTableMap(["traces"])).toThrow() + expect(() => parseTableMap(["=traces_t2"])).toThrow() + expect(() => parseTableMap(["traces="])).toThrow() + }) +}) + +describe("remapTables", () => { + const map = parseTableMap(["traces=traces_t2", "logs=logs_l2"]) + + it("rewrites FROM and JOIN table positions", () => { + expect(remapTables("SELECT * FROM traces WHERE OrgId = 'x'", map)).toBe( + "SELECT * FROM traces_t2 WHERE OrgId = 'x'", + ) + expect(remapTables("SELECT * FROM a JOIN logs ON a.id = logs.id", map)).toContain("JOIN logs_l2 ON") + }) + + it("does NOT touch longer table names that share a prefix", () => { + // `traces` rule must not clobber trace_detail_spans / traces_aggregates_hourly. + expect(remapTables("SELECT * FROM trace_detail_spans", map)).toBe("SELECT * FROM trace_detail_spans") + expect(remapTables("SELECT * FROM traces_aggregates_hourly", map)).toBe( + "SELECT * FROM traces_aggregates_hourly", + ) + }) + + it("does NOT rewrite the name outside a FROM/JOIN position", () => { + // A column or literal that happens to equal a table name is left alone. + expect(remapTables("SELECT traces FROM x WHERE note = 'traces'", map)).toBe( + "SELECT traces FROM x WHERE note = 'traces'", + ) + }) + + it("is a no-op for an empty map", () => { + const sql = "SELECT * FROM traces" + expect(remapTables(sql, parseTableMap([]))).toBe(sql) + }) +}) diff --git a/apps/api/scripts/bench/table-map.ts b/apps/api/scripts/bench/table-map.ts new file mode 100644 index 000000000..3561d3303 --- /dev/null +++ b/apps/api/scripts/bench/table-map.ts @@ -0,0 +1,50 @@ +// --------------------------------------------------------------------------- +// bench/table-map.ts — rewrite table identifiers in compiled SQL for A/B runs +// +// The sort-key spike compares the same suite against shadow tables (e.g. +// `traces` vs `traces_t2`). Rather than recompile the DSL against alternate +// table names, we rewrite `FROM ` / `JOIN ` in the already-compiled +// SQL. Rules apply longest-source-name-first and are anchored on whole +// identifiers so a `traces` rule never touches `trace_detail_spans` or +// `traces_aggregates_hourly`. +// --------------------------------------------------------------------------- + +export type TableMap = ReadonlyMap + +const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +/** + * Parse `["traces=traces_t2", "logs=logs_l2"]` (or a single comma-joined string + * split by the caller) into a source→target map. Throws on a malformed entry. + */ +export function parseTableMap(entries: ReadonlyArray): TableMap { + const map = new Map() + for (const raw of entries) { + const entry = raw.trim() + if (entry === "") continue + const eq = entry.indexOf("=") + if (eq <= 0 || eq === entry.length - 1) { + throw new Error(`Invalid --table-map entry "${raw}" (expected from=to, e.g. traces=traces_t2)`) + } + map.set(entry.slice(0, eq).trim(), entry.slice(eq + 1).trim()) + } + return map +} + +/** + * Rewrite table identifiers in `sql`. Only rewrites the table position (directly + * after `FROM` / `JOIN`), matching the whole identifier, so string literals or + * column names that happen to share a table's name are untouched. Rules are + * applied longest-source-first to avoid a short name shadowing a longer one. + */ +export function remapTables(sql: string, map: TableMap): string { + if (map.size === 0) return sql + const names = [...map.keys()].sort((a, b) => b.length - a.length) + let out = sql + for (const name of names) { + const target = map.get(name)! + const re = new RegExp(`(\\b(?:FROM|JOIN)\\s+)${escapeRegExp(name)}\\b`, "g") + out = out.replace(re, `$1${target}`) + } + return out +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index d9673cda1..81fb190fb 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -16,7 +16,6 @@ import { HttpDemoLive } from "./routes/demo.http" import { HttpDigestLive } from "./routes/digest.http" import { HttpIntegrationsLive, IntegrationsCallbackRouter } from "./routes/integrations.http" import { HttpInvestigationsLive } from "./routes/investigations.http" -import { HttpInvestigationsInternalLive } from "./routes/investigations-internal.http" import { HttpIngestAttributeMappingsLive } from "./routes/ingest-attribute-mappings.http" import { HttpIngestKeysLive } from "./routes/ingest-keys.http" import { HttpObservabilityLive } from "./routes/observability.http" @@ -45,7 +44,6 @@ import { NotificationDispatcher } from "./services/NotificationDispatcher" import { ApiKeysService } from "./services/ApiKeysService" import { AuthService } from "./services/AuthService" import { ApiAuthorizationLayer } from "./services/ApiAuthorizationLayer" -import { InternalServiceAuthorizationLayer } from "./services/InternalServiceAuthorizationLayer" import { CloudflareAnalyticsService } from "./services/CloudflareAnalyticsService" import { CloudflareOAuthService } from "./services/CloudflareOAuthService" import { DashboardPersistenceService } from "./services/DashboardPersistenceService" @@ -240,15 +238,7 @@ export const MainLive = Layer.mergeAll( const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe( Layer.provide(HttpAuthPublicLive), Layer.provide(HttpAuthLive), - Layer.provide( - Layer.mergeAll( - HttpAiTriageLive, - HttpAnomaliesLive, - HttpChatLive, - HttpInvestigationsLive, - HttpInvestigationsInternalLive, - ), - ), + Layer.provide(Layer.mergeAll(HttpAiTriageLive, HttpAnomaliesLive, HttpChatLive, HttpInvestigationsLive)), Layer.provide(HttpApiKeysLive), Layer.provide(Layer.mergeAll(HttpBillingLive, HttpBillingPublicLive)), Layer.provide(HttpAlertsLive), @@ -304,15 +294,6 @@ export const ApiAuthLive = ApiAuthorizationLayer.pipe( Layer.provideMerge(Env.layer), ) -// Internal-service-token middleware (the chat-flue `submit_diagnosis` write). -// Mirrors ApiAuthLive; AuthService/ApiKeysService/Env are layer-memoized, so this -// shares instances with MainLive rather than constructing duplicates. -export const InternalServiceAuthLive = InternalServiceAuthorizationLayer.pipe( - Layer.provideMerge(ApiKeysService.layer), - Layer.provideMerge(AuthService.layer), - Layer.provideMerge(Env.layer), -) - // The OTLP tracer/logger is constructed once at worker module scope and // provided to the same runtime as the routes. This shared layer only installs // the `TracerDisabledWhen` filter, which is a ServiceMap.Reference read by diff --git a/apps/api/src/internal-rpc.test.ts b/apps/api/src/internal-rpc.test.ts new file mode 100644 index 000000000..180cf49bf --- /dev/null +++ b/apps/api/src/internal-rpc.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest" +import { Effect } from "effect" +import type { InternalRpcInvalidInputError } from "@maple/domain/internal-rpc" +import { callMcpToolRpc, submitDiagnosisRpc } from "./internal-rpc" +import { InvestigationService, type InvestigationServiceShape } from "./services/InvestigationService" + +const investigationId = "00000000-0000-4000-8000-000000000001" +const report = { + summary: "Checkout latency doubled after deploy.", + suspectedCause: "Connection pool regression", + severityAssessment: "high", + affectedScope: "checkout-api", + evidence: [ + { + traceIds: ["trace-1"], + logPatterns: ["pool exhausted"], + relatedServices: ["payments"], + note: "The failing traces share the same pool exhaustion event.", + }, + ], + suggestedActions: ["Roll back the deploy"], + confidence: "high", +} as const + +const unusedInvestigationService: InvestigationServiceShape = { + listInvestigations: () => Effect.die("unused"), + getInvestigation: () => Effect.die("unused"), + createInvestigation: () => Effect.die("unused"), + updateStatus: () => Effect.die("unused"), + submitDiagnosis: () => Effect.die("unused"), +} + +describe("internal RPC boundary", () => { + it("rejects invalid org IDs before MCP dispatch", async () => { + const error = await Effect.runPromise( + Effect.flip( + callMcpToolRpc({ orgId: " ", name: "inspect_trace", input: {} }) as Effect.Effect< + never, + InternalRpcInvalidInputError, + never + >, + ), + ) + expect(error._tag).toBe("@maple/internal-rpc/InvalidInputError") + expect(error.method).toBe("callMcpTool") + }) + + it("rejects invalid investigation IDs and model-produced reports", async () => { + for (const input of [ + { orgId: "org_1", investigationId: "not-a-uuid", report }, + { orgId: "org_1", investigationId, report: { summary: "incomplete" } }, + ]) { + const error = await Effect.runPromise( + Effect.flip( + submitDiagnosisRpc(input).pipe( + Effect.provideService(InvestigationService, unusedInvestigationService), + ), + ), + ) + expect(error._tag).toBe("@maple/internal-rpc/InvalidInputError") + if (error._tag !== "@maple/internal-rpc/InvalidInputError") { + throw new Error(`Expected invalid input, received ${error._tag}`) + } + expect(error.method).toBe("submitDiagnosis") + } + }) + + it("submits a decoded diagnosis to the org-scoped service", async () => { + const calls: Array<{ orgId: string; investigationId: string; summary: string }> = [] + const expected = { id: investigationId, status: "diagnosed" } as never + const service: InvestigationServiceShape = { + ...unusedInvestigationService, + submitDiagnosis: (orgId, id, request) => + Effect.sync(() => { + calls.push({ orgId, investigationId: id, summary: request.report.summary }) + return expected + }), + } + + const result = await Effect.runPromise( + submitDiagnosisRpc({ orgId: "org_1", investigationId, report }).pipe( + Effect.provideService(InvestigationService, service), + ), + ) + expect(result).toBe(expected) + expect(calls).toEqual([ + { orgId: "org_1", investigationId, summary: "Checkout latency doubled after deploy." }, + ]) + }) +}) diff --git a/apps/api/src/internal-rpc.ts b/apps/api/src/internal-rpc.ts new file mode 100644 index 000000000..43905edd2 --- /dev/null +++ b/apps/api/src/internal-rpc.ts @@ -0,0 +1,61 @@ +import { + CallMcpToolRpcRequest, + InternalRpcInvalidInputError, + SubmitDiagnosisRpcRequest, +} from "@maple/domain/internal-rpc" +import { SubmitDiagnosisRequest } from "@maple/domain/http" +import { UserId } from "@maple/domain/primitives" +import { Effect, Schema } from "effect" +import type { TenantContext } from "./lib/tenant-context" +import { callMcpTool, listMcpTools } from "./mcp/dispatcher" +import { CurrentMcpTenant } from "./mcp/lib/query-warehouse" +import { InvestigationService } from "./services/InvestigationService" + +const internalServiceUserId = Schema.decodeUnknownSync(UserId)("internal-service") + +const invalidInput = (method: "callMcpTool" | "submitDiagnosis") => (error: { message: string }) => + new InternalRpcInvalidInputError({ method, message: error.message }) + +const decodeCallMcpTool = (input: unknown) => + Schema.decodeUnknownEffect(CallMcpToolRpcRequest)(input).pipe( + Effect.mapError(invalidInput("callMcpTool")), + ) + +const decodeSubmitDiagnosis = (input: unknown) => + Schema.decodeUnknownEffect(SubmitDiagnosisRpcRequest)(input).pipe( + Effect.mapError(invalidInput("submitDiagnosis")), + ) + +const makeInternalTenant = (orgId: CallMcpToolRpcRequest["orgId"]): TenantContext => ({ + orgId, + userId: internalServiceUserId, + roles: [], + authMode: "self_hosted", +}) + +export const listMcpToolsRpc = listMcpTools.pipe(Effect.withSpan("InternalRpc.listMcpTools")) + +export const callMcpToolRpc = (input: unknown) => + decodeCallMcpTool(input).pipe( + Effect.flatMap((request) => + callMcpTool(request.name, request.input).pipe( + Effect.provideService(CurrentMcpTenant, makeInternalTenant(request.orgId)), + ), + ), + Effect.withSpan("InternalRpc.callMcpTool"), + ) + +export const submitDiagnosisRpc = (input: unknown) => + Effect.gen(function* () { + const request = yield* decodeSubmitDiagnosis(input) + yield* Effect.annotateCurrentSpan({ + "maple.org_id": request.orgId, + "maple.investigation.id": request.investigationId, + }) + const investigations = yield* InvestigationService + return yield* investigations.submitDiagnosis( + request.orgId, + request.investigationId, + new SubmitDiagnosisRequest({ report: request.report }), + ) + }).pipe(Effect.withSpan("InternalRpc.submitDiagnosis")) diff --git a/apps/api/src/mcp/__evals__/eval-runtime.ts b/apps/api/src/mcp/__evals__/eval-runtime.ts index 66ecfbcc9..5437ce209 100644 --- a/apps/api/src/mcp/__evals__/eval-runtime.ts +++ b/apps/api/src/mcp/__evals__/eval-runtime.ts @@ -1,10 +1,11 @@ import { ConfigProvider, Effect, Layer, ManagedRuntime, Schema } from "effect" -import { HttpServerRequest } from "effect/unstable/http" +import { OrgId, UserId } from "@maple/domain/http" import { MainLive } from "@/app" import { Env } from "@/lib/Env" import { WorkerEnvironment } from "@/lib/WorkerEnvironment" import { createTestDb } from "@/lib/test-pglite" import { mapleToolDefinitions } from "@/mcp/tools/registry" +import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" import { FIXTURES } from "./utils" const INTERNAL_TOKEN = "eval-internal-token" @@ -36,8 +37,8 @@ export interface EvalRuntime { * test config (mirrors apps/api `getMapleAgentSetup`/buildSetup, swapping Hyperdrive→PGlite). * The warehouse client must be faked separately via `installFakeWarehouse` — * this runtime uses the REAL WarehouseQueryService. The returned `requestLayer` - * carries an internal-service-token request so tool handlers resolve the tenant - * without exercising Clerk/API-key auth. + * carries the already-resolved MCP tenant, matching the post-auth dispatcher + * context shared by HTTP and RPC. */ export const makeEvalRuntime = (): EvalRuntime => { const testDb = createTestDb() @@ -57,17 +58,12 @@ export const makeEvalRuntime = (): EvalRuntime => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const runtime = ManagedRuntime.make(layer as any) as ManagedRuntime.ManagedRuntime - const requestLayer = Layer.succeed( - HttpServerRequest.HttpServerRequest, - HttpServerRequest.fromWeb( - new Request("https://maple.eval/mcp", { - headers: { - Authorization: `Bearer maple_svc_${INTERNAL_TOKEN}`, - "X-Org-Id": FIXTURES.orgId, - }, - }), - ), - ) + const requestLayer = Layer.succeed(CurrentMcpTenant, { + orgId: Schema.decodeUnknownSync(OrgId)(FIXTURES.orgId), + userId: Schema.decodeUnknownSync(UserId)("internal-service"), + roles: [], + authMode: "self_hosted", + }) return { runtime, diff --git a/apps/api/src/mcp/__evals__/tools.ts b/apps/api/src/mcp/__evals__/tools.ts index 6335f3a02..f97e46320 100644 --- a/apps/api/src/mcp/__evals__/tools.ts +++ b/apps/api/src/mcp/__evals__/tools.ts @@ -22,7 +22,7 @@ export const buildPredictionToolSet = (): ToolSet => /** * Like `buildPredictionToolSet` but with a real `execute` that runs the tool * handler through the given runtime (which must provide the app services) and a - * request layer (which carries the tenant). Mirrors + * request layer (which carries the resolved tenant). Mirrors * apps/chat-agent/src/services/direct-tools.ts `createMapleAiTools`, but the * runtime is wired with a FAKE warehouse for full-execution evals. */ diff --git a/apps/api/src/mcp/dispatcher.test.ts b/apps/api/src/mcp/dispatcher.test.ts new file mode 100644 index 000000000..4d63ba995 --- /dev/null +++ b/apps/api/src/mcp/dispatcher.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest" +import { Effect } from "effect" +import type { InternalRpcToolNotFoundError } from "@maple/domain/internal-rpc" +import { callMcpTool, listMcpTools } from "./dispatcher" +import { mapleToolDefinitions, toInputSchema } from "./tools/registry" + +describe("MCP dispatcher", () => { + it("publishes the same names, descriptions, and schemas used by HTTP MCP", async () => { + const descriptors = await Effect.runPromise(listMcpTools) + expect(descriptors).toEqual( + mapleToolDefinitions.map((definition) => ({ + name: definition.name, + description: definition.description, + inputSchema: toInputSchema(definition.schema), + })), + ) + }) + + it("returns MCP validation feedback for invalid model tool input", async () => { + const result = await Effect.runPromise( + callMcpTool("inspect_trace", {}) as unknown as Effect.Effect< + { + readonly content: ReadonlyArray<{ readonly type: "text"; readonly text: string }> + readonly isError?: boolean + }, + never, + never + >, + ) + expect(result.isError).toBe(true) + expect(result.content[0]?.text).toContain("Invalid parameters") + expect(result.content[0]?.text).toContain("inspect_trace") + }) + + it("fails unknown RPC tool names with a typed error", async () => { + const error = await Effect.runPromise( + Effect.flip( + callMcpTool("not_a_maple_tool", {}) as Effect.Effect< + never, + InternalRpcToolNotFoundError, + never + >, + ), + ) + expect(error._tag).toBe("@maple/internal-rpc/ToolNotFoundError") + expect(error.name).toBe("not_a_maple_tool") + }) +}) diff --git a/apps/api/src/mcp/dispatcher.ts b/apps/api/src/mcp/dispatcher.ts new file mode 100644 index 000000000..822348b58 --- /dev/null +++ b/apps/api/src/mcp/dispatcher.ts @@ -0,0 +1,133 @@ +import { InternalRpcToolNotFoundError, type InternalMcpToolDescriptor } from "@maple/domain/internal-rpc" +import { Effect, Schema } from "effect" +import { mapleToolDefinitions, toInputSchema, type MapleToolDefinition } from "./tools/registry" +import type { McpToolResult } from "./tools/types" + +class McpDecodeError extends Schema.TaggedErrorClass()("@maple/mcp/decode-error", { + errorMessage: Schema.String, +}) {} + +const toErrorMessage = (error: unknown): string => { + if (error instanceof Error && "error" in error && error.error != null) { + const inner = error.error + return inner instanceof Error ? inner.message : String(inner) + } + if (error instanceof Error) return error.message + return String(error) +} + +const toDecodeErrorMessage = (definition: MapleToolDefinition, error: unknown): string => { + if (Schema.isSchemaError(error)) { + return `${String(error)}. Check the "${definition.name}" tool schema for valid parameter names and types.` + } + return String(error) +} + +const toolDescriptors: ReadonlyArray = mapleToolDefinitions.map((definition) => ({ + name: definition.name, + description: definition.description, + inputSchema: toInputSchema(definition.schema), +})) + +export const listMcpTools = Effect.succeed(toolDescriptors) + +/** Shared tool dispatcher for public MCP-over-HTTP and internal Worker RPC. */ +export const callMcpTool = Effect.fn("McpToolDispatcher.call")(function* (name: string, input: unknown) { + const definition = mapleToolDefinitions.find((candidate) => candidate.name === name) + if (!definition) { + return yield* new InternalRpcToolNotFoundError({ + name, + message: `Unknown MCP tool: ${name}`, + }) + } + + const execute = Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ tool: definition.name }) + const decoded = yield* Effect.try({ + try: () => Schema.decodeUnknownSync(definition.schema)(input), + catch: (error) => error, + }).pipe( + Effect.mapError( + (error) => + new McpDecodeError({ + errorMessage: toDecodeErrorMessage(definition, error), + }), + ), + ) + + return yield* definition.handler(decoded).pipe(Effect.tap(() => Effect.logInfo("Tool completed"))) + }) + + return yield* execute.pipe( + Effect.catchTag("@maple/mcp/decode-error", (error) => + Effect.logWarning("Invalid parameters").pipe( + Effect.annotateLogs({ error: error.errorMessage }), + Effect.as({ + isError: true, + content: [ + { + type: "text" as const, + text: `Invalid parameters: ${error.errorMessage}`, + }, + ], + } satisfies McpToolResult), + ), + ), + Effect.catchTags({ + "@maple/mcp/errors/McpQueryError": (error) => + Effect.logError(`Tool error: ${error.message}`).pipe( + Effect.annotateLogs({ errorTag: error._tag, pipe: error.pipeName }), + Effect.as({ + isError: true, + content: [{ type: "text", text: `${error._tag}: ${error.message}` }], + } satisfies McpToolResult), + ), + "@maple/mcp/errors/McpTenantError": (error) => + Effect.logError(`Tool error: ${error.message}`).pipe( + Effect.annotateLogs({ errorTag: error._tag }), + Effect.as({ + isError: true, + content: [{ type: "text", text: `${error._tag}: ${error.message}` }], + } satisfies McpToolResult), + ), + "@maple/mcp/errors/McpAuthMissingError": (error) => + Effect.logError(`Auth error: ${error.message}`).pipe( + Effect.annotateLogs({ errorTag: error._tag }), + Effect.as({ + isError: true, + content: [{ type: "text", text: `${error._tag}: ${error.message}` }], + } satisfies McpToolResult), + ), + "@maple/mcp/errors/McpAuthInvalidError": (error) => + Effect.logError(`Auth error: ${error.message}`).pipe( + Effect.annotateLogs({ errorTag: error._tag }), + Effect.as({ + isError: true, + content: [{ type: "text", text: `${error._tag}: ${error.message}` }], + } satisfies McpToolResult), + ), + "@maple/mcp/errors/McpInvalidTenantError": (error) => + Effect.logError(`Tenant validation error [${error.field}]: ${error.message}`).pipe( + Effect.annotateLogs({ errorTag: error._tag, field: error.field }), + Effect.as({ + isError: true, + content: [ + { + type: "text", + text: `${error._tag} (${error.field}): ${error.message}`, + }, + ], + } satisfies McpToolResult), + ), + }), + Effect.catchDefect((error) => + Effect.logError(`Tool defect: ${toErrorMessage(error)}`).pipe( + Effect.as({ + isError: true, + content: [{ type: "text", text: `Error: ${toErrorMessage(error)}` }], + } satisfies McpToolResult), + ), + ), + Effect.annotateLogs({ tool: definition.name }), + ) +}) diff --git a/apps/api/src/mcp/lib/dashboard-mutations.test.ts b/apps/api/src/mcp/lib/dashboard-mutations.test.ts index 114042a8c..454f24035 100644 --- a/apps/api/src/mcp/lib/dashboard-mutations.test.ts +++ b/apps/api/src/mcp/lib/dashboard-mutations.test.ts @@ -12,20 +12,12 @@ import { afterEach, assert, describe, it } from "@effect/vitest" import { ConfigProvider, Effect, Layer, Schema } from "effect" -import { HttpServerRequest } from "effect/unstable/http" -import { - DashboardDocument, - DashboardId, - IsoDateTimeString, - OrgId, - UserId, -} from "@maple/domain/http" +import { DashboardDocument, DashboardId, IsoDateTimeString, OrgId, UserId } from "@maple/domain/http" import { DashboardPersistenceService } from "@/services/DashboardPersistenceService" -import { AuthService } from "@/services/AuthService" -import { ApiKeysService } from "@/services/ApiKeysService" import { Env } from "@/lib/Env" import { cleanupTestDbs, createTestDb, type TestDb } from "@/lib/test-pglite" import { withDashboardMutation } from "./dashboard-mutations" +import { CurrentMcpTenant } from "./query-warehouse" import { registerUpdateDashboardTool } from "@/mcp/tools/update-dashboard" import type { McpToolError, McpToolRegistrar, McpToolResult } from "@/mcp/tools/types" @@ -33,9 +25,8 @@ const trackedDbs: TestDb[] = [] afterEach(() => cleanupTestDbs(trackedDbs)) -// The dashboard-mutation tools resolve their tenant from the inbound HTTP -// request. We take the internal-service auth branch (a `maple_svc_` bearer + -// `x-org-id`), which only needs `Env`, so no API key / session plumbing. +// MCP transport authentication resolves the tenant before dispatch. Tool tests +// inject that already-resolved context directly, matching both HTTP and RPC. const INTERNAL_TOKEN = "test-internal-token" const ORG = "org_no_tags" @@ -55,33 +46,16 @@ const testConfig = () => }), ) -const requestLayer = Layer.succeed( - HttpServerRequest.HttpServerRequest, - HttpServerRequest.fromWeb( - new Request("http://api.localhost/mcp", { - method: "POST", - headers: { - authorization: `Bearer maple_svc_${INTERNAL_TOKEN}`, - "x-org-id": ORG, - }, - }), - ), -) - const makeLayer = (testDb: TestDb) => Layer.mergeAll( DashboardPersistenceService.layer, - AuthService.layer, - ApiKeysService.layer, - requestLayer, - ).pipe( - Layer.provide(testDb.layer), - // `provideMerge` so `Env` is both satisfied for the services above and - // exposed in the output — `withDashboardMutation` → `resolveTenant` reads - // `Env` directly from the outer context. - Layer.provideMerge(Env.layer), - Layer.provide(testConfig()), - ) + Layer.succeed(CurrentMcpTenant, { + orgId: Schema.decodeUnknownSync(OrgId)(ORG), + userId: Schema.decodeUnknownSync(UserId)("internal-service"), + roles: [], + authMode: "self_hosted", + }), + ).pipe(Layer.provide(testDb.layer), Layer.provideMerge(Env.layer), Layer.provide(testConfig())) const asDashboardId = Schema.decodeUnknownSync(DashboardId) const asIsoDateTimeString = Schema.decodeUnknownSync(IsoDateTimeString) diff --git a/apps/api/src/mcp/lib/query-warehouse.ts b/apps/api/src/mcp/lib/query-warehouse.ts index 35641425b..b0e33a53e 100644 --- a/apps/api/src/mcp/lib/query-warehouse.ts +++ b/apps/api/src/mcp/lib/query-warehouse.ts @@ -1,14 +1,19 @@ import { HttpServerRequest } from "effect/unstable/http" import type { WarehouseQueryName } from "@maple/domain" -import { Effect } from "effect" +import { Context, Effect } from "effect" import { resolveMcpTenantContext } from "@/mcp/lib/resolve-tenant" +import type { TenantContext } from "@/lib/tenant-context" import { toMcpQueryError } from "@/mcp/lib/map-warehouse-error" import { McpAuthMissingError } from "@/mcp/tools/types" import { WarehouseQueryService } from "@/lib/WarehouseQueryService" import { WarehouseExecutor } from "@maple/query-engine/observability" import { makeWarehouseExecutorFromTenant } from "@/lib/WarehouseQueryService" -export const resolveTenant = Effect.gen(function* () { +export class CurrentMcpTenant extends Context.Service()( + "@maple/api/mcp/CurrentMcpTenant", +) {} + +export const resolveHttpMcpTenant = Effect.gen(function* () { const req = yield* HttpServerRequest.HttpServerRequest const nativeReq = yield* HttpServerRequest.toWeb(req).pipe( Effect.mapError((e) => new McpAuthMissingError({ message: `Failed to read request: ${e.message}` })), @@ -16,6 +21,8 @@ export const resolveTenant = Effect.gen(function* () { return yield* resolveMcpTenantContext(nativeReq) }) +export const resolveTenant = CurrentMcpTenant + /** Infrastructure binding: resolves tenant and provides WarehouseExecutor layer. */ export const withTenantExecutor = (effect: Effect.Effect) => Effect.fn("withTenantExecutor")(function* () { diff --git a/apps/api/src/mcp/server.ts b/apps/api/src/mcp/server.ts index 6b1e08384..cd5252dab 100644 --- a/apps/api/src/mcp/server.ts +++ b/apps/api/src/mcp/server.ts @@ -1,21 +1,9 @@ import { McpSchema, McpServer as EffectMcpServer } from "effect/unstable/ai" -import { Effect, Layer, Schema, Context } from "effect" -import { mapleToolDefinitions, toInputSchema, type MapleToolDefinition } from "./tools/registry" +import { Context, Effect, Layer } from "effect" +import { callMcpTool, listMcpTools } from "./dispatcher" +import { CurrentMcpTenant, resolveHttpMcpTenant } from "./lib/query-warehouse" import type { McpToolResult } from "./tools/types" -class McpDecodeError extends Schema.TaggedErrorClass()("@maple/mcp/decode-error", { - errorMessage: Schema.String, -}) {} - -const toErrorMessage = (error: unknown): string => { - if (error instanceof Error && "error" in error && (error as any).error != null) { - const inner = (error as any).error - return inner instanceof Error ? inner.message : String(inner) - } - if (error instanceof Error) return error.message - return String(error) -} - const toCallToolResult = (result: McpToolResult): typeof McpSchema.CallToolResult.Type => new McpSchema.CallToolResult({ isError: result.isError === true ? true : undefined, @@ -25,141 +13,43 @@ const toCallToolResult = (result: McpToolResult): typeof McpSchema.CallToolResul })), }) -const toDecodeErrorMessage = (definition: MapleToolDefinition, error: unknown): string => { - if (Schema.isSchemaError(error)) { - return `${String(error)}. Check the "${definition.name}" tool schema for valid parameter names and types.` - } - return String(error) -} +const toBoundaryErrorResult = (error: { readonly _tag: string; readonly message: string }) => + toCallToolResult({ + isError: true, + content: [{ type: "text", text: `${error._tag}: ${error.message}` }], + }) +/** Public MCP transport backed by the same dispatcher as internal Worker RPC. */ export const McpToolsLive = Layer.effectDiscard( Effect.gen(function* () { const server = yield* EffectMcpServer.McpServer - yield* Effect.forEach(mapleToolDefinitions, (definition) => + const descriptors = yield* listMcpTools + yield* Effect.forEach(descriptors, (descriptor) => server.addTool({ tool: new McpSchema.Tool({ - name: definition.name, - description: definition.description, - inputSchema: toInputSchema(definition.schema), + name: descriptor.name, + description: descriptor.description, + inputSchema: descriptor.inputSchema, }), annotations: Context.empty(), - handle: Effect.fn("McpTool.handle")( - function* (payload) { - yield* Effect.annotateCurrentSpan({ tool: definition.name }) - const decoded = yield* Effect.try({ - try: () => Schema.decodeUnknownSync(definition.schema)(payload), - catch: (error) => error, - }).pipe( - Effect.mapError( - (error) => - new McpDecodeError({ - errorMessage: toDecodeErrorMessage(definition, error), - }), - ), - ) - - return yield* definition.handler(decoded).pipe( - Effect.tap(() => Effect.logInfo("Tool completed")), - Effect.map(toCallToolResult), - ) - }, - Effect.catchTag("@maple/mcp/decode-error", (error) => - Effect.logWarning("Invalid parameters").pipe( - Effect.annotateLogs({ error: error.errorMessage }), - Effect.as( - toCallToolResult({ - isError: true, - content: [ - { - type: "text", - text: `Invalid parameters: ${error.errorMessage}`, - }, - ], - }), + handle: (payload) => + resolveHttpMcpTenant.pipe( + Effect.flatMap((tenant) => + callMcpTool(descriptor.name, payload).pipe( + Effect.provideService(CurrentMcpTenant, tenant), ), ), - ), - Effect.catchTags({ - "@maple/mcp/errors/McpQueryError": (error) => - Effect.logError(`Tool error: ${error.message}`).pipe( - Effect.annotateLogs({ - errorTag: error._tag, - pipe: error.pipeName, - }), - Effect.as( - toCallToolResult({ - isError: true, - content: [ - { type: "text", text: `${error._tag}: ${error.message}` }, - ], - }), - ), - ), - "@maple/mcp/errors/McpTenantError": (error) => - Effect.logError(`Tool error: ${error.message}`).pipe( - Effect.annotateLogs({ errorTag: error._tag }), - Effect.as( - toCallToolResult({ - isError: true, - content: [ - { type: "text", text: `${error._tag}: ${error.message}` }, - ], - }), - ), - ), + Effect.map(toCallToolResult), + Effect.catchTags({ + "@maple/internal-rpc/ToolNotFoundError": (error) => + Effect.succeed(toBoundaryErrorResult(error)), "@maple/mcp/errors/McpAuthMissingError": (error) => - Effect.logError(`Auth error: ${error.message}`).pipe( - Effect.annotateLogs({ errorTag: error._tag }), - Effect.as( - toCallToolResult({ - isError: true, - content: [ - { type: "text", text: `${error._tag}: ${error.message}` }, - ], - }), - ), - ), + Effect.succeed(toBoundaryErrorResult(error)), "@maple/mcp/errors/McpAuthInvalidError": (error) => - Effect.logError(`Auth error: ${error.message}`).pipe( - Effect.annotateLogs({ errorTag: error._tag }), - Effect.as( - toCallToolResult({ - isError: true, - content: [ - { type: "text", text: `${error._tag}: ${error.message}` }, - ], - }), - ), - ), + Effect.succeed(toBoundaryErrorResult(error)), "@maple/mcp/errors/McpInvalidTenantError": (error) => - Effect.logError( - `Tenant validation error [${error.field}]: ${error.message}`, - ).pipe( - Effect.annotateLogs({ errorTag: error._tag, field: error.field }), - Effect.as( - toCallToolResult({ - isError: true, - content: [ - { - type: "text", - text: `${error._tag} (${error.field}): ${error.message}`, - }, - ], - }), - ), - ), + Effect.succeed(toBoundaryErrorResult(error)), }), - Effect.catchDefect((error) => - Effect.logError(`Tool defect: ${toErrorMessage(error)}`).pipe( - Effect.as( - toCallToolResult({ - isError: true, - content: [{ type: "text", text: `Error: ${toErrorMessage(error)}` }], - }), - ), - ), - ), - Effect.annotateLogs({ tool: definition.name }), ), }), ) diff --git a/apps/api/src/routes/investigations-internal.http.ts b/apps/api/src/routes/investigations-internal.http.ts deleted file mode 100644 index 2439521d0..000000000 --- a/apps/api/src/routes/investigations-internal.http.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { HttpApiBuilder } from "effect/unstable/httpapi" -import { CurrentTenant, MapleApi } from "@maple/domain/http" -import { Effect } from "effect" -import { InvestigationService } from "../services/InvestigationService" - -/** - * Internal `submit_diagnosis` write the chat-flue investigate agent posts once - * it finishes its diagnostic pass: - * - * POST /api/internal/investigations/:id/diagnosis - * - * Server-to-server, authed by the internal-service token via the - * `InternalServiceAuthorization` middleware (see InternalServiceAuthorizationLayer), - * which provides the same `CurrentTenant.Context` the Clerk-authed groups use — so - * a service caller can only write investigations in the org it resolves to. - * - * The framework owns the boilerplate: `:id`/payload decode (→ 400), auth (→ 401), - * and the declared `InvestigationNotFoundError`/`InvestigationPersistenceError` - * → 404/503 mapping (via their `httpApiStatus`). The handler is just the write. - */ -export const HttpInvestigationsInternalLive = HttpApiBuilder.group( - MapleApi, - "investigationsInternal", - (handlers) => - Effect.gen(function* () { - const service = yield* InvestigationService - - return handlers.handle("submitDiagnosis", ({ params, payload }) => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - yield* Effect.annotateCurrentSpan({ - "maple.org_id": tenant.orgId, - "maple.investigation.id": params.id, - }) - return yield* service.submitDiagnosis(tenant.orgId, params.id, payload) - }).pipe(Effect.withSpan("HttpInvestigationsInternal.submitDiagnosis")), - ) - }), -) diff --git a/apps/api/src/services/InternalServiceAuthorizationLayer.ts b/apps/api/src/services/InternalServiceAuthorizationLayer.ts deleted file mode 100644 index b91f8e1de..000000000 --- a/apps/api/src/services/InternalServiceAuthorizationLayer.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { HttpServerRequest } from "effect/unstable/http" -import { CurrentTenant, UnauthorizedError } from "@maple/domain/http" -import { Effect, Layer } from "effect" -import { resolveMcpTenantContext } from "../mcp/lib/resolve-tenant" -import { AuthService } from "./AuthService" -import { ApiKeysService } from "./ApiKeysService" -import { Env } from "../lib/Env" - -const messageOf = (error: unknown): string => - typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" - ? error.message - : "Unauthorized" - -/** - * Implementation of the {@link CurrentTenant.InternalServiceAuthorization} - * middleware — the server-to-server counterpart of {@link ApiAuthorizationLayer}. - * - * Resolves the tenant from the internal-service bearer token (+ `x-org-id`) via - * the same {@link resolveMcpTenantContext} the MCP server uses, then provides the - * shared {@link CurrentTenant.Context} so internal HttpApi handlers read the org - * exactly like the Clerk-authed ones. Any resolution failure becomes a declared - * `UnauthorizedError` (→ 401), so the route never maps errors by hand. - */ -export const InternalServiceAuthorizationLayer = Layer.effect( - CurrentTenant.InternalServiceAuthorization, - Effect.gen(function* () { - const env = yield* Env - const apiKeys = yield* ApiKeysService - const auth = yield* AuthService - - return CurrentTenant.InternalServiceAuthorization.of({ - bearer: (httpEffect) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest - const webRequest = yield* HttpServerRequest.toWeb(request).pipe( - Effect.mapError(() => new UnauthorizedError({ message: "Failed to read request" })), - ) - - const tenant = yield* Effect.provideService( - Effect.provideService( - Effect.provideService(resolveMcpTenantContext(webRequest), Env, env), - ApiKeysService, - apiKeys, - ), - AuthService, - auth, - ).pipe(Effect.mapError((error) => new UnauthorizedError({ message: messageOf(error) }))) - - return yield* Effect.provideService( - httpEffect, - CurrentTenant.Context, - new CurrentTenant.TenantSchema({ - orgId: tenant.orgId, - userId: tenant.userId, - roles: tenant.roles, - authMode: tenant.authMode, - }), - ) - }), - }) - }), -) diff --git a/apps/api/src/services/PlanetScaleConnectionService.test.ts b/apps/api/src/services/PlanetScaleConnectionService.test.ts index 075cae756..fbfbecf51 100644 --- a/apps/api/src/services/PlanetScaleConnectionService.test.ts +++ b/apps/api/src/services/PlanetScaleConnectionService.test.ts @@ -68,6 +68,17 @@ const stubPlanetScaleApi = (options?: { * unknown/bad service tokens) 403, only `token tok_good:*` passes. */ readonly denyBearerMetrics?: boolean + /** http_sd payload served by the control-plane SD endpoint (any auth). */ + readonly sdGroups?: ReadonlyArray<{ + targets: ReadonlyArray + labels?: Record + }> + /** + * Prod-faithful data-plane split: discovered metrics hosts (anything off + * api.planetscale.com) 403 everything but the service-token scheme — the + * behavior that made OAuth-auth'd scrapes fail while the SD probe passed. + */ + readonly denyDataPlaneBearer?: boolean }) => { const organizations = options?.organizations ?? [{ id: "psorg_1", name: "acme" }] const stub = (async (input: string | URL | Request, init?: RequestInit) => { @@ -92,6 +103,18 @@ const stubPlanetScaleApi = (options?: { if (denied) { return new Response("{}", { status: denied[1], headers: { "content-type": "application/json" } }) } + if (!requestUrl.includes("api.planetscale.com") && options?.denyDataPlaneBearer) { + const authorization = headers.get("authorization") ?? "" + return authorization.startsWith("token ") + ? new Response("up 1\n", { status: 200 }) + : new Response("forbidden", { status: 403 }) + } + if (options?.sdGroups && requestUrl.includes("api.planetscale.com") && requestUrl.includes("/metrics")) { + return new Response(JSON.stringify(options.sdGroups), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } if (options?.denyBearerMetrics && requestUrl.includes("/metrics")) { const authorization = headers.get("authorization") ?? "" if (!authorization.startsWith("token tok_good:")) { @@ -313,6 +336,83 @@ describe("PlanetScaleConnectionService", () => { ) }) + it.effect("pauses metrics when the data plane rejects the bearer despite a passing SD probe", () => { + const testDb = createTestDb(trackedDbs) + const calls: Array<{ url: string; authorization: string | null }> = [] + // The prod bug: api.planetscale.com's SD endpoint 2xx'd the OAuth bearer, + // so the target auto-enabled — but the discovered metrics.psdb.cloud hosts + // only accept service tokens and 403'd every actual scrape. + const stub = stubPlanetScaleApi({ + calls, + denyDataPlaneBearer: true, + sdGroups: [ + { + targets: ["branch-1.metrics.psdb.cloud:443"], + labels: { __metrics_path__: "/metrics", planetscale_database_branch_id: "branch-1" }, + }, + ], + }) + + return Effect.gen(function* () { + const service = yield* PlanetScaleConnectionService + const orgId = asOrgId("org_1") + + yield* storeGrant(orgId) + const bound = yield* service.finalizeOrgSelection(orgId, { organization: "acme" }) + + // Binding succeeds, but scraping is paused until a service token arrives. + assert.isTrue(bound.connected) + assert.strictEqual(bound.metricsAuth, "missing") + assert.isFalse(bound.scrapeTarget!.enabled) + assert.isFalse(bound.detectedPermissions?.readMetricsEndpoints) + + // The verdict came from an actual data-plane scrape probe with the + // bearer. (Match on the host: fetch normalizes away the :443 port.) + assert.isTrue( + calls.some( + (call) => + call.url.includes("branch-1.metrics.psdb.cloud") && + call.authorization === "Bearer ps-access-token", + ), + ) + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, stub), + Effect.provide(Layer.mergeAll(makeLayer(testDb), Layer.succeed(FetchHttpClient.Fetch, stub))), + ) + }) + + it.effect("keeps oauth metrics enabled when the data-plane scrape accepts the bearer", () => { + const testDb = createTestDb(trackedDbs) + const calls: Array<{ url: string; authorization: string | null }> = [] + // Data-plane host answers 200 to the bearer (the stub's default) — the + // probe must not pause a working OAuth-auth'd target. + const stub = stubPlanetScaleApi({ + calls, + sdGroups: [ + { + targets: ["branch-1.metrics.psdb.cloud:443"], + labels: { __metrics_path__: "/metrics", planetscale_database_branch_id: "branch-1" }, + }, + ], + }) + + return Effect.gen(function* () { + const service = yield* PlanetScaleConnectionService + const orgId = asOrgId("org_1") + + yield* storeGrant(orgId) + const bound = yield* service.finalizeOrgSelection(orgId, { organization: "acme" }) + + assert.strictEqual(bound.metricsAuth, "oauth") + assert.isTrue(bound.scrapeTarget!.enabled) + assert.isTrue(bound.detectedPermissions?.readMetricsEndpoints) + assert.isTrue(calls.some((call) => call.url.includes("branch-1.metrics.psdb.cloud"))) + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, stub), + Effect.provide(Layer.mergeAll(makeLayer(testDb), Layer.succeed(FetchHttpClient.Fetch, stub))), + ) + }) + it.effect("classifies transient metrics probe failures as upstream errors", () => { const testDb = createTestDb(trackedDbs) const stub = stubPlanetScaleApi({ deny: { "/metrics": 503 } }) diff --git a/apps/api/src/services/PlanetScaleConnectionService.ts b/apps/api/src/services/PlanetScaleConnectionService.ts index fe2ccb0bd..cb7c3fe9a 100644 --- a/apps/api/src/services/PlanetScaleConnectionService.ts +++ b/apps/api/src/services/PlanetScaleConnectionService.ts @@ -25,6 +25,7 @@ import { decryptAes256Gcm, encryptAes256Gcm, parseBase64Aes256GcmKey } from "../ import { Database } from "../lib/DatabaseLive" import { Env } from "../lib/Env" import { decodeDiscoveryConfig } from "./planetscale/discovery-config" +import { HttpSdResponse, subTargetsFromGroup } from "./PlanetScaleDiscoveryService" import { PlanetScaleOAuthService, planetScaleBearerHeader } from "./PlanetScaleOAuthService" import { ScrapeTargetsService } from "./ScrapeTargetsService" @@ -128,25 +129,25 @@ export class PlanetScaleConnectionService extends Context.Service< const apiBase = env.MAPLE_PLANETSCALE_API_BASE_URL.replace(/\/$/, "") /** - * GET a management-API path with the given Authorization header (an OAuth - * bearer or a service-token scheme). Returns the HTTP status; + * GET an absolute URL with the given Authorization header (an OAuth bearer + * or a service-token scheme). Returns the HTTP status and body text; * network-level failures surface as IntegrationsUpstreamError. */ - const probeStatus = Effect.fn("PlanetScaleConnectionService.probeStatus")(function* ( - path: string, + const probeUrl = Effect.fn("PlanetScaleConnectionService.probeUrl")(function* ( + url: string, authorization: string, ) { return yield* Effect.gen(function* () { - const request = HttpClientRequest.get(`${apiBase}${path}`).pipe( + const request = HttpClientRequest.get(url).pipe( HttpClientRequest.setHeaders({ Authorization: authorization, Accept: "application/json", }), ) const res = yield* httpClient.execute(request) - // Drain the body so the connection is released. - yield* res.text - return res.status + // Read (and thereby drain) the body so the connection is released. + const text = yield* res.text + return { status: res.status, text } }).pipe( Effect.mapError( (error) => @@ -166,6 +167,66 @@ export class PlanetScaleConnectionService extends Context.Service< ) }) + /** GET a management-API path; returns only the HTTP status. */ + const probeStatus = Effect.fn("PlanetScaleConnectionService.probeStatus")(function* ( + path: string, + authorization: string, + ) { + const response = yield* probeUrl(`${apiBase}${path}`, authorization) + return response.status + }) + + const decodeHttpSd = Schema.decodeUnknownEffect(Schema.fromJsonString(HttpSdResponse)) + + /** + * Whether an ACTUAL branch-metrics scrape works. The SD endpoint on + * api.planetscale.com accepting the credential proves nothing about the + * data plane: metrics.psdb.cloud authenticates with the signed `?sig=&exp=` + * URL params minted in the SD response, so we probe a discovered endpoint + * via its `signedUrl` and let `readMetricsEndpoints` reflect scraping, not + * listing. Inconclusive outcomes (no branches discovered yet, transport + * blip, undecodable payload) keep the control-plane answer instead of + * pausing a possibly-working target. + */ + const probeDataPlaneScrape = Effect.fn("PlanetScaleConnectionService.probeDataPlaneScrape")( + function* (organization: string, bearer: string) { + const org = encodeURIComponent(organization) + const outcome: "ok" | "rejected" | "inconclusive" = yield* Effect.gen(function* () { + const sd = yield* probeUrl(`${apiBase}/v1/organizations/${org}/metrics`, bearer) + if (sd.status === 401 || sd.status === 403) return "rejected" as const + if (sd.status < 200 || sd.status >= 300) return "inconclusive" as const + + const groups = yield* decodeHttpSd(sd.text).pipe( + Effect.catch(() => Effect.succeed(null)), + ) + if (groups === null) return "inconclusive" as const + + // subTargetsFromGroup already SSRF-validates the discovered URLs. + const first = groups.flatMap((group) => subTargetsFromGroup(group).ok)[0] + if (first === undefined) return "inconclusive" as const + + // signedUrl carries the `?sig=&exp=` params the data plane requires. + const scrape = yield* probeUrl(first.signedUrl, bearer) + return scrape.status >= 200 && scrape.status < 300 + ? ("ok" as const) + : ("rejected" as const) + }).pipe( + Effect.catchTag("@maple/http/errors/IntegrationsUpstreamError", (error) => + Effect.logWarning("PlanetScale data-plane scrape probe failed; keeping SD result").pipe( + Effect.annotateLogs({ organization, error: error.message }), + Effect.as("inconclusive" as const), + ), + ), + ) + if (outcome !== "ok") { + yield* Effect.logInfo("PlanetScale data-plane scrape probe outcome").pipe( + Effect.annotateLogs({ organization, outcome }), + ) + } + return outcome + }, + ) + const probePermissions = Effect.fn("PlanetScaleConnectionService.probePermissions")(function* ( organization: string, accessToken: string, @@ -181,9 +242,13 @@ export class PlanetScaleConnectionService extends Context.Service< { concurrency: 3 }, ) const ok = (status: number) => status >= 200 && status < 300 + // The SD listing passing is necessary but not sufficient for scraping — + // confirm against the data plane before declaring the bearer scrape-capable. + const readMetricsEndpoints = + ok(metricsStatus) && (yield* probeDataPlaneScrape(organization, bearer)) !== "rejected" const permissions: PlanetScaleDetectedPermissions = { readOrganization: ok(orgStatus), - readMetricsEndpoints: ok(metricsStatus), + readMetricsEndpoints, readDatabases: ok(databasesStatus), } return { permissions, orgStatus, metricsStatus } @@ -248,9 +313,9 @@ export class PlanetScaleConnectionService extends Context.Service< const discoveryConfig = target ? decodeDiscoveryConfig(target.discoveryConfigJson) : null // How scraping authenticates: a stored service token wins; grant-resolved // bearer auth counts only while the target is enabled (finalize disables - // it when the bearer probe failed — PlanetScale's metrics endpoints only - // document service-token auth); anything else means scraping is paused - // until a token is added. + // it unless the bearer passed an end-to-end data-plane scrape probe — + // PlanetScale's metrics endpoints only document service-token auth); + // anything else means scraping is paused until a token is added. const metricsAuth = target === null ? ("missing" as const) @@ -367,11 +432,12 @@ export class PlanetScaleConnectionService extends Context.Service< ) } - // Probe what the grant can do against this org. The metrics endpoints - // only document service-token auth, so a failing bearer probe does NOT - // block the binding — inventory/insights/webhooks work on the grant, and - // scraping stays paused until a service token is added via - // setMetricsToken (the card's follow-up step). + // Probe what the grant can do against this org (readMetricsEndpoints + // requires an actual data-plane scrape to pass — see probePermissions). + // The metrics endpoints only document service-token auth, so a failing + // bearer probe does NOT block the binding — inventory/insights/webhooks + // work on the grant, and scraping stays paused until a service token is + // added via setMetricsToken (the card's follow-up step). const { permissions } = yield* probePermissions(organization, accessToken) // Attribution comes from the grant, so finalize behaves identically when diff --git a/apps/api/src/services/PlanetScaleDiscoveryService.test.ts b/apps/api/src/services/PlanetScaleDiscoveryService.test.ts index dc7659b88..f04d3c946 100644 --- a/apps/api/src/services/PlanetScaleDiscoveryService.test.ts +++ b/apps/api/src/services/PlanetScaleDiscoveryService.test.ts @@ -168,7 +168,7 @@ describe("PlanetScaleDiscoveryService", () => { // Prod hazard: an http_sd payload with several groups that carry no // `planetscale_database_branch_id`, so subTargetKey falls back to the - // shared host. Without dedup these become N rows with the SAME + // shared host+path. Without dedup these become N rows with the SAME // (id, subTargetKey) and the scraper forks a leaking loop fiber per row. const DUP_HOST_PAYLOAD = [ { targets: ["metrics.psdb.cloud:443"], labels: { planetscale_database: "mydb" } }, @@ -184,11 +184,100 @@ describe("PlanetScaleDiscoveryService", () => { ) assert.strictEqual(entries.length, 1) - assert.strictEqual(entries[0]?.subTargetKey, "metrics.psdb.cloud:443") + assert.strictEqual(entries[0]?.subTargetKey, "metrics.psdb.cloud:443/metrics") assert.strictEqual(entries[0]?.url, "https://metrics.psdb.cloud:443/metrics") }).pipe(Effect.provide(makeLayer(testDb))) }) + it.effect("keeps branch-id-less groups distinct when they differ only by metrics path", () => { + const testDb = createTestDb(trackedDbs) + const recorded: Array = [] + return Effect.gen(function* () { + const discovery = yield* PlanetScaleDiscoveryService + const row = yield* createPlanetScaleTargetRow("my-org") + + // Same host, distinct `__metrics_path__` per group, no branch-id label — + // a bare-host fallback key would collapse these to ONE endpoint and + // silently drop the other's metrics. + const PATH_ONLY_PAYLOAD = [ + { targets: ["metrics.psdb.cloud:443"], labels: { __metrics_path__: "/metrics/db-a" } }, + { targets: ["metrics.psdb.cloud:443"], labels: { __metrics_path__: "/metrics/db-b" } }, + ] + + const entries = yield* discovery.discover(row).pipe( + Effect.provideService( + FetchHttpClient.Fetch, + stubFetch(recorded, () => Response.json(PATH_ONLY_PAYLOAD)), + ), + ) + + assert.deepStrictEqual( + entries.map((entry) => entry.subTargetKey), + ["metrics.psdb.cloud:443/metrics/db-a", "metrics.psdb.cloud:443/metrics/db-b"], + ) + assert.deepStrictEqual( + entries.map((entry) => entry.url), + [ + "https://metrics.psdb.cloud:443/metrics/db-a", + "https://metrics.psdb.cloud:443/metrics/db-b", + ], + ) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + + it.effect("promotes __param_* meta labels to signed scrape-url query params", () => { + const testDb = createTestDb(trackedDbs) + const recorded: Array = [] + return Effect.gen(function* () { + const discovery = yield* PlanetScaleDiscoveryService + const row = yield* createPlanetScaleTargetRow("my-org") + + // PlanetScale authenticates the metrics data plane with a signed, expiring + // URL: the http_sd group carries `__param_sig`/`__param_exp`, which + // Prometheus promotes to `?sig=&exp=` on the scrape URL. Dropping them + // yields `403 invalid signature` on every scrape (the real prod outage). + const SIGNED_PAYLOAD = [ + { + targets: ["metrics.psdb.cloud"], + labels: { + __metrics_path__: "/metrics/branch/3a1nf2gvu9rf", + __scheme__: "https", + __param_sig: "abc-_signature123", + __param_exp: "1784238737", + planetscale_branch_name: "main", + planetscale_database_name: "mydb", + }, + }, + ] + + const entries = yield* discovery.discover(row).pipe( + Effect.provideService( + FetchHttpClient.Fetch, + stubFetch(recorded, () => Response.json(SIGNED_PAYLOAD)), + ), + ) + + assert.strictEqual(entries.length, 1) + // Base url + fiber-identity key stay param-free so identity is stable as + // PlanetScale rotates the signature each refresh. + assert.strictEqual(entries[0]?.url, "https://metrics.psdb.cloud/metrics/branch/3a1nf2gvu9rf") + assert.strictEqual( + entries[0]?.subTargetKey, + "metrics.psdb.cloud/metrics/branch/3a1nf2gvu9rf", + ) + // signedUrl carries the auth params the data plane actually verifies. + assert.strictEqual( + entries[0]?.signedUrl, + "https://metrics.psdb.cloud/metrics/branch/3a1nf2gvu9rf?sig=abc-_signature123&exp=1784238737", + ) + // The signed params must never leak into the metric labels. + assert.deepStrictEqual(entries[0]?.labels, { + planetscale_branch_name: "main", + planetscale_database_name: "mydb", + }) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + it.effect("caches discovery for the TTL and refreshes after it elapses", () => { const testDb = createTestDb(trackedDbs) const recorded: Array = [] diff --git a/apps/api/src/services/PlanetScaleDiscoveryService.ts b/apps/api/src/services/PlanetScaleDiscoveryService.ts index b936a9064..85fd26cfa 100644 --- a/apps/api/src/services/PlanetScaleDiscoveryService.ts +++ b/apps/api/src/services/PlanetScaleDiscoveryService.ts @@ -20,10 +20,14 @@ type ScrapeTargetRow = typeof scrapeTargets.$inferSelect /** * Resolves PlanetScale `planetscale`-type scrape targets into their concrete * per-database-branch scrape endpoints via PlanetScale's Prometheus http_sd - * discovery API (`GET /v1/organizations/{org}/metrics`). Managed targets - * (`authType "planetscale_oauth"`) authenticate with the org's OAuth grant - * (`Authorization: Bearer …`); manual escape-hatch targets keep the service - * token scheme (`Authorization: token {ID}:{SECRET}`). + * discovery API (`GET /v1/organizations/{org}/metrics`). The Authorization + * header authenticates the DISCOVERY call only: managed targets + * (`authType "planetscale_oauth"`) use the org's OAuth grant + * (`Authorization: Bearer …`), manual escape-hatch targets the service-token + * scheme (`Authorization: token {ID}:{SECRET}`). The metrics DATA PLANE + * (`metrics.psdb.cloud`) does not use that header at all — it authenticates + * with a signed, expiring URL (`?sig=…&exp=…`) that the SD response mints per + * branch (see {@link subTargetsFromGroup} / {@link PlanetScaleSubTarget.signedUrl}). * * Discovery results are cached in-memory per target with a 10-minute TTL * (PlanetScale's documented refresh cadence). On refresh failure stale entries @@ -32,15 +36,27 @@ type ScrapeTargetRow = typeof scrapeTargets.$inferSelect */ export interface PlanetScaleSubTarget { - /** Concrete per-branch scrape URL (`https://{host}{__metrics_path__}`). */ + /** + * Per-branch scrape endpoint WITHOUT auth params (`https://{host}{__metrics_path__}`). + * Stable across discovery refreshes, so it's the scraper-facing target url + * (fiber identity + `instance` label). NOT fetched directly — see `signedUrl`. + */ readonly url: string - /** Stable discriminator: `planetscale_database_branch_id` SD label, falling back to `host:port`. */ + /** + * `url` plus PlanetScale's signed, expiring auth query params (`?sig=…&exp=…`), + * promoted from the http_sd `__param_*` meta labels. This is the URL that + * actually authenticates against the metrics data plane; fetching `url` + * without them returns `403 invalid signature`. Equals `url` when the SD group + * carried no `__param_*` labels. + */ + readonly signedUrl: string + /** Stable discriminator: `planetscale_database_branch_id` SD label, falling back to `host:port` + metrics path. */ readonly subTargetKey: string /** SD labels minus `__`-prefixed Prometheus meta labels. */ readonly labels: Record } -const HttpSdResponse = Schema.Array( +export const HttpSdResponse = Schema.Array( Schema.Struct({ targets: Schema.Array(Schema.String), labels: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), @@ -74,17 +90,26 @@ const toUpstreamError = (message: string, status?: number) => new ScrapeTargetUpstreamError({ message, ...(status === undefined ? {} : { status }) }) /** Convert one http_sd group into sub-targets, dropping SSRF-invalid hosts. */ -const subTargetsFromGroup = (group: { +export const subTargetsFromGroup = (group: { readonly targets: ReadonlyArray readonly labels?: Record | undefined }): { readonly ok: Array; readonly dropped: Array } => { const sdLabels = group.labels ?? {} const scheme = sdLabels.__scheme__ ?? "https" const path = sdLabels.__metrics_path__ ?? "/metrics" + // PlanetScale authenticates the metrics data plane with a signed, expiring URL + // (`?sig=…&exp=…`) minted per branch in the http_sd response — NOT the discovery + // credential (the service token / OAuth bearer only auths the SD listing). + // Prometheus convention promotes `__param_` meta labels to `?=` + // query params on the scrape URL; forward them or every scrape returns + // `403 invalid signature`. const labels: Record = {} + const authParams = new URLSearchParams() for (const [key, value] of Object.entries(sdLabels)) { - if (!key.startsWith("__")) labels[key] = value + if (key.startsWith("__param_")) authParams.set(key.slice("__param_".length), value) + else if (!key.startsWith("__")) labels[key] = value } + const query = authParams.toString() const branchId = sdLabels.planetscale_database_branch_id const ok: Array = [] @@ -97,13 +122,18 @@ const subTargetsFromGroup = (group: { dropped.push(url) continue } + // The no-branch-id fallback keys on host + path (not bare host): groups + // that differ only by `__metrics_path__` are distinct endpoints, and a + // host-only key would silently collapse them away in dedupe. The signed + // `?sig=&exp=` params are deliberately excluded from the key so per-branch + // fiber identity stays stable as PlanetScale rotates them each refresh. const subTargetKey = branchId && group.targets.length === 1 ? branchId : branchId ? `${branchId}:${hostPort}` - : hostPort - ok.push({ url, subTargetKey, labels }) + : `${hostPort}${path}` + ok.push({ url, signedUrl: query ? `${url}?${query}` : url, subTargetKey, labels }) } return { ok, dropped } } @@ -112,9 +142,10 @@ const subTargetsFromGroup = (group: { * Collapse sub-targets sharing a `subTargetKey` (last wins). The scraper keys * one scrape-loop fiber per `(targetId, subTargetKey)`, so duplicate keys would * each fork a fiber that the scheduler can't track — a runaway scrape loop. - * Two entries with the same key resolve to the same logical endpoint, so - * collapsing them is lossless. Happens when an http_sd payload exposes several - * groups that fall back to the same host key (no `planetscale_database_branch_id`). + * Two entries with the same key resolve to the same scrape URL (the fallback + * key is host + path), so collapsing them is lossless. Happens when an http_sd + * payload exposes several groups that fall back to the same host+path key + * (no `planetscale_database_branch_id`). */ const dedupeBySubTargetKey = ( entries: ReadonlyArray, @@ -277,6 +308,24 @@ export class PlanetScaleDiscoveryService extends Context.Service< collected.push(...converted.ok) dropped.push(...converted.dropped) } + + // Track label drift: PlanetScale documents `planetscale_database_branch_id` + // on every group, and its absence forces the host+path fallback key (losing + // per-branch attribution). Log the label keys actually seen so a rename on + // their side is diagnosable from prod logs. + const missingBranchLabel = groups.filter( + (group) => (group.labels ?? {}).planetscale_database_branch_id === undefined, + ) + if (missingBranchLabel.length > 0) { + yield* Effect.logInfo("PlanetScale http_sd groups missing the branch-id label").pipe( + Effect.annotateLogs({ + scrapeTargetId: row.id, + groupsMissingLabel: missingBranchLabel.length, + groupsTotal: groups.length, + observedLabelKeys: Object.keys(missingBranchLabel[0]?.labels ?? {}).join(", "), + }), + ) + } if (dropped.length > 0) { yield* Effect.logWarning( "Dropped PlanetScale discovered targets failing URL validation", diff --git a/apps/api/src/services/ScrapeTargetsService.ts b/apps/api/src/services/ScrapeTargetsService.ts index c806abcfc..bb4bb360c 100644 --- a/apps/api/src/services/ScrapeTargetsService.ts +++ b/apps/api/src/services/ScrapeTargetsService.ts @@ -844,9 +844,12 @@ export class ScrapeTargetsService extends Context.Service entry.subTargetKey === subTargetKey) if (!match) { @@ -857,7 +860,7 @@ export class ScrapeTargetsService extends Context.Service httpAp // the top level near-empty; the cost moves to the first request, which runs // under the far larger per-request CPU budget. const buildHandler = async () => { - const { AllRoutes, ApiAuthLive, InternalServiceAuthLive, ApiObservabilityLive, MainLive } = - await import("./app") + const { AllRoutes, ApiAuthLive, ApiObservabilityLive, MainLive } = await import("./app") const { layerPg } = await import("./lib/DatabasePgLive") return HttpRouter.toWebHandler( AllRoutes.pipe( Layer.provideMerge(MainLive), Layer.provideMerge(ApiAuthLive), - Layer.provideMerge(InternalServiceAuthLive), Layer.provideMerge(ApiObservabilityLive), Layer.provideMerge(WorkerPlatformLive), Layer.provideMerge(layerPg), @@ -106,6 +110,74 @@ const buildHandler = async () => { let handlerPromise: ReturnType | undefined const getHandler = () => (handlerPromise ??= buildHandler()) +// RPC has no HttpApi request to construct the application services for it, so +// it gets a sibling isolate-wide ManagedRuntime. The heavy route/service graph +// stays behind a dynamic import, preserving the worker's startup-CPU budget. +const buildRpcRuntime = async (env: Record) => { + const { MainLive } = await import("./app") + const { layerPg } = await import("./lib/DatabasePgLive") + return ManagedRuntime.make( + MainLive.pipe( + Layer.provideMerge(WorkerPlatformLive), + Layer.provideMerge(layerPg), + Layer.provideMerge(layerFromEnvRecord(env)), + Layer.provideMerge(telemetry.layer), + Layer.provideMerge(WorkerConfigProviderLayer), + ), + ) +} + +let rpcRuntimePromise: ReturnType | undefined +const getRpcRuntime = (env: Record) => (rpcRuntimePromise ??= buildRpcRuntime(env)) + +type InternalRpcMethod = "listMcpTools" | "callMcpTool" | "submitDiagnosis" + +const ALCHEMY_RPC_ERROR_TAG = "~alchemy/rpc/error" as const + +// Alchemy's schemaless RPC error envelope is deliberately tiny. Keeping this +// encoder local avoids pulling its full Worker bridge into an already large API +// bundle; chat-flue's `toRpcAsync` decodes this exact public wire shape. +const encodeRpcError = (error: unknown): unknown => { + if (error == null || typeof error !== "object") return error + const object = error as Record + if (typeof object._tag === "string") { + const encoded = Object.fromEntries(Object.keys(object).map((key) => [key, object[key]])) + if (error instanceof Error && !("message" in encoded)) encoded.message = error.message + return encoded + } + if (error instanceof Error) { + return { name: error.name, message: error.message, stack: error.stack } + } + return error +} + +const runInternalRpc = async ( + method: InternalRpcMethod, + input: unknown, + env: Record, + ctx: ExecutionContext, +) => { + const [runtime, rpc] = await Promise.all([getRpcRuntime(env), import("./internal-rpc")]) + const program = + method === "listMcpTools" + ? rpc.listMcpToolsRpc + : method === "callMcpTool" + ? rpc.callMcpToolRpc(input) + : rpc.submitDiagnosisRpc(input) + const exit = await runtime.runPromiseExit(program as Effect.Effect) + ctx.waitUntil(flushTelemetry(env)) + if (exit._tag === "Success") return exit.value + const failure = exit.cause.reasons.find(Cause.isFailReason) + if (failure) { + return { + _tag: ALCHEMY_RPC_ERROR_TAG, + error: encodeRpcError(failure.error), + } + } + const defect = exit.cause.reasons.find(Cause.isDieReason) + throw defect?.defect ?? new Error("RPC method failed with an unexpected cause") +} + const isMcpPost = (request: Request): boolean => { if (request.method !== "POST") return false try { @@ -257,11 +329,34 @@ const handleScheduled = async (env: Record, ctx: ExecutionConte } } -export default { - fetch: (request: Request, env: Record, ctx: ExecutionContext) => - handle(request, env, ctx), - queue: (batch: MessageBatch, env: Record, ctx: ExecutionContext) => - handleQueue(batch, env, ctx), - scheduled: (_event: ScheduledController, env: Record, ctx: ExecutionContext) => - handleScheduled(env, ctx), +/** + * Class entrypoint keeps fetch/queue/cron intact while publishing Alchemy's + * schemaless RPC methods over a Cloudflare service binding. RPC failures are + * encoded with Alchemy's wire envelope so a plain Worker caller can recover tagged + * Effect errors via `toRpcAsync`. + */ +export default class MapleApiWorker extends WorkerEntrypoint> { + override fetch(request: Request): Promise { + return handle(request, this.env, this.ctx) + } + + override queue(batch: MessageBatch): Promise { + return handleQueue(batch, this.env, this.ctx) + } + + override scheduled(_event: ScheduledController): Promise { + return handleScheduled(this.env, this.ctx) + } + + listMcpTools() { + return runInternalRpc("listMcpTools", undefined, this.env, this.ctx) + } + + callMcpTool(input: unknown) { + return runInternalRpc("callMcpTool", input, this.env, this.ctx) + } + + submitDiagnosis(input: unknown) { + return runInternalRpc("submitDiagnosis", input, this.env, this.ctx) + } } diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 81ef9aaec..a2b2b316e 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ }, test: { environment: "node", - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "scripts/**/*.test.ts"], // Generous timeouts: the DB-backed suites boot a fresh PGlite (WASM) per // test and some retry tests run real exponential backoff. Under CI's // parallel `turbo test`, CPU starvation stretches these past the 5s diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index af677dd52..06be5ce8b 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -27,6 +27,7 @@ "id": "00000000000000000000000000000000", }, ], + "services": [{ "binding": "CHAT_FLUE", "service": "maple-chat-flue" }], // Cloudflare Email Service (Email Sending). `remote: true` routes sends through // the real Cloudflare sender even under local dev. (Mirrored in alchemy.run.ts // for deploy.) Pure `bun dev` (non-wrangler) has no EMAIL binding → sends skip. diff --git a/apps/chat-flue/.dev.vars.example b/apps/chat-flue/.dev.vars.example index 7d37b72c0..78f31e31c 100644 --- a/apps/chat-flue/.dev.vars.example +++ b/apps/chat-flue/.dev.vars.example @@ -1,7 +1,7 @@ # Copy to `.dev.vars` for `flue dev`. Do not commit `.dev.vars`. # Shared secret for Maple internal-service auth. Must match apps/api's -# INTERNAL_SERVICE_TOKEN — the Flue agent sends `Bearer maple_svc_`. +# INTERNAL_SERVICE_TOKEN — authenticates API → Flue private workflow routes. INTERNAL_SERVICE_TOKEN="dev-internal-service-token" # Optional Workers AI model override (provider id `cloudflare` + @cf model id). diff --git a/apps/chat-flue/README.md b/apps/chat-flue/README.md index 873e4e392..5fcbaaed3 100644 --- a/apps/chat-flue/README.md +++ b/apps/chat-flue/README.md @@ -11,8 +11,8 @@ the architecture before the full rebuild. See the plan at | File | Role | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/agents/maple-chat.ts` | The addressable chat agent. `export const route` exposes it at `POST/GET /agents/maple-chat/:id`; Workers AI model + `connectMcpServer` → Maple MCP tools (`mcp__maple__*`). | -| `src/lib/env.ts` | Worker bindings/vars (`AI`, `MAPLE_API_URL`, `INTERNAL_SERVICE_TOKEN`). | +| `src/agents/maple-chat.ts` | The addressable chat agent. `export const route` exposes it at `POST/GET /agents/maple-chat/:id`; Workers AI model + API Worker RPC → Maple MCP tools (`mcp__maple__*`). | +| `src/lib/env.ts` | Worker bindings/vars (`AI`, `MAPLE_API_RPC`, `INTERNAL_SERVICE_TOKEN`). | | `src/lib/org.ts` | Recovers `orgId` from the `":"` instance id. | | `flue.config.ts` / `wrangler.jsonc` | Flue + Cloudflare build config (Workers AI `AI` binding, DO migrations). | @@ -60,7 +60,7 @@ bun run connect # flue connect maple-chat local - [x] `bun run build` (`flue build --target cloudflare`) discovers `maple-chat` and generates a valid worker (AI binding + `FlueMapleChatAgent`/`FlueRegistry` DOs). - [x] `flue dev` boots the worker on the Cloudflare target. - [x] A prompt runs end-to-end on **Workers AI** (`@cf/moonshotai/kimi-k2.6`): `POST /agents/maple-chat/:?wait=result` → HTTP 200 with the model's reply (~1.5s). The binding path works; the legacy-parity kimi model is live. -- [ ] A prompt calls `mcp__maple__search_traces` against `apps/api` — **needs a `flue dev` restart** so it loads `.dev.vars` (`INTERNAL_SERVICE_TOKEN`), plus a running `apps/api` and a real org id. +- [ ] A prompt calls `mcp__maple__search_traces` through the `MAPLE_API_RPC` service binding, with a running `apps/api` and a real org id. - [ ] A browser page streams the agent's events via `@flue/sdk` (`agents.send` + `agents.stream`). - [ ] Tool-calling quality across the full tool set is acceptable on the chosen model. diff --git a/apps/chat-flue/alchemy.run.ts b/apps/chat-flue/alchemy.run.ts index c7017401a..e0ddcffa2 100644 --- a/apps/chat-flue/alchemy.run.ts +++ b/apps/chat-flue/alchemy.run.ts @@ -10,6 +10,7 @@ import { resolveDeploymentEnvironment, resolveWorkerName, } from "@maple/infra/cloudflare" +import type { MapleApiWorker } from "../api/alchemy.run.ts" const requireEnv = (key: string): string => { const value = process.env[key]?.trim() @@ -32,10 +33,14 @@ const optionalSecret = (key: string): Record> export interface CreateChatFlueWorkerOptions { stage: MapleStage domains: MapleDomains - mapleApiUrl: string + /** API worker deployed first, then bound privately for schemaless RPC. */ + mapleApiRpc: MapleApiWorker + /** Production-owned OTLP log destination; non-production temporarily uses the legacy slug. */ + logsDestination?: Cloudflare.Workers.ObservabilityDestination + /** Production-owned OTLP trace destination; non-production temporarily uses the legacy slug. */ + tracesDestination?: Cloudflare.Workers.ObservabilityDestination } - /** * Deploy the Flue chat worker (`apps/chat-flue`) via alchemy, consistent with * the rest of the stack. @@ -53,7 +58,13 @@ export interface CreateChatFlueWorkerOptions { * Manual fallback (Flue-native): `cd apps/chat-flue && bun run build && * wrangler deploy --config dist/maple_chat_flue/wrangler.json`. */ -export const createChatFlueWorker = ({ stage, domains, mapleApiUrl }: CreateChatFlueWorkerOptions) => +export const createChatFlueWorker = ({ + stage, + domains, + mapleApiRpc, + logsDestination, + tracesDestination, +}: CreateChatFlueWorkerOptions) => Effect.gen(function* () { // Flue generates the Worker entrypoint + DO classes; build before deploy. const build = yield* Command.Build("chat-flue-build", { @@ -87,15 +98,18 @@ export const createChatFlueWorker = ({ stage, domains, mapleApiUrl }: CreateChat compatibility: { date: "2026-06-01", flags: ["nodejs_compat"] }, placement: CLOUDFLARE_WORKER_PLACEMENT, // Workers Observability. `traces.enabled` is required for the `tracing.enterSpan` - // custom spans in src/agents/maple-chat.ts to emit; the `"maple"` destination - // forwards both logs and traces over Cloudflare's native pipeline → Maple ingest - // (the same path the existing auto-spans use, unlike the @flue/opentelemetry - // export which doesn't flush reliably from DO isolates). `"maple"` is the - // account-level telemetry destination id. + // custom spans in src/agents/maple-chat.ts to emit. Production uses one + // Alchemy-owned destination per signal; non-production temporarily keeps + // the legacy account-level `"maple"` destination until the production + // resources have been bootstrapped and can be referenced from prd state. observability: { enabled: true, - logs: { enabled: true, invocationLogs: true, destinations: ["maple"] }, - traces: { enabled: true, destinations: ["maple"] }, + logs: { + enabled: true, + invocationLogs: true, + destinations: [logsDestination?.slug ?? "maple"], + }, + traces: { enabled: true, destinations: [tracesDestination?.slug ?? "maple"] }, }, url: true, domain: domains.chat, @@ -109,7 +123,7 @@ export const createChatFlueWorker = ({ stage, domains, mapleApiUrl }: CreateChat FLUE_MAPLE_CHAT_AGENT: chatAgent, FLUE_TRIAGE_WORKFLOW: triageWorkflow, FLUE_REGISTRY: registry, - MAPLE_API_URL: mapleApiUrl, + MAPLE_API_RPC: mapleApiRpc, INTERNAL_SERVICE_TOKEN: Redacted.make(requireEnv("INTERNAL_SERVICE_TOKEN")), // OpenTelemetry → Maple ingest. Provide the internal-org ingest key so // chat-flue spans land beside `maple-api`; telemetry no-ops when unset. diff --git a/apps/chat-flue/package.json b/apps/chat-flue/package.json index a6d34b7b7..67f5c0bc9 100644 --- a/apps/chat-flue/package.json +++ b/apps/chat-flue/package.json @@ -14,6 +14,7 @@ "@clerk/backend": "^2.30.1", "@flue/opentelemetry": "1.0.0-beta.1", "@flue/runtime": "1.0.0-beta.2", + "@maple/domain": "workspace:*", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.0.0", "@opentelemetry/exporter-trace-otlp-http": "^0.205.0", @@ -21,6 +22,7 @@ "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.36.0", "agents": "^0.14.1", + "alchemy": "2.0.0-beta.61", "hono": "^4.8.3", "valibot": "^1.1.0" }, diff --git a/apps/chat-flue/src/agents/maple-chat.ts b/apps/chat-flue/src/agents/maple-chat.ts index 9c614310f..3836ad681 100644 --- a/apps/chat-flue/src/agents/maple-chat.ts +++ b/apps/chat-flue/src/agents/maple-chat.ts @@ -78,7 +78,7 @@ export default createAgent(async (ctx) => { const instructions = buildSystemPrompt({ mode }) // Connect to Maple's MCP server (all tools). We tolerate connection failures so - // the agent still answers on Workers AI when apps/api or INTERNAL_SERVICE_TOKEN + // the agent still answers on Workers AI when the API RPC binding // isn't wired yet. The initializer runs per interaction and we don't `close()` // here because tool calls need the connection live for the whole turn — // connection lifecycle/pooling is a follow-up. diff --git a/apps/chat-flue/src/lib/api-rpc.test.ts b/apps/chat-flue/src/lib/api-rpc.test.ts new file mode 100644 index 000000000..11201b77b --- /dev/null +++ b/apps/chat-flue/src/lib/api-rpc.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest" +import type { ChatFlueEnv } from "./env.ts" +import { mapleApiRpc } from "./api-rpc.ts" +import { buildSubmitDiagnosisTool, DIAGNOSIS_STATUS } from "./submit-diagnosis.ts" + +const report = { + summary: "Checkout latency doubled after deploy.", + suspectedCause: "Connection pool regression", + severityAssessment: "high" as const, + affectedScope: "checkout-api", + evidence: [ + { + traceIds: ["trace-1"], + logPatterns: ["pool exhausted"], + relatedServices: ["payments"], + note: "Correlated evidence", + }, + ], + suggestedActions: ["Roll back"], + confidence: "high" as const, +} + +const envWithBinding = (binding: Record): ChatFlueEnv => ({ + AI: { run: async () => ({}) }, + MAPLE_API_RPC: binding as never, + INTERNAL_SERVICE_TOKEN: "workflow-token", +}) + +describe("Maple API Worker RPC", () => { + it("preserves tagged errors across the Alchemy RPC envelope", async () => { + const api = mapleApiRpc( + envWithBinding({ + callMcpTool: async () => ({ + _tag: "~alchemy/rpc/error", + error: { + _tag: "@maple/internal-rpc/InvalidInputError", + method: "callMcpTool", + message: "invalid org", + }, + }), + }), + ) + + await expect(api.callMcpTool({ orgId: " ", name: "x", input: {} })).rejects.toMatchObject({ + _tag: "@maple/internal-rpc/InvalidInputError", + method: "callMcpTool", + }) + }) + + it("submits a diagnosis over RPC and returns the existing render marker", async () => { + const calls: unknown[] = [] + const env = envWithBinding({ + submitDiagnosis: async (request: unknown) => { + calls.push(request) + return { id: "ignored-by-chat" } + }, + }) + const tool = buildSubmitDiagnosisTool(env, "org_1", "00000000-0000-4000-8000-000000000001") + const result = JSON.parse(await tool.execute(report)) + + expect(calls).toEqual([ + { + orgId: "org_1", + investigationId: "00000000-0000-4000-8000-000000000001", + report, + }, + ]) + expect(result).toEqual({ status: DIAGNOSIS_STATUS, report }) + }) +}) diff --git a/apps/chat-flue/src/lib/api-rpc.ts b/apps/chat-flue/src/lib/api-rpc.ts new file mode 100644 index 000000000..202ed9f03 --- /dev/null +++ b/apps/chat-flue/src/lib/api-rpc.ts @@ -0,0 +1,6 @@ +import { toRpcAsync } from "alchemy/Cloudflare/Bridge" +import type { MapleApiRpcShape } from "@maple/domain/internal-rpc" +import type { ChatFlueEnv } from "./env.ts" + +/** Fresh per-invocation async facade over the API Worker service binding. */ +export const mapleApiRpc = (env: ChatFlueEnv) => toRpcAsync(env.MAPLE_API_RPC) diff --git a/apps/chat-flue/src/lib/auth.ts b/apps/chat-flue/src/lib/auth.ts index 2c94caf20..0e53e5c23 100644 --- a/apps/chat-flue/src/lib/auth.ts +++ b/apps/chat-flue/src/lib/auth.ts @@ -107,7 +107,8 @@ const SERVICE_TOKEN_PREFIX = "maple_svc_" /** * Verify an internal-service-token request (apps/api → chat-flue server-to-server, * e.g. the headless triage workflow). The bearer value must equal - * `maple_svc_` — the same scheme the MCP connection uses + * `maple_svc_` — the API uses this private route to start + * Flue workflows over its service binding. * (see lib/mcp.ts). Constant-time compared; fails closed when the token is unset. */ export const verifyInternalServiceToken = (request: Request, env: ChatFlueEnv): boolean => { diff --git a/apps/chat-flue/src/lib/env.ts b/apps/chat-flue/src/lib/env.ts index 795020759..0abd01595 100644 --- a/apps/chat-flue/src/lib/env.ts +++ b/apps/chat-flue/src/lib/env.ts @@ -4,9 +4,9 @@ import type { CloudflareAIBinding } from "@flue/runtime/cloudflare" export interface ChatFlueEnv { /** Workers AI binding. Backs the `cloudflare/*` model provider (env.AI.run). */ AI: CloudflareAIBinding - /** Base URL of the Maple API worker that hosts the MCP server (`/mcp`). */ - MAPLE_API_URL: string - /** Shared secret for Maple internal-service auth (`Bearer maple_svc_`). */ + /** Private service binding to the Maple API's schemaless RPC surface. */ + MAPLE_API_RPC: Service + /** Shared secret used to authenticate the API's private Flue workflow calls. */ INTERNAL_SERVICE_TOKEN: string /** Optional Workers AI model override, e.g. `cloudflare/@cf/meta/llama-3.3-70b-instruct-fp8-fast`. */ MAPLE_CHAT_MODEL?: string diff --git a/apps/chat-flue/src/lib/mcp.ts b/apps/chat-flue/src/lib/mcp.ts index a551a6560..5b16d3236 100644 --- a/apps/chat-flue/src/lib/mcp.ts +++ b/apps/chat-flue/src/lib/mcp.ts @@ -1,7 +1,9 @@ -import { connectMcpServer, type McpServerConnection } from "@flue/runtime" +import type { McpServerConnection, ToolDefinition } from "@flue/runtime" +import type { InternalMcpToolResult } from "@maple/domain/internal-rpc" import type { ChatFlueEnv } from "./env.ts" +import { mapleApiRpc } from "./api-rpc.ts" -/** Prefix `connectMcpServer` adapts MCP tool names with: `mcp____`. */ +/** Prefix retained for parity with Flue's MCP HTTP adapter. */ const MCP_PREFIX = "mcp__maple__" /** Strip the `mcp__maple__` prefix to recover the registry tool name. */ @@ -14,45 +16,89 @@ export const filterMcpTools = ( allowlist: ReadonlySet, ): T[] => tools.filter((tool) => allowlist.has(baseToolName(tool.name))) -/** Default MCP request timeout (ms): an unreachable endpoint fails fast rather than hanging the turn. */ +/** Default internal RPC timeout (ms): an unavailable API fails the turn promptly. */ export const MCP_DEFAULT_TIMEOUT_MS = 12_000 export interface ConnectMapleMcpOptions { /** If set, keep only tools whose base name is in this allowlist (e.g. the triage subset). */ allowlist?: ReadonlySet - /** MCP request timeout in ms. Defaults to {@link MCP_DEFAULT_TIMEOUT_MS}. */ + /** Worker RPC timeout in ms. Defaults to {@link MCP_DEFAULT_TIMEOUT_MS}. */ timeoutMs?: number } +const sanitizeToolNamePart = (value: string): string => + value.replace(/[^A-Za-z0-9_-]/g, "_").replace(/^_+|_+$/g, "") || "unnamed" + +const normalizeInputSchema = (schema: Record): Record => ({ + ...schema, + type: schema.type ?? "object", + properties: schema.properties ?? {}, +}) + +const formatResult = (result: InternalMcpToolResult): string => + result.content + .map((entry) => entry.text) + .filter(Boolean) + .join("\n\n") || "(MCP tool returned no content)" + +const withTimeout = async (promise: Promise, timeoutMs: number, operation: string): Promise => { + let timeout: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), + timeoutMs, + ) + }), + ]) + } finally { + if (timeout !== undefined) clearTimeout(timeout) + } +} + /** - * Connect to Maple's MCP server with internal-service auth. The tenant rides - * out-of-band in `x-org-id` (never trusted from model/tool output); the contract - * is apps/api/src/mcp/lib/resolve-tenant.ts. Tools arrive adapted as - * `mcp__maple__`; an optional `allowlist` narrows them by base name. - * - * The caller owns the returned connection's lifecycle and must `close()` it. + * Adapt API Worker RPC descriptors into the same ordinary Flue tools that the + * former streamable-HTTP MCP connection returned. The service binding itself + * authenticates the Worker-to-Worker hop; org scope is explicit and validated + * again by the API boundary. */ export const connectMapleMcp = async ( env: ChatFlueEnv, orgId: string, options: ConnectMapleMcpOptions = {}, ): Promise => { - // Fail fast rather than sending `Bearer maple_svc_` (empty): an empty token - // would match an empty server-side INTERNAL_SERVICE_TOKEN via the constant-time - // compare in apps/api resolve-tenant.ts. Callers already tolerate a throw. - const token = env.INTERNAL_SERVICE_TOKEN - if (!token) throw new Error("INTERNAL_SERVICE_TOKEN is not configured") - - const maple = await connectMcpServer("maple", { - url: new URL("/mcp", env.MAPLE_API_URL).toString(), - transport: "streamable-http", - headers: { - Authorization: `Bearer maple_svc_${token}`, - "x-org-id": orgId, - }, - timeoutMs: options.timeoutMs ?? MCP_DEFAULT_TIMEOUT_MS, + const timeoutMs = options.timeoutMs ?? MCP_DEFAULT_TIMEOUT_MS + const api = mapleApiRpc(env) + const descriptors = await withTimeout(api.listMcpTools(), timeoutMs, "listMcpTools") + const seen = new Set() + const tools: ToolDefinition[] = descriptors.map((descriptor) => { + const name = `${MCP_PREFIX}${sanitizeToolNamePart(descriptor.name)}` + if (seen.has(name)) throw new Error(`Maple RPC tools produced duplicate tool name "${name}"`) + seen.add(name) + + return { + name, + description: `MCP tool "${descriptor.name}" from server "maple". ${descriptor.description}`, + parameters: normalizeInputSchema(descriptor.inputSchema), + execute: async (input, signal) => { + if (signal?.aborted) throw new Error("Operation aborted") + const result = await withTimeout( + api.callMcpTool({ orgId, name: descriptor.name, input }), + timeoutMs, + `callMcpTool(${descriptor.name})`, + ) + const text = formatResult(result) + if (result.isError) throw new Error(text) + return text + }, + } }) - if (!options.allowlist) return maple - return { ...maple, tools: filterMcpTools(maple.tools, options.allowlist) } + return { + name: "maple", + tools: options.allowlist ? filterMcpTools(tools, options.allowlist) : tools, + close: async () => undefined, + } } diff --git a/apps/chat-flue/src/lib/submit-diagnosis.ts b/apps/chat-flue/src/lib/submit-diagnosis.ts index 1fb5c9027..9cee9896d 100644 --- a/apps/chat-flue/src/lib/submit-diagnosis.ts +++ b/apps/chat-flue/src/lib/submit-diagnosis.ts @@ -1,6 +1,7 @@ import type { ToolDefinition } from "@flue/runtime" import type { ChatFlueEnv } from "./env.ts" import { AiTriageResultSchema, type AiTriageResult } from "./triage-result.ts" +import { mapleApiRpc } from "./api-rpc.ts" /** Marker the `submit_diagnosis` tool returns; the web renders it as the report card. */ export const DIAGNOSIS_STATUS = "diagnosis" as const @@ -33,23 +34,7 @@ export const buildSubmitDiagnosisTool = ( "Record your structured diagnosis for THIS investigation. Call it exactly once, after you have gathered evidence, with your final assessment (summary, suspectedCause, severityAssessment, affectedScope, evidence, suggestedActions, confidence). It persists the report and renders it for the user. After calling it, stop unless the user asks a follow-up question.", parameters: AiTriageResultSchema, execute: async (report) => { - const url = new URL( - `/api/internal/investigations/${investigationId}/diagnosis`, - env.MAPLE_API_URL, - ).toString() - const res = await fetch(url, { - method: "POST", - headers: { - "content-type": "application/json", - Authorization: `Bearer maple_svc_${env.INTERNAL_SERVICE_TOKEN}`, - "x-org-id": orgId, - }, - body: JSON.stringify({ report }), - }) - if (!res.ok) { - const detail = await res.text().catch(() => "") - throw new Error(`submit_diagnosis failed (${res.status}): ${detail.slice(0, 200)}`) - } + await mapleApiRpc(env).submitDiagnosis({ orgId, investigationId, report }) return JSON.stringify({ status: DIAGNOSIS_STATUS, report } satisfies DiagnosisMarker) }, }) diff --git a/apps/chat-flue/src/lib/triage.test.ts b/apps/chat-flue/src/lib/triage.test.ts index ec4b2b89d..cf2e892aa 100644 --- a/apps/chat-flue/src/lib/triage.test.ts +++ b/apps/chat-flue/src/lib/triage.test.ts @@ -55,15 +55,35 @@ describe("mcp tool filtering", () => { expect(kept).toEqual(["mcp__maple__search_traces", "mcp__maple__find_errors"]) }) - it("connectMapleMcp fails fast (no empty bearer) when the token is unset", async () => { + it("connectMapleMcp discovers tools over the API Worker binding", async () => { + const calls: unknown[] = [] const env: ChatFlueEnv = { AI: { run: async () => ({}) }, - MAPLE_API_URL: "http://maple-test.invalid", + MAPLE_API_RPC: { + listMcpTools: async () => [ + { + name: "search_traces", + description: "Search traces", + inputSchema: { type: "object", properties: {} }, + }, + ], + callMcpTool: async (request: unknown) => { + calls.push(request) + return { content: [{ type: "text", text: "ok" }] } + }, + } as never, INTERNAL_SERVICE_TOKEN: "", } - await expect(connectMapleMcp(env, "org_1")).rejects.toThrow( - "INTERNAL_SERVICE_TOKEN is not configured", - ) + const connection = await connectMapleMcp(env, "org_1") + expect(connection.tools.map((tool) => tool.name)).toEqual(["mcp__maple__search_traces"]) + expect(await connection.tools[0]!.execute({ service: "checkout" })).toBe("ok") + expect(calls).toEqual([ + { + orgId: "org_1", + name: "search_traces", + input: { service: "checkout" }, + }, + ]) }) }) diff --git a/apps/chat-flue/wrangler.jsonc b/apps/chat-flue/wrangler.jsonc index 5e106caa9..f7430558d 100644 --- a/apps/chat-flue/wrangler.jsonc +++ b/apps/chat-flue/wrangler.jsonc @@ -21,10 +21,7 @@ "new_sqlite_classes": ["FlueRegistry", "FlueMapleChatAgent", "FlueTriageWorkflow"], }, ], - "vars": { - // Maple API worker that hosts the MCP server at `/mcp`. - "MAPLE_API_URL": "http://localhost:3472", - }, + "services": [{ "binding": "MAPLE_API_RPC", "service": "maple-api" }], // Workers Observability — `traces.enabled` is required for `tracing.enterSpan` // custom spans; the "maple" destination forwards logs + traces to Maple ingest. // The real deploy is Alchemy-owned (alchemy.run.ts sets this too); kept here in diff --git a/apps/cli/src/server/schema/local-inserts.json b/apps/cli/src/server/schema/local-inserts.json index 8eae6a96b..b84309ffd 100644 --- a/apps/cli/src/server/schema/local-inserts.json +++ b/apps/cli/src/server/schema/local-inserts.json @@ -1,5 +1,5 @@ { - "projectRevision": "ffade4bccc59af00fd33a561c4c919fd0229e0505f659d3242081f670f034a41", + "projectRevision": "82756d56b48d81c2bdac48eadda474321a463346a3da36e705b8d62e34777328", "orgPlaceholder": "__ORG__", "datasources": { "traces": { diff --git a/apps/cli/src/server/schema/local-schema.sql b/apps/cli/src/server/schema/local-schema.sql index 248205b3a..d36e3c00e 100644 --- a/apps/cli/src/server/schema/local-schema.sql +++ b/apps/cli/src/server/schema/local-schema.sql @@ -1,6 +1,6 @@ -- This file is generated by scripts/generate-clickhouse-schema-sql.ts -- Do not edit manually. Run `bun run clickhouse:schema` to regenerate. --- projectRevision: ffade4bccc59af00fd33a561c4c919fd0229e0505f659d3242081f670f034a41 +-- projectRevision: 82756d56b48d81c2bdac48eadda474321a463346a3da36e705b8d62e34777328 CREATE TABLE IF NOT EXISTS alert_checks ( OrgId LowCardinality(String), @@ -132,7 +132,12 @@ CREATE TABLE IF NOT EXISTS logs ( ScopeVersion String, ScopeAttributes Map(LowCardinality(String), String), LogAttributes Map(LowCardinality(String), String), - INDEX idx_trace_id TraceId TYPE bloom_filter(0.01) GRANULARITY 1 + INDEX idx_trace_id TraceId TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_body_tokens lower(Body) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1, + INDEX idx_log_attr_keys mapKeys(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_log_attr_vals mapValues(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_log_resource_attr_keys mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_log_resource_attr_vals mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1 ) ENGINE = MergeTree PARTITION BY toDate(TimestampTime) @@ -655,7 +660,9 @@ CREATE TABLE IF NOT EXISTS traces ( INDEX idx_span_attr_keys mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, INDEX idx_span_attr_vals mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, INDEX idx_resource_attr_keys mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_resource_attr_vals mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1 + INDEX idx_resource_attr_vals mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_span_attr_items arrayMap((k, v) -> concat(k, '=', v), mapKeys(SpanAttributes), mapValues(SpanAttributes)) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_attr_items arrayMap((k, v) -> concat(k, '=', v), mapKeys(ResourceAttributes), mapValues(ResourceAttributes)) TYPE bloom_filter(0.01) GRANULARITY 1 ) ENGINE = MergeTree PARTITION BY toDate(Timestamp) diff --git a/apps/ingest/src/clickhouse_insert_mappings.rs b/apps/ingest/src/clickhouse_insert_mappings.rs index dd4f94280..5383af816 100644 --- a/apps/ingest/src/clickhouse_insert_mappings.rs +++ b/apps/ingest/src/clickhouse_insert_mappings.rs @@ -1,12 +1,12 @@ // This file is generated by scripts/generate-clickhouse-insert-mappings.ts // Do not edit manually. -pub const PROJECT_REVISION: &str = "ffade4bccc59af00fd33a561c4c919fd0229e0505f659d3242081f670f034a41"; +pub const PROJECT_REVISION: &str = "82756d56b48d81c2bdac48eadda474321a463346a3da36e705b8d62e34777328"; // Gate for BYO-ClickHouse ingest readiness — the migration version, NOT the // Tinybird-coupled PROJECT_REVISION. Compared against // org_clickhouse_settings.schema_version. See @maple/domain/clickhouse // clickHouseSchemaVersion. -pub const SCHEMA_VERSION: &str = "7"; +pub const SCHEMA_VERSION: &str = "8"; pub const ORG_PLACEHOLDER: &str = "__ORG__"; #[derive(Debug)] diff --git a/apps/scraper/src/ScrapeScheduler.test.ts b/apps/scraper/src/ScrapeScheduler.test.ts index 632f9770b..7ded4af3d 100644 --- a/apps/scraper/src/ScrapeScheduler.test.ts +++ b/apps/scraper/src/ScrapeScheduler.test.ts @@ -451,6 +451,24 @@ describe("ScrapeScheduler", () => { }), ) + it.effect("backs off a target whose credential is rejected (403) instead of hammering it", () => + Effect.gen(function* () { + // Prod scenario: PlanetScale's metrics.psdb.cloud rejects an OAuth + // bearer with 403 on every scrape — the loop must escalate its delay + // exactly like a rate limit, not retry every interval forever. + const harness = makeHarness([mkTarget(TARGET_A, 10)]) + harness.scrapeImpl = () => Effect.succeed(proxyResponse({ status: 403, body: "forbidden" })) + yield* startScheduler.pipe(Effect.provide(harnessLayer(harness))) + + yield* TestClock.adjust(Duration.seconds(60)) + + // Exponential backoff off a 10s base: scrapes at t=0, 10, 30 (next is + // t=70, outside the window). A fixed interval would have fired 7 times. + assert.strictEqual(harness.scrapeCalls.length, 3) + assert.include(harness.reportedResults[0]?.error ?? "", "HTTP 403") + }), + ) + it.effect("honors a longer Retry-After before the next scrape", () => Effect.gen(function* () { const harness = makeHarness([mkTarget(TARGET_A, 10)]) @@ -588,40 +606,58 @@ describe("sendResultsInChunks", () => { }) describe("nextScrapeDelayMs", () => { - const ok: ScrapeOutcome = { error: null, rateLimited: false, retryAfterMs: null } + const ok: ScrapeOutcome = { error: null, rateLimited: false, authFailed: false, retryAfterMs: null } const limited = (retryAfterMs: number | null = null): ScrapeOutcome => ({ error: "target returned HTTP 429", rateLimited: true, + authFailed: false, retryAfterMs, }) + const authRejected: ScrapeOutcome = { + error: "target returned HTTP 403", + rateLimited: false, + authFailed: true, + retryAfterMs: null, + } it("holds the base interval on a healthy scrape, ignoring the counter", () => { - assert.strictEqual(nextScrapeDelayMs({ baseMs: 5_000, outcome: ok, consecutiveRateLimits: 3 }), 5_000) + assert.strictEqual(nextScrapeDelayMs({ baseMs: 5_000, outcome: ok, consecutiveBackoffs: 3 }), 5_000) }) it("escalates exponentially while rate-limited", () => { - assert.strictEqual(nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(), consecutiveRateLimits: 0 }), 10_000) - assert.strictEqual(nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(), consecutiveRateLimits: 1 }), 20_000) - assert.strictEqual(nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(), consecutiveRateLimits: 3 }), 80_000) + assert.strictEqual(nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(), consecutiveBackoffs: 0 }), 10_000) + assert.strictEqual(nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(), consecutiveBackoffs: 1 }), 20_000) + assert.strictEqual(nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(), consecutiveBackoffs: 3 }), 80_000) + }) + + it("escalates exponentially on a rejected credential (401/403) too", () => { + assert.strictEqual( + nextScrapeDelayMs({ baseMs: 10_000, outcome: authRejected, consecutiveBackoffs: 1 }), + 20_000, + ) + assert.strictEqual( + nextScrapeDelayMs({ baseMs: 60_000, outcome: authRejected, consecutiveBackoffs: 5 }), + Duration.toMillis(Duration.minutes(5)), + ) }) it("caps the backoff at 5 minutes", () => { assert.strictEqual( - nextScrapeDelayMs({ baseMs: 60_000, outcome: limited(), consecutiveRateLimits: 5 }), + nextScrapeDelayMs({ baseMs: 60_000, outcome: limited(), consecutiveBackoffs: 5 }), Duration.toMillis(Duration.minutes(5)), ) }) it("honors Retry-After when it exceeds the exponential backoff", () => { assert.strictEqual( - nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(120_000), consecutiveRateLimits: 0 }), + nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(120_000), consecutiveBackoffs: 0 }), 120_000, ) }) it("prefers the exponential backoff when Retry-After is shorter", () => { assert.strictEqual( - nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(5_000), consecutiveRateLimits: 2 }), + nextScrapeDelayMs({ baseMs: 10_000, outcome: limited(5_000), consecutiveBackoffs: 2 }), 40_000, ) }) diff --git a/apps/scraper/src/ScrapeScheduler.ts b/apps/scraper/src/ScrapeScheduler.ts index 38897eab1..d1d84cee4 100644 --- a/apps/scraper/src/ScrapeScheduler.ts +++ b/apps/scraper/src/ScrapeScheduler.ts @@ -41,31 +41,44 @@ export interface ScrapeOutcome { readonly samplesPostMetricRelabeling?: number /** Upstream signalled a rate limit (HTTP 429/503) — back off before retrying. */ readonly rateLimited: boolean + /** + * Upstream rejected the credential (HTTP 401/403) — back off like a rate + * limit: the failure won't clear until the org's auth is fixed, so retrying + * every interval just hammers the target (prod hit this with PlanetScale + * rejecting OAuth bearers on metrics.psdb.cloud every 60s). + */ + readonly authFailed: boolean /** Upstream `Retry-After` translated to ms, when present. */ readonly retryAfterMs: number | null } +/** A scrape outcome that must escalate the delay instead of holding cadence. */ +export const shouldBackOff = (outcome: ScrapeOutcome): boolean => + outcome.rateLimited || outcome.authFailed + /** * The target period before a target's next scrape. The happy path returns the * configured interval; the caller ({@link ScrapeScheduler}'s target loop) * subtracts the scrape's own elapsed time so the happy-path cadence stays - * start-to-start. A rate-limited scrape escalates exponentially — honoring - * `Retry-After` when it is longer — capped at {@link MAX_BACKOFF_MS} so the - * target keeps probing for recovery; that delay runs from scrape end. + * start-to-start. A rate-limited or auth-rejected scrape escalates + * exponentially — honoring `Retry-After` when it is longer — capped at + * {@link MAX_BACKOFF_MS} so the target keeps probing for recovery (an auth fix + * needs no restart: the credential is resolved server-side per scrape); that + * delay runs from scrape end. */ export const nextScrapeDelayMs = ({ baseMs, outcome, - consecutiveRateLimits, + consecutiveBackoffs, }: { readonly baseMs: number readonly outcome: ScrapeOutcome - readonly consecutiveRateLimits: number + readonly consecutiveBackoffs: number }): number => { - if (!outcome.rateLimited) return baseMs - // exponential is always >= baseMs (consecutiveRateLimits >= 0), so baseMs + if (!shouldBackOff(outcome)) return baseMs + // exponential is always >= baseMs (consecutiveBackoffs >= 0), so baseMs // never needs to be a floor here. - const exponential = baseMs * 2 ** consecutiveRateLimits + const exponential = baseMs * 2 ** consecutiveBackoffs const retryAfter = outcome.retryAfterMs ?? 0 return Math.min(MAX_BACKOFF_MS, Math.max(exponential, retryAfter)) } @@ -191,10 +204,12 @@ export class ScrapeScheduler extends Context.Service= 300) { // A non-2xx is a recorded failure, not an Effect error: only - // 429/503 trigger backoff, and we need the Retry-After hint. + // 429/503 (rate limit) and 401/403 (rejected credential) + // trigger backoff, and we need the Retry-After hint. return { error: `target returned HTTP ${response.status}`, rateLimited: response.status === 429 || response.status === 503, + authFailed: response.status === 401 || response.status === 403, retryAfterMs: response.retryAfterSeconds !== null ? response.retryAfterSeconds * 1000 @@ -240,6 +255,7 @@ export class ScrapeScheduler extends Context.Service({ error: error.message, rateLimited: false, + authFailed: false, retryAfterMs: null, }), ), @@ -254,6 +271,7 @@ export class ScrapeScheduler extends Context.Service({ error: Cause.pretty(Cause.die(defect)), rateLimited: false, + authFailed: false, retryAfterMs: null, }), ), @@ -285,35 +303,41 @@ export class ScrapeScheduler extends Context.Service { const baseMs = target.scrapeIntervalSeconds * 1000 - const loop = (consecutiveRateLimits: number): Effect.Effect => + const loop = (consecutiveBackoffs: number): Effect.Effect => Effect.gen(function* () { const startedAt = yield* Clock.currentTimeMillis const outcome = yield* scrapeOnce(target) const elapsedMs = (yield* Clock.currentTimeMillis) - startedAt - const delayMs = nextScrapeDelayMs({ baseMs, outcome, consecutiveRateLimits }) - if (outcome.rateLimited) { - yield* Effect.logWarning("Scrape rate-limited, backing off").pipe( + const backingOff = shouldBackOff(outcome) + const delayMs = nextScrapeDelayMs({ baseMs, outcome, consecutiveBackoffs }) + if (backingOff) { + yield* Effect.logWarning( + outcome.authFailed + ? "Scrape auth rejected, backing off" + : "Scrape rate-limited, backing off", + ).pipe( Effect.annotateLogs({ targetId: target.id, orgId: target.orgId, ...(target.subTargetKey ? { subTargetKey: target.subTargetKey } : {}), delayMs, retryAfterMs: outcome.retryAfterMs, - consecutiveRateLimits: consecutiveRateLimits + 1, + consecutiveBackoffs: consecutiveBackoffs + 1, }), ) } // Happy path: subtract the scrape's own elapsed time so cadence // stays start-to-start (matching the old Schedule.fixed). Backoff // runs the full delay from scrape end so Retry-After is honored. - const sleepMs = outcome.rateLimited ? delayMs : Math.max(0, delayMs - elapsedMs) + const sleepMs = backingOff ? delayMs : Math.max(0, delayMs - elapsedMs) yield* Effect.sleep(Duration.millis(sleepMs)) - return yield* loop(outcome.rateLimited ? consecutiveRateLimits + 1 : 0) + return yield* loop(backingOff ? consecutiveBackoffs + 1 : 0) }) // Plain targets have nothing to de-sync against; only stagger the // branches of a discovered (PlanetScale) target so they spread across diff --git a/apps/web/src/components/alerts/alert-rule-chart.tsx b/apps/web/src/components/alerts/alert-rule-chart.tsx index dafd85765..da8f67c0c 100644 --- a/apps/web/src/components/alerts/alert-rule-chart.tsx +++ b/apps/web/src/components/alerts/alert-rule-chart.tsx @@ -62,12 +62,12 @@ interface AlertRuleChartProps { } const SINGLE_KEY = "value" -const CHART_HEIGHT = 240 +const CHART_HEIGHT = 300 type ChartPoint = { t: number } & Record type SignalSource = "preview" | "checks" | "none" -const Y_AXIS_WIDTH = 62 -const PLOT_RIGHT = 8 +const Y_AXIS_WIDTH = 72 +const PLOT_RIGHT = 12 const RAIL_CELLS = 60 type RailStatus = "breached" | "error" | "skipped" | "healthy" | "empty" @@ -506,7 +506,7 @@ export function AlertRuleChart({ {isMultiSeries && ( )} + {/* Dashed threshold line(s) only — the labels live in the caption below + the plot so they can't clip at the right edge or collide with the + legend/series. */} {thresholdUpper != null && ( )} @@ -586,6 +577,28 @@ export function AlertRuleChart({
{chartArea} + {hasSignal && ( +
+ + + {thresholdUpper != null + ? "Threshold range " + : breachBelow + ? "Breach below " + : "Breach above "} + + {formatSignalValue(signalType, threshold)} + {thresholdUpper != null + ? ` – ${formatSignalValue(signalType, thresholdUpper)}` + : ""} + + +
+ )} + {hasSignal && error != null && source === "checks" && (

Live signal preview unavailable — showing recorded checks. diff --git a/apps/web/src/components/alerts/rule-detail/rule-diagnosis-panel.tsx b/apps/web/src/components/alerts/rule-detail/rule-diagnosis-panel.tsx index b27b75514..5b8cb076e 100644 --- a/apps/web/src/components/alerts/rule-detail/rule-diagnosis-panel.tsx +++ b/apps/web/src/components/alerts/rule-detail/rule-diagnosis-panel.tsx @@ -82,11 +82,23 @@ export function RuleDiagnosisPanel({ const [expanded, setExpanded] = useState(null) const isOpen = expanded ?? hasProblems + // When something's wrong, lead with the stage(s) that broke — the passing + // steps are noise you're not looking at. They fold behind a disclosure. + const problemStages = useMemo( + () => stages.filter((s) => s.status === "fail" || s.status === "warn"), + [stages], + ) + const [showAll, setShowAll] = useState(false) + const hasHiddenSteps = hasProblems && problemStages.length < stages.length + const visibleStages = showAll || !hasProblems ? stages : problemStages + const passingCount = useMemo(() => stages.filter((s) => s.status === "pass").length, [stages]) + const hiddenCount = stages.length - problemStages.length + return ( {isOpen && ( -

+
{groupKeys.length > 0 && (
Group @@ -116,17 +128,36 @@ export function RuleDiagnosisPanel({ />
)} -
    - {stages.map((stage) => ( +
      + {visibleStages.map((stage) => ( ))}
    + {hasHiddenSteps && ( + + )}
)} ) } +const EVIDENCE_CAP = 4 + function DiagnosisStageRow({ stage, onToggleEnabled, @@ -136,12 +167,16 @@ function DiagnosisStageRow({ }) { const problematic = stage.status === "fail" || stage.status === "warn" const [open, setOpen] = useState(null) + const [showAllEvidence, setShowAllEvidence] = useState(false) const showEvidence = (open ?? problematic) && stage.evidence.length > 0 + // A chatty stage shouldn't blow out the panel height — cap it, reveal on demand. + const evidence = showAllEvidence ? stage.evidence : stage.evidence.slice(0, EVIDENCE_CAP) + const hiddenEvidence = stage.evidence.length - evidence.length return (
  • {showEvidence && (
      - {stage.evidence.map((line) => ( + {evidence.map((line) => (
    • {line}
    • ))} + {hiddenEvidence > 0 && ( +
    • + +
    • + )}
    )}
  • diff --git a/apps/web/src/components/infra/planetscale/planetscale-branch-table.tsx b/apps/web/src/components/infra/planetscale/planetscale-branch-table.tsx new file mode 100644 index 000000000..3e1f91f97 --- /dev/null +++ b/apps/web/src/components/infra/planetscale/planetscale-branch-table.tsx @@ -0,0 +1,137 @@ +import { Badge } from "@maple/ui/components/ui/badge" +import { cn } from "@maple/ui/lib/utils" + +import type { PlanetScaleBranchStat } from "@/api/warehouse/service-map" +import { formatNumber } from "@/lib/format" +import { ColumnHead, TableShell, useTableSort } from "../primitives/data-table" +import { formatLag, lagClass, utilizationClass } from "./planetscale-database-table" + +type SortKey = "branch" | "connectionsAvg" | "cpuMaxPercent" | "memMaxPercent" | "replicaLagMaxSeconds" + +/** + * Per-branch health for one database. Branch flags (production/ready) come from + * the polled inventory; the metric columns from the window's rollups. + */ +export function PlanetScaleBranchTable({ + branches, + branchInfoByName, + waiting, +}: { + branches: ReadonlyArray + branchInfoByName: ReadonlyMap + waiting?: boolean +}) { + const { sorted, sortKey, sortDir, handleSort } = useTableSort( + branches, + { initialKey: "connectionsAvg", stringKeys: ["branch"] }, + ) + + return ( + + + label="Branch" + sortKey="branch" + currentKey={sortKey} + dir={sortDir} + onSort={handleSort} + width="flex-1 min-w-[200px]" + /> + + label="Connections" + sortKey="connectionsAvg" + currentKey={sortKey} + dir={sortDir} + onSort={handleSort} + align="right" + width="w-[96px]" + /> + + label="CPU (max)" + sortKey="cpuMaxPercent" + currentKey={sortKey} + dir={sortDir} + onSort={handleSort} + align="right" + width="w-[88px]" + /> + + label="Memory (max)" + sortKey="memMaxPercent" + currentKey={sortKey} + dir={sortDir} + onSort={handleSort} + align="right" + width="w-[104px]" + hidden="hidden md:flex" + /> + + label="Replica lag" + sortKey="replicaLagMaxSeconds" + currentKey={sortKey} + dir={sortDir} + onSort={handleSort} + align="right" + width="w-[88px]" + /> + + } + > + {sorted.map((row) => { + const info = branchInfoByName.get(row.branch) + return ( +
    +
    + {row.branch} + {info?.production ? ( + + production + + ) : null} + {info !== undefined && !info.ready ? ( + + provisioning + + ) : null} +
    +
    + {formatNumber(row.connectionsAvg)} +
    +
    + {row.cpuMaxPercent.toFixed(0)}% +
    +
    + {row.memMaxPercent.toFixed(0)}% +
    +
    + {formatLag(row.replicaLagMaxSeconds)} +
    +
    + ) + })} +
    + ) +} diff --git a/apps/web/src/components/infra/planetscale/planetscale-database-table.tsx b/apps/web/src/components/infra/planetscale/planetscale-database-table.tsx index f70fa0519..eddd565fd 100644 --- a/apps/web/src/components/infra/planetscale/planetscale-database-table.tsx +++ b/apps/web/src/components/infra/planetscale/planetscale-database-table.tsx @@ -7,101 +7,250 @@ import { cn } from "@maple/ui/lib/utils" import type { PlanetScaleDatabaseSummary } from "@maple/domain/http" import type { PlanetScaleDatabaseStat } from "@/api/warehouse/service-map" import { formatNumber } from "@/lib/format" +import { + ColumnHead, + MetaChip, + ROW_LINK_CLASS, + TableShell, + TableSkeleton, + useTableSort, +} from "../primitives/data-table" -export function PlanetScaleDatabaseTableLoading() { - return -} - -const formatLag = (seconds: number) => +export const formatLag = (seconds: number) => seconds >= 1 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds * 1000)}ms` -function utilizationClass(percent: number): string | undefined { +export function utilizationClass(percent: number): string | undefined { if (percent > 80) return "text-severity-error" if (percent > 60) return "text-severity-warn" return undefined } -function lagClass(seconds: number): string | undefined { +export function lagClass(seconds: number): string | undefined { if (seconds > 10) return "text-severity-error" if (seconds > 1) return "text-severity-warn" return undefined } +/** States that indicate the database isn't serving normally — worth a badge. */ +export function abnormalState(state: string | null): string | null { + if (state === null) return null + const normalized = state.toLowerCase() + return normalized === "ready" || normalized === "active" ? null : normalized +} + +type SortKey = + | "name" + | "branchCount" + | "connectionsAvg" + | "cpuMaxPercent" + | "memMaxPercent" + | "replicaLagMaxSeconds" + +interface DatabaseRow { + id: string + name: string + kind: string + region: string | null + plan: string | null + state: string | null + branchCount: number + hasStats: boolean + connectionsAvg: number + cpuMaxPercent: number + memMaxPercent: number + replicaLagMaxSeconds: number +} + +// Databases with no metrics in the window sort below every real value. +const MISSING = Number.NEGATIVE_INFINITY + +const headerCells = (sort?: { + sortKey: SortKey + sortDir: "asc" | "desc" + handleSort: (k: SortKey) => void +}) => ( + <> + + label="Database" + sortKey={sort ? "name" : undefined} + currentKey={sort?.sortKey} + dir={sort?.sortDir} + onSort={sort?.handleSort} + width="flex-1 min-w-[220px]" + /> + + label="Branches" + sortKey={sort ? "branchCount" : undefined} + currentKey={sort?.sortKey} + dir={sort?.sortDir} + onSort={sort?.handleSort} + align="right" + width="w-[80px]" + hidden="hidden md:flex" + /> + + label="Connections" + sortKey={sort ? "connectionsAvg" : undefined} + currentKey={sort?.sortKey} + dir={sort?.sortDir} + onSort={sort?.handleSort} + align="right" + width="w-[96px]" + /> + + label="CPU (max)" + sortKey={sort ? "cpuMaxPercent" : undefined} + currentKey={sort?.sortKey} + dir={sort?.sortDir} + onSort={sort?.handleSort} + align="right" + width="w-[88px]" + /> + + label="Memory (max)" + sortKey={sort ? "memMaxPercent" : undefined} + currentKey={sort?.sortKey} + dir={sort?.sortDir} + onSort={sort?.handleSort} + align="right" + width="w-[104px]" + hidden="hidden md:flex" + /> + + label="Replica lag" + sortKey={sort ? "replicaLagMaxSeconds" : undefined} + currentKey={sort?.sortKey} + dir={sort?.sortDir} + onSort={sort?.handleSort} + align="right" + width="w-[88px]" + /> + +) + +export function PlanetScaleDatabaseTableLoading() { + return ( + ( + <> +
    + +
    + + + + + + + )} + /> + ) +} + /** * Fleet table: one row per database from the polled inventory, joined with the - * window's scraped-metric rollups. Databases with no metrics in the window - * (excluded branches, asleep) still render with muted dashes. + * window's metric rollups. Databases with no metrics in the window (excluded + * branches, asleep) still render with muted dashes and sort last. */ export function PlanetScaleDatabaseTable({ databases, statsByName, + waiting, }: { databases: ReadonlyArray statsByName: ReadonlyMap + waiting?: boolean }) { + const rows: DatabaseRow[] = databases.map((db) => { + const stats = statsByName.get(db.name.toLowerCase()) + return { + id: db.id, + name: db.name, + kind: db.kind, + region: db.region, + plan: db.plan, + state: db.state, + branchCount: db.branches.length, + hasStats: stats !== undefined, + connectionsAvg: stats?.connectionsAvg ?? MISSING, + cpuMaxPercent: stats?.cpuMaxPercent ?? MISSING, + memMaxPercent: stats?.memMaxPercent ?? MISSING, + replicaLagMaxSeconds: stats?.replicaLagMaxSeconds ?? MISSING, + } + }) + + const { sorted, sortKey, sortDir, handleSort } = useTableSort(rows, { + initialKey: "connectionsAvg", + stringKeys: ["name"], + }) + return ( -
    -
    - Database - Branches - Connections - CPU (max) - Memory (max) - Replica lag -
    - {databases.map((db) => { - const stats = statsByName.get(db.name.toLowerCase()) + + {sorted.map((row) => { + const state = abnormalState(row.state) return ( - - {db.name} +
    + + {row.name} + - {db.kind === "postgresql" ? "Postgres" : "MySQL"} + {row.kind === "postgresql" ? "Postgres" : "MySQL"} - {db.region ? ( - - {db.region} - + {state !== null ? ( + + {state} + ) : null} - - - {db.branches.length} - - - {stats ? formatNumber(stats.connectionsAvg) : "—"} - - {row.region} : null} + {row.plan ? {row.plan} : null} +
    +
    + {row.branchCount} +
    +
    + {row.hasStats ? formatNumber(row.connectionsAvg) : "—"} +
    +
    - {stats ? `${stats.cpuMaxPercent.toFixed(0)}%` : "—"} - - +
    - {stats ? `${stats.memMaxPercent.toFixed(0)}%` : "—"} - - +
    - {stats ? formatLag(stats.replicaLagMaxSeconds) : "—"} - + {row.hasStats ? formatLag(row.replicaLagMaxSeconds) : "—"} +
    ) })} -
    + ) } diff --git a/apps/web/src/components/infra/planetscale/planetscale-not-connected.tsx b/apps/web/src/components/infra/planetscale/planetscale-not-connected.tsx index df189f06d..103a42522 100644 --- a/apps/web/src/components/infra/planetscale/planetscale-not-connected.tsx +++ b/apps/web/src/components/infra/planetscale/planetscale-not-connected.tsx @@ -21,9 +21,9 @@ export function PlanetScaleNotConnected() { Connect PlanetScale to see database health - Authorize your PlanetScale organization with one click and Maple continuously scrapes - every branch's metrics — connections, CPU, memory, replication lag — with no agent to - run. + Authorize your PlanetScale organization with one click and Maple tracks every + branch's health — connections, CPU, memory, replication lag — with nothing to + install. diff --git a/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx b/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx index bc128d285..6b0cada7d 100644 --- a/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx +++ b/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx @@ -6,7 +6,7 @@ import { cn } from "@maple/ui/lib/utils" import { Result } from "@/lib/effect-atom" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { planetscaleQueryInsightsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" -import { formatLatency, formatNumber } from "@/lib/format" +import { formatLatency, formatNumber, formatRelativeTime } from "@/lib/format" /** Warehouse "YYYY-MM-DD HH:mm:ss" → epoch ms (values are UTC). */ const warehouseTimeToMs = (value: string): number => new Date(`${value.replace(" ", "T")}Z`).getTime() @@ -103,6 +103,11 @@ export function PlanetScaleTopQueries({ {formatNumber(row.rowsReadPerQuery)} rows read/query
    {row.statementType ? {row.statementType} : null} + {row.lastRunAt !== null ? ( + + last run {formatRelativeTime(new Date(row.lastRunAt).toISOString())} + + ) : null}
    ))} diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx index ca61e1325..293802143 100644 --- a/apps/web/src/components/integrations/integration-catalog.tsx +++ b/apps/web/src/components/integrations/integration-catalog.tsx @@ -60,7 +60,7 @@ const CATALOG: ReadonlyArray = [ id: "planetscale", name: "PlanetScale", description: - "Authorize your organization with one click — Maple discovers and scrapes every database branch.", + "Authorize your organization with one click — Maple tracks every database branch automatically.", icon: PlanetScaleIcon, // PlanetScale's mark is monochrome — neutral wash that works in both themes. accent: "#8B8B8B", diff --git a/apps/web/src/components/integrations/planetscale-integration-card.tsx b/apps/web/src/components/integrations/planetscale-integration-card.tsx index 429fd5472..4cb239733 100644 --- a/apps/web/src/components/integrations/planetscale-integration-card.tsx +++ b/apps/web/src/components/integrations/planetscale-integration-card.tsx @@ -25,11 +25,10 @@ import { toast } from "sonner" import { CheckIcon, CircleWarningIcon, LoaderIcon, PlanetScaleIcon } from "@/components/icons" import { cn } from "@maple/ui/utils" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" -import { formatRelativeTime } from "@/lib/format" import { MapleApiAtomClient } from "@/lib/services/common/atom-client" -import { ScrapeTargetsSection } from "@/components/settings/scrape-targets-section" import { IntegrationIconPlate, catalogEntry } from "./integration-catalog" import { IntegrationEmptyState } from "./integration-empty-state" +import { PlanetScaleMetricsHealth } from "./planetscale-metrics-health" const PLANETSCALE_ENTRY = catalogEntry("planetscale") @@ -43,9 +42,9 @@ const parsePatternList = (value: string): string[] => /** * First-class PlanetScale connection card: authorize Maple's OAuth application * in a popup, pick the PlanetScale organization (auto-bound when the grant - * reaches exactly one), and Maple provisions the managed branch-metrics scrape - * target, polls database inventory, and proxies query insights. The managed - * scrape target's per-branch health renders below via the shared list. + * reaches exactly one), and Maple collects branch metrics, polls database + * inventory, and proxies query insights automatically. Collection health shows + * as a single status row — the machinery stays out of the UI. */ export function PlanetScaleIntegrationCard() { const statusQuery = MapleApiAtomClient.query("integrations", "planetscaleStatus", { @@ -204,7 +203,7 @@ export function PlanetScaleIntegrationCard() { description="Authorize Maple in PlanetScale and databases, branches, query insights, and webhooks connect instantly. Branch metrics take one more paste: a read-only service token, since PlanetScale only exposes its metrics endpoints to service tokens." features={[ "Databases appear on the service map with live health", - "Branch metrics scraped automatically, no agent required", + "Branch metrics collected automatically — nothing to run", "Branch filters keep preview branches out", ]} footer="You'll authorize Maple in a PlanetScale popup; a read_metrics_endpoints service token completes metrics afterwards." @@ -249,22 +248,6 @@ export function PlanetScaleIntegrationCard() { )}

    - {target ? ( -

    - {target.lastScrapeAt ? ( - <> - Last scrape{" "} - {formatRelativeTime(new Date(target.lastScrapeAt).toISOString())} · - every {target.scrapeIntervalSeconds}s - - ) : ( - "First scrape starts within a minute." - )} - {target.excludeBranches.length > 0 ? ( - <> · excluding {target.excludeBranches.join(", ")} - ) : null} -

    - ) : null}
    @@ -291,6 +274,10 @@ export function PlanetScaleIntegrationCard() { /> ) : null} + {status && target ? ( + + ) : null} + {missingDatabasesPermission ? (
    @@ -306,32 +293,18 @@ export function PlanetScaleIntegrationCard() {
    ) : null} - {target?.lastScrapeError && status?.metricsAuth !== "missing" ? ( -
    - - - Metrics collection degraded - - {target.lastScrapeError} - - -
    - ) : null} - {/* The managed target's per-branch scrape health, via the shared scrape-target list. */} - - {/* Re-binding to another org the grant covers — finalize is an upsert. */} Change PlanetScale organization - Pick another organization the authorization covers. The managed scrape target - follows the new organization. + Pick another organization the authorization covers. Metrics collection follows + automatically. @@ -571,7 +544,7 @@ function PlanetScaleOrgPicker(props: { autoComplete="off" />

    - Glob patterns for branches to skip — keeps preview branches from being scraped. + Glob patterns for branches to skip — keeps preview branches out of your metrics.

    diff --git a/apps/web/src/components/integrations/planetscale-metrics-health.tsx b/apps/web/src/components/integrations/planetscale-metrics-health.tsx new file mode 100644 index 000000000..f4fc3dce3 --- /dev/null +++ b/apps/web/src/components/integrations/planetscale-metrics-health.tsx @@ -0,0 +1,92 @@ +import { useState } from "react" + +import { cn } from "@maple/ui/utils" + +import type { PlanetScaleScrapeTargetSummary } from "@maple/domain/http" +import { formatRelativeTime } from "@/lib/format" + +type HealthState = "degraded" | "waiting" | "stalled" | "healthy" + +/** + * Outcome-level health for the managed branch-metrics collection. Collection + * itself is fully automatic (Maple provisions and runs it), so the card shows a + * single status row instead of the underlying machinery: one dot, one label, + * and — only when degraded — the raw error behind a disclosure. + */ +export function PlanetScaleMetricsHealth({ + target, + metricsAuth, +}: { + target: PlanetScaleScrapeTargetSummary + metricsAuth: "oauth" | "service_token" | "missing" +}) { + const [detailsOpen, setDetailsOpen] = useState(false) + + // The token-setup step owns the missing-auth state — don't show two messages. + if (metricsAuth === "missing") return null + + const state: HealthState = + target.lastScrapeError !== null + ? "degraded" + : target.lastScrapeAt === null + ? "waiting" + : Date.now() - target.lastScrapeAt > 3 * target.scrapeIntervalSeconds * 1000 + ? "stalled" + : "healthy" + + const updatedAgo = + target.lastScrapeAt !== null + ? formatRelativeTime(new Date(target.lastScrapeAt).toISOString()) + : null + + return ( +
    +
    + + + {state === "degraded" + ? "Metrics collection degraded" + : state === "stalled" + ? "Metrics collection stalled" + : state === "waiting" + ? "Waiting for first metrics" + : "Metrics"} + + + {state === "waiting" + ? "Branch metrics usually appear within a minute of connecting." + : state === "healthy" + ? `Updated ${updatedAgo}` + : updatedAgo !== null + ? `Last data ${updatedAgo}` + : null} + {state === "healthy" && target.excludeBranches.length > 0 ? ( + <> · excluding {target.excludeBranches.join(", ")} + ) : null} + + {state === "degraded" ? ( + + ) : null} +
    + {state === "degraded" && detailsOpen ? ( +

    + {target.lastScrapeError} +

    + ) : null} +
    + ) +} diff --git a/apps/web/src/components/settings/scrape-targets-section.tsx b/apps/web/src/components/settings/scrape-targets-section.tsx index 1579edb34..6a8f71698 100644 --- a/apps/web/src/components/settings/scrape-targets-section.tsx +++ b/apps/web/src/components/settings/scrape-targets-section.tsx @@ -10,7 +10,6 @@ import type { ScrapeTargetChecksListResponse, ScrapeTargetId, ScrapeTargetResponse, - ScrapeTargetType, } from "@maple/domain/http" import { useState, type KeyboardEvent, type ReactNode } from "react" import { Exit, Schema } from "effect" @@ -190,39 +189,21 @@ function scheduledStatus( } } -interface SourceCopy { - readonly description: string - readonly emptyTitle: string - readonly emptyDescription: string -} - -const SOURCE_COPY: Record<"all" | ScrapeTargetType, SourceCopy> = { - all: { - description: "Scrape Prometheus exporters and inspect scheduled scrape health.", - emptyTitle: "No scrape targets", - emptyDescription: "Add a Prometheus exporter endpoint to start scraping metrics.", - }, - prometheus: { - description: "Scrape Prometheus exporters and inspect scheduled scrape health.", - emptyTitle: "No scrape targets", - emptyDescription: "Add a Prometheus exporter endpoint to start scraping metrics.", - }, - planetscale: { - description: - "Connect PlanetScale organizations — Maple discovers and scrapes every database branch automatically.", - emptyTitle: "No PlanetScale organizations", - emptyDescription: "Connect an organization with a service token to start scraping branch metrics.", - }, -} +const COPY = { + description: "Scrape Prometheus exporters and inspect scheduled scrape health.", + emptyTitle: "No scrape targets", + emptyDescription: "Add a Prometheus exporter endpoint to start scraping metrics.", +} as const +/** + * Prometheus scrape-target manager. PlanetScale metrics collection is fully + * managed by its integration and never surfaces here — this section only + * lists and edits user-created prometheus targets. + */ export function ScrapeTargetsSection({ - sourceFilter, + sourceFilter = "prometheus", }: { - /** - * Scope this section to one target type (Integrations hub drill-ins): - * filters the list, presets the add dialog, and hides the source selector. - */ - sourceFilter?: ScrapeTargetType + sourceFilter?: "prometheus" } = {}) { const [dialogOpen, setDialogOpen] = useState(false) const [isSaving, setIsSaving] = useState(false) @@ -232,15 +213,9 @@ export function ScrapeTargetsSection({ const [selectedTargetId, setSelectedTargetId] = useState(null) const [editingTarget, setEditingTarget] = useState(null) - const [formTargetType, setFormTargetType] = useState("prometheus") const [formName, setFormName] = useState("") const [formServiceName, setFormServiceName] = useState("") const [formUrl, setFormUrl] = useState("") - const [formOrganization, setFormOrganization] = useState("") - const [formTokenId, setFormTokenId] = useState("") - const [formTokenSecret, setFormTokenSecret] = useState("") - const [formIncludeBranches, setFormIncludeBranches] = useState("") - const [formExcludeBranches, setFormExcludeBranches] = useState("") const [formInterval, setFormInterval] = useState("15") const [formAuthType, setFormAuthType] = useState("none") const [formAuthToken, setFormAuthToken] = useState("") @@ -269,20 +244,18 @@ export function ScrapeTargetsSection({ const targets = Result.builder(listResult) .onSuccess((response) => [...response.targets] as ScrapeTarget[]) .orElse(() => []) - .filter((target) => !sourceFilter || target.targetType === sourceFilter) + .filter((target) => target.targetType === sourceFilter) const selectedTarget = targets.find((target) => target.id === selectedTargetId) ?? null - const copy = SOURCE_COPY[sourceFilter ?? "all"] + const copy = COPY // When empty, the centered empty state owns the primary action — hide the toolbar row. const isEmpty = Result.isSuccess(listResult) && targets.length === 0 - const emptyEntry = sourceFilter ? catalogEntry(sourceFilter) : null + const emptyEntry = catalogEntry(sourceFilter) async function handleProbe(target: ScrapeTarget) { setProbingId(target.id) const result = await probeMutation({ params: { targetId: target.id }, - // Invalidate the list plus the PlanetScale card's status (which surfaces - // this target's lastScrapeError/enabled) instead of only refreshing locally. - reactivityKeys: ["scrapeTargets", "planetscaleIntegrationStatus"], + reactivityKeys: ["scrapeTargets"], }) if (Exit.isSuccess(result)) { if (result.value.success) { @@ -297,18 +270,11 @@ export function ScrapeTargetsSection({ } function openAddDialog() { - const targetType = sourceFilter ?? "prometheus" setEditingTarget(null) - setFormTargetType(targetType) setFormName("") setFormServiceName("") setFormUrl("") - setFormOrganization("") - setFormTokenId("") - setFormTokenSecret("") - setFormIncludeBranches("") - setFormExcludeBranches("") - setFormInterval(targetType === "planetscale" ? "30" : "15") + setFormInterval("15") setFormAuthType("none") setFormAuthToken("") setFormAuthUsername("") @@ -318,15 +284,9 @@ export function ScrapeTargetsSection({ function openEditDialog(target: ScrapeTarget) { setEditingTarget(target) - setFormTargetType(target.targetType) setFormName(target.name) setFormServiceName(target.serviceName ?? "") setFormUrl(target.url) - setFormOrganization(target.organization ?? "") - setFormTokenId("") - setFormTokenSecret("") - setFormIncludeBranches(target.includeBranches.join(", ")) - setFormExcludeBranches(target.excludeBranches.join(", ")) setFormInterval(String(target.scrapeIntervalSeconds)) setFormAuthType(target.authType) setFormAuthToken("") @@ -335,25 +295,7 @@ export function ScrapeTargetsSection({ setDialogOpen(true) } - function selectTargetType(type: ScrapeTargetType) { - setFormTargetType(type) - setFormInterval(type === "planetscale" ? "30" : "15") - } - - // Managed rows (provisioned by the PlanetScale integration) resolve auth from - // the org's OAuth grant — the edit dialog must not flip them back to "token" - // or ask for credentials. - const isManagedPlanetScaleAuth = editingTarget?.authType === "planetscale_oauth" - function buildAuthCredentials(): string | null { - if (formTargetType === "planetscale") { - if (isManagedPlanetScaleAuth) return null - if (!formTokenId.trim() || !formTokenSecret.trim()) return null - return JSON.stringify({ - tokenId: formTokenId.trim(), - tokenSecret: formTokenSecret.trim(), - }) - } if (formAuthType === "bearer") { if (!formAuthToken.trim()) return null return JSON.stringify({ token: formAuthToken.trim() }) @@ -368,35 +310,21 @@ export function ScrapeTargetsSection({ return null } - function parseBranchList(value: string): string[] { - return value - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - } - async function handleSave() { - const isPlanetScale = formTargetType === "planetscale" - if (!formName.trim() || (isPlanetScale ? !formOrganization.trim() : !formUrl.trim())) { - toast.error(isPlanetScale ? "Name and organization are required" : "Name and URL are required") + if (!formName.trim() || !formUrl.trim()) { + toast.error("Name and URL are required") return } let parsedInterval: ScrapeIntervalSeconds try { - parsedInterval = asScrapeIntervalSeconds( - Number.parseInt(formInterval, 10) || (isPlanetScale ? 30 : 15), - ) + parsedInterval = asScrapeIntervalSeconds(Number.parseInt(formInterval, 10) || 15) } catch { toast.error("Scrape interval must be an integer from 5 to 300 seconds") return } const authCredentials = buildAuthCredentials() - if (isPlanetScale && !editingTarget && authCredentials === null) { - toast.error("Service token ID and secret are required") - return - } setIsSaving(true) @@ -407,22 +335,11 @@ export function ScrapeTargetsSection({ name: formName.trim(), scrapeIntervalSeconds: parsedInterval, serviceName: formServiceName.trim() || null, - ...(isPlanetScale - ? { - organization: formOrganization.trim(), - authType: isManagedPlanetScaleAuth - ? ("planetscale_oauth" as const) - : ("token" as const), - includeBranches: parseBranchList(formIncludeBranches), - excludeBranches: parseBranchList(formExcludeBranches), - } - : { - url: formUrl.trim(), - authType: formAuthType, - }), + url: formUrl.trim(), + authType: formAuthType, ...(authCredentials !== null ? { authCredentials } : {}), }), - reactivityKeys: ["scrapeTargets", "planetscaleIntegrationStatus"], + reactivityKeys: ["scrapeTargets"], }) if (Exit.isSuccess(result)) { toast.success("Scrape target updated") @@ -436,21 +353,11 @@ export function ScrapeTargetsSection({ name: formName.trim(), scrapeIntervalSeconds: parsedInterval, serviceName: formServiceName.trim() || null, - ...(isPlanetScale - ? { - targetType: "planetscale" as const, - organization: formOrganization.trim(), - authType: "token" as const, - includeBranches: parseBranchList(formIncludeBranches), - excludeBranches: parseBranchList(formExcludeBranches), - } - : { - url: formUrl.trim(), - authType: formAuthType, - }), + url: formUrl.trim(), + authType: formAuthType, ...(authCredentials !== null ? { authCredentials } : {}), }), - reactivityKeys: ["scrapeTargets", "planetscaleIntegrationStatus"], + reactivityKeys: ["scrapeTargets"], }) if (Exit.isSuccess(result)) { toast.success("Scrape target created") @@ -467,7 +374,7 @@ export function ScrapeTargetsSection({ setDeleteConfirmTarget(null) const result = await deleteMutation({ params: { targetId }, - reactivityKeys: ["scrapeTargets", "planetscaleIntegrationStatus"], + reactivityKeys: ["scrapeTargets"], }) if (Exit.isSuccess(result)) { toast.success("Scrape target deleted") @@ -484,7 +391,7 @@ export function ScrapeTargetsSection({ payload: new UpdateScrapeTargetRequest({ enabled: !target.enabled, }), - reactivityKeys: ["scrapeTargets", "planetscaleIntegrationStatus"], + reactivityKeys: ["scrapeTargets"], }) if (!Exit.isSuccess(result)) { toast.error("Failed to update scrape target") @@ -541,7 +448,6 @@ export function ScrapeTargetsSection({ selected={target.id === selectedTarget?.id} toggling={togglingId === target.id} probing={probingId === target.id} - hideTypeBadge={sourceFilter === "planetscale"} onSelect={setSelectedTargetId} onProbe={handleProbe} onToggle={handleToggleEnabled} @@ -581,32 +487,10 @@ export function ScrapeTargetsSection({ {editingTarget ? "Update the scrape target configuration." - : formTargetType === "planetscale" - ? "Connect a PlanetScale organization. Maple discovers every database branch's metrics endpoint and scrapes them automatically." - : "Enter the URL of a Prometheus exporter endpoint. Maple will periodically scrape this endpoint for metrics."} + : "Enter the URL of a Prometheus exporter endpoint. Maple will periodically scrape this endpoint for metrics."}
    - {!editingTarget && !sourceFilter && ( -
    - - -
    - )}
    - {formTargetType === "prometheus" ? ( -
    - - setFormUrl(e.target.value)} - /> -
    - ) : ( - <> -
    - - setFormOrganization(e.target.value)} - /> -

    - Your PlanetScale organization name as it appears in the dashboard URL. -

    -
    - {isManagedPlanetScaleAuth ? ( -

    - Authentication is managed by the PlanetScale integration — this target - scrapes with the connected organization's OAuth authorization, no - credentials to enter. -

    - ) : ( - <> -
    - - setFormTokenId(e.target.value)} - /> -
    -
    - - setFormTokenSecret(e.target.value)} - /> -

    - Create a service token with the{" "} - read_metrics_endpoints{" "} - organization permission. -

    -
    - - )} -
    - - setFormIncludeBranches(e.target.value)} - /> -

    - Comma-separated branch globs. When set, only matching branches are - scraped. Leave blank to scrape all branches. -

    -
    -
    - - setFormExcludeBranches(e.target.value)} - /> -

    - Comma-separated branch globs to skip — e.g.{" "} - pr-* to avoid scraping PR-preview - branches (a common source of PlanetScale rate-limit 429s). -

    -
    - - )} +
    + + setFormUrl(e.target.value)} + /> +
    setFormInterval(e.target.value)} />
    - {formTargetType === "prometheus" && ( -
    - - -
    - )} - {formTargetType === "prometheus" && formAuthType === "bearer" && ( +
    + + +
    + {formAuthType === "bearer" && (
    )} - {formTargetType === "prometheus" && formAuthType === "basic" && ( + {formAuthType === "basic" && ( <>
    @@ -868,7 +664,6 @@ function ScrapeTargetRow({ selected, toggling, probing, - hideTypeBadge, onSelect, onProbe, onToggle, @@ -879,8 +674,6 @@ function ScrapeTargetRow({ selected: boolean toggling: boolean probing: boolean - /** The PlanetScale drill-in shows only planetscale targets — the badge is noise there. */ - hideTypeBadge?: boolean onSelect: (targetId: ScrapeTargetId) => void onProbe: (target: ScrapeTarget) => void onToggle: (target: ScrapeTarget) => void @@ -923,21 +716,6 @@ function ScrapeTargetRow({ {status.label} - {target.targetType === "planetscale" && !hideTypeBadge && ( - - PlanetScale - - )} - {target.managedBy && ( - - }> - Managed - - - Provisioned by the PlanetScale integration — edit it from the integration card. - - - )} {target.serviceName && ( {target.serviceName} @@ -950,11 +728,7 @@ function ScrapeTargetRow({ )}
    - - {target.targetType === "planetscale" - ? (target.organization ?? hostnameFromUrl(target.url)) - : hostnameFromUrl(target.url)} - + {hostnameFromUrl(target.url)} {target.scrapeIntervalSeconds}s interval {status.detail} {target.lastScrapeAt && ( @@ -1182,25 +956,7 @@ function ScrapeTargetDetails({
    - {target.targetType === "planetscale" ? ( - <> - - {target.includeBranches.length > 0 && ( - {target.includeBranches.join(", ")}} - /> - )} - {target.excludeBranches.length > 0 && ( - {target.excludeBranches.join(", ")}} - /> - )} - - ) : ( - - )} + { * (rootSpansOnly, exclude-services), same no-data semantics. */ export function useAlertRulePreview( - form: RuleFormState, + form: RuleFormState | null, range?: { startTime: string; endTime: string }, ): AlertRulePreviewState { // Callers that own a page-level time window (the rule detail page) pass it in; @@ -65,7 +65,7 @@ export function useAlertRulePreview( const deferredForm = useDeferredValue(form) const payload = useMemo(() => { - if (!isPreviewQueryReady(deferredForm)) return null + if (deferredForm === null || !isPreviewQueryReady(deferredForm)) return null try { // The upsert schema requires a non-empty name; the preview doesn't care, // so substitute a placeholder while the user hasn't typed one yet. @@ -100,8 +100,9 @@ export function useAlertRulePreview( return useMemo(() => { if (!payload) { - // Unpreviewable state: surface the compile issue inline when there is one. - const issues = deriveRuleQueryIssues(deferredForm) + // No form yet (rule still resolving) is not an authoring error — stay clean. + // A present-but-unpreviewable form surfaces its compile issue inline. + const issues = deferredForm === null ? [] : deriveRuleQueryIssues(deferredForm) return { preview: null, previewLoading: false, previewError: issues[0] ?? null } } return Result.builder(result) diff --git a/apps/web/src/routes/alerts/$ruleId.tsx b/apps/web/src/routes/alerts/$ruleId.tsx index 46ca9df1b..1245fce0a 100644 --- a/apps/web/src/routes/alerts/$ruleId.tsx +++ b/apps/web/src/routes/alerts/$ruleId.tsx @@ -24,7 +24,6 @@ import { signalLabels, comparatorLabels, formatSignalValue, - defaultRuleForm, ruleToFormState, formatAlertDateTimeFull, formatAlertDuration, @@ -226,7 +225,9 @@ function RuleDetailContent() { [startTime, endTime], ) - const formState = useMemo(() => (rule ? ruleToFormState(rule) : defaultRuleForm()), [rule]) + // null until the rule resolves — keeps the chart from firing a throwaway preview + // for the default form (and flashing a wrong chart) before we know the real rule. + const formState = useMemo(() => (rule ? ruleToFormState(rule) : null), [rule]) const { preview, previewLoading, previewError } = useAlertRulePreview(formState, { startTime, endTime, @@ -244,9 +245,15 @@ function RuleDetailContent() { -
    - - + {/* Mirror the settled Overview rhythm so the first paint doesn't snap. */} +
    + +
    + + +
    + +
    ) @@ -422,7 +429,11 @@ function RuleDetailContent() { />
    - {overviewIncident ? ( + {/* Reserve the slot while incidents sync so the card doesn't pop in + and shove Configuration + Checks down once it resolves. */} + {Result.isInitial(incidentsResult) ? ( + + ) : overviewIncident ? (