Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/late-oranges-like.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ponder-sdk": minor
---

Extend `PonderClient` with additional methods: `health()`, `metrics()`.
3 changes: 3 additions & 0 deletions packages/ponder-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
"lint": "biome check --write .",
"lint:ci": "biome ci"
},
"dependencies": {
"parse-prometheus-text-format": "^1.1.1"
},
"devDependencies": {
"@ensnode/shared-configs": "workspace:*",
"@types/node": "catalog:",
Expand Down
16 changes: 8 additions & 8 deletions packages/ponder-sdk/src/blocks.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
import { z } from "zod/v4";

import { nonnegativeIntegerSchema } from "./numbers";
import { unixTimestampSchema } from "./time";
import { schemaNonnegativeInteger } from "./numbers";
import { schemaUnixTimestamp } from "./time";

//// Block Number

export const blockNumberSchema = nonnegativeIntegerSchema;
export const schemaBlockNumber = schemaNonnegativeInteger;

/**
* Block Number
*
* Guaranteed to be a non-negative integer.
*/
export type BlockNumber = z.infer<typeof blockNumberSchema>;
export type BlockNumber = z.infer<typeof schemaBlockNumber>;

export const blockRefSchema = z.object({
number: blockNumberSchema,
timestamp: unixTimestampSchema,
export const schemaBlockRef = z.object({
number: schemaBlockNumber,
timestamp: schemaUnixTimestamp,
});

/**
* BlockRef
*
* Reference to a block.
*/
export type BlockRef = z.infer<typeof blockRefSchema>;
export type BlockRef = z.infer<typeof schemaBlockRef>;
7 changes: 3 additions & 4 deletions packages/ponder-sdk/src/chains.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import type { z } from "zod/v4";

import { positiveIntegerSchema } from "./numbers";
import { schemaPositiveInteger } from "./numbers";

// Chain ID

export const chainIdSchema = positiveIntegerSchema;

export const schemaChainId = schemaPositiveInteger;
/**
* Chain ID
*
Expand All @@ -14,4 +13,4 @@ export const chainIdSchema = positiveIntegerSchema;
*
* Chain id standards are organized by the Ethereum Community @ https://github.com/ethereum-lists/chains
**/
export type ChainId = z.infer<typeof chainIdSchema>;
export type ChainId = z.infer<typeof schemaChainId>;
199 changes: 199 additions & 0 deletions packages/ponder-sdk/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";

import { PonderClient } from "./client";
import {
indexingMetricsMockInvalidApplicationSettingsOrdering,
indexingMetricsMockInvalidConflictingMetrics,
indexingMetricsMockInvalidNoIndexedChains,
indexingMetricsMockInvalidNonIntegerChainNames,
indexingMetricsMockValid,
} from "./deserialize/indexing-metrics.mock";
import { deserializePonderIndexingStatus } from "./deserialize/indexing-status";
import {
mockSerializedPonderIndexingStatusInvalidBlockNumber,
Expand All @@ -24,6 +31,188 @@ describe("Ponder Client", () => {
vi.unstubAllGlobals();
});

describe("health()", () => {
it("should handle healthy response", async () => {
// Arrange
mockFetch.mockResolvedValueOnce(
new Response(null, {
status: 200,
}),
);

const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act & Assert
await expect(ponderClient.health()).resolves.toBeUndefined();
});

it("should handle unhealthy response", async () => {
// Arrange
mockFetch.mockResolvedValueOnce(
new Response("Service Unavailable", {
status: 503,
statusText: "Service Unavailable",
}),
);

const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act & Assert
await expect(ponderClient.health()).rejects.toThrowError(
/Failed to fetch Ponder health response/,
);
});
});

describe("metrics()", () => {
it("should handle valid Ponder metrics response", async () => {
// Arrange
mockFetch.mockResolvedValueOnce(
new Response(indexingMetricsMockValid.text, {
status: 200,
headers: { "Content-Type": "text/plain" },
}),
);

const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act & Assert
await expect(ponderClient.metrics()).resolves.toStrictEqual(
indexingMetricsMockValid.deserialized,
);
});

describe("Invalid response handling", () => {
it("should handle empty response", async () => {
// Arrange
mockFetch.mockResolvedValueOnce(
new Response("", {
status: 200,
headers: { "Content-Type": "text/plain" },
}),
);
const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act & Assert
await expect(ponderClient.metrics()).rejects.toThrowError(
/Invalid serialized Ponder Indexing Metrics.*Ponder Indexing Metrics must be a non-empty string/,
);
});

it("should handle invalid Ponder application settings in the response", async () => {
// Arrange
mockFetch.mockResolvedValueOnce(
new Response(indexingMetricsMockInvalidApplicationSettingsOrdering.text, {
status: 200,
headers: { "Content-Type": "text/plain" },
}),
);

const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act
try {
await ponderClient.metrics();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "unknown error";
// Assert
expect(errorMessage).toContain("Invalid serialized Ponder Indexing Metrics");
expect(errorMessage).toContain("Missing required Prometheus metric: ponder_sync_block");
expect(errorMessage).toContain(
"Missing required Prometheus metric: ponder_sync_block_timestamp",
);
expect(errorMessage).toContain(
"Missing required Prometheus metric: ponder_historical_total_blocks",
);
expect(errorMessage).toContain(
"Missing required Prometheus metric: ponder_sync_is_complete",
);
expect(errorMessage).toContain(
"Missing required Prometheus metric: ponder_sync_is_realtime",
);
}
});

it("should handle metrics using non-int chain names in the response", async () => {
// Arrange
mockFetch.mockResolvedValueOnce(
new Response(indexingMetricsMockInvalidNonIntegerChainNames.text, {
status: 200,
headers: { "Content-Type": "text/plain" },
}),
);

const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act
try {
await ponderClient.metrics();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "unknown error";
// Assert
expect(errorMessage).toContain("Invalid serialized Ponder Indexing Metrics");
expect(errorMessage).toContain("'optimism' must be a string representing a chain ID");
expect(errorMessage).toContain("'mainnet' must be a string representing a chain ID");
expect(errorMessage).toContain("'base' must be a string representing a chain ID");
expect(errorMessage).toContain("'scroll' must be a string representing a chain ID");
expect(errorMessage).toContain("'linea' must be a string representing a chain ID");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it("should handle no indexed chains in the response", async () => {
// Arrange
mockFetch.mockResolvedValueOnce(
new Response(indexingMetricsMockInvalidNoIndexedChains.text, {
status: 200,
headers: { "Content-Type": "text/plain" },
}),
);

const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act & Assert
await expect(ponderClient.metrics()).rejects.toThrowError(
/Invalid serialized Ponder Indexing Metrics.*Missing required Prometheus metric: ponder_sync_block/,
);
});

it("should handle conflicting metrics in the response", async () => {
// Arrange
mockFetch.mockResolvedValueOnce(
new Response(indexingMetricsMockInvalidConflictingMetrics.text, {
status: 200,
headers: { "Content-Type": "text/plain" },
}),
);

const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act & Assert
await expect(ponderClient.metrics()).rejects.toThrowError(
/Invalid serialized Ponder Indexing Metrics.*Chain Indexing Metrics cannot have both `indexingCompleted` and `indexingRealtime` as `true`/,
);
});
});

describe("HTTP error handling", () => {
it("should handle non-OK HTTP responses", async () => {
// Arrange
mockFetch.mockResolvedValueOnce(
new Response("Internal Server Error", {
status: 500,
statusText: "Internal Server Error",
}),
);
const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act & Assert
await expect(ponderClient.metrics()).rejects.toThrowError(
/Failed to fetch Ponder Indexing Metrics response/,
);
});
});
Comment thread
tk-o marked this conversation as resolved.
});
Comment thread
tk-o marked this conversation as resolved.

describe("status()", () => {
it("should handle valid Ponder status response", async () => {
// Arrange
Expand All @@ -44,6 +233,7 @@ describe("Ponder Client", () => {

describe("Invalid response handling", () => {
it("should handle invalid block numbers in the response", async () => {
// Arrange
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(mockSerializedPonderIndexingStatusInvalidBlockNumber), {
status: 200,
Expand All @@ -53,12 +243,14 @@ describe("Ponder Client", () => {

const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act & Assert
await expect(ponderClient.status()).rejects.toThrowError(
/Invalid serialized Ponder Indexing Status.*Value must be a non-negative integer/,
);
});

it("should handle invalid chain IDs in the response", async () => {
// Arrange
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(mockSerializedPonderIndexingStatusInvalidChainId), {
status: 200,
Expand All @@ -68,12 +260,14 @@ describe("Ponder Client", () => {

const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act & Assert
await expect(ponderClient.status()).rejects.toThrowError(
/Invalid serialized Ponder Indexing Status.*Value must be a positive integer/,
);
});

it("should handle zero indexed chains in the response", async () => {
// Arrange
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({}), {
status: 200,
Expand All @@ -83,6 +277,7 @@ describe("Ponder Client", () => {

const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act & Assert
await expect(ponderClient.status()).rejects.toThrowError(
/Invalid serialized Ponder Indexing Status.*Ponder Indexing Status must include at least one indexed chain/,
);
Expand All @@ -99,20 +294,24 @@ describe("Ponder Client", () => {
}),
);
const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act & Assert
await expect(ponderClient.status()).rejects.toThrowError(
/Failed to fetch Ponder Indexing Status response/,
);
});

it("should handle JSON parsing errors", async () => {
// Arrange
mockFetch.mockResolvedValueOnce(
new Response("not valid json", {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const ponderClient = new PonderClient(new URL("http://localhost:3000"));

// Act & Assert
await expect(ponderClient.status()).rejects.toThrowError(
/Failed to parse Ponder Indexing Status response as JSON/,
);
Expand Down
43 changes: 42 additions & 1 deletion packages/ponder-sdk/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,52 @@
import { deserializePonderIndexingMetrics } from "./deserialize/indexing-metrics";
import { deserializePonderIndexingStatus } from "./deserialize/indexing-status";
import type { PonderIndexingMetrics } from "./indexing-metrics";
import type { PonderIndexingStatus } from "./indexing-status";

/**
* PonderClient for fetching data from Ponder apps.
*/
export class PonderClient {
constructor(private baseUrl: URL) {}
constructor(private readonly baseUrl: URL) {}

/**
* Check Ponder Health
*
* If the Ponder instance is healthy, this method resolves successfully.
*
* @throws Error if the health check fails.
*/
async health(): Promise<void> {
const requestUrl = new URL("/health", this.baseUrl);
const response = await fetch(requestUrl);

if (!response.ok) {
throw new Error(
`Failed to fetch Ponder health response: ${response.status} ${response.statusText}`,
);
}
}

/**
* Get Ponder Indexing Metrics
*
* @returns Ponder Indexing Metrics.
* @throws Error if the response could not be fetched or was invalid.
*/
async metrics(): Promise<PonderIndexingMetrics> {
const requestUrl = new URL("/metrics", this.baseUrl);
const response = await fetch(requestUrl);

if (!response.ok) {
throw new Error(
`Failed to fetch Ponder Indexing Metrics response: ${response.status} ${response.statusText}`,
);
}

const responseText = await response.text();

return deserializePonderIndexingMetrics(responseText);
}

/**
* Get Ponder Indexing Status
Expand Down
Loading
Loading