Skip to content
Metric VaultHelp Center
Open app

Request lifecycle

A request traced from the Cloudflare edge to the response - route inclusion, the tracking wrapper, identity, plan and quota gates, the cache layers, the provider call and the telemetry writes.

Last updated 2026-08-06

Summary#

This page follows one metered tool run from the moment a packet reaches Cloudflare to the moment the last telemetry row is written. It names every checkpoint, the order they run in, what each one can return, and which of them charge the customer.

Two facts drive everything else. First, the quota gate runs even on a cache hit: a served-from-cache result still costs the customer their full credit, because they ran a report. Only the provider bill is saved. Second, metering happens before the upstream call, so a request that returns 200 has always been charged and a request that is refused by a gate has never been charged.

Purpose#

Knowing this sequence is how you answer three recurring questions without guessing: why was this request charged, why did it return this exact status, and where do I put a new check so it runs at the right moment.

It is also the map you need before changing anything in the gate chain. The checks are not independent; several of them exist only because an earlier version allowed something through, and the comments in the code record each of those incidents.

Architecture#

The pipeline, top to bottom:

text
1  Cloudflare edge
      _routes.json → 15 excluded paths bypass the worker entirely
2  export default.fetch
      → mvTrackedFetch(request, env, ctx, mvRouteRequest)
3  mvTrackedFetch
      route descriptor lookup · JSON body buffer (≤24,000 B) · start timer
4  mvRouteRequest
      OPTIONS short-circuit · billing routes · diagnostic gate · the if chain
5  Wrapper (premium AI only)
      mvSaveAroundPremiumAI → shared cache read
6  Gates, in order
      kill switch → reco gate → quota gate → metering
7  Providers
      DataForSEO (cached) → LLM
8  Post-processing
      real-data honesty check · year scrub · data-source stamps
9  Persistence
      mv_seo_cache write-through · tool_results save
10 Telemetry
      metrics_hourly · error_issues (on failure)
11 mvTrackedFetch
      activity_log row inside ctx.waitUntil · return the response

1. The edge and _routes.json#

