Skip to main content

Analytics pipeline

How rekkal turns a monitored prompt into measured brand visibility: the engine adapters, the scheduled runner, mention extraction, metering, and the statistics layer. The database side is in supabase.md; the design record and its rationale is data-model-proposal.md.

The shape of it

Engine adapters

One interface, four implementations — lib/engines/types.ts:
A fifth engine is a new file, one entry in lib/engines/index.ts, and one row in public.engines. The runner does not change. Each adapter returns the answer text verbatim — mention offsets are UTF-16 indexes into that exact string, so an adapter must never trim or re-wrap what it received.

Errors carry a billed flag

EngineError.billed drives the meter. A failure that never reached the provider refunds the customer’s slot via release_run_slot(); a provider failure after generation stays metered because it still cost money. Getting this backwards either charges customers for our outages or hides real spend. Only the engine call itself is in that branch. A database failure after a successful, paid-for call is ours, not the provider’s: it is recorded as persist_error, counted separately as persistFailed, and never refunded. Nor is it terminal. Such a run keeps status = 'succeeded' and its stored answer_text, and carries extraction_status = 'failed'; a later pass re-extracts it from the stored text at zero engine spend and without touching the meter, reported as reExtracted. Setting status = 'failed' would make it terminal to claim_run_slot and to every rollup, stranding data the provider billed us for. Recovery is bounded by runs.extraction_attempt_count, capped at MAX_EXTRACTION_ATTEMPTS. It is deliberately separate from attempt_count, which counts engine attempts and caps the reclaim loop — conflating them would corrupt a counter that guards spend. The cap matters less for the wasted work than for the starvation: the recovery queue is ordered and capped, so a run that can never be recovered would otherwise hold a slot a recoverable one needs. Past the cap the run stays queryable (succeeded + failed + extraction_attempt_count >= 3) and stays out of both denominators, because giving up on extraction is not the same as measuring a zero. The extraction marker is written last, never first. saveRunSuccess lands the answer and its sources as extraction_status = 'pending'; only once the mentions and citations have actually persisted does markExtractionComplete advance it to alias_only. Marking first would let a crash in that window leave a succeeded run with zero mentions that the rollups admit as a genuine 0-of-n. A missing number is recoverable; a confidently wrong one is not. A run awaiting extraction is held out of the denominator. run_count counts runs that both succeeded and were extracted; the rest land in workspace_daily_runs.awaiting_extraction_run_count. Admitting an unextracted answer would report every brand as a measured 0 against it, when the truth is that nothing has looked at it yet — the exact fabricated zero the sample-size rules exist to prevent. Sources are as durable as the answer, and the source denominator is narrower. runs.source_payload stores the adapter’s normalised sources in the same statement as answer_text, so recovery replays both halves — mentions from the text, citations from the payload — rather than marking a run measured while leaving it permanently source-less. It holds normalised sources only, deliberately never a verbatim HTTP dump or a whole provider response. refresh_domain_daily_metrics denominates on workspace_daily_runs.sourced_run_count, which counts only extracted runs that carry a payload: [] is an answer that genuinely cited nothing, NULL is one whose sources were never captured, and the two must not read alike. Each provider call is also bounded by ENGINE_CALL_TIMEOUT_MS (and never runs past the pass deadline), because the budget below is only checked between pairs. An abort surfaces as an unbilled transport error, so the slot refunds correctly. EngineResult.costMicros is null when a provider does not itemise the call. The runner then falls back to engines.list_cost_micros, so runs.cost_micros is never a silent zero and sum(cost_micros) cannot understate spend.

Two gotchas the adapters exist to contain

  • Gemini returns redirect wrappers, not URLs. Grounded sources come back as vertexaisearch.cloud.google.com links. Store those as-is and every domain in the corpus becomes Google’s — and because the corpus is shared across workspaces, one bad write damages every customer’s source analytics. Unresolved wrappers are stored as redirect_unresolved with url_id = null, kept for later re-resolution, and excluded from the source rollups. A database CHECK enforces it independently of the code.
  • Gemini’s grounding offsets are UTF-8 bytes, not UTF-16 code units. They coincide on ASCII and diverge on the first accented character, which in a French-market product means immediately. parseGeminiResponse converts them.

