Plugins

Tracing plugin

Wrap resolvers with tracing and logging, and export spans to OpenTelemetry, Datadog, New Relic, Sentry, or AWS X-Ray.

Tracing measures how long each resolver takes and reports it to your observability backend. In Pothos, you add a tracing option to the builder and to individual fields, and the plugin wraps the matching resolvers in a tracer you supply. The plugin only decides which fields to trace and hands each resolver to your wrap function; you produce the actual span or log, by hand or through one of the provider packages below.

The interface has three parts:

  1. A tracing option on every field, to enable or configure tracing for that field.
  2. tracing.default on the builder, the fallback for any field that doesn't set tracing itself.
  3. tracing.wrap on the builder, which receives a resolver, its tracing options, and the field config, and returns a wrapped resolver.

Install

npm install --save @pothos/plugin-tracing

A traced schema

The two builder options work together: default picks the fields worth tracing, and wrap decides what tracing does. This setup traces every root field and logs its duration with the wrapResolver helper.

import SchemaBuilder from '@pothos/core';
import TracingPlugin, { isRootField, wrapResolver } from '@pothos/plugin-tracing';

const builder = new SchemaBuilder({
  plugins: [TracingPlugin],
  tracing: {
    // Trace root fields by default; other fields must opt in.
    default: (config) => isRootField(config),
    // Log how long each traced resolver ran.
    wrap: (resolver, options, config) =>
      wrapResolver(resolver, (error, duration) => {
        console.log(`Executed resolver ${config.parentType}.${config.name} in ${duration}ms`);
      }),
  },
});

Tracing every resolver adds real overhead for little benefit; leaf fields that read a property finish in microseconds. Keep default narrow and let expensive fields opt in.

Enabling tracing on a field

Set tracing: true on any field to trace it regardless of the default:

builder.queryType({
  fields: (t) => ({
    topScorer: t.string({
      args: { teamId: t.arg.id() },
      // Trace this field even if the default skips it.
      tracing: true,
      resolve: (parent, { teamId }) => Teams.get(Number(teamId))?.topScorer ?? 'Unknown',
    }),
  }),
});

Custom tracing options

Bare true/false is enough for a logger, but a real tracer usually wants per-field detail. Widen the Tracing generic on the builder to accept an options object, and fields can pass it through the tracing option:

import TracingPlugin, { isRootField, wrapResolver } from '@pothos/plugin-tracing';

export const builder = new SchemaBuilder<{
  // `tracing` can now be a boolean or an object with a formatMessage function.
  Tracing: boolean | { formatMessage: (duration: number) => string };
}>({
  plugins: [TracingPlugin],
  tracing: {
    /* wrap reads these options — shown below */
  },
});

builder.queryType({
  fields: (t) => ({
    topScorer: t.string({
      args: { teamId: t.arg.id() },
      // The custom option is now type-checked on the field.
      tracing: { formatMessage: (duration) => `Looked up the top scorer in ${duration}ms` },
      resolve: (parent, { teamId }) => Teams.get(Number(teamId))?.topScorer ?? 'Unknown',
    }),
  }),
});

The Tracing generic defaults to boolean when you don't set it.

Enabling tracing by default

tracing.default decides tracing for any field that doesn't set its own tracing option. It receives the field's config, so you can key the decision off the field's shape:

export const builder = new SchemaBuilder<{
  Tracing: boolean | { formatMessage: (duration: number) => string };
}>({
  plugins: [TracingPlugin],
  tracing: {
    // Trace root fields; leave everything else untraced.
    default: (config) => isRootField(config),
    wrap: (resolver) => resolver, // real wrappers are shown below
  },
});

The plugin exports predicates for the common cases, so you rarely inspect the config by hand:

HelperReturns true for
isRootFieldFields of the Query, Mutation, and Subscription types.
isScalarFieldFields that return a scalar or a list of scalars.
isEnumFieldFields that return an enum or a list of enums.
isExposedFieldFields defined with t.expose*, or any field falling back to the default resolver.

Compose them to trace everything expensive while skipping trivial reads:

tracing: {
  // Trace root fields and object relations, but not scalars or enums.
  default: (config) => isRootField(config) || (!isScalarField(config) && !isEnumField(config)),
  wrap: (resolver, options) => resolver,
};

Writing a wrapper