_routes.json declares include: ["/*"] and excludes 15 paths: /free/*, /blog.html, the favicons, /apple-touch-icon.png, /og-image.png, /logo-dark.png, /logo-light.png, the four icon files and /manifest.json. Those are served straight from the static asset set and never reach the worker, which is cheaper and skips tracking entirely.

Note

Note: the worker still contains handlers for /blog.html, /logo-dark.png and /logo-light.png. Those handlers are unreachable on any deployment that honours _routes.json.

_headers applies Cache-Control: no-cache, no-store, must-revalidate to /login.html, /login, /dashboard.html, /dashboard, /sw.js, /index.html and /.

2 and 3. The tracking wrapper#

mvTrackedFetch runs before the router on every request. It looks up a route descriptor; if there is none it delegates immediately, which is the path taken by static assets, reads and pollers. Otherwise it buffers the JSON body when content-type is application/json and the length is at or below MV_ACT_MAX_BODY (24,000 bytes), rebuilds an identical Request, and starts a timer. Multipart and streaming bodies pass through unbuffered.

4. The router#

mvRouteRequest is a flat ordered chain. Two blanket checks sit inside it:

  • OPTIONS is answered immediately for every path with Allow-Origin: *, Allow-Methods: GET, POST, OPTIONS, Allow-Headers: Content-Type, Authorization.
  • /api/dbgdfs and /api/diag/* require ?key=<MV_INTERNAL_SECRET> and return 403 {"ok":false,"error":"forbidden"} with Cache-Control: no-store otherwise. This gate fails closed when the secret is unset.

The full dispatch order is in The worker (_worker.js).

5. The shared-cache wrapper#

/api/premium-ai is not routed to its handler directly. It goes through mvSaveAroundPremiumAI, which:

  1. Clones and parses the body. An unparseable body passes straight through to the handler.
  2. Looks the request up in mv_seo_cache if, and only if, three things hold: the tool appears in MV_CACHE_TTL_MS, the body sets neither forceFresh nor _mvForceFresh, and body.query is truthy.
  3. The cache key is a revision, then |, then mvToolParamsKey(body). The revision is MV_CACHE_KEY_REV[type], or v1 for a tool it does not list. It lets a reader fix retire answers the broken reader stored: keyword_overview is v2, because its v1 entries held rate-limited Google Ads calls stored as 0 searches. A Keyword Overview result marked headlinePartial is never written to this cache. mvToolParamsKey(body) is type|query|country|device|range|minVolume|intent, lowercased, trimmed and capped at 300 characters. The key contains no user identity, which is what makes the cache cross-customer.
  4. Expiry is checked at read time only: expires_at > Date.now().

On a hit, in this exact order: resolve the cost, run enforceAiQuota, run incrementUsage, bump the row's hits, record cache_hit metrics, and return the stored body with X-MV-Cache: hit, X-MV-Cache-Age-Days: <n> and Access-Control-Expose-Headers: X-MV-Cache, X-MV-Cache-Age-Days, X-MV-Saved-Id.

Important

Important: a cache hit still charges the customer their full credit. The in-code rationale is that they ran a report; only the provider bill is saved.

On a miss, the handler runs and the response text is written through with mvSeoCachePut, but only if the payload is real: if (!parsed || !parsed.data || parsed.error) return res;.

Components#

The gate chain, in order#

Everything below happens inside handlePremiumAI unless noted.

1. The dedicated growth_actions path. type === 'growth_actions' diverts to handleGrowthActions, which charges nothing at all when it finds no saved source data and returns 200 {needsData:true, ...}.

2. The per-tool kill switch. platform_config key tool_off:<type> set to '1' returns 503 {"error":"This tool is temporarily unavailable. Please try again shortly."}.

3. The recommendation gate. Only for types in AI_RECO_TYPES = { result_guide: 1 }, that is every "Get Recommendations" button. enforceAiRecoQuota checks a minimum plan (default pro, overridable with the ai_reco_min_plan config key) and a per-plan monthly allowance (AI_RECO_QUOTAS_DEFAULT = { starter: 30, pro: 100, agency: 400, enterprise: 2000, unlimited: Infinity }, overridable per plan with ai_reco_quota:<plan>). Metering runs before the upstream call so concurrent requests cannot all pass.

4. The quota gate. enforceAiQuota(env, user_email, { cost }), in this order:

#ConditionResult
1No user_email and cost > 0401 {"error":"Please sign in to run this.","code":"auth_required"}
2No user_email and cost 0Allowed. The free technical tools stay public
3Email in DEV_UNLIMITED_EMAILSAllowed with no database work at all
4platform_config key suspended:<email> is '1'403 {"error":"This account is suspended. Please contact support.","code":"account_suspended"}
5MONITOR_DB unboundAllowed. See Failure modes
6Plan is free and cost > 0403 code:"upgrade_required", required_plan:"pro"
7cost < 3 (PREMIUM_COST_THRESHOLD)Never quota-blocked. The hourly limiter runs, then the hourly counter increments, then allowed
8used + cost > quota429 code:"quota_exceeded" with plan, used, quota, cost, reset_at, reset_date, days_until_reset, upgrade_url
9Any other exception503 {"error":"Usage check temporarily unavailable, please retry.","code":"quota_check_failed"}

used is ai_runs + tool_runs for the current UTC month (YYYY-MM), minus any USAGE_RESET_BASELINE forgiveness for that account and month.

The hourly limiter is LIGHT_RATE_LIMIT_PER_HOUR = 100 per user per UTC hour, bucketed YYYY-MM-DD-HH in usage_hourly. Over the limit returns 429 with code: "hourly_rate_limit", limit, used and reset_in_minutes. Anonymous /api/tools callers and every /api/translate caller are bucketed by 'ip:' + CF-Connecting-IP instead of by email.

5. Metering. incrementUsage(db, email, 'ai', cost, type) writes usage_counters (the monthly meter) and usage_by_tool (the per-tool breakdown, described in code as best-effort).

7. The provider calls#

DataForSEO calls go through callDataForSEOCached(endpoint, body, env, ttlSec, tool), whose key is endpoint + ':' + JSON.stringify(body). That key also contains no user identity, so this layer is cross-customer too. The TTL is resolved as platform_config['cache_days:<tool>'], then platform_config['cache_days_default'], then the caller's literal, then 86,400 seconds. If D1 is unavailable the helper catches and falls through to an uncached callDataForSEO, so a database problem degrades freshness rather than breaking the call.

The LLM call uses mvOpenAIChatURL(env), which is env.OPENAI_BASE_URL or the default Cloudflare AI Gateway URL, plus /chat/completions. The default model is gpt-4o; article_writer uses gpt-4o-mini to fit the wall-time budget. max_tokens defaults to 4000 with per-type overrides, temperature is 0.7 and the response format is json_object.

Full cache detail, including the per-tool TTL map and the admin override path, is in Caching architecture.

8. Post-processing#

  • The real-data honesty check. 30 types are in REAL_DATA_TYPES. When one of them has no real provider data, the response is 200 with noDataAvailable: true and a message that says so explicitly rather than falling through to AI fabrication.
  • The year scrub. 19 content types have stale year references rewritten.
  • Data-source stamps. _dataSource is set to dataforseo or ai-generated, with a _dataSourceVerified boolean.
  • Friendly error rewriting. mvFriendlyAIError translates five upstream error classes into customer-readable text, including the region, rate-limit, quota-exhausted and bad-key cases.

9 and 10. Persistence and telemetry#

WriteTableNotes
Shared cache write-throughmv_seo_cacheOnly for real payloads
Saved resulttool_resultsPayloads over 400,000 bytes are silently not saved. Returned as X-MV-Saved-Id
Run metricsmetrics_hourlyScopes tool:<type>, all and provider:<vendor>. Columns include runs, errors, timeouts, quota_hits, dur_sum, cache_hit, cache_miss, cost_micros, tokens_in, tokens_out
Grouped errorerror_issuesOnly on a non-ok response. Fingerprint is a hash of the tool plus a normalised message
Activity rowactivity_logWritten by the tracking wrapper inside ctx.waitUntil

Provider cost is real for DataForSEO (read from the provider's own cost field) and estimated for LLMs from a price table in USD per million tokens. Unknown models record tokens with a zero-dollar estimate.

Data flow#

A concrete Domain Overview run by a Pro account, cold cache:

  1. POST /api/premium-ai with {type:"domain_overview", query:"nike.com", user_email:"..."}. The dashboard's patched fetch also attaches Authorization: Bearer <jwt>, which this handler does not read.
  2. Tracking wrapper buffers the body, starts the timer.
  3. Router matches, calls mvSaveAroundPremiumAI.
  4. MV_CACHE_TTL_MS.domain_overview is 10 days, query is present, no force flag. Cache lookup misses.
  5. Kill switch clear. Not a recommendation type.
  6. getToolCost('domain_overview') is 6. enforceAiQuota finds the plan is pro (quota 500), used + 6 <= 500, returns null.
  7. incrementUsage adds 6 to usage_counters.ai_runs and a usage_by_tool row.
  8. Three DataForSEO endpoints are called through callDataForSEOCached with TTL literals 3600, 86400 and 604800 seconds. The LLM call composes the report.
  9. The body is written to mv_seo_cache with expires_at = now + 10 days, and saved to tool_results.
  10. metrics_hourly gains runs +1, cache_miss +1, dur_sum += duration on tool:domain_overview and all, plus a provider:dataforseo row carrying the real cost.
  11. The response returns with X-MV-Saved-Id. The activity row is written in the background.

The next customer who runs the same query within 10 days takes the cache-hit path: gate, charge 6 credits, bump hits, return in a few milliseconds with X-MV-Cache: hit. No provider is called.

Failure modes#

StageFailureBehavior
Route inclusionPath is in the _routes.json exclude listThe worker never runs. Its handler for that path is dead code
TrackingAny error in the recording blockSwallowed. Tracking must never break a response
TrackingThe router throwsRethrown. Cloudflare's own error page is served, not a JSON body
Config readD1 unreachablemvConfigAll fails open and returns an empty map, so suspensions and cache overrides momentarily disappear
Quota gateMONITOR_DB is unboundFails open. monRequireDB throws and the gate returns null, allowing the request
Quota gateAny other exceptionFails closed with 503 quota_check_failed
Hourly limiterAny database errorFails open, returns null
Plan gateAny database errorFails closed with 503 plan_check_failed
DataForSEO cacheD1 unavailableFalls through to an uncached provider call
ProviderCredentials missingReal-data tools return the honest empty result; some routes 503
ProviderUpstream error500 with a rewritten friendly message, plus an error_issues row
PersistenceResult over 400 KBSilently not saved to the Library. The response is unaffected
TelemetryAny metric or error write failsSwallowed. Observability must never break the product

The open-versus-closed split is deliberate but not uniform, and the two behaviors inside enforceAiQuota are the subtlest part of the whole pipeline. Authentication and authorization flow lays out every gate side by side.

See also

Was this article helpful?