Running without provider keys

There are no provider API keys required to develop or test. pnpm test is green with an empty environment: the fixture adapter replays recorded payloads from lib/engines/__fixtures__/ through parsePerplexityResponse, parseOpenAIResponse and parseGeminiResponse, so the production parsing code is exercised rather than mocked around. The recordings are hand-authored against each provider’s documented schema (not captured from live calls) and are deliberately shaped to carry the cases that break parsers: multi-block answers with per-block annotation offsets, redirect-wrapped URIs, accented text, a legacy response shape, and an unparseable URL. There is also an opt-in integration test against the hosted database, skipped by default:
It creates a throwaway user and workspace, runs the real pipeline with the fixture adapter (no spend), and deletes the user, which cascades everything it made.

The runner

runWorkspace() resolves the workspace’s due prompts and engines for the current slot, executes them, persists answers, extracts mentions and citations, and refreshes the rollups. Idempotent per (prompt, engine, run_date). Re-running a slot performs zero engine calls and moves the meter by zero. That guarantee lives in the database — claim_run_slot() returns exists without touching usage_periods — so it holds under concurrent runners, which a cron retry makes likely rather than theoretical. A run is contained: one dead engine fails its own runs and the pass continues. Only an unknown workspace throws. A slot a killed runner left behind is reclaimed, not stranded. A serverless function that hits its wall clock mid-pass leaves rows at running with metered = true. claim_run_slot() returns those as reclaimed once they are older than p_stale_after (30 minutes) and under p_max_attempts (3), and re-executes them without moving the meter — the slot was counted at its first claim, so finishing it is free. Past the attempt cap, or once the row is terminal, the answer is exists as before, so a completed slot is never re-run. A failure that was refunded is retried inside its own slot. saveRunFailure writes status = 'failed', and for a failure the provider billed us for that is terminal and stays terminal — paying twice for one slot is the money-losing direction. But a transport error (the deadline abort included) or a 429 never reached generation, so release_run_slot() refunded it. claim_run_slot() hands those back as retried when both metered = false and error_code in ('transport', 'rate_limited'), and loadCompletedPairs stops reporting them as done so the dispatch loop actually sees them. Without this, one 429 on Monday cost that prompt × engine the whole ISO week — sample size the customer paid for and never received, across the ~167 hourly fires still to come. retried is deliberately not reclaimed: the refund took the slot off the meter, so putting it back on re-checks the quota and charges it again. Refund (−1) plus retry (+1) is exactly one, so a slot that eventually succeeds is counted once. The cap is the same p_max_attempts (3) — three engine calls per slot in total, however they split between the two paths — and at the cap the run stays failed, so its slice carries a real failed_run_count rather than a hidden gap. The sweep below deliberately does not extend this to earlier slots: re-metering charges the period covering the run’s own run_date, which by then may be closed and already reported. Both branches are the one path that could hand the same slot to two runners, so the row is locked FOR UPDATE before anything is evaluated and each UPDATE re-checks its whole predicate under that lock (the reclaim branch also advances started_at, its staleness marker). The loser blocks, re-reads the row the winner just advanced, matches nothing, and falls through to exists. attempt_count is incremented by mark_run_running() rather than by a read-modify-write, because that counter is what caps both loops. The sweep covers earlier slots, not just this one. Reclaim only fires for the slot the dispatch loop is walking, and only once the row is stale — so a row left pending/running when the slot rolls over would be orphaned: metered, answerless, and holding its (prompt, engine, run_date) atom so nothing can replace it. loadIncompleteSlots resolves those in one query over the last PAST_SLOT_LOOKBACK slots and re-dispatches them through the same reclaimed path, so the meter moves by zero — the slot was paid for once and the customer is owed the data. What cannot be re-dispatched — past the lookback, past the engine-attempt cap, or its prompt is gone — is closed out as status = 'skipped' with error_code = 'abandoned', counted in workspace_daily_runs.abandoned_run_count. skipped is deliberately not failed: nothing went wrong at the engine. It enters no denominator, because there is no answer to measure. The meter rule is asymmetric, on purpose. A run may be re-dispatched across a billing-period boundary freely — that costs the meter nothing. It may only be released from the meter while its own period is still open: refunding a run in a closed, already-reported period would retroactively decrement a number the customer has been shown, for money that was genuinely spent. That is the same reasoning that keeps a persist_error run metered. A reported period is immutable once closed, and that governs every branch that can move the meter — not just close-out. release_run_slot() decrements the period covering the run’s own run_date, so all three of the sweep’s meter-moving paths consult one isSlotInPeriod(slot.runDate, period) decided once per slot: whether the close-out refunds, whether a raced retried claim is released again, and whether an unbilled engine failure of a re-dispatched slot refunds. That third one is the easy path to miss — a swept slot executes through the same code as a fresh one, and a hung connection or a deadline abort surfaces as an unbilled transport error on a slot that may be two weeks old. When the period is no longer current the refund is withheld and reported as refundsWithheld: the slot stays metered against the period that paid for it. The provider call came out of that period’s budget, and the run stays terminal too — isRetryableFailure requires metered = false — so no later fire can re-charge a closed period either. The decision is threaded into executeClaimedRun as a required argument rather than re-derived at each site, so a fourth path cannot silently omit it. Each engine gets its own bounded pool. config.runner.dispatchConcurrency sets how many calls may be in flight per engine — 4 today — and the engines proceed independently of each other. Per-engine rather than one shared pool because the three providers have separate rate limits and separate latencies, so a single pool lets the slowest of them occupy every worker while the other two idle. Concurrency changes how many calls are in flight, not when the pass stops: every worker re-checks the deadline before taking a prompt, and each call is still bounded by ENGINE_CALL_TIMEOUT_MS and the caller’s abort signal. The cap and the cron interval are one decision, and the arithmetic tying them to the Pro quota is in deployment.md. The pass carries a time budget, and resumes rather than re-walks. runWorkspace() takes an optional deadline and stops dispatching cleanly when it passes, reporting undispatched. The cron route gives each workspace the whole of what is left of DISPATCH_BUDGET_MS rather than a slice of it, and stops when the window is gone; a Pro workspace of 300 grounded calls cannot starve the ones behind it because the next fire starts where this one stopped — workspaces are ordered by workspaces.last_dispatch_started_at ascending, nulls first, stamped when dispatch begins. Pairs that already reached a terminal status in this slot come back from a single query (loadCompletedPairs) instead of one claim_run_slot round-trip each, so the next fire spends its budget on work it has not done — progress is monotonic. Anything left mid-flight is picked up through the reclaim path above. The budget covers all three phases, and the split is deliberate in every direction. Extraction recovery and the orphan sweep each run first under their own share (RECOVERY_BUDGET_SHARE, SWEEP_BUDGET_SHARE), leaving dispatch the clear majority. Neither catch-up phase may consume the whole slice — fresh measurements are the product, and a workspace with a backlog would otherwise never dispatch again. Dispatch must not consume it either, because a stranded run is paid-for data sitting unusable and only those phases can free it. Each share is computed from the time remaining when the phase starts, so one that finishes early hands its unspent budget to the next. Stopping early costs nothing: the rows keep their markers and the next fire re-selects them. unrecovered and unswept report the true outstanding backlog, not the untouched remainder of one capped batch — a workspace that clears its 50-row slice while 450 wait behind it is still behind, and saying zero there is the exact blindness those numbers exist to remove. The deadline is checked before markRunRunning, not after: a pair skipped purely on clock alignment must not burn one of the engine attempts claim_run_slot allows, nor start the staleness clock it would then have to wait out.

