Skip to content

feat: introduce ENSAnalytics API into ENSApi#1239

Merged
Goader merged 22 commits intomainfrom
feat/ensanalytics
Nov 18, 2025
Merged

feat: introduce ENSAnalytics API into ENSApi#1239
Goader merged 22 commits intomainfrom
feat/ensanalytics

Conversation

@Goader
Copy link
Copy Markdown
Contributor

@Goader Goader commented Nov 1, 2025

advances: #1185

This PR introduces ENSAnalytics API as part of ENSApi. ENSAnalytics implements a Stale-While-Revalidate (SWR) caching strategy via middleware to provide sub-millisecond response times while keeping data fresh with automatic background updates every 5 minutes. All required ENV variables are already available in ENSApi, so nothing was added there.

Architecture:

  • Middleware-based caching: aggregatedReferrerSnapshotCacheMiddleware uses SWR pattern to serve cached data immediately (even if stale) while asynchronously revalidating in the background
  • Cache warmup: On server startup, the cache is pre-populated to ensure immediate availability
  • Date filtering: Currently hardcoded to ENS Holiday Awards period using ENS_HOLIDAY_AWARDS_START_DATE and ENS_HOLIDAY_AWARDS_END_DATE from @namehash/ens-referrals

SDK additions:

  • TypeScript types and interfaces (AggregatedReferrerMetrics, AggregatedReferrerMetricsContribution, etc.)
  • Zod schemas for runtime validation
  • Serialization/deserialization functions
  • Constants: ITEMS_PER_PAGE_DEFAULT (25), ITEMS_PER_PAGE_MAX (100)

Endpoint implemented:

  • /ensanalytics/aggregated-referrers: Main endpoint for paginated aggregated referrer leaderboard
    • Input parameters:
      • page (optional): Page number, must be >= 1 (default: 1)
      • itemsPerPage (optional): Items per page, must be 1-100 (default: 25)
    • Validation:
      • page >= 1 (error: 400)
      • 1 <= itemsPerPage <= 100 (error: 400)
      • page must not exceed total pages (error: 400)
    • Error handling:
      • Returns 500 if cache failed to load
    • Response:
    {
      responseCode: "ok",
      data: {
        referrers: [
          {
            referrer: Address;
            totalReferrals: number;
            totalIncrementalDuration: number; // in seconds
            totalReferralsContribution: number; // 0-1 (percentage of grand total)
            totalIncrementalDurationContribution: number; // 0-1 (percentage of grand total)
          },
          ...
        ];
        total: number; // total number of referrers across all pages
        paginationParams: {
          page: number;
          itemsPerPage: number;
        };
        hasNext: boolean;
        hasPrev: boolean;
        updatedAt: UnixTimestamp; // when cache was last updated
      }
    }

@Goader Goader requested a review from a team as a code owner November 1, 2025 14:56
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Nov 1, 2025

🦋 Changeset detected

Latest commit: 2cb7b7f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
ensapi Minor
@ensnode/ensnode-sdk Minor
ensadmin Minor
ensindexer Minor
ensrainbow Minor
@ensnode/ensnode-react Minor
@ensnode/ensrainbow-sdk Minor
@ensnode/datasources Minor
@ensnode/ponder-metadata Minor
@ensnode/ensnode-schema Minor
@ensnode/ponder-subgraph Minor
@ensnode/shared-configs Minor
@docs/ensnode Minor
@docs/ensrainbow Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Nov 1, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
admin.ensnode.io Ready Ready Preview Comment Nov 18, 2025 3:55pm
ensnode.io Ready Ready Preview Comment Nov 18, 2025 3:55pm
ensrainbow.io Ready Ready Preview Comment Nov 18, 2025 3:55pm

Copy link
Copy Markdown
Member

@lightwalker-eth lightwalker-eth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Goader Hey great to see this. Nice work. Reviewed and shared some suggestions with feedback 👍

await this.refreshData();

