-
Notifications
You must be signed in to change notification settings - Fork 16
feat: introduce ENSAnalytics API into ENSApi #1239
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 894e88c
fix: atomical cache update, zod for validation, jsdocs, refactoring
Goader 406b7b0
fix: linter errors
Goader 9c19837
Merge remote-tracking branch 'origin/main' into feat/ensanalytics
Goader 55dbcb3
Merged main
Goader 324f716
fix: migrated to new ensdb schema after registrars plugin introduction
Goader 7a9bf14
Merge remote-tracking branch 'origin/main' into feat/ensanalytics
Goader c144c00
fix: revert accidental docker-compose commit
Goader b2c849b
moved types to ensnode-sdk, refactored parts of code and docs
Goader eba8739
fix: lint
Goader db5d61f
apply the feedback
Goader 30efb18
add swr for cache building, zod-like error for page out of range, pag…
Goader 01d3d9a
minor refactoring
Goader d556cec
docs(changeset):
Goader 6272a51
docs(changeset):
Goader f19f749
docs(changeset):
Goader b3a3a1f
apply review, changeset, fixed swr caching, serialization & deseriali…
Goader 2222271
Merge remote-tracking branch 'origin/main' into feat/ensanalytics
Goader ec5a417
docs(changeset):
Goader 2d9d82e
moved cache to unixtimestamp and duration
Goader 055284a
fixed circular import
Goader 2cb7b7f
Merge remote-tracking branch 'origin/main' into feat/ensanalytics
Goader File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| 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. |
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
| 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. |
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
| 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. |
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
| 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. |
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
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
| 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; |
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
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
| 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}`); | ||
| } | ||
| } | ||
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
| 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`. | ||
| */ | ||
lightwalker-eth marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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; | ||
| } | ||
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
| 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; | ||
| }>(); |
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.