tracing.wrap turns the tracing decision into behavior. It runs with three arguments and returns the resolver the schema will actually call:

  1. resolver is the field's original resolver.
  2. options is the tracing options for the field, whether set on the field or returned by default.
  3. fieldConfig is the config object describing the field being wrapped.

Resolvers can throw or return a promise, and timing both paths correctly is fiddly, so the plugin ships two helpers that handle it for you.

wrapResolver(resolver, onEnd) returns a wrapped resolver and calls onEnd(error, duration) when it settles, where error is null on success and duration is the elapsed milliseconds:

export const builder = new SchemaBuilder<{
  Tracing: boolean | { formatMessage: (duration: number) => string };
}>({
  plugins: [TracingPlugin],
  tracing: {
    default: (config) => isRootField(config),
    wrap: (resolver, options, config) =>
      wrapResolver(resolver, (error, duration) => {
        const message =
          typeof options === 'object'
            ? options.formatMessage(duration)
            : `Executed resolver ${config.parentType}.${config.name} in ${duration}ms`;

        console.log(message);
      }),
  },
});

runFunction(fn, onEnd) is the same timing logic, but it runs a zero-argument function immediately instead of wrapping a resolver. Use it when you need the resolver arguments, or want to do work before the resolver starts:

import TracingPlugin, { isEnumField, isRootField, isScalarField, runFunction } from '@pothos/plugin-tracing';

export const builder = new SchemaBuilder({
  plugins: [TracingPlugin],
  tracing: {
    default: (config) => isRootField(config) || (!isScalarField(config) && !isEnumField(config)),
    wrap: (resolver, options) => (source, args, ctx, info) => {
      doSomethingFirst(args);

      return runFunction(
        () => resolver(source, args, ctx, info),
        (error, duration) => {
          console.log(`Executed resolver for ${info.parentType}.${info.fieldName} in ${duration}ms`);
        },
      );
    },
  },
});

Using resolver arguments in tracers

Sometimes the tracing options depend on the resolver's arguments, attaching a team id to a span, say. Both the field-level tracing option and tracing.default accept a function (parent, args, context, info) => options:

// A tracer that opens a span and attaches custom attributes when they're provided.
export const builder = new SchemaBuilder<{
  Tracing: false | { attributes?: Record<string, unknown> };
}>({
  plugins: [TracingPlugin],
  tracing: {
    default: (config) => (isRootField(config) ? {} : false),
    // `options` is the resolved tracing option for the field.
    wrap: (resolver, options, fieldConfig) => (source, args, ctx, info) => {
      const span = tracer.createSpan();

      if (options.attributes) {
        span.setAttributes(options.attributes);
      }

      return runFunction(
        () => resolver(source, args, ctx, info),
        () => {
          span.end();
        },
      );
    },
  },
});

builder.queryType({
  fields: (t) => ({
    topScorer: t.string({
      args: { teamId: t.arg.id() },
      // Attach this field's args to the span as an attribute.
      tracing: (parent, args) => ({ attributes: { args } }),
      resolve: (parent, { teamId }) => Teams.get(Number(teamId))?.topScorer ?? 'Unknown',
    }),
  }),
});

tracing.default can return the same kind of function to reach the arguments of every matching field:

tracing: {
  default: (config) => {
    // Root fields: attach their args as an attribute.
    if (isRootField(config)) {
      return (parent, args) => ({ attributes: { args } });
    }

    // Skip exposed fields entirely.
    if (isExposedField(config)) {
      return false;
    }

    // Trace, but add no attributes.
    return {};
  },
  wrap: /* ... */,
};

Returning a function changes when wrap runs. Normally wrap is called once per field at build time. When the tracing option is a function, its result depends on the resolver arguments, so wrap runs on every execution of that field instead. It's usually cheap, but as a rule of thumb, tracing options that don't depend on the resolver arguments are faster.

The example above can be reshaped to keep wrap at build time: decide whether to include args with a static flag, and read the args inside the wrapper where they're free:

export const builder = new SchemaBuilder<{
  Tracing: false | { includeArgs?: boolean };
}>({
  plugins: [TracingPlugin],
  tracing: {
    default: (config) => (isRootField(config) ? { includeArgs: true } : false),
    // Static options, so wrap runs once per field at build time.
    wrap: (resolver, options, fieldConfig) => (source, args, ctx, info) => {
      const span = tracer.createSpan();

      if (options.includeArgs) {
        span.setAttributes({ args });
      }

      return runFunction(
        () => resolver(source, args, ctx, info),
        () => {
          span.end();
        },
      );
    },
  },
});

