Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fb1cbe8
feat: introduced ensanalytics api
Goader Oct 30, 2025
894e88c
fix: atomical cache update, zod for validation, jsdocs, refactoring
Goader Nov 5, 2025
406b7b0
fix: linter errors
Goader Nov 5, 2025
9c19837
Merge remote-tracking branch 'origin/main' into feat/ensanalytics
Goader Nov 7, 2025
55dbcb3
Merged main
Goader Nov 10, 2025
324f716
fix: migrated to new ensdb schema after registrars plugin introduction
Goader Nov 10, 2025
7a9bf14
Merge remote-tracking branch 'origin/main' into feat/ensanalytics
Goader Nov 10, 2025
c144c00
fix: revert accidental docker-compose commit
Goader Nov 10, 2025
b2c849b
moved types to ensnode-sdk, refactored parts of code and docs
Goader Nov 11, 2025
eba8739
fix: lint
Goader Nov 11, 2025
db5d61f
apply the feedback
Goader Nov 12, 2025
30efb18
add swr for cache building, zod-like error for page out of range, pag…
Goader Nov 13, 2025
01d3d9a
minor refactoring
Goader Nov 13, 2025
d556cec
docs(changeset):
Goader Nov 14, 2025
6272a51
docs(changeset):
Goader Nov 14, 2025
f19f749
docs(changeset):
Goader Nov 14, 2025
b3a3a1f
apply review, changeset, fixed swr caching, serialization & deseriali…
Goader Nov 14, 2025
2222271
Merge remote-tracking branch 'origin/main' into feat/ensanalytics
Goader Nov 14, 2025
ec5a417
docs(changeset):
Goader Nov 17, 2025
2d9d82e
moved cache to unixtimestamp and duration
Goader Nov 17, 2025
055284a
fixed circular import
Goader Nov 18, 2025
2cb7b7f
Merge remote-tracking branch 'origin/main' into feat/ensanalytics
Goader Nov 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/five-cows-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"ensapi": minor
"@ensnode/ensnode-sdk": minor
---

Introduces ENS Analytics API for tracking and analyzing referral metrics. Adds `/ensanalytics/aggregated-referrers` endpoint with pagination support to retrieve aggregated referrer metrics and contribution percentages.
5 changes: 5 additions & 0 deletions .changeset/lemon-flies-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensnode-sdk": minor
---

Added `staleWhileRevalidate` function for Stale-While-Revalidate caching pattern.
5 changes: 5 additions & 0 deletions .changeset/olive-carrots-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensnode-sdk": patch
---

Migrated cache implementation to use `UnixTimestamp` and `Duration` types for better type safety and consistency.
5 changes: 5 additions & 0 deletions .changeset/small-apes-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensnode-sdk": minor
---

Added ENS Analytics module with types, serialization/deserialization functions, and Zod validation schemas for `PaginatedAggregatedReferrersResponse`. This includes support for aggregated referrer metrics with contribution percentages and pagination.
1 change: 1 addition & 0 deletions apps/ensapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@ensnode/ensnode-schema": "workspace:*",
"@ensnode/ensnode-sdk": "workspace:*",
"@ensnode/ponder-subgraph": "workspace:*",
"@namehash/ens-referrals": "workspace:*",
"@hono/node-server": "^1.19.5",
"@hono/otel": "^0.2.2",
"@hono/zod-validator": "^0.7.2",
Expand Down
152 changes: 152 additions & 0 deletions apps/ensapi/src/handlers/ensanalytics-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { z } from "zod/v4";

import {
type AggregatedReferrerMetrics,
type AggregatedReferrerMetricsContribution,
ITEMS_PER_PAGE_DEFAULT,
ITEMS_PER_PAGE_MAX,
type PaginatedAggregatedReferrersRequest,
type PaginatedAggregatedReferrersResponse,
PaginatedAggregatedReferrersResponseCodes,
serializePaginatedAggregatedReferrersResponse,
} from "@ensnode/ensnode-sdk";

import { errorResponse } from "@/lib/handlers/error-response";
import { validate } from "@/lib/handlers/validate";
import { factory } from "@/lib/hono-factory";
import { islice } from "@/lib/itertools";
import logger from "@/lib/logger";
import { aggregatedReferrerSnapshotCacheMiddleware } from "@/middleware/aggregated-referrer-snapshot-cache.middleware";