// Set up automatic refresh every N minutes
this.refreshTask = cron.schedule(`*/${CACHE_REFRESH_INTERVAL_MINUTES} * * * *`, async () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appreciate if you can investigate how we might align the way we're solving similar problems in ENSApi.

If we define the problem to be solved as:

  • At startup, build cache X into memory
  • Asynchronously refresh the cache of X every Y duration, such that if a refresh fails for some reason, we retain the previously cached stale value of X.

We have a current implementation of this goal where X is the "Indexing Status" which can be found here: https://github.com/namehash/ensnode/blob/main/apps/ensapi/src/middleware/indexing-status.middleware.ts

If I'm missing something please let me know, but ideally we could align the strategy used to solve what seems like an identical problem / need.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked at that, and it depends. As far as I understand, the indexing status is checked every time a request arrives, and if it is still in cache, then we use it. But if the cache entry is too old, then we ask the ENS Indexer its status. This is nice, since if we do not have any requests coming, we are not generating redundant traffic.

However, it will work differently than what #1185 describes (it will be dependent on the requests). And another concern I have is that if the cache cannot be renewed for some reason (SQL queries fail), then we will not retain the previously cached referrers, since they will expire anyway with a TTL cache. But at the same time, if we were to run into such a problem, that ENSApi could not execute the SQL query to ESNDb, then I think the cache status of ENSAnalytics would be the least of our concerns.

So if switching our strategy from refreshing cache independently from the incoming requests to the way it is done with indexing status, then I think it's ok, and I will implement it that way (should it be a middleware, though?). Correct me if I misunderstood something.

Copy link
Copy Markdown
Member

@lightwalker-eth lightwalker-eth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Goader Hey super work 🚀 Very happy with this feature! I shared a few small suggestions. Please feel welcome to take the lead to merge this PR when ready 🚀

/**
* Timestamp (from `Date.now()`) indicating when the value was last successfully updated
*/
updatedAt: number;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest making this a UnixTimestamp

Goal: prefer that as much as possible all timestamp related math in our work uses our UnixTimestamp type alias to reduce cognitive load of different time formats in different places.

Comment on lines +217 to +218
* Optional promise tracking an in-progress revalidation request.
* Used to deduplicate concurrent revalidation attempts.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Optional promise tracking an in-progress revalidation request.
* Used to deduplicate concurrent revalidation attempts.
* Optional promise of the in-progress revalidation attempt.
* If undefined, no revalidation attempt is in-progress.
* If defined, a revalidation attempt is already in-progress.
* Used to enforce no concurrent revalidation attempts.

* @link https://datatracker.ietf.org/doc/html/rfc5861
*/
export function staleWhileRevalidate<T>(fn: () => Promise<T>, ttl: number): () => Promise<T> {
if (!Number.isInteger(ttl) || ttl <= 0) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Continue to suggest making this a Duration value for the goal that I put a lot of value on standardizing the types we use for timestamp math as much as possible. I'm perfectly ok if this removes the ability to set ttl values in millisecond resolutions.

let cache: SWRCache<T> | null = null;

return async (): Promise<T> => {
const now = Date.now();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Continue to suggest this 👍

// Start initial build
initialBuild = fn()
.then((value) => {
cache = { value, updatedAt: now };
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest calculating a fresh value for now here.

import { factory } from "@/lib/hono-factory";
import logger from "@/lib/logger";

const TTL_MS = 5 * 60 * 1000; // 5 minutes
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Continue to think this would be a nice step 👍

if (cache) {
logger.info(`ENSAnalytics cache warmed up with ${cache.referrers.size} referrers`);
} else {
logger.warn("ENSAnalytics cache returned null - no cached data available yet");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you have a look at referrersCacheFetcher to see if it is still possible that it might throw an error?

If it can't throw an error anymore, we can:

  • remove the try / catch logic here
  • Update this log statement so it's an error not a warning

However, if my assumption here is wrong then please feel welcome to ignore. Appreciate your advice 👍

Comment on lines +7 to +13
export const ENS_HOLIDAY_AWARDS_START_DATE = getUnixTime(new Date("2025-12-01T00:00:00.000Z"));

/**
* End date for the ENS Holiday Awards referral program.
* December 31, 2025 at 23:59:59 UTC
*/
export const ENS_HOLIDAY_AWARDS_END_DATE = getUnixTime(new Date("2025-12-31T23:59:59.999Z"));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might not need to include date-fns package in ens-referrals package in case this is converting a date into unix timestamp is the sole need for the date-fns package.

Instead, let's set these const directly to unix timestamp values

Suggested change
export const ENS_HOLIDAY_AWARDS_START_DATE = getUnixTime(new Date("2025-12-01T00:00:00.000Z"));
/**
* End date for the ENS Holiday Awards referral program.
* December 31, 2025 at 23:59:59 UTC
*/
export const ENS_HOLIDAY_AWARDS_END_DATE = getUnixTime(new Date("2025-12-31T23:59:59.999Z"));
export const ENS_HOLIDAY_AWARDS_START_DATE: UnixTimestamp = 1764547200;
/**
* End date for the ENS Holiday Awards referral program.
* December 31, 2025 at 23:59:59 UTC
*/
export const ENS_HOLIDAY_AWARDS_END_DATE: UnixTimestamp = 1767225599;

Comment on lines +7 to +13
* "BaseRegistrar" contract for direct subnames of .eth) for the provided namespace.
*
* @param namespace The ENS namespace to get the Ethnames Subregistry ID for
* @returns The AccountId for the Ethnames Subregistry contract for the provided namespace.
* @throws Error if the contract is not found for the given namespace.
*/
export function getEthnamesSubregistryId(namespace: ENSNamespaceId): AccountId {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! I can see how we can later add getEthnamesSubregistryNode function here 🚀

Copy link
Copy Markdown
Contributor

@tk-o tk-o left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR looks awesome! Let's fix the circular dependency problem, and we'll be good to go 🚀

},
"dependencies": {
"date-fns": "catalog:"
"@ensnode/ensnode-sdk": "workspace:*"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a circular dependency. @ensnode/ensnode-sdk has had @namehash/ens-referrals package on the list of dependencies already.

@@ -1,13 +1,13 @@
import { getUnixTime } from "date-fns";
import type { UnixTimestamp } from "@ensnode/ensnode-sdk";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to not use ensnode-sdk package inside the ens-referrals one. Doing so creates circular dependency.

Since UnixTimestamp is just an alias for the number type, I suggest we re-create the UnixTimestamp type here.

Suggested change
import type { UnixTimestamp } from "@ensnode/ensnode-sdk";
/**
* Unix timestamp value
*
* Represents the number of seconds that have elapsed
* since January 1, 1970 (midnight UTC/GMT).
*
* Guaranteed to be an integer. May be zero or negative to represent a time at or
* before Jan 1, 1970.
*/
export type UnixTimestamp = number;

Copy link
Copy Markdown
Member

@lightwalker-eth lightwalker-eth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Goader Nice updates 😄 Great work 🚀 Just 1 small question. You're welcome to take the lead to merge this!

return result;
} catch (error) {
logger.error({ error }, "Failed to build aggregated referrer snapshot");
throw error;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this return null instead? Appreciate your advice. Please ignore if not relevant 👍

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, missed it. The responsibility for handling the error is inside the SWR logic, so this function can throw errors without disrupting the flow. And also this way SWR will know that revalidation failed and will return stale data, instead of treating null as the updated value.

Copy link
Copy Markdown
Contributor

@tk-o tk-o left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome updates 🚀 Merge when ready.

@Goader Goader merged commit 965707d into main Nov 18, 2025
10 checks passed
@Goader Goader deleted the feat/ensanalytics branch November 18, 2025 16:35
@github-actions github-actions bot mentioned this pull request Nov 18, 2025
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.

3 participants