diff --git a/.changeset/fast-coins-grab.md b/.changeset/fast-coins-grab.md new file mode 100644 index 0000000000..cc17a83a40 --- /dev/null +++ b/.changeset/fast-coins-grab.md @@ -0,0 +1,5 @@ +--- +"ensindexer": minor +--- + +Enhanced application logging approach to use a streamlined logger implementation across ENSIndexer app. diff --git a/.changeset/warm-geese-fall.md b/.changeset/warm-geese-fall.md new file mode 100644 index 0000000000..eae67144cc --- /dev/null +++ b/.changeset/warm-geese-fall.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ponder-sdk": minor +--- + +Added `logger` field to `PonderAppContext` data model. diff --git a/apps/ensindexer/ponder/ponder.config.ts b/apps/ensindexer/ponder/ponder.config.ts index bdb21adccf..5fbb2be5ea 100644 --- a/apps/ensindexer/ponder/ponder.config.ts +++ b/apps/ensindexer/ponder/ponder.config.ts @@ -1,23 +1,26 @@ import config from "@/config"; import { PluginName } from "@ensnode/ensnode-sdk"; -import { prettyPrintJson } from "@ensnode/ensnode-sdk/internal"; import { redactENSIndexerConfig } from "@/config/redact"; +import { logger } from "@/lib/logger"; import ponderConfig from "@/ponder/config"; //////// // Log redacted ENSIndexerConfig for debugging. //////// -console.log("ENSIndexer running with config:"); -console.log(prettyPrintJson(redactENSIndexerConfig(config))); +logger.info({ + msg: "ENSIndexer starting", + config: redactENSIndexerConfig(config), +}); -// log warning about dual activation of subgraph and ensv2 plugins +// Log warning about dual activation of subgraph and ensv2 plugins if (config.plugins.includes(PluginName.Subgraph) && config.plugins.includes(PluginName.ENSv2)) { - console.warn( - `Both the '${PluginName.Subgraph}' and '${PluginName.ENSv2}' plugins are enabled. This results in the availability of both the legacy Subgraph-Compatible GraphQL API (/subgraph) _and_ ENSNode's Omnigraph API (/api/omnigraph), and comes with an associated increase in indexing time. If your intent is to have both APIs available in parallel, excellent, otherwise you may benefit from only enabling the plugin for the API you plan to use.`, - ); + logger.warn({ + msg: `Both the '${PluginName.Subgraph}' and '${PluginName.ENSv2}' plugins are enabled. This results in the availability of both the legacy Subgraph-Compatible GraphQL API (/subgraph) _and_ ENSNode's Omnigraph API (/api/omnigraph), and comes with an associated increase in indexing time.`, + advice: `If your intent is to have both APIs available in parallel, excellent, otherwise you may benefit from only enabling the plugin for the API you plan to use.`, + }); } //////// diff --git a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts index fc6a46e796..af826cb617 100644 --- a/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts +++ b/apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts @@ -11,6 +11,7 @@ import { } from "@ensnode/ensnode-sdk"; import { ensDbClient } from "@/lib/ensdb/singleton"; +import { logger } from "@/lib/logger"; const app = new Hono(); @@ -55,8 +56,12 @@ app.get("/indexing-status", async (c) => { } satisfies IndexingStatusResponseOk), ); } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - console.error(`Indexing Status Snapshot is currently not available: ${errorMessage}`); + logger.error({ + msg: "Indexing status snapshot unavailable", + error, + module: "ensnode-api", + endpoint: "/indexing-status", + }); return c.json( serializeIndexingStatusResponse({ diff --git a/apps/ensindexer/ponder/src/api/index.ts b/apps/ensindexer/ponder/src/api/index.ts index 9b184f1280..4b7573d751 100644 --- a/apps/ensindexer/ponder/src/api/index.ts +++ b/apps/ensindexer/ponder/src/api/index.ts @@ -7,6 +7,7 @@ import type { ErrorResponse } from "@ensnode/ensnode-sdk"; import { migrateEnsNodeSchema } from "@/lib/ensdb/migrate-ensnode-schema"; import { startEnsDbWriterWorker } from "@/lib/ensdb-writer-worker/singleton"; +import { logger } from "@/lib/logger"; import ensNodeApi from "./handlers/ensnode-api"; @@ -19,7 +20,11 @@ import ensNodeApi from "./handlers/ensnode-api"; migrateEnsNodeSchema() .then(startEnsDbWriterWorker) .catch((error) => { - console.error("Failed to migrate ENSNode Schema — ", error); + logger.error({ + msg: "Failed to initialize ENSNode metadata", + error, + module: "ponder-api", + }); process.exit(1); }); @@ -39,7 +44,12 @@ app.route("/api", ensNodeApi); // log hono errors to console app.onError((error, ctx) => { - console.error(error); + logger.error({ + msg: "Internal server error", + error, + path: ctx.req.path, + module: "ponder-api", + }); return ctx.json({ message: "Internal Server Error" } satisfies ErrorResponse, 500); }); diff --git a/apps/ensindexer/src/lib/__test__/mockLogger.ts b/apps/ensindexer/src/lib/__test__/mockLogger.ts new file mode 100644 index 0000000000..d2c1e356d2 --- /dev/null +++ b/apps/ensindexer/src/lib/__test__/mockLogger.ts @@ -0,0 +1,21 @@ +import { vi } from "vitest"; + +// Set up the global PONDER_COMMON.logger before mocking to allow importOriginal to work +const mockLogger = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +(globalThis as any).PONDER_COMMON = { logger: mockLogger }; + +/** + * Mock the logger module to avoid the globalThis.PONDER_COMMON check. + */ +vi.mock("@/lib/logger", async () => { + return { + logger: mockLogger, + }; +}); diff --git a/apps/ensindexer/src/lib/dns-helpers.test.ts b/apps/ensindexer/src/lib/dns-helpers.test.ts index 38eaac0a14..3e6a7cd13f 100644 --- a/apps/ensindexer/src/lib/dns-helpers.test.ts +++ b/apps/ensindexer/src/lib/dns-helpers.test.ts @@ -3,6 +3,8 @@ import { bytesToHex, decodeEventLog, stringToHex, zeroHash } from "viem"; import { packetToBytes } from "viem/ens"; import { describe, expect, it } from "vitest"; +import "@/lib/__test__/mockLogger"; + import { getDatasource } from "@ensnode/datasources"; import type { DNSEncodedLiteralName } from "@ensnode/ensnode-sdk"; diff --git a/apps/ensindexer/src/lib/dns-helpers.ts b/apps/ensindexer/src/lib/dns-helpers.ts index fc4b772905..d2e831e2c6 100644 --- a/apps/ensindexer/src/lib/dns-helpers.ts +++ b/apps/ensindexer/src/lib/dns-helpers.ts @@ -14,6 +14,7 @@ import { } from "@ensnode/ensnode-sdk"; import { interpretTextRecordKey, interpretTextRecordValue } from "@ensnode/ensnode-sdk/internal"; +import { logger } from "@/lib/logger"; import { isLabelSubgraphIndexable } from "@/lib/subgraph/is-label-subgraph-indexable"; /** @@ -111,15 +112,16 @@ export function decodeTXTData(data: Buffer[]): string | null { // soft-invariant: we never receive 0 data results in a TXT record if (decoded.length === 0) { - console.warn(`decodeTXTData zero 'data' results, this is unexpected.`); + logger.warn({ msg: `decodeTXTData zero 'data' results, this is unexpected.` }); return null; } // soft-invariant: we never receive more than 1 data result in a TXT record if (decoded.length > 1) { - console.warn( - `decodeTXTData received multiple 'data' results, this is unexpected. data = '${decoded.join(",")}'`, - ); + logger.warn({ + msg: `decodeTXTData received multiple 'data' results, this is unexpected.`, + data: decoded, + }); } // biome-ignore lint/style/noNonNullAssertion: guaranteed to exist due to length check above @@ -166,16 +168,23 @@ export function parseDnsTxtRecordArgs({ }); if (txtDatas.length === 0) { - console.warn(`parseDNSRecordArgs: No TXT answers found in DNS record for key '${key}'`); + logger.warn({ + msg: "No TXT answers found in DNS record", + fn: "parseDnsTxtRecordArgs", + textRecordKey: key, + }); // no text answers? interpret as deletion return { key, value: null }; } if (txtDatas.length > 1) { - console.warn( - `parseDNSRecordArgs: received multiple TXT answers, this is unexpected. answers = '${txtDatas.join(",")}'. Only using the first one.`, - ); + logger.warn({ + msg: `Received multiple TXT answers, this is unexpected. Only using the first one.`, + fn: "parseDnsTxtRecordArgs", + textRecordKey: key, + answers: txtDatas, + }); } // biome-ignore lint/style/noNonNullAssertion: ok due to checks above diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts index f55d752479..ba0f0bee5b 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts @@ -6,6 +6,8 @@ import { validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; +import "@/lib/__test__/mockLogger"; + import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; import type { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index 1645d196a3..7204dd535d 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -14,6 +14,7 @@ import { import type { LocalPonderClient } from "@ensnode/ponder-sdk"; import type { IndexingStatusBuilder } from "@/lib/indexing-status-builder/indexing-status-builder"; +import { logger } from "@/lib/logger"; import type { PublicConfigBuilder } from "@/lib/public-config-builder/public-config-builder"; /** @@ -100,16 +101,24 @@ export class EnsDbWriterWorker { const inMemoryConfig = await this.getValidatedEnsIndexerPublicConfig(); // Task 1: upsert ENSDb version into ENSDb. - console.log(`[EnsDbWriterWorker]: Upserting ENSDb version into ENSDb...`); + logger.debug({ msg: "Upserting ENSDb version", module: "EnsDbWriterWorker" }); await this.ensDbClient.upsertEnsDbVersion(inMemoryConfig.versionInfo.ensDb); - console.log( - `[EnsDbWriterWorker]: ENSDb version upserted successfully: ${inMemoryConfig.versionInfo.ensDb}`, - ); + logger.info({ + msg: "Upserted ENSDb version", + ensDbVersion: inMemoryConfig.versionInfo.ensDb, + module: "EnsDbWriterWorker", + }); // Task 2: upsert of EnsIndexerPublicConfig into ENSDb. - console.log(`[EnsDbWriterWorker]: Upserting ENSIndexer Public Config into ENSDb...`); + logger.debug({ + msg: "Upserting ENSIndexer public config", + module: "EnsDbWriterWorker", + }); await this.ensDbClient.upsertEnsIndexerPublicConfig(inMemoryConfig); - console.log(`[EnsDbWriterWorker]: ENSIndexer Public Config upserted successfully`); + logger.info({ + msg: "Upserted ENSIndexer public config", + module: "EnsDbWriterWorker", + }); // Task 3: recurring upsert of Indexing Status Snapshot into ENSDb. this.indexingStatusInterval = setInterval( @@ -163,12 +172,23 @@ export class EnsDbWriterWorker { * will be thrown and the worker will not start, as the ENSIndexer Public Config * is a critical dependency for the worker's tasks. */ + const configFetchRetries = 3; + + logger.debug({ + msg: "Fetching ENSIndexer public config", + retries: configFetchRetries, + module: "EnsDbWriterWorker", + }); + const inMemoryConfigPromise = pRetry(() => this.publicConfigBuilder.getPublicConfig(), { - retries: 3, - onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { - console.warn( - `ENSIndexer Config fetch attempt ${attemptNumber} failed (${error.message}). ${retriesLeft} retries left.`, - ); + retries: configFetchRetries, + onFailedAttempt: ({ attemptNumber, retriesLeft }) => { + logger.warn({ + msg: "Config fetch attempt failed", + attempt: attemptNumber, + retriesLeft, + module: "EnsDbWriterWorker", + }); }, }); @@ -180,12 +200,19 @@ export class EnsDbWriterWorker { this.ensDbClient.getEnsIndexerPublicConfig(), inMemoryConfigPromise, ]); + logger.info({ + msg: "Fetched ENSIndexer public config", + module: "EnsDbWriterWorker", + config: inMemoryConfig, + }); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - console.error( - `[EnsDbWriterWorker]: Failed to fetch ENSIndexer Public Config: ${errorMessage}`, - ); + logger.error({ + msg: "Failed to fetch ENSIndexer public config", + error, + module: "EnsDbWriterWorker", + }); // Throw the error to terminate the ENSIndexer process due to failed fetch of critical dependency throw new Error(errorMessage, { @@ -205,9 +232,11 @@ export class EnsDbWriterWorker { } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - console.error( - `[EnsDbWriterWorker]: In-memory ENSIndexer Public Config object is not compatible with its counterpart stored in ENSDb. Cause: ${errorMessage}`, - ); + logger.error({ + msg: "In-memory config incompatible with stored config", + error, + module: "EnsDbWriterWorker", + }); // Throw the error to terminate the ENSIndexer process due to // found config incompatibility @@ -240,10 +269,11 @@ export class EnsDbWriterWorker { await this.ensDbClient.upsertIndexingStatusSnapshot(crossChainSnapshot); } catch (error) { - console.error( - `[EnsDbWriterWorker]: Error retrieving or validating Indexing Status Snapshot:`, + logger.error({ + msg: "Failed to upsert indexing status snapshot", error, - ); + module: "EnsDbWriterWorker", + }); // Do not throw the error, as failure to retrieve the Indexing Status // should not cause the ENSDb Writer Worker to stop functioning. } diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts index 5e0a9d9df2..22fd6a5e9b 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/singleton.ts @@ -1,6 +1,7 @@ import { ensDbClient } from "@/lib/ensdb/singleton"; import { indexingStatusBuilder } from "@/lib/indexing-status-builder/singleton"; import { localPonderClient } from "@/lib/local-ponder-client"; +import { logger } from "@/lib/logger"; import { publicConfigBuilder } from "@/lib/public-config-builder/singleton"; import { EnsDbWriterWorker } from "./ensdb-writer-worker"; @@ -35,7 +36,10 @@ export function startEnsDbWriterWorker() { // Abort the worker on error to trigger cleanup ensDbWriterWorker.stop(); - console.error("EnsDbWriterWorker encountered an error:", error); + logger.error({ + msg: "EnsDbWriterWorker encountered an error", + error, + }); // Re-throw the error to ensure the application shuts down with a non-zero exit code. process.exitCode = 1; diff --git a/apps/ensindexer/src/lib/ensdb/migrate-ensnode-schema.ts b/apps/ensindexer/src/lib/ensdb/migrate-ensnode-schema.ts index 6a3efdebb0..b5c1116c56 100644 --- a/apps/ensindexer/src/lib/ensdb/migrate-ensnode-schema.ts +++ b/apps/ensindexer/src/lib/ensdb/migrate-ensnode-schema.ts @@ -1,6 +1,8 @@ import { createRequire } from "node:module"; import { join } from "node:path"; +import { logger } from "@/lib/logger"; + import { ensDbClient } from "./singleton"; // Resolve the path to the migrations directory within the ENSDb SDK package @@ -13,7 +15,13 @@ const migrationsDirPath = join( * Execute database migrations for ENSNode Schema in ENSDb. */ export async function migrateEnsNodeSchema(): Promise { - console.log(`Running database migrations for ENSNode Schema in ENSDb.`); + logger.debug({ + msg: "Started database migrations", + module: "migrate-ensnode-schema", + }); await ensDbClient.migrateEnsNodeSchema(migrationsDirPath); - console.log(`Database migrations for ENSNode Schema in ENSDb completed successfully.`); + logger.info({ + msg: "Completed database migrations", + module: "migrate-ensnode-schema", + }); } diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index db2c167955..c6785560c0 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -5,12 +5,15 @@ import pRetry from "p-retry"; import { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; +import { logger } from "@/lib/logger"; + const { ensRainbowUrl, labelSet } = config; if (ensRainbowUrl.href === EnsRainbowApiClient.defaultOptions().endpointUrl.href) { - console.warn( - `Using default public ENSRainbow server which may cause increased network latency. For production, use your own ENSRainbow server that runs on the same network as the ENSIndexer server.`, - ); + logger.warn({ + msg: `Using default public ENSRainbow server which may cause increased network latency`, + advice: `For production, use your own ENSRainbow server that runs on the same network as the ENSIndexer server.`, + }); } /** @@ -48,23 +51,40 @@ export function waitForEnsRainbowToBeReady(): Promise { return waitForEnsRainbowToBeReadyPromise; } - console.log(`Waiting for ENSRainbow instance to be ready at '${ensRainbowUrl}'...`); + logger.info({ + msg: `Waiting for ENSRainbow instance to be ready`, + ensRainbowInstance: ensRainbowUrl.href, + }); waitForEnsRainbowToBeReadyPromise = pRetry(async () => ensRainbowClient.health(), { retries: 60, // This allows for a total of over 1 hour of retries with 1 minute between attempts. minTimeout: secondsToMilliseconds(60), maxTimeout: secondsToMilliseconds(60), onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { - console.warn( - `Attempt ${attemptNumber} failed for the ENSRainbow health check at '${ensRainbowUrl}' (${error.message}). ${retriesLeft} retries left. This might be due to ENSRainbow having a cold start, which can take 30+ minutes.`, - ); + logger.warn({ + msg: `ENSRainbow health check failed`, + attempt: attemptNumber, + retriesLeft, + error: retriesLeft === 0 ? error : undefined, + ensRainbowInstance: ensRainbowUrl.href, + advice: `This might be due to ENSRainbow having a cold start, which can take 30+ minutes.`, + }); }, }) - .then(() => console.log(`ENSRainbow instance is ready at '${ensRainbowUrl}'.`)) + .then(() => { + logger.info({ + msg: `ENSRainbow instance is ready`, + ensRainbowInstance: ensRainbowUrl.href, + }); + }) .catch((error) => { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - console.error(`ENSRainbow health check failed after multiple attempts: ${errorMessage}`); + logger.error({ + msg: `ENSRainbow health check failed after multiple attempts`, + error, + ensRainbowInstance: ensRainbowUrl.href, + }); // Throw the error to terminate the ENSIndexer process due to the failed health check of a critical dependency throw new Error(errorMessage, { diff --git a/apps/ensindexer/src/lib/graphnode-helpers.test.ts b/apps/ensindexer/src/lib/graphnode-helpers.test.ts index aeb4af1e33..cc8d62324c 100644 --- a/apps/ensindexer/src/lib/graphnode-helpers.test.ts +++ b/apps/ensindexer/src/lib/graphnode-helpers.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { LabelHash } from "@ensnode/ensnode-sdk"; import { setupConfigMock } from "@/lib/__test__/mockConfig"; +import "@/lib/__test__/mockLogger"; setupConfigMock(); // setup config mock before importing dependent modules @@ -20,6 +21,8 @@ vi.mock("p-retry", async () => { // Mock fetch globally to prevent real network calls global.fetch = vi.fn(); +import { logger } from "@/lib/logger"; + import { labelByLabelHash } from "./graphnode-helpers"; describe("labelByLabelHash", () => { @@ -153,7 +156,7 @@ describe("labelByLabelHash", () => { // carrying over cacheable responses (HealSuccess, HealNotFoundError) and bypassing fetch. it("retries on network/fetch failure and succeeds on a later attempt", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); (fetch as any) .mockRejectedValueOnce(new Error("network error")) @@ -173,7 +176,7 @@ describe("labelByLabelHash", () => { }); it("retries on HealServerError and succeeds on a later attempt", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); (fetch as any) .mockResolvedValueOnce({ @@ -229,7 +232,7 @@ describe("labelByLabelHash", () => { }); it("throws after exhausting retries on persistent network/fetch failures", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); (fetch as any).mockRejectedValue(new Error("network error")); @@ -245,7 +248,7 @@ describe("labelByLabelHash", () => { }); it("throws after exhausting retries on persistent HealServerError responses", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); (fetch as any).mockResolvedValue({ ok: true, diff --git a/apps/ensindexer/src/lib/graphnode-helpers.ts b/apps/ensindexer/src/lib/graphnode-helpers.ts index cb04339ace..2115d23936 100644 --- a/apps/ensindexer/src/lib/graphnode-helpers.ts +++ b/apps/ensindexer/src/lib/graphnode-helpers.ts @@ -4,6 +4,7 @@ import type { LabelHash, LiteralLabel } from "@ensnode/ensnode-sdk"; import { type EnsRainbow, ErrorCode, isHealError } from "@ensnode/ensrainbow-sdk"; import { ensRainbowClient } from "@/lib/ensrainbow/singleton"; +import { logger } from "@/lib/logger"; /** * Attempt to heal a labelHash to its original label. @@ -61,10 +62,12 @@ export async function labelByLabelHash(labelHash: LabelHash): Promise { // console.log is used so it can't be skipped by the logger console.log("ENSRainbow running with config:"); - console.log(prettyPrintJson(options)); + console.log(stringifyConfig(options, { pretty: true })); logger.info(`ENS Rainbow server starting on port ${options.port}...`); @@ -28,7 +28,7 @@ export async function serverCommand(options: ServerCommandOptions): Promise JSON.stringify(json, configJSONReplacer, 2); +export const stringifyConfig = (json: any, options: { pretty: boolean } = { pretty: false }) => + JSON.stringify(json, configJSONReplacer, options.pretty ? 2 : undefined); diff --git a/packages/ponder-sdk/src/deserialize/ponder-app-context.ts b/packages/ponder-sdk/src/deserialize/ponder-app-context.ts index 2939185e1f..f1e20fcfb0 100644 --- a/packages/ponder-sdk/src/deserialize/ponder-app-context.ts +++ b/packages/ponder-sdk/src/deserialize/ponder-app-context.ts @@ -15,6 +15,7 @@ import { PonderAppCommands, type PonderAppContext, } from "../ponder-app-context"; +import { wrapPonderAppLogger } from "./ponder-app-logger"; import type { Unvalidated } from "./utils"; /** @@ -26,6 +27,36 @@ export const schemaPortNumber = z .min(1, { error: "Port must be greater than or equal to 1." }) .max(65535, { error: "Port must be less than or equal to 65535." }); +/** + * Represents the Ponder app logger method + */ +const schemaPonderAppLoggerMethod = z.function({ + input: [ + z.looseObject({ + msg: z.string({ error: "Log message must be a string." }), + error: z.optional(z.unknown()), + }), + ], + output: z.void(), +}); + +/** + * Represents the "raw" logger provided by the Ponder runtime to a local Ponder app. + */ +const schemaRawPonderAppLogger = z.looseObject({ + error: schemaPonderAppLoggerMethod, + warn: schemaPonderAppLoggerMethod, + info: schemaPonderAppLoggerMethod, + debug: schemaPonderAppLoggerMethod, + trace: schemaPonderAppLoggerMethod, +}); + +/** + * Represents the "wrapper" logger that formats log parameters + * before passing to the underlying logger. + */ +const schemaPonderAppLogger = schemaRawPonderAppLogger.transform(wrapPonderAppLogger); + /** * Type representing the "raw" context of a local Ponder app. */ @@ -34,6 +65,7 @@ const schemaRawPonderAppContext = z.object({ command: z.enum(PonderAppCommands), port: schemaPortNumber, }), + logger: schemaRawPonderAppLogger, }); /** @@ -47,6 +79,7 @@ export type RawPonderAppContext = z.infer; const schemaPonderAppContext = z.object({ command: z.enum(PonderAppCommands), localPonderAppUrl: z.instanceof(URL, { error: "localPonderAppUrl must be a valid URL." }), + logger: schemaPonderAppLogger, }); /** @@ -62,6 +95,7 @@ function buildUnvalidatedPonderAppContext( return { command: rawPonderAppContext.options.command as Unvalidated, localPonderAppUrl: new URL(`http://localhost:${rawPonderAppContext.options.port}`), + logger: rawPonderAppContext.logger, }; } diff --git a/packages/ponder-sdk/src/deserialize/ponder-app-logger.ts b/packages/ponder-sdk/src/deserialize/ponder-app-logger.ts new file mode 100644 index 0000000000..c7ad9cada4 --- /dev/null +++ b/packages/ponder-sdk/src/deserialize/ponder-app-logger.ts @@ -0,0 +1,110 @@ +import type { PonderAppLog, PonderAppLogger } from "../ponder-app-logger"; + +/** + * Represents a primitive value type that can be logged directly + * without formatting. + */ +type Primitive = string | number | boolean | bigint | symbol | null | undefined; + +/** + * Type guard helper to check if a value is a primitive type. + * + * Used with {@link formatLogValue}. + */ +function isPrimitive(value: unknown): value is Primitive { + return value === null || (typeof value !== "object" && typeof value !== "function"); +} + +/** + * JSON replacer function that handles special types for serialization. + * - bigints are converted to strings + * - URL objects are converted to their href string + * - Map objects are converted to plain objects + * - Set objects are converted to arrays + * + * Used with {@link formatLogValue}. + */ +function replacer(_key: string, value: unknown): unknown { + // stringify bigints + if (typeof value === "bigint") return value.toString(); + + // stringify a URL object + if (value instanceof URL) return value.href; + + // convert Map to plain object for serialization + if (value instanceof Map) return Object.fromEntries(value); + + // convert Set to array for serialization + if (value instanceof Set) return Array.from(value); + + // pass-through value + return value; +} + +/** + * Formats a value for logging. + * - Primitives and Errors are returned as-is + * - Objects are JSON stringified with the replacer and collapsed to single line + * + * Used with {@link wrapLogMethod} to automatically format log parameters before + * passing to the underlying logger. + */ +function formatLogValue(value: unknown): unknown { + // Primitives pass through + if (isPrimitive(value)) return value; + + // Error instances pass through (handled specially by logger) + if (value instanceof Error) return value; + + // Otherwise JSON stringify with replacer + try { + return JSON.stringify(value, replacer); + } catch { + // And if JSON.stringify throws, fall back to String() + return String(value); + } +} + +/** + * Wraps a logger method to provide automatic parameter formatting. + * - Non-Error values in the `error` field are filtered out + * - Complex values are automatically JSON stringified + */ +function wrapLogMethod(fn: (options: Log) => void) { + return (options: Log) => { + const formattedOptions = Object.fromEntries( + Object.entries(options) + // Filter out non-Error values in the `error` field + .filter(([key, value]) => { + if (key === "error" && !(value instanceof Error)) return false; + return true; + }) + // Format values + .map(([key, value]) => [key, formatLogValue(value)]), + ) as Log; + + return fn(formattedOptions); + }; +} + +/** + * Wraps the raw Ponder App Logger provided by the Ponder runtime to + * automatically format log parameters: + * + * - Primitives are passed through as-is + * - Error instances are passed through as-is (and handled specially by the logger) + * - Objects are JSON stringified (with special handling for bigint, URL, Map, Set) + * - Non-Error `error` values are automatically filtered out + * + * This maintains full compatibility with the {@link PonderAppLogger} interface. + */ +export function wrapPonderAppLogger(rawLogger: PonderAppLogger): PonderAppLogger { + return Object.freeze({ + ...rawLogger, + error: wrapLogMethod(rawLogger.error.bind(rawLogger)), + warn: wrapLogMethod(rawLogger.warn.bind(rawLogger)), + info: wrapLogMethod(rawLogger.info.bind(rawLogger)), + debug: wrapLogMethod(rawLogger.debug.bind(rawLogger)), + trace: wrapLogMethod(rawLogger.trace.bind(rawLogger)), + }); +} diff --git a/packages/ponder-sdk/src/index.ts b/packages/ponder-sdk/src/index.ts index a3be62ccc8..2dc1103ed9 100644 --- a/packages/ponder-sdk/src/index.ts +++ b/packages/ponder-sdk/src/index.ts @@ -10,4 +10,5 @@ export * from "./local-indexing-metrics"; export * from "./local-ponder-client"; export * from "./numbers"; export * from "./ponder-app-context"; +export * from "./ponder-app-logger"; export * from "./time"; diff --git a/packages/ponder-sdk/src/local-ponder-client.mock.ts b/packages/ponder-sdk/src/local-ponder-client.mock.ts index a1e6daa88e..708f7f402c 100644 --- a/packages/ponder-sdk/src/local-ponder-client.mock.ts +++ b/packages/ponder-sdk/src/local-ponder-client.mock.ts @@ -38,6 +38,13 @@ export function createLocalPonderClientMock(overrides?: { const ponderAppContext = { command: overrides?.ponderAppContext?.command ?? PonderAppCommands.Start, localPonderAppUrl: new URL("http://localhost:3000"), + logger: { + error: () => {}, + warn: () => {}, + info: () => {}, + debug: () => {}, + trace: () => {}, + }, } satisfies PonderAppContext; return new LocalPonderClient( diff --git a/packages/ponder-sdk/src/ponder-app-context.ts b/packages/ponder-sdk/src/ponder-app-context.ts index 0afb487455..4e560f1aa8 100644 --- a/packages/ponder-sdk/src/ponder-app-context.ts +++ b/packages/ponder-sdk/src/ponder-app-context.ts @@ -1,3 +1,5 @@ +import type { PonderAppLogger } from "./ponder-app-logger"; + /** * Ponder app commands * @@ -25,4 +27,9 @@ export interface PonderAppContext { * URL of the local Ponder app. */ localPonderAppUrl: URL; + + /** + * Logger provided by the Ponder runtime + */ + logger: PonderAppLogger; } diff --git a/packages/ponder-sdk/src/ponder-app-logger.ts b/packages/ponder-sdk/src/ponder-app-logger.ts new file mode 100644 index 0000000000..66cb90f5de --- /dev/null +++ b/packages/ponder-sdk/src/ponder-app-logger.ts @@ -0,0 +1,127 @@ +/** + * Represents a single log entry for the Ponder app logger. + * + * It is a loose object that: + * - must contain a `msg` property of type string, and + * - can optionally include an `error` property of type Error, and + * - can optionally include any additional properties relevant to + * the log message. The additional properties can be used to provide more + * context about the log message, and will be included in the log output. + */ +export type PonderAppLog = { + /** + * Log message + */ + msg: string; + + /** + * Optional error object to log. + * + * If provided, the logger will log the error's stack trace and message. + */ + error?: unknown; + + /** + * Optional additional properties. + * + * If provided, they will be included in the log output. + */ + [key: string]: unknown; +}; + +/** + * Ponder app logger + * + * Represents the logger provided by the Ponder runtime to a local Ponder app. + * @see https://github.com/ponder-sh/ponder/blob/6fcc15d4234e43862cb6e21c05f3c57f4c2f7464/packages/core/src/internal/logger.ts#L8-L31 + */ +export interface PonderAppLogger { + /** + * Logs a message at the "error" level. + * + * @param options - The log message and additional properties to log. + * + * @example + * ```ts + * logger.error({ + * msg: "Incorrect omnichain status", + * error: new Error("The omnichain status must be either 'omnichain-backfill' or 'omnichain-following'"), + * expected: "omnichain-backfill or omnichain-following", + * actual: "omnichain-unstarted" + * }); + * + * logger.error({ + * msg: "Incorrect omnichain status", + * error: new Error("The omnichain status must be either 'omnichain-backfill' or 'omnichain-following'"), + * }); + * + * logger.error({ + * msg: "The omnichain status must be either 'omnichain-backfill' or 'omnichain-following'" + * }); + * ``` + */ + error(options: T): void; + + /** + * Logs a message at the "warn" level. + * + * @param options - The log message and additional properties to log. + * + * @example + * ```ts + * logger.warn({ + * msg: "Both the '${PluginName.Subgraph}' and '${PluginName.ENSv2}' plugins are enabled.", + * effects: "This results in the availability of both the legacy Subgraph-Compatible GraphQL API (/subgraph) _and_ ENSNode's Omnigraph API (/api/omnigraph), and comes with an associated increase in indexing time. If your intent is to have both APIs available in parallel, excellent, otherwise you may benefit from only enabling the plugin for the API you plan to use." + * }); + * + * logger.warn({ + * msg: "Both the '${PluginName.Subgraph}' and '${PluginName.ENSv2}' plugins are enabled." + * }); + * ``` + */ + warn(options: T): void; + + /** + * Logs a message at the "info" level. + * @param options + * + * @example + * ```ts + * logger.info({ + * msg: "An informational message", + * details: "Here are some details about the info" + * }); + * ``` + */ + info(options: T): void; + + /** + * Logs a message at the "debug" level. + * @param options + * + * @example + * ```ts + * logger.debug({ + * msg: "A debug message", + * arg1: "Here is some debug information about arg1", + * arg2: "Here is some debug information about arg2" + * }); + * ``` + */ + debug(options: T): void; + + /** + * Logs a message at the "trace" level. + * @param options + * + * @example + * ```ts + * logger.trace({ + * msg: "A trace message", + * detailA: "Here are some details about the trace message", + * detailB: "Here are some more details about the trace message" + * }); + * ``` + */ + trace(options: T): void; +}