const app = factory.createApp();

// Apply aggregated referrer snapshot cache middleware to all routes in this handler
app.use(aggregatedReferrerSnapshotCacheMiddleware);

// Pagination query parameters schema (mirrors PaginatedAggregatedReferrersRequest)
const paginationQuerySchema = z.object({
page: z.optional(z.coerce.number().int().min(1, "Page must be a positive integer")).default(1),
itemsPerPage: z
.optional(
z.coerce
.number()
.int()
.min(1, "Items per page must be at least 1")
.max(ITEMS_PER_PAGE_MAX, `Items per page must not exceed ${ITEMS_PER_PAGE_MAX}`),
)
.default(ITEMS_PER_PAGE_DEFAULT),
}) satisfies z.ZodType<Required<PaginatedAggregatedReferrersRequest>>;

/**
* Converts an AggregatedReferrerMetrics object to AggregatedReferrerMetricsContribution
* by calculating contribution percentages based on grand totals.
*
* @param referrer - The referrer metrics to convert
* @param grandTotalReferrals - The sum of all referrals across all referrers
* @param grandTotalIncrementalDuration - The sum of all incremental duration across all referrers
* @returns The referrer metrics with contribution percentages
*/
function calculateContribution(
referrer: AggregatedReferrerMetrics,
grandTotalReferrals: number,
grandTotalIncrementalDuration: number,
): AggregatedReferrerMetricsContribution {
return {
...referrer,
totalReferralsContribution:
grandTotalReferrals > 0 ? referrer.totalReferrals / grandTotalReferrals : 0,
totalIncrementalDurationContribution:
grandTotalIncrementalDuration > 0
? referrer.totalIncrementalDuration / grandTotalIncrementalDuration
: 0,
};
}