Integrations

The plugin ships provider packages that build wrap for you. Each exposes a factory that returns a wrapper, plus AttributeNames/SpanNames enums for instrumenting the execution phase. They all trace resolvers only; to time the surrounding GraphQL execution phase you add a server plugin, shown per provider below. The first-server examples all use graphql-yoga, whose envelop plugins the wiring below hooks into; other servers expose an equivalent hook that will look slightly different.

OpenTelemetry

npm install --save @pothos/tracing-opentelemetry @opentelemetry/semantic-conventions @opentelemetry/api

createOpenTelemetryWrapper(tracer, options) returns a wrap implementation that opens a span per resolver, nesting child spans under their parents automatically.

import SchemaBuilder from '@pothos/core';
import TracingPlugin, { isRootField } from '@pothos/plugin-tracing';
import { createOpenTelemetryWrapper } from '@pothos/tracing-opentelemetry';
import { tracer } from './tracer';

const createSpan = createOpenTelemetryWrapper(tracer, {
  includeSource: true,
});

export const builder = new SchemaBuilder({
  plugins: [TracingPlugin],
  tracing: {
    default: (config) => isRootField(config),
    wrap: (resolver, options) => createSpan(resolver, options),
  },
});

The wrapper accepts:

OptionDefaultPurpose
includeArgsfalseRecord the resolver arguments on the span.
includeSourcefalseRecord the field's source location on the span.
ignoreErrorfalseDon't mark the span as errored when the resolver throws.
onSpannone(span, tracingOptions, parent, args, context, info) => void, called after the span opens so you can add your own attributes.

Use onSpan together with a custom Tracing generic to attach per-field attributes:

import { AttributeValue } from '@opentelemetry/api';
import SchemaBuilder from '@pothos/core';
import TracingPlugin, { isRootField } from '@pothos/plugin-tracing';
import { createOpenTelemetryWrapper } from '@pothos/tracing-opentelemetry';
import { tracer } from './tracer';

type TracingOptions = boolean | { attributes?: Record<string, AttributeValue> };

const createSpan = createOpenTelemetryWrapper<TracingOptions>(tracer, {
  includeSource: true,
  onSpan: (span, options) => {
    if (typeof options === 'object' && options.attributes) {
      span.setAttributes(options.attributes);
    }
  },
});

export const builder = new SchemaBuilder<{
  Tracing: TracingOptions;
}>({
  plugins: [TracingPlugin],
  tracing: {
    default: (config) => isRootField(config),
    wrap: (resolver, options) => createSpan(resolver, options),
  },
});

builder.queryType({
  fields: (t) => ({
    topScorer: t.string({
      args: { name: t.arg.string() },
      tracing: (parent, { name }) => ({ attributes: { name } }),
      resolve: (parent, { name }) => `Top scorer for ${name ?? 'the league'}`,
    }),
  }),
});

Instrumenting the execution phase

The plugin spans resolvers only. To capture the surrounding execution phase, wrap the server's execute function. This graphql-yoga plugin opens an EXECUTE span with the operation name and source:

import { tracer } from './tracer'; // Import the tracer first if it sets up extra instrumentation.
import { print } from 'graphql';
import { createYoga, Plugin } from 'graphql-yoga';
import { createServer } from 'node:http';
import { AttributeNames, SpanNames } from '@pothos/tracing-opentelemetry';
import { schema } from './schema';

const tracingPlugin: Plugin = {
  onExecute: ({ setExecuteFn, executeFn }) => {
    setExecuteFn((options) =>
      tracer.startActiveSpan(
        SpanNames.EXECUTE,
        {
          attributes: {
            [AttributeNames.OPERATION_NAME]: options.operationName ?? undefined,
            [AttributeNames.SOURCE]: print(options.document),
          },
        },
        async (span) => {
          try {
            return await executeFn(options);
          } catch (error) {
            span.recordException(error as Error);
            throw error;
          } finally {
            span.end();
          }
        },
      ),
    );
  },
};

const yoga = createYoga({ schema, plugins: [tracingPlugin] });
const server = createServer(yoga);

Envelop's own @envelop/opentelemetry plugin can replace the custom plugin. Its drawback: the current version doesn't track parent/child relationships between the spans it creates. Disable its resolver tracing so it doesn't duplicate the plugin's spans:

