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': {}