Extraction scales with the answer, not the competitor count

Pro tracks an unlimited number of competitors, so lib/runner/extract.ts tokenises the answer once and looks up n-grams in a hash map: O(answer tokens × longest alias), independent of how many aliases exist. A workspace with 10 competitors and one with 10,000 extract at the same speed. The obvious implementation — loop each alias over the text — would be thousands of times the work. Stage 1 is deterministic alias matching, always runs, costs nothing. Open-ended LLM extraction (stage 2) is what Position actually requires, because an untracked rival appearing ahead of the customer changes the customer’s rank. Until it runs, runs.extraction_status = 'alias_only' records that the brand set is knowably incomplete, so no aggregate silently reports a position computed from it.

Metering and quotas

config.limits is the single source of truth for what a plan may consume; lib/runner/limits.ts is the only place that reads it. The per-plan numbers are in measurement.md — this section is only how the runner enforces them. config.limits.maxTrackedBrandsPerWorkspace (5,000) is a stability guard, not a pricing limit — it bounds the auto-discovery write path so one malformed extraction cannot grow the table without bound. Hitting it is an incident, not an upsell. It is enforced in the database, by the brands_enforce_ceiling trigger (20260725121400_brand_ceiling.sql), because brands carries an unrestricted INSERT policy for members and a TypeScript-only guard is not a guard at all. The trigger raises SQLSTATE RK001, which createBrand maps to the same refusal canAddBrand returns from its pre-check — so a caller sees one outcome type whether the ceiling was caught before the write or by the write. Refusal is a result, never an exception: auto-discovery must be able to stop at a ceiling without failing the extraction around it. The plan canAddBrand is evaluated against is not a parameter of createBrand — it is resolved inside, from the owner’s Stripe subscription, by the same helper loadWorkspace uses, so entitlement has one source of truth and no caller can pick its own branch. If it cannot be resolved the answer is plan_unresolved, never free: a fallback would be conservative but still a confident claim about a plan nothing knows. workspaceId cannot be derived — it is the target of the write — so the caller must have verified membership before calling, which the TSDoc states as a precondition; this module writes through the service-role client and bypasses RLS. If the alias index cannot be read at all, the pass degrades rather than dies: dispatch and extraction recovery are skipped (an empty index would certify a measured zero for every brand an answer actually named), orphan close-out still runs because it needs no index and is what frees already-metered slots, and the reason is reported in RunSummary.aliasIndexError. Over-limit prompts are skipped, never deleted: a workspace that downgrades keeps its 100 prompts and runs the first 10. The limit in force is snapshotted into usage_periods.run_limit when a period opens, and only ever rises — so a mid-period downgrade cannot retroactively put a customer over quota, and changing pricing next quarter does not rewrite past enforcement.