import { provider } from './tracer'; // Import the tracer first if it sets up extra instrumentation.
import { useOpenTelemetry } from '@envelop/opentelemetry';
import { createYoga } from 'graphql-yoga';
import { createServer } from 'node:http';
import { schema } from './schema';

const yoga = createYoga({
  schema,
  plugins: [
    useOpenTelemetry(
      {
        // Turn off envelop's resolver tracing to avoid duplicate spans.
        resolvers: false,
        variables: false,
        result: false,
      },
      provider,
    ),
  ],
});

const server = createServer(yoga);

Setting up a tracer

This ./tracer module wires a minimal OpenTelemetry provider that logs spans to the console. Real applications swap the console exporter for one that matches your backend:

import { diag, DiagConsoleLogger, DiagLogLevel, trace } from '@opentelemetry/api';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';

export const provider = new NodeTracerProvider({
  spanProcessors: [new SimpleSpanProcessor(new ConsoleSpanExporter())],
});

provider.register();

registerInstrumentations({
  // Create spans for incoming http requests automatically.
  instrumentations: [new HttpInstrumentation({})],
});

diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO);

export const tracer = trace.getTracer('graphql');

Datadog

Datadog ingests OpenTelemetry, so tracing to Datadog is the OpenTelemetry setup above with an exporter and agent pointed at Datadog. Swap the console exporter for the OTLP HTTP exporter:

import { trace } from '@opentelemetry/api';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';

export const provider = new NodeTracerProvider({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: 'Pothos-OTEL-example',
  }),
});

provider.addSpanProcessor(
  new SimpleSpanProcessor(
    new OTLPTraceExporter({
      // Point at your collector if you aren't using the default port.
      // url: 'http://host:port',
    }),
  ),
);

provider.register();

registerInstrumentations({
  instrumentations: [new HttpInstrumentation({})],
});

export const tracer = trace.getTracer('graphql');

Then tell the Datadog agent to receive OTLP over HTTP:

otlp_config:
  receiver:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

New Relic

npm install --save @pothos/tracing-newrelic newrelic @types/newrelic

createNewrelicWrapper(options) returns the wrap implementation:

import SchemaBuilder from '@pothos/core';
import TracingPlugin, { isRootField } from '@pothos/plugin-tracing';
import { createNewrelicWrapper } from '@pothos/tracing-newrelic';

const wrapResolver = createNewrelicWrapper({
  includeArgs: true,
  includeSource: true,
});

export const builder = new SchemaBuilder({
  plugins: [TracingPlugin],
  tracing: {
    default: (config) => isRootField(config),
    wrap: (resolver) => wrapResolver(resolver),
  },
});
OptionDefaultPurpose
includeArgsfalseRecord the resolver arguments on the segment.
includeSourcefalseRecord the field's source location on the segment.

Instrumenting the execution phase

Add the operation name and source as custom attributes on the New Relic transaction. newrelic must be imported before anything it instruments:

import newrelic from 'newrelic'; // newrelic must be imported first.
import { print } from 'graphql';
import { createYoga, Plugin } from 'graphql-yoga';
import { createServer } from 'node:http';
import { AttributeNames } from '@pothos/tracing-newrelic';
import { schema } from './schema';

const tracingPlugin: Plugin = {
  onExecute: ({ args }) => {
    newrelic.addCustomAttributes({
      [AttributeNames.OPERATION_NAME]: args.operationName ?? '<unnamed operation>',
      [AttributeNames.SOURCE]: print(args.document),
    });
  },
};

const yoga = createYoga({ schema, plugins: [tracingPlugin] });
const server = createServer(yoga);

Envelop's @envelop/newrelic plugin can run alongside the tracing plugin. Disable its resolver tracking so it doesn't duplicate the resolver segments. If you want all resolvers tracked, you can use the envelop plugin on its own instead of the Pothos tracing plugin:

import { useNewRelic } from '@envelop/newrelic';
import { createYoga } from 'graphql-yoga';
import { createServer } from 'node:http';
import { schema } from './schema';

const yoga = createYoga({
  schema,
  plugins: [
    useNewRelic({
      // The tracing plugin already covers resolvers.
      trackResolvers: false,
    }),
  ],
});

const server = createServer(yoga);

Sentry

npm install --save @pothos/tracing-sentry @sentry/node

createSentryWrapper(options) returns the wrap implementation:

