Repository navigation
feat(provider): analytics day sync and the Analytics page - #384
Open
jorgecuesta wants to merge 1 commit into
Open
jorgecuesta wants to merge 1 commit into
jorgecuesta wants to merge 1 commit into
Conversation
jorgecuesta
force-pushed
the
feat/fleet-day-sync
branch
from
October 26, 2026 19:27
8d31820 to
99d534a
Compare
jorgecuesta
force-pushed
the
feat/fleet-day-sync
branch
2 times, most recently
from
October 27, 2026 19:54
a7a3530 to
337a314
Compare
Adds the analytics_* day tables, analytics_owner_day and application_settings.analytics_day_retention_days (migration 0024, with a settings field); the AnalyticsSync schedule (forward only) and the manual AnalyticsBackfill, which run one AnalyticsSyncDay per day under a single workflow id, so a day is never synced by both at once, fanning out to AnalyticsSyncBatch children that read getSupplierSessionFunnelJson and getSupplierSessionListJson for the provider's verified suppliers only, keep exactly the rows of each answer, close a (day, supplier) only when its member rows match its funnel seats, and then rebuild the day's breakdown rollups (analytics_owner_day and analytics_session.own_*, versioned per day in analytics_day_rollup); the owner-only Admin > Analytics page (summary, breakdown, sessions, CSV exports, backfill button) over all verified suppliers, reading only those tables; and the "What if your fleet changed?" card, a port of @mykyal's selection model over analytics_selection_reference (migration 0025), which AnalyticsSync fills per closed day and service from a count-only supplierServiceConfigs query over our ids. postGraphql moves to @igniter/graphql, built for ./client and ./analytics, and every CSV export now starts with a UTF-8 BOM.
jorgecuesta
force-pushed
the
feat/fleet-day-sync
branch
from
October 28, 2026 22:12
337a314 to
4a63033
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Credits
The Analytics page reimplements, inside the provider app, the analysis that @mykyal built: the metrics, the formulas (actual earnings Y, equal share B, F = Y / B, presence, the 95% bootstrap range, the breakdowns, the session view and the "What if your fleet changed?" selection model) and the page structure. Credit for the analysis, the metrics and the formulas goes to @mykyal. This PR ports that work to Igniter's data and scope; where it differs, the list under "Differences from @mykyal's fleet analysis" below gives what changed and why.
Summary
whatif.ts#L59-L117): a session draws min(cap, N) of the N eligible suppliers uniformly without replacement, so the number of yours is Hypergeometric(N, n, K). The expected selected is K × n / N. "Find count" is a doubling then bisection search on the monotone tail, capped at 100,000. It is a port of @mykyal's math with the same operations in the same order, and the unit tests assert equality with outputs of their own code.candidates, K is the session paramnum_suppliers_per_sessionat that height, and n is how many of the day's verified suppliers were eligible there.fetchSelectionCounts):supplierServiceConfigstotalCountover our ids (at most 200 per request, at most 20 aliases), with pocketdex's candidate rule (live at the start or closed by the start block, activated at or below the start, or not activated yet). It returns counts, never a supplier id.AnalyticsSync,syncAnalyticsSelectionReferenceswrites the pending closed days, newest first, best-effort (activities/analytics.ts#L370-L422). Each row carries the day's rollup version, so a supplier the backfill adds later, or a key leaving the verified states, makes the day count again. A day that fails is skipped for the rest of the run. A service counted above its candidates is not stored.AnalyticsSyncis a new schedule, every 10 min (SCHEDULE_ANALYTICS_SYNC_INTERVAL), forward only: today and every day still open.AnalyticsBackfillis manual, started from the page's "Fill missing days" button. It fills the days of the retention window that were never synced.AnalyticsSyncDayper day under a single workflow id, so the schedule and the backfill never sync a day at once. Each day fans out toAnalyticsSyncBatchchildren that read the pocketdex indexer'sgetSupplierSessionFunnelJsonandgetSupplierSessionListJson(the_jsontwins are generated insettlement/functions.ts).analytics_owner_dayandanalytics_session.own_*, versioned per day inanalytics_day_rollup. A day that is syncing, or stale, is read from the member rows instead.postGraphqlmoves to@igniter/graphql, now built for./clientand./analytics.packages/ui/src/lib/csv.ts#L51-L61). No app in the repo re-reads its own exports.How the sync keeps the stored rows exact
lib/dal/analytics.ts#L142-L161). The funnel rows work the same way, by (supplier, service) (L83-L117). An earlier version deleted rows older than the run's start. Local testing lost a page of member rows that way: the test machine's clock stepped back by about 0.8 s, so a fresh page looked older than the run.activities/analytics.ts#L99-L108).seats + active). A mismatch keeps it open, logs both counts, and the next sync fetches it again (lib/analytics.ts#L45-L85,activities/analytics.ts#L279-L333).lib/dal/analytics.ts#L163-L210). The earlier shape took 19 s instead of 0.12 s on a 245k-member day, and twelve of those held the worker's whole DB pool.activities/analytics.ts#L54-L62,lib/semaphore.ts).Differences from @mykyal's fleet analysis
Compared with @mykyal's implementation as of 13 Oct 2026. Their code is cited as
file:linewithout a link. Igniter links are pinned to this PR's head (4a6303357faf9a6f842a8b285ee283ef1e42c600), and pocketdex links to5ec4b0aonmain.Data source and how it is fetched.
docs/data-collection.md:L3–L21). Payments come fromeventClaimSettledsandmodToAcctTransferson data.pocket.network (collector/settlement_collect.py:L41–L52). Each session's roster is rebuilt from archive REST at its end height (collector/settlement_collect.py:L250–L283).indexerApiUrl, one day and at most 200 suppliers per call, throughgetSupplierSessionFunnelJson(per day, service and supplier) andgetSupplierSessionListJson(one row per session, 1,000 per page) (packages/graphql/src/analytics.ts#L3-L43,activities/analytics.ts#L134-L277). pocketdex builds the rosters, V, Y and B when it indexes (thesession_rosterstable and thesession_supplier_dailyrollup).Session day axis: start, not end.
endedAt(src/settlements.js:L45–L53,L130–L137).lib/analytics.ts#L3-L6, funnel doc).Scope: the provider's verified keys only.
src/engine.js:L85–L94,src/settlements.js:L78–L87).schema/analytics.ts#L22-L34). There is no domain selector. The queries accept only supplier ids, an empty list is refused (graphql/src/analytics.ts#L261-L267), and any returned row of another supplier is dropped (activities/analytics.ts#L147-L150,L226-L229). Every page query filters on those keys (dal/analytics.ts#L46-L52).How V, K, Y and B are computed, and the per-seat columns.
src/settlements.js:L129–L136; sampled framessrc/engine.js:L189–L197).math.ts#L1-L38,L174-L176). The CSV's ratio and % columns are exact decimal strings from BigInt, rounded half up (csv.ts#L19-L30). The funnel'ssessions_with_selected,pool_upoktandroster_seatsare per supplier: two of your suppliers in one session each carry that session's full V and K. So they are never summed across suppliers. Distinct sessions, V and K come fromanalytics_sessionrows with at least one member in scope (schema/analytics.ts#L36-L40,dal/analytics.ts#L9-L16,L159-L186).Presence: closed days only, clamped, and a different denominator.
src/engine.js:L261–L280,docs/data-collection.md:L13). An expected presence from a hypergeometric model is shown beside it.network_sessions), over closed days only, and capped at 100% (math.ts#L139-L173,L217-L218,actions/Analytics.ts#L155-L171). No expected presence is computed.network_sessions, so your sessions can exceed it. A closed day above 100% is a data error. It is logged, listed on the page and shown capped.The 95% range (bootstrap).
src/math.js:L104–L124,src/engine.js:L304,L476–L490). Every breakdown row gets its own range.math.ts#L76-L85,L204-L214). Below it, the page shows "Limited history" with both counts instead of a range. The range always carries a line that it reflects variation across the recorded days in the period, not a forecast of future earnings (SummaryTab.tsx#L190-L206). The resampling: 2,000 resamples, the same mulberry32, one fixed seed (20261025). Only days with B > 0 are drawn, so every draw is defined. The quantile is the nearest rank (math.ts#L64-L119). The range is shown for the Summary only, not per breakdown row.Weeks.
src/engine.js:L153–L163,src/settlements.js:L93).coverage.ts#L100-L127,SummaryTab.tsx#L299-L308) and the Week breakdown, which groups in SQL (dal/analytics.ts#L240-L244,L327-L342). The chart shows single days for periods up to 14 days. The labels and the CSV file name use the period the server answered with, not the one in the page header.When a day is final: the close rule and
money_missing.Theirs: the snapshot is frozen at one cutoff. A session either has verified payouts, whose sum must equal the pool, or is "unknown". Unknown sessions add to neither Y nor B but stay in the coverage count (
src/settlements.js:L61–L66).Igniter: a (day, supplier) closes only when all of these hold (
lib/analytics.ts#L11-L12,L45-L85):money_missing;A closed day reopens if the indexer's coverage moves back (
L87-L100). Until amoney_missingsession's money is filled, pocketdex counts its V as 0, so it adds 0 to Y and B, and its per-member B is null. It is fetched again until it settles.Why: live data keeps changing. A day has to be marked final before presence uses it (point 5), and it has to be fetched again until it is final.
Effect: Y, B and F include open days, and the page shows how many days are still open. Presence uses closed days only.
Retention and periods.
src/engine.js:L106–L111).analytics_day_retention_days(1–365, default 90), ending today (UTC). Periods are 7, 30 and 90 days, capped by the retention, or a custom range (coverage.ts#L11-L53). Older days are deleted on everyAnalyticsSyncrun (activities/analytics.ts#L424-L431,lib/dal/analytics.ts#L392-L410).The session list and its phases.
src/app.js:L459,L674–L684).active…settled). The filter has three groups that make up all sessions: settled (with its money), in progress (any phase before settled), and money missing (dal/analytics.ts#L417-L462). The drawer lists only your members of the session (L466-L497).Owner attribution.
src/engine.js:L3–L5,src/settlements.js:L29–L35).BreakdownTab.tsx#L26,schema/analytics.ts#L139-L140,dal/analytics.ts#L314-L326).Breakdown groups.
src/engine.js:L141–L147,L358–L361,src/app.js:L16–L20).dal/analytics.ts#L223,L356-L364).No availability labels.
src/engine.js:L476–L492,docs/data-collection.md:L55).coverage.ts#L55-L85).Left out.
src/engine.js:L426–L461).src/engine.js:L261–L280,L393–L424).src/app.js:L16–L20).These either model the network beyond the provider's own suppliers or need data per session that this sync does not store. Each can be a follow-up.
The What-if selection model.
src/engine.js:L223–L231), with the model insrc/math.js:L26–L102and the card insrc/app.js:L267–L322.candidates, the cap param at that height and our verified suppliers' eligible count (Summary). The card's state stays in the card and resets on a new period or service filter.Migration 0024
analytics_supplier_day,analytics_service_day,analytics_session,analytics_session_member,analytics_supplier_day_state,analytics_owner_dayandanalytics_day_rollup, with their indexes.application_settings.analytics_day_retention_days, an integer, default 90.Migration 0025
analytics_selection_reference(per closed day and service: height, candidates, cap, own eligible and the day's version) andanalytics_selection_reference_day(the record that a day was counted under a version, also when it stored no row).Settings
Settings > Analytics history sets
analytics_day_retention_days(1–365).AnalyticsSyncdeletes the days before it on every run, with a plain delete.CSV exports
analytics-summary-services_<from>_<to>andanalytics-by-<dimension>_<from>_<to>, plus_<n>svcwhen a services filter is set, then the export's date and time.After the walkthrough review
DataTablehas a newboundedprop. It defaults to true, so every other table keeps its viewport-height box. The three Analytics tables (Summary services, Breakdown details, Sessions) passbounded={false}, so the page scrolls as one and the tables only scroll sideways.safeQueryParamsnow logs an array param longer than 50 items as[N items: first 3 …]. The analytics queries pass up to 2,385 supplier ids. At debug level, each of those queries made logtape warn that redaction exceededmaxProperties. With this change, the e2e logs no such warning (measured).next dev(Turbopack) on about 7–10% of same-tab reloads, as a RadixuseIdmismatch. It never showed in 60 reloads onnext start(measured). Removing single layout providers did not remove it. So it looks like a dev-toolchain effect, not app code (inferred, not proven).Testing
Local e2e with the 2,385 nodefleet.net suppliers that @mykyal's fleet analysis groups under that domain, backfilled from scratch from the mainnet pocketdex indexer (the same GraphQL roots are served publicly at data.pocket.network). For 6 Sep – 5 Oct 2026 and the same 8 services:
Actual and equal-share amounts are within 0.05% (3,633,438.4 and 5,650,096.4 POKT here), and every per-service result is within 0.1%. The remaining difference is consistent with the day axis (difference 2): @mykyal's analysis keys sessions by end, this PR by start.
The backfill of that e2e (51 days, 2,385 suppliers):
CSV exports. A script exported all eight CSVs of that view from the page (Summary services and the seven breakdowns). It checked each one against the page and against SQL written from the table definitions: rows and groups, every amount, the exact ratio and %, the week blocks, the value bands with their Min V and Max V, the BOM and the file name. All eight pass.
Postgres tests, run only with
MIGRATION_TEST_DATABASE_URL, like middleman's pg tests, against the real migrations:analytics.rollup.pg.test.ts: stored = live; key owner and verified-state changes; a day with no state rows; the sync/rebuild race, including two-connection cases; an abandoned sync; the per-batch refresh.analytics.sessions.pg.test.ts: a late page write of an abandoned attempt that lands before the next attempt's delete (it lost rows with the earlier time-based delete); a re-sync that drops rows the indexer no longer returns, and only those; the close counts, including null funnel values.On the e2e data, every breakdown also returned the same numbers from the rollups as from the member rows (checked before the final week and value changes; not re-run since).
Timings, 30 days on the e2e DB (about 11.6M member rows over the 51-day history). These were measured before the final changes to the week and value queries, which were not timed again:
The page's first view is read on the server, and the summary appears with the HTML.
What-if on that e2e (retention 54, so 6 Sep is kept), base on 5 Oct, against @mykyal's tool:
candidateson 21 of 21 (service, day) pairs.analytics.reference.pg.test.ts: which days are pending; a recount when the day's version changes (a supplier added, a key leaving the verified states); no write when the day changed during the count; a day done with no rows; the inputs (the latest session per service, verified suppliers only).Unit tests for the planning and close rules, the single-flight day id, the DAL SQL and scoping, cancellation, the DB slot limit, the actions, the page math (including the 95% range gate), the exact rounding and the CSV content; and for the selection model against @mykyal's outputs, the selection-count fetcher (our ids only, one cap per height, the 20-alias split) and the reference activity (batches of 200, the over-count guard, the failing-day skip, the empty day).
test,check-types(provider-workflows),lintandbuildpass.Operator notes
ANALYTICS_BATCH_CONCURRENCY = 12). Each batch is one funnel call plus pages of 1,000 sessions. In the e2e that took about 25 s per day, so about 38 min for 90 days. The button asks for confirmation before it starts.ANALYTICS_DB_SLOTS; the pool isPG_POOL_MAX, 10 by default).AnalyticsSyncthen rebuilds that day.AnalyticsSyncruns fill the retention window, newest first; until a day has one, the What-if card says it appears once a day closes.