Sample size

lib/stats.ts is deliberately the most heavily tested module in the repo, because the product’s positioning is the statistics.
  • Measure carries successes, n, and value: number | null. TypeScript will not let a caller render a number it does not have.
  • Comparison has no numeric fallback: an empty prior window yields previous: null and delta: null, so “no comparison” is the only renderable state. There is no ?? 0 in the read path, and there must never be one.
  • Intervals are Clopper–Pearson (exact binomial), not the normal approximation, which collapses to zero width at 0/n — precisely the case that must not render as a confident “0%”.
  • combine() sums components rather than averaging ratios, which is why the rollups store sums and counts.

Scheduling

app/api/cron/run-prompts is a GET gated on CRON_SECRET, which fails closed: with no secret configured nothing is authorised, because an unauthenticated runner lets anyone spend the engine budget. It is excluded from the session proxy in proxy.ts for the same reason webhooks are — it carries no session cookie. The runner writes through the service-role client, and the tables it writes have no authenticated write policy. That alone does not keep the meter off the browser: the metering RPCs are SECURITY DEFINER and PostgREST publishes them as /rest/v1/rpc/<name>, so the guarantee also depends on their EXECUTE grants being revoked — see supabase.md and 20260725120600_function_grants.sql. One trigger runs the whole pass. Reclaim, extraction recovery and orphan close-out are phases of the same fire rather than a separate endpoint, so all three are exercised on every fire and there is no second schedule that can drift out of step with this one. The trigger fires hourly; the measurement is still weekly. run_date is the ISO week, so a prompt × engine is measured exactly once per week however often the cron fires — the extra fires drain what is left of the week’s quota instead of asking one fire to carry a whole Pro workspace. The interval is also where total dispatch capacity comes from, since a fire’s window is not divided across workspaces. The schedule is declared in vercel.json rather than in the Vercel dashboard; the interval, the per-engine concurrency, the rotation, and the arithmetic tying them together are in deployment.md. Since the pipeline is idempotent per slot, firing more often than the cadence is safe and costs nothing: it just returns alreadyRun. A pass with nothing to do — no active prompts, or no engine whose key is set — claims nothing and writes nothing at all, not even a usage period, so the schedule can be live before the first workspace exists.