diff --git a/apps/ensadmin/src/app/@breadcrumbs/(apis)/api/graphql/page.tsx b/apps/ensadmin/src/app/@breadcrumbs/(apis)/api/omnigraph/page.tsx
similarity index 71%
rename from apps/ensadmin/src/app/@breadcrumbs/(apis)/api/graphql/page.tsx
rename to apps/ensadmin/src/app/@breadcrumbs/(apis)/api/omnigraph/page.tsx
index 9e80c30977..4b3ba8c771 100644
--- a/apps/ensadmin/src/app/@breadcrumbs/(apis)/api/graphql/page.tsx
+++ b/apps/ensadmin/src/app/@breadcrumbs/(apis)/api/omnigraph/page.tsx
@@ -3,7 +3,7 @@ import { BreadcrumbItem, BreadcrumbPage } from "@/components/ui/breadcrumb";
export default function Page() {
return (
- GraphQL API (ENS v1 + v2)
+ Omnigraph API (ENS v1 + v2)
);
}
diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json
index a536fba066..522b318156 100644
--- a/apps/ensapi/package.json
+++ b/apps/ensapi/package.json
@@ -46,6 +46,8 @@
"@pothos/core": "^4.10.0",
"@pothos/plugin-dataloader": "^4.4.3",
"@pothos/plugin-relay": "^4.6.2",
+ "@pothos/plugin-tracing": "^1.1.2",
+ "@pothos/tracing-opentelemetry": "^1.1.3",
"@standard-schema/utils": "^0.3.0",
"dataloader": "^2.2.3",
"date-fns": "catalog:",
diff --git a/apps/ensapi/src/config/config.singleton.test.ts b/apps/ensapi/src/config/config.singleton.test.ts
index 29f52cd493..8e5584a5cb 100644
--- a/apps/ensapi/src/config/config.singleton.test.ts
+++ b/apps/ensapi/src/config/config.singleton.test.ts
@@ -29,7 +29,7 @@ describe("ensdb singleton bootstrap", () => {
expect(ensDbClient.ensIndexerSchemaName).toBe(VALID_SCHEMA_NAME);
expect(ensDb).toBeDefined();
expect(ensIndexerSchema).toBeDefined();
- });
+ }, 10_000);
it("exits when DATABASE_URL is missing", async () => {
const mockExit = vi.spyOn(process, "exit").mockImplementation((() => {
diff --git a/apps/ensapi/src/lib/instrumentation/auto-span.ts b/apps/ensapi/src/lib/instrumentation/auto-span.ts
index fc143f7b7b..9c35cb1744 100644
--- a/apps/ensapi/src/lib/instrumentation/auto-span.ts
+++ b/apps/ensapi/src/lib/instrumentation/auto-span.ts
@@ -9,12 +9,12 @@ import {
// The following internal functions implement the pattern of executing `fn` in the context of a span
// named `name` with attributes via `args` and semantic OTel handling of responses / errors.
-export async function withActiveSpanAsync Promise>(
+export async function withActiveSpanAsync(
tracer: Tracer,
name: string,
args: Record,
- fn: Fn,
-): Promise> {
+ fn: (span: Span) => Promise,
+): Promise {
return tracer.startActiveSpan(name, async (span) => {
// add provded args to span attributes
for (const [key, value] of Object.entries(args)) {
@@ -71,12 +71,12 @@ export function withSpan any>(
}
}
-export async function withSpanAsync Promise>(
+export async function withSpanAsync(
tracer: Tracer,
name: string,
args: Record,
- fn: Fn,
-): Promise> {
+ fn: (span: Span) => Promise,
+): Promise {
const span = tracer.startSpan(name);
// add provded args to span attributes
diff --git a/apps/ensapi/src/omnigraph-api/builder.ts b/apps/ensapi/src/omnigraph-api/builder.ts
index 7299207112..d96a460971 100644
--- a/apps/ensapi/src/omnigraph-api/builder.ts
+++ b/apps/ensapi/src/omnigraph-api/builder.ts
@@ -1,6 +1,9 @@
+import { trace } from "@opentelemetry/api";
import SchemaBuilder, { type MaybePromise } from "@pothos/core";
import DataloaderPlugin from "@pothos/plugin-dataloader";
import RelayPlugin from "@pothos/plugin-relay";
+import TracingPlugin, { isRootField } from "@pothos/plugin-tracing";
+import { AttributeNames, createOpenTelemetryWrapper } from "@pothos/tracing-opentelemetry";
import type {
ChainId,
CoinType,
@@ -16,10 +19,40 @@ import type {
ResolverId,
ResolverRecordsId,
} from "enssdk";
+import { getNamedType } from "graphql";
+import superjson from "superjson";
import type { Address, Hex } from "viem";
import type { context } from "@/omnigraph-api/context";
+const tracer = trace.getTracer("graphql");
+const createSpan = createOpenTelemetryWrapper(tracer, {
+ includeSource: false,
+ // NOTE: the native implementation of `includeArgs` doesn't handle bigints, so we re-implement in onSpan below
+ // https://github.com/hayes/pothos/blob/9fadc4916929a838671714fb7cf8d6bb382bcf14/packages/tracing-opentelemetry/src/index.ts#L54
+ includeArgs: false,
+ onSpan: (span, options, parent, args, ctx, info) => {
+ // inject the graphql.field.args attribute using superjson to handle our BigInt scalar
+ span.setAttribute(AttributeNames.FIELD_ARGS, superjson.stringify(args));
+
+ // name edge spans as the parent's "Typename.fieldName" for clarity
+ if (info.fieldName === "edges") {
+ return span.updateName(`${info.path.prev?.typename}.${info.path.prev?.key}`);
+ }
+
+ // turn an *Edge.node span name into "Typename([:id])", ex: 'ENSv2Domain([:id])'
+ if (info.parentType.name.endsWith("Edge") && info.fieldName === "node") {
+ const typename = getNamedType(info.returnType).name;
+ const id = (parent as any).node?.id ?? "?";
+
+ return span.updateName(`${typename}(${id})`);
+ }
+
+ // otherwise name the span as "Typename.fieldName", ex: 'Query.domain'
+ return span.updateName(`${info.parentType.name}.${info.fieldName}`);
+ },
+});
+
export const builder = new SchemaBuilder<{
Context: ReturnType;
Scalars: {
@@ -46,7 +79,27 @@ export const builder = new SchemaBuilder<{
totalCount: MaybePromise;
};
}>({
- plugins: [DataloaderPlugin, RelayPlugin],
+ plugins: [TracingPlugin, DataloaderPlugin, RelayPlugin],
+ tracing: {
+ default: (config) => {
+ // NOTE: if you need all the tracing possible in order to debug something, you can return true
+ // from this fn and pothos will wrap every resolver in a span for your otel trace, but it may
+ // be quite verbose
+
+ // always start a root span
+ if (isRootField(config)) return true;
+
+ // always include edges for hierarchy
+ // NOTE: this means that setting tracing: true on connection fields will result in (somewhat)
+ // superfluous spans, though technically they measure different things
+ if (config.name === "edges") return true;
+
+ // note that we don't trace node by default, as this results in lots of spans (one for each node)
+
+ return false;
+ },
+ wrap: (resolver, options) => createSpan(resolver, options),
+ },
relay: {
// disable the Query.node & Query.nodes methods
nodeQueryOptions: false,
diff --git a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts
index 2b5d677aba..142030da67 100644
--- a/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts
+++ b/apps/ensapi/src/omnigraph-api/lib/find-domains/find-domains-resolver.ts
@@ -1,7 +1,9 @@
+import { trace } from "@opentelemetry/api";
import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay";
import { and, count } from "drizzle-orm";
import { ensDb } from "@/lib/ensdb/singleton";
+import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span";
import { makeLogger } from "@/lib/logger";
import type { context as createContext } from "@/omnigraph-api/context";
import type {
@@ -44,6 +46,7 @@ interface FindDomainsOrderArg {
*/
type DomainWithOrderValue = Domain & { __orderValue: DomainOrderValue };
+const tracer = trace.getTracer("find-domains");
const logger = makeLogger("find-domains-resolver");
/**
@@ -97,11 +100,10 @@ export function resolveFindDomains(
return lazyConnection({
totalCount: () =>
- ensDb
- .with(domains)
- .select({ count: count() })
- .from(domains)
- .then((rows) => rows[0].count),
+ withActiveSpanAsync(tracer, "find-domains.totalCount", {}, async () => {
+ const rows = await ensDb.with(domains).select({ count: count() }).from(domains);
+ return rows[0].count;
+ }),
connection: () =>
resolveCursorConnection(
@@ -146,12 +148,25 @@ export function resolveFindDomains(
// log the generated SQL for debugging
logger.debug({ sql: query.toSQL() });
- // execute query
- const results = await query;
+ // execute paginated query
+ const results = await withActiveSpanAsync(
+ tracer,
+ "find-domains.connection",
+ { orderBy, orderDir, limit },
+ () => query.execute(),
+ );
// load Domain entities via dataloader
- const loadedDomains = await rejectAnyErrors(
- DomainInterfaceRef.getDataloader(context).loadMany(results.map((result) => result.id)),
+ const loadedDomains = await withActiveSpanAsync(
+ tracer,
+ "find-domains.dataloader",
+ { count: results.length },
+ () =>
+ rejectAnyErrors(
+ DomainInterfaceRef.getDataloader(context).loadMany(
+ results.map((result) => result.id),
+ ),
+ ),
);
// map results by id for faster order value lookup
diff --git a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts
index 598d0e9c3c..ef2d77f3a5 100644
--- a/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts
+++ b/apps/ensapi/src/omnigraph-api/lib/get-domain-by-interpreted-name.ts
@@ -1,5 +1,6 @@
import config from "@/config";
+import { trace } from "@opentelemetry/api";
import { Param, sql } from "drizzle-orm";
import { namehash } from "viem";
@@ -16,13 +17,15 @@ import {
} from "@ensnode/ensnode-sdk";
import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton";
+import { withActiveSpanAsync } from "@/lib/instrumentation/auto-span";
import { lazy } from "@/lib/lazy";
import { makeLogger } from "@/lib/logger";
// lazy() defers construction until first use so that this module can be
// imported without env vars being present (e.g. during OpenAPI generation).
-const getRootRegistryId = lazy(() => maybeGetENSv2RootRegistryId(config.namespace));
+const _maybeGetENSv2RootRegistryId = lazy(() => maybeGetENSv2RootRegistryId(config.namespace));
+const tracer = trace.getTracer("get-domain-by-interpreted-name");
const logger = makeLogger("get-domain-by-interpreted-name");
const v1Logger = makeLogger("get-domain-by-interpreted-name:v1");
const v2Logger = makeLogger("get-domain-by-interpreted-name:v2");
@@ -63,18 +66,27 @@ const v2Logger = makeLogger("get-domain-by-interpreted-name:v2");
export async function getDomainIdByInterpretedName(
name: InterpretedName,
): Promise {
- // Domains addressable in v2 are preferred, but v1 lookups are cheap, so just do them both ahead of time
- const rootRegistryId = getRootRegistryId();
- const [v1DomainId, v2DomainId] = await Promise.all([
- v1_getDomainIdByInterpretedName(name),
- // only resolve v2Domain if ENSv2 Root Registry is defined
- rootRegistryId ? v2_getDomainIdByInterpretedName(rootRegistryId, name) : null,
- ]);
-
- logger.debug({ v1DomainId, v2DomainId });
-
- // prefer v2Domain over v1Domain
- return v2DomainId || v1DomainId || null;
+ return withActiveSpanAsync(tracer, "getDomainIdByInterpretedName", { name }, async () => {
+ // Domains addressable in v2 are preferred, but v1 lookups are cheap, so just do them both ahead of time
+ const rootRegistryId = _maybeGetENSv2RootRegistryId();
+
+ const [v1DomainId, v2DomainId] = await Promise.all([
+ withActiveSpanAsync(tracer, "v1_getDomainId", {}, () =>
+ v1_getDomainIdByInterpretedName(name),
+ ),
+ // only resolve v2Domain if ENSv2 Root Registry is defined
+ rootRegistryId
+ ? withActiveSpanAsync(tracer, "v2_getDomainId", {}, () =>
+ v2_getDomainIdByInterpretedName(rootRegistryId, name),
+ )
+ : null,
+ ]);
+
+ logger.debug({ v1DomainId, v2DomainId });
+
+ // prefer v2Domain over v1Domain
+ return v2DomainId || v1DomainId || null;
+ });
}
/**
diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts
index 7c19fa3235..716f5ff218 100644
--- a/apps/ensapi/src/omnigraph-api/schema/domain.ts
+++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts
@@ -1,3 +1,4 @@
+import { trace } from "@opentelemetry/api";
import { type ResolveCursorConnectionArgs, resolveCursorConnection } from "@pothos/plugin-relay";
import { and, count, eq, getTableColumns } from "drizzle-orm";
@@ -9,6 +10,7 @@ import {
} from "@ensnode/ensnode-sdk";
import { ensDb, ensIndexerSchema } from "@/lib/ensdb/singleton";
+import { withSpanAsync } from "@/lib/instrumentation/auto-span";
import { builder } from "@/omnigraph-api/builder";
import {
orderPaginationBy,
@@ -41,6 +43,7 @@ import { RegistrationInterfaceRef } from "@/omnigraph-api/schema/registration";
import { RegistryRef } from "@/omnigraph-api/schema/registry";
import { ResolverRef } from "@/omnigraph-api/schema/resolver";
+const tracer = trace.getTracer("schema/Domain");
const isENSv1Domain = (domain: Domain): domain is ENSv1Domain => "parentId" in domain;
/////////////////////////////
@@ -49,10 +52,12 @@ const isENSv1Domain = (domain: Domain): domain is ENSv1Domain => "parentId" in d
export const ENSv1DomainRef = builder.loadableObjectRef("ENSv1Domain", {
load: (ids: ENSv1DomainId[]) =>
- ensDb.query.v1Domain.findMany({
- where: (t, { inArray }) => inArray(t.id, ids),
- with: { label: true },
- }),
+ withSpanAsync(tracer, "ENSv1Domain.load", { count: ids.length }, () =>
+ ensDb.query.v1Domain.findMany({
+ where: (t, { inArray }) => inArray(t.id, ids),
+ with: { label: true },
+ }),
+ ),
toKey: getModelId,
cacheResolved: true,
sort: true,
@@ -60,10 +65,12 @@ export const ENSv1DomainRef = builder.loadableObjectRef("ENSv1Domain", {
export const ENSv2DomainRef = builder.loadableObjectRef("ENSv2Domain", {
load: (ids: ENSv2DomainId[]) =>
- ensDb.query.v2Domain.findMany({
- where: (t, { inArray }) => inArray(t.id, ids),
- with: { label: true },
- }),
+ withSpanAsync(tracer, "ENSv2Domain.load", { count: ids.length }, () =>
+ ensDb.query.v2Domain.findMany({
+ where: (t, { inArray }) => inArray(t.id, ids),
+ with: { label: true },
+ }),
+ ),
toKey: getModelId,
cacheResolved: true,
sort: true,
@@ -126,6 +133,7 @@ DomainInterfaceRef.implement({
name: t.field({
description:
"The Canonical Name for this Domain. If the Domain is not Canonical, then `name` will be null.",
+ tracing: true,
type: "Name",
nullable: true,
resolve: async (domain, args, context) => {
@@ -161,6 +169,7 @@ DomainInterfaceRef.implement({
path: t.field({
description:
"The Canonical Path from the ENS Root to this Domain. `path` is null if the Domain is not Canonical.",
+ tracing: true,
type: [DomainInterfaceRef],
nullable: true,
resolve: async (domain, args, context) => {
diff --git a/apps/ensapi/src/omnigraph-api/yoga.ts b/apps/ensapi/src/omnigraph-api/yoga.ts
index aa66bedf96..b68a484614 100644
--- a/apps/ensapi/src/omnigraph-api/yoga.ts
+++ b/apps/ensapi/src/omnigraph-api/yoga.ts
@@ -8,7 +8,7 @@ import { makeLogger } from "@/lib/logger";
import { context } from "@/omnigraph-api/context";
import { schema } from "@/omnigraph-api/schema";
-const logger = makeLogger("ensnode-graphql");
+const logger = makeLogger("omnigraph");
export const yoga = createYoga({
graphqlEndpoint: "*",
@@ -40,10 +40,10 @@ export const yoga = createYoga({
// integrate logging with pino
logging: logger,
- // TODO: plugins
- // plugins: [
- // maxTokensPlugin({ n: maxOperationTokens }),
- // maxDepthPlugin({ n: maxOperationDepth, ignoreIntrospection: false }),
- // maxAliasesPlugin({ n: maxOperationAliases, allowList: [] }),
- // ],
+ plugins: [
+ // TODO: plugins
+ // maxTokensPlugin({ n: maxOperationTokens }),
+ // maxDepthPlugin({ n: maxOperationDepth, ignoreIntrospection: false }),
+ // maxAliasesPlugin({ n: maxOperationAliases, allowList: [] }),
+ ],
});
diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts
index f462531a1e..5d943248aa 100644
--- a/packages/integration-test-env/src/orchestrator.ts
+++ b/packages/integration-test-env/src/orchestrator.ts
@@ -335,7 +335,7 @@ async function main() {
// Phase 6: Run integration tests
log("Running integration tests...");
- execaSync("pnpm", ["test:integration"], {
+ execaSync("pnpm", ["test:integration", "--", "--bail", "1"], {
cwd: MONOREPO_ROOT,
stdio: "inherit",
env: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8fa5a0b225..8dcd10a47e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -384,6 +384,12 @@ importers:
'@pothos/plugin-relay':
specifier: ^4.6.2
version: 4.6.2(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0)
+ '@pothos/plugin-tracing':
+ specifier: ^1.1.2
+ version: 1.1.2(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0)
+ '@pothos/tracing-opentelemetry':
+ specifier: ^1.1.3
+ version: 1.1.3(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@opentelemetry/semantic-conventions@1.37.0)(@pothos/core@4.10.0(graphql@16.11.0))(@pothos/plugin-tracing@1.1.2(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0))(graphql@16.11.0)
'@standard-schema/utils':
specifier: ^0.3.0
version: 0.3.0
@@ -3273,6 +3279,21 @@ packages:
'@pothos/core': '*'
graphql: ^16.10.0
+ '@pothos/plugin-tracing@1.1.2':
+ resolution: {integrity: sha512-2DBLbCqvITS83lSPeuwpNd31fYoPhX6HnoIR5G5p3cQW6cfhEQ1yltJTMq62PrSd91Aadr0HhbT9R0Bhuw6sdQ==}
+ peerDependencies:
+ '@pothos/core': '*'
+ graphql: ^16.10.0
+
+ '@pothos/tracing-opentelemetry@1.1.3':
+ resolution: {integrity: sha512-AjcF/hqXejuTw2rQrQweSLXLM8g63gzLPy1IW2hrCjJT1reqCVzcFoYfJeNm2O2QbrA+Ww44RA6BZE6HJCbHjQ==}
+ peerDependencies:
+ '@opentelemetry/api': '*'
+ '@opentelemetry/semantic-conventions': '*'
+ '@pothos/core': '*'
+ '@pothos/plugin-tracing': '*'
+ graphql: ^16.10.0
+
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
@@ -12018,6 +12039,19 @@ snapshots:
'@pothos/core': 4.10.0(graphql@16.11.0)
graphql: 16.11.0
+ '@pothos/plugin-tracing@1.1.2(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0)':
+ dependencies:
+ '@pothos/core': 4.10.0(graphql@16.11.0)
+ graphql: 16.11.0
+
+ '@pothos/tracing-opentelemetry@1.1.3(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@opentelemetry/semantic-conventions@1.37.0)(@pothos/core@4.10.0(graphql@16.11.0))(@pothos/plugin-tracing@1.1.2(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0))(graphql@16.11.0)':
+ dependencies:
+ '@opentelemetry/api': 1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a)
+ '@opentelemetry/semantic-conventions': 1.37.0
+ '@pothos/core': 4.10.0(graphql@16.11.0)
+ '@pothos/plugin-tracing': 1.1.2(@pothos/core@4.10.0(graphql@16.11.0))(graphql@16.11.0)
+ graphql: 16.11.0
+
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}