// Get all aggregated referrers with pagination
app.get("/aggregated-referrers", validate("query", paginationQuerySchema), async (c) => {
try {
const aggregatedReferrerSnapshotCache = c.var.aggregatedReferrerSnapshotCache;

// Check if cache failed to load
if (aggregatedReferrerSnapshotCache === null) {
return c.json(
serializePaginatedAggregatedReferrersResponse({
responseCode: PaginatedAggregatedReferrersResponseCodes.Error,
error: "Internal Server Error",
errorMessage: "Failed to load aggregated referrer data.",
} satisfies PaginatedAggregatedReferrersResponse),
500,
);
}

const { page, itemsPerPage } = c.req.valid("query");

const totalAggregatedReferrers = aggregatedReferrerSnapshotCache.referrers.size;

// Calculate total pages
const totalPages = Math.ceil(totalAggregatedReferrers / itemsPerPage);

// Check if requested page exceeds available pages
if (totalAggregatedReferrers > 0) {
const pageValidationSchema = z
.number()
.max(totalPages, `Page ${page} exceeds total pages ${totalPages}`);

const pageValidation = pageValidationSchema.safeParse(page);
if (!pageValidation.success) {
return errorResponse(c, pageValidation.error);
}
}

// Use iterator slice to extract paginated results
const startIndex = (page - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedReferrers = islice(
aggregatedReferrerSnapshotCache.referrers.values(),
startIndex,
endIndex,
);

// Convert AggregatedReferrerMetrics to AggregatedReferrerMetricsContribution
const referrersWithContribution = Array.from(paginatedReferrers).map((referrer) =>
calculateContribution(
referrer,
aggregatedReferrerSnapshotCache.grandTotalReferrals,
aggregatedReferrerSnapshotCache.grandTotalIncrementalDuration,
),
);

return c.json(
serializePaginatedAggregatedReferrersResponse({
responseCode: PaginatedAggregatedReferrersResponseCodes.Ok,
data: {
referrers: referrersWithContribution,
total: totalAggregatedReferrers,
paginationParams: {
page,
itemsPerPage,
},
hasNext: endIndex < totalAggregatedReferrers,
hasPrev: page > 1,
updatedAt: aggregatedReferrerSnapshotCache.updatedAt,
},
} satisfies PaginatedAggregatedReferrersResponse),
);
} catch (error) {
logger.error({ error }, "Error in /ensanalytics/aggregated-referrers endpoint");
const errorMessage =
error instanceof Error
? error.message
: "An unexpected error occurred while processing your request";
return c.json(
serializePaginatedAggregatedReferrersResponse({
responseCode: PaginatedAggregatedReferrersResponseCodes.Error,
error: "Internal server error",
errorMessage,
} satisfies PaginatedAggregatedReferrersResponse),
500,
);
}
});

export default app;
15 changes: 15 additions & 0 deletions apps/ensapi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import { errorResponse } from "@/lib/handlers/error-response";
import { factory } from "@/lib/hono-factory";
import logger from "@/lib/logger";
import { sdk } from "@/lib/tracing/instrumentation";
import { fetcher as referrersCacheFetcher } from "@/middleware/aggregated-referrer-snapshot-cache.middleware";
import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware";

import ensanalyticsApi from "./handlers/ensanalytics-api";
import ensNodeApi from "./handlers/ensnode-api";
import subgraphApi from "./handlers/subgraph-api";

Expand Down Expand Up @@ -41,6 +43,9 @@ app.route("/api", ensNodeApi);
// use Subgraph GraphQL API at /subgraph
app.route("/subgraph", subgraphApi);

// use ENSAnalytics API at /ensanalytics
app.route("/ensanalytics", ensanalyticsApi);

// will automatically 500 if config is not available due to ensIndexerPublicConfigMiddleware
app.get("/health", async (c) => {
return c.json({ ok: true });
Expand Down Expand Up @@ -68,6 +73,16 @@ const server = serve(

// self-healthcheck to connect to ENSIndexer & warm Indexing Status / Can Accelerate cache
await app.request("/health");

// warm start ENSAnalytics aggregated referrer snapshot cache
logger.info("Warming up ENSAnalytics aggregated referrer snapshot cache...");
const cache = await referrersCacheFetcher();
if (cache) {
logger.info(`ENSAnalytics cache warmed up with ${cache.referrers.size} referrers`);
} else {
logger.error("Failed to warm up ENSAnalytics cache - no cached data available yet");
// Don't exit - let the service run without pre-warmed analytics
}
},
);

Expand Down
120 changes: 120 additions & 0 deletions apps/ensapi/src/lib/ensanalytics/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { getUnixTime } from "date-fns";
import { and, count, desc, eq, gte, isNotNull, lte, ne, sql, sum } from "drizzle-orm";
import { zeroAddress } from "viem";

import * as schema from "@ensnode/ensnode-schema";
import {
type AccountId,
deserializeDuration,
serializeAccountId,
type UnixTimestamp,
} from "@ensnode/ensnode-sdk";

import { db } from "@/lib/db";
import type { AggregatedReferrerSnapshot } from "@/lib/ensanalytics/types";
import { ireduce } from "@/lib/itertools";
import logger from "@/lib/logger";

/**
* Fetches all referrers with 1 or more qualified referrals from the `registrar_actions` table
* and builds an `AggregatedReferrerSnapshot`.
*
* Step 1: Filter for "qualified" referrals where:
* - timestamp is between startDate and endDate
* - decodedReferrer is not null and not the zero address
* - subregistryId matches the provided subregistryId
*
* Step 2: Group by decodedReferrer and calculate:
* - Sum total incrementalDuration for each decodedReferrer
* - Count of qualified referrals for each decodedReferrer
*
* Step 3: Sort by sum total incrementalDuration from highest to lowest
*
* Step 4: Calculate grand totals and build the snapshot object
*
* @param startDate - The start date (Unix timestamp, inclusive) for filtering registrar actions
* @param endDate - The end date (Unix timestamp, inclusive) for filtering registrar actions
* @param subregistryId - The account ID of the subregistry to filter by
* @returns `AggregatedReferrerSnapshot` containing all referrers with at least one qualified referral, grand totals, and updatedAt timestamp
* @throws Error if startDate > endDate (invalid date range)
* @throws Error if the database query fails
*/
export async function getAggregatedReferrerSnapshot(
startDate: UnixTimestamp,
endDate: UnixTimestamp,
subregistryId: AccountId,
): Promise<AggregatedReferrerSnapshot> {
if (startDate > endDate) {
throw new Error(
`Invalid date range: startDate (${startDate}) must be less than or equal to endDate (${endDate})`,
);
}

try {
const updatedAt = getUnixTime(new Date());

const result = await db
.select({
referrer: schema.registrarActions.decodedReferrer,
totalReferrals: count().as("total_referrals"),
totalIncrementalDuration: sum(schema.registrarActions.incrementalDuration).as(
"total_incremental_duration",
),
})
.from(schema.registrarActions)
.where(
and(
// Filter by timestamp range
gte(schema.registrarActions.timestamp, BigInt(startDate)),
lte(schema.registrarActions.timestamp, BigInt(endDate)),
// Filter by decodedReferrer not null
isNotNull(schema.registrarActions.decodedReferrer),
// Filter by decodedReferrer not zero address
ne(schema.registrarActions.decodedReferrer, zeroAddress),
// Filter by subregistryId matching the provided subregistryId
eq(schema.registrarActions.subregistryId, serializeAccountId(subregistryId)),
),
)
.groupBy(schema.registrarActions.decodedReferrer)
.orderBy(desc(sql`total_incremental_duration`));

// Transform the result to an ordered map (preserves SQL sort order)
const referrers = new Map(
result.map((row) => {
// biome-ignore lint/style/noNonNullAssertion: referrer is guaranteed to be non-null due to isNotNull filter in WHERE clause
const address = row.referrer!;
const metrics = {
referrer: address,
totalReferrals: row.totalReferrals,
// biome-ignore lint/style/noNonNullAssertion: totalIncrementalDuration is guaranteed to be non-null as it is the sum of non-null bigint values
totalIncrementalDuration: deserializeDuration(row.totalIncrementalDuration!),
};
return [address, metrics];
}),
);

// Calculate grand totals across all referrers
const grandTotalReferrals = ireduce(
referrers.values(),
(sum, metrics) => sum + metrics.totalReferrals,
0,
);
const grandTotalIncrementalDuration = ireduce(
referrers.values(),
(sum, metrics) => sum + metrics.totalIncrementalDuration,
0,
);

// Build and return the complete snapshot
return {
referrers,
updatedAt,
grandTotalReferrals,
grandTotalIncrementalDuration,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
logger.error({ error }, "Failed to fetch aggregated referrer snapshot from database");
throw new Error(`Failed to fetch aggregated referrer snapshot: ${errorMessage}`);
}
}
32 changes: 32 additions & 0 deletions apps/ensapi/src/lib/ensanalytics/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Address } from "viem";

import type { AggregatedReferrerMetrics, Duration, UnixTimestamp } from "@ensnode/ensnode-sdk";

/**
* Represents a snapshot of aggregated metrics for all referrers with 1 or more qualifying referrals as of `updatedAt`.
*/
export interface AggregatedReferrerSnapshot {
/**
* Ordered map containing `AggregatedReferrerMetrics` for all referrers with 1 or more qualifying referrals as of `updatedAt`.
* @invariant Map entries are ordered by `totalIncrementalDuration` (descending).
* @invariant Map may be empty if there are no referrers with 1 or more qualifying referrals as of `updatedAt`.
* @invariant If an `Address` is not a key in this map then that `Address` had 0 qualifying referrals as of `updatedAt`.
* @invariant Each `Address` key in this map is unique.
*/
referrers: Map<Address, AggregatedReferrerMetrics>;

/** Unix timestamp identifying when this `AggregatedReferrerSnapshot` was generated. */
updatedAt: UnixTimestamp;

/**
* @invariant The sum of `totalReferrals` across all `referrers`.
* @invariant Guaranteed to be a non-negative integer (>= 0)
*/
grandTotalReferrals: number;

/**
* @invariant The sum of `totalIncrementalDuration` across all `referrers`.
* @invariant Guaranteed to be a non-negative integer (>= 0), measured in seconds
*/
grandTotalIncrementalDuration: Duration;
}
6 changes: 5 additions & 1 deletion apps/ensapi/src/lib/hono-factory.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { createFactory } from "hono/factory";

import type { AggregatedReferrerSnapshotCacheVariables } from "@/middleware/aggregated-referrer-snapshot-cache.middleware";
import type { CanAccelerateVariables } from "@/middleware/can-accelerate.middleware";
import type { IndexingStatusVariables } from "@/middleware/indexing-status.middleware";
import type { IsRealtimeVariables } from "@/middleware/is-realtime.middleware";

export const factory = createFactory<{
Variables: IndexingStatusVariables & IsRealtimeVariables & CanAccelerateVariables;
Variables: IndexingStatusVariables &
IsRealtimeVariables &
CanAccelerateVariables &
AggregatedReferrerSnapshotCacheVariables;
}>();
Loading