import SchemaBuilder from '@pothos/core';
import TracingPlugin, { isRootField } from '@pothos/plugin-tracing';
import { createSentryWrapper } from '@pothos/tracing-sentry';

const traceResolver = createSentryWrapper({
  includeArgs: true,
  includeSource: true,
});

export const builder = new SchemaBuilder({
  plugins: [TracingPlugin],
  tracing: {
    default: (config) => isRootField(config),
    wrap: (resolver, options) => traceResolver(resolver, options),
  },
});
OptionDefaultPurpose
includeArgsfalseRecord the resolver arguments on the span.
includeSourcefalseRecord the field's source location on the span.
ignoreErrorfalseDon't mark the span as errored when the resolver throws.
onSpannone(span, tracingOptions, parent, args, context, info) => void, called after the span opens so you can add your own attributes.

Instrumenting the execution phase

Open a Sentry span around execution with the operation name and source:

import { print } from 'graphql';
import { createYoga, Plugin } from 'graphql-yoga';
import { createServer } from 'node:http';
import { AttributeNames } from '@pothos/tracing-sentry';
import * as Sentry from '@sentry/node';
import { schema } from './schema';

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  tracesSampleRate: 1,
});

const tracingPlugin: Plugin = {
  onExecute: ({ setExecuteFn, executeFn }) => {
    setExecuteFn((options) =>
      Sentry.startSpan(
        {
          op: 'graphql.execute',
          name: options.operationName ?? '<unnamed operation>',
          forceTransaction: true,
          attributes: {
            [AttributeNames.OPERATION_NAME]: options.operationName ?? undefined,
            [AttributeNames.SOURCE]: print(options.document),
          },
        },
        () => executeFn(options),
      ),
    );
  },
};

const yoga = createYoga({ schema, plugins: [tracingPlugin] });
const server = createServer(yoga);

Envelop's @envelop/sentry plugin can be combined with the tracing plugin:

import { useSentry } from '@envelop/sentry';
import { createYoga } from 'graphql-yoga';
import { createServer } from 'node:http';
import { schema } from './schema';

const yoga = createYoga({
  schema,
  plugins: [useSentry({})],
});

const server = createServer(yoga);

AWS X-Ray

npm install --save @pothos/tracing-xray aws-xray-sdk-core

createXRayWrapper(options) returns the wrap implementation. X-Ray needs a parent segment, so this example also traces object relations rather than root fields alone:

import SchemaBuilder from '@pothos/core';
import TracingPlugin, { isEnumField, isRootField, isScalarField } from '@pothos/plugin-tracing';
import { createXRayWrapper } from '@pothos/tracing-xray';

const traceResolver = createXRayWrapper({
  includeArgs: true,
  includeSource: true,
});

export const builder = new SchemaBuilder({
  plugins: [TracingPlugin],
  tracing: {
    default: (config) => isRootField(config) || (!isScalarField(config) && !isEnumField(config)),
    wrap: (resolver, options) => traceResolver(resolver, options),
  },
});
OptionDefaultPurpose
includeArgsfalseRecord the resolver arguments on the subsegment.
includeSourcefalseRecord the field's source location on the subsegment.
onSegmentnone(segment, tracingOptions, parent, args, context, info) => void, called after the subsegment opens so you can add your own annotations.

Instrumenting the execution phase

Open a parent X-Ray segment around execution so resolver subsegments nest under it:

import AWSXRay from 'aws-xray-sdk-core';
import { print } from 'graphql';
import { createYoga, Plugin } from 'graphql-yoga';
import { createServer } from 'node:http';
import { AttributeNames, SpanNames } from '@pothos/tracing-xray';
import { schema } from './schema';

const tracingPlugin: Plugin = {
  onExecute: ({ setExecuteFn, executeFn }) => {
    setExecuteFn(async (options) => {
      const parent = new AWSXRay.Segment('parent');

      return AWSXRay.getNamespace().runAndReturn(() => {
        AWSXRay.setSegment(parent);

        return AWSXRay.captureAsyncFunc(
          SpanNames.EXECUTE,
          (segment) => {
            if (segment) {
              segment.addAttribute(
                AttributeNames.OPERATION_NAME,
                options.operationName ?? '<unnamed operation>',
              );
              segment.addAttribute(AttributeNames.SOURCE, print(options.document));
            }

            return executeFn(options);
          },
          parent,
        );
      });
    });
  },
};

const yoga = createYoga({ schema, plugins: [tracingPlugin] });
const server = createServer(yoga);