Skip to content

feat(scraper): fetch targets directly, delete the API-side scrape proxy - #750

Merged
Makisuo merged 1 commit into
mainfrom
scraper/fetch-targets-directly
Sep 2, 2026
Merged

feat(scraper): fetch targets directly, delete the API-side scrape proxy#750
Makisuo merged 1 commit into
mainfrom
scraper/fetch-targets-directly

Conversation

@Makisuo

@Makisuo Makisuo commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Why

Tonight's PlanetScale metrics blip (metrics.psdb.cloud 502/504s and 29s timeouts from 21:54 UTC) surfaced as maple-api 5xxs on GET /api/internal/prometheus-scrape and paged the org-wide "High error rate" rule for maple-api as well as scraper. Every branch scrape also parked a Worker invocation for up to 29s waiting on a third-party target. The proxy predates PlanetScale OAuth and existed to keep credentials server-side; both services already read secrets from the same Infisical project, and PlanetScale's data plane never used the Authorization header (it authenticates with the signed URL minted by discovery), so the proxy no longer bought anything.

What changed

  • lib/safe-fetch (@maple/safe-fetch): the SSRF-safe fetch and URL validator moved out of apps/api unchanged so both apps share one guard. The API's five import sites now point at the lib. validateExternalUrlSync uses URL.canParse because lib/** enforces maple/no-try-catch.
  • Target list carries what the scraper needs. InternalScrapeTarget gains targetType, scrapeUrl (the signed PlanetScale branch URL, otherwise the row url) and authHeaders. The API decrypts each row's credential while building the list, so the master key and the OAuth grant never leave the API. A row whose credential fails to decrypt is skipped for that round with a warning rather than failing the whole list.
  • apps/scraper fetches directly through a new TargetFetcher service: same timeout formula, Retry-After parsing and span attributes the proxy had. The client span is scraper.fetch_target; it records server.address and url.path but never the signed query.
  • Rotation without restarts. scrapeUrl and authHeaders are deliberately outside the scheduler's loop fingerprint. Each scrape reads the latest reconciled copy, so a re-signed PlanetScale URL or a rotated credential is picked up mid-loop without re-jittering the cadence.
  • Failure mapping matches the old behaviour: timeouts and connection failures back off like the proxy's 502 did (target_error); an SSRF-rejected URL reports at interval without backoff (scrape_failed).
  • Deleted from the API: the proxy route, scrapeForCollector, fetchUpstream, the per-isolate row memo and its invalidation hooks. ScrapeTargetsService exposes authHeaders(row) instead.

Verification

Check Result
Typecheck: api, scraper, domain, lib clean
Scraper tests (10 new: fetcher, SSRF, redirect credential strip, timeout abort, span redaction, rotation, backoff) 104 passed
API scrape-target tests 29 passed
lib/safe-fetch tests 69 passed
oxlint (repo Effect config), knip on touched workspaces clean

Reviewer notes

  • Repoint the "Scraper Planetscale Timeout" alert rule after merge. It watches the deleted ScrapeTargetsService.fetchUpstream span and will go silent. New where clause: service.name = "scraper" AND span.name = "scraper.fetch_target" AND attr.maple.scrape.target_type = "planetscale".
  • Rollout window. An old scraper against the new API gets 404s on the proxy and self-heals on its next reconcile once the scraper ships. A new scraper against the old API fails to decode the target list and keeps its running loops (none on a cold boot) until the API ships. Both are transient; ship together.
  • The scraper's own error rate now reflects target failures honestly, so a third-party outage pages for scraper rather than maple-api. Excluding it from the critical rule is a separate decision (the rule's excludeServiceNames field).
  • lib/safe-fetch is a new workspace, registered with bun install --minimum-release-age=0; the lockfile diff is 17 added lines with no version moves.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Every upstream failure of a scrape target (PlanetScale metrics.psdb.cloud
502/504s, 29s timeouts) surfaced as a maple-api 5xx on
GET /api/internal/prometheus-scrape and paged the org-wide error-rate rule
for maple-api as well as scraper, while each branch scrape parked a Worker
invocation for up to 29s waiting on the target.

The scraper now fetches each target itself:

- lib/safe-fetch (@maple/safe-fetch): the SSRF-safe fetch + URL validator
  moved out of apps/api unchanged so both apps share one guard.
- InternalScrapeTarget carries targetType, scrapeUrl (the signed PlanetScale
  branch URL, otherwise the row url) and authHeaders, decrypted API-side so
  the master key and the OAuth grant never leave the API.
- apps/scraper TargetFetcher does the GET with the same timeout formula,
  Retry-After parsing and span attributes the proxy had (client span
  scraper.fetch_target; host and path only, never the signed query).
- scrapeUrl/authHeaders are outside the scheduler's loop fingerprint; each
  scrape reads the latest reconciled copy, so a rotated signature or
  credential is picked up without restarting the loop.
- Timeouts and transport failures back off like the proxy's 502 did; an
  SSRF-rejected URL reports at interval without backoff.
- Deleted: the proxy route, scrapeForCollector, fetchUpstream and the
  per-isolate row memo. The service exposes authHeaders(row) instead.
@Makisuo
Makisuo merged commit b06775c into main Sep 2, 2026
41 checks passed
@Makisuo
Makisuo deleted the scraper/fetch-targets-directly branch September 2, 2026 23:36
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

🍁 Maple PR preview

Warning

Preview cleanup could not be confirmed. The Alchemy teardown outcome was skipped.

Final commit e7c9608 · View workflow run

Makisuo added a commit that referenced this pull request Sep 3, 2026
Third version collision in a row. Main landed #738 (agent-session filter
columns), taking migration 0026 and local schema v16 — the slots this branch
moved into yesterday. As with the previous two, nothing semantic conflicts:
two unrelated changes each took the next free number.

- `0026_product_events_from_traces.ts` -> `0027_...`, `version: 26` -> `27`,
  export and every doc reference renamed, docs/product-events-funnels.md
  included.
- `migrations/index.ts` and its test carry 0024, 0025, 0026 and 0027.
  `clickHouseSchemaVersion` stays "21" — all four are
  `requiredForIngest: false`.
- Local edge re-derived via `local-schema:bump` for v17; the v16->v17 module
  body is the previous one unchanged apart from version plumbing.
- Restored the same four local-store-migration test assertions that taking
  main's copy of that file reverts on every one of these merges.

Schema is 39 tables / 42 MVs, local schema v17, 81 objects.

Verified on the merged tree: `bun typecheck` 41/41, `bun run lint` clean,
ClickHouse schema / local-manifest / Tinybird gates up to date, apps/api 2585
passed, apps/web 2382, packages/domain 703, query-engine 1373, apps/cli 534.
`bun.lock` is byte-identical to main's.

Note for the next merge: main's #750 adds a new `@maple/safe-fetch` workspace,
so a worktree installed before the merge needs `bun install` again afterwards —
without it, `@maple/scraper` typecheck and one ScrapeTargetsService lint rule
fail for missing types rather than for anything in the diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant