Skip to content

Extensions

Extensions provide hooks into the GraphQL request lifecycle, allowing you to capture timing data, compute metrics, and add metadata to the response’s extensions field.

FeatureExtensionsMiddleware
ScopeRequest lifecycle (parse, validate, execute)Individual resolver execution
Data outputResponse extensions fieldTransforms resolver result
Use caseTracing, metrics, request infoAuth, logging, caching per-field
  1. Register an extension on the builder

    import { GraphQLSchemaBuilder, extension, ExtensionsService } from "@effect-gql/core"
    const builder = GraphQLSchemaBuilder.empty.pipe(
    extension({
    name: "tracing",
    onExecuteStart: () =>
    Effect.gen(function* () {
    const ext = yield* ExtensionsService
    yield* ext.set("tracing", { startTime: Date.now() })
    }),
    onExecuteEnd: () =>
    Effect.gen(function* () {
    const ext = yield* ExtensionsService
    const data = yield* ext.get()
    const startTime = (data.tracing as any)?.startTime
    yield* ext.merge("tracing", {
    endTime: Date.now(),
    durationMs: Date.now() - startTime,
    })
    }),
    }),
    )
  2. Build and execute

    const schema = builder.buildSchema()
    const extensions = builder.getExtensions()
    const result = await Effect.runPromise(
    execute(schema, serviceLayer, extensions)("{ hello }")
    )
  3. Extension data appears in response

    {
    "data": { "hello": "world" },
    "extensions": {
    "tracing": {
    "startTime": 1703001234567,
    "endTime": 1703001234589,
    "durationMs": 22
    }
    }
    }

Extensions can hook into four phases of request processing:

extension({
name: "lifecycle",
// Called after parsing succeeds
onParse: (source, document) => Effect.Effect<void>,
// Called after validation (with any errors)
onValidate: (document, errors) => Effect.Effect<void>,
// Called before resolver execution begins
onExecuteStart: (args) => Effect.Effect<void>,
// Called after execution completes (with result)
onExecuteEnd: (result) => Effect.Effect<void>,
})
HookParametersDescription
onParsesource: string, document: DocumentNodeThe query string and parsed AST
onValidatedocument: DocumentNode, errors: readonly GraphQLError[]The AST and any validation errors
onExecuteStart{ source, document, variableValues, operationName }Execution arguments
onExecuteEndresult: ExecutionResultThe GraphQL result (data and/or errors)
Parse → onParse → Validate → onValidate → onExecuteStart → Execute → onExecuteEnd

The ExtensionsService provides methods to write data to the response extensions:

Set a value, overwriting any existing data for that key:

const ext = yield* ExtensionsService
yield* ext.set("tracing", { startTime: Date.now() })

Deep merge an object into an existing key:

yield* ext.set("tracing", { startTime: 100 })
yield* ext.merge("tracing", { endTime: 200 })
// Result: { tracing: { startTime: 100, endTime: 200 } }

Get all accumulated extension data:

const data = yield* ext.get()
const startTime = (data.tracing as any)?.startTime

Track total request duration:

extension({
name: "timing",
onExecuteStart: () =>
Effect.gen(function* () {
const ext = yield* ExtensionsService
yield* ext.set("timing", { startTime: Date.now() })
}),
onExecuteEnd: () =>
Effect.gen(function* () {
const ext = yield* ExtensionsService
const data = yield* ext.get()
const timing = data.timing as { startTime: number }
yield* ext.merge("timing", {
endTime: Date.now(),
durationMs: Date.now() - timing.startTime,
})
}),
})

Report complexity metrics in the response:

extension({
name: "complexity",
onValidate: (document, errors) =>
Effect.gen(function* () {
if (errors.length > 0) return
// Calculate complexity from AST
let complexity = 0
const visit = (selections: any, depth: number) => {
for (const sel of selections ?? []) {
complexity += depth
if (sel.selectionSet) {
visit(sel.selectionSet.selections, depth + 1)
}
}
}
for (const def of document.definitions) {
if (def.kind === "OperationDefinition") {
visit(def.selectionSet?.selections, 1)
}
}
const ext = yield* ExtensionsService
yield* ext.set("complexity", {
score: complexity,
limit: 1000,
})
}),
})

Capture information about the request:

extension({
name: "requestInfo",
onParse: (source, document) =>
Effect.gen(function* () {
const ext = yield* ExtensionsService
yield* ext.set("requestInfo", {
queryLength: source.length,
operationCount: document.definitions.filter(
(d) => d.kind === "OperationDefinition"
).length,
})
}),
onValidate: (document, errors) =>
Effect.gen(function* () {
const ext = yield* ExtensionsService
yield* ext.merge("requestInfo", {
validationErrors: errors.length,
validated: true,
})
}),
})

Build Apollo Tracing compatible output:

extension({
name: "apolloTracing",
onExecuteStart: () =>
Effect.gen(function* () {
const ext = yield* ExtensionsService
yield* ext.set("tracing", {
version: 1,
startTime: new Date().toISOString(),
execution: { resolvers: [] },
})
}),
onExecuteEnd: () =>
Effect.gen(function* () {
const ext = yield* ExtensionsService
yield* ext.merge("tracing", {
endTime: new Date().toISOString(),
duration: /* calculated duration in nanoseconds */,
})
}),
})

Extensions run in registration order and their data is merged:

const builder = GraphQLSchemaBuilder.empty.pipe(
extension({ name: "timing", ... }),
extension({ name: "complexity", ... }),
extension({ name: "requestInfo", ... }),
)

Response:

{
"data": { "hello": "world" },
"extensions": {
"timing": { "durationMs": 22 },
"complexity": { "score": 5 },
"requestInfo": { "queryLength": 12 }
}
}

Extensions can depend on Effect services:

class TracingService extends Context.Tag("TracingService")<
TracingService,
{ readonly createSpan: (name: string) => Effect.Effect<Span> }
>() {}
extension<TracingService>({
name: "openTelemetry",
onExecuteStart: (args) =>
Effect.gen(function* () {
const tracing = yield* TracingService
const span = yield* tracing.createSpan("graphql.execute")
// Store span reference for onExecuteEnd
}),
})

When using the HTTP router, extensions are automatically extracted from the builder:

import { toRouter } from "@effect-gql/core/server"
const builder = GraphQLSchemaBuilder.empty.pipe(
extension({ name: "timing", ... }),
query("hello", { ... }),
)
// Extensions are automatically included
const router = toRouter(builder, serviceLayer)

Or with makeGraphQLRouter:

import { makeGraphQLRouter } from "@effect-gql/core/server"
const schema = builder.buildSchema()
const extensions = builder.getExtensions()
const router = makeGraphQLRouter(schema, serviceLayer, {
extensions,
})
import { Effect, Layer } from "effect"
import * as S from "effect/Schema"
import {
GraphQLSchemaBuilder,
extension,
query,
execute,
ExtensionsService,
} from "@effect-gql/core"
const builder = GraphQLSchemaBuilder.empty.pipe(
// Tracing extension
extension({
name: "tracing",
onExecuteStart: () =>
Effect.gen(function* () {
const ext = yield* ExtensionsService
yield* ext.set("tracing", { startTime: Date.now() })
}),
onExecuteEnd: () =>
Effect.gen(function* () {
const ext = yield* ExtensionsService
const data = yield* ext.get()
const startTime = (data.tracing as any)?.startTime ?? Date.now()
yield* ext.merge("tracing", {
endTime: Date.now(),
durationMs: Date.now() - startTime,
})
}),
}),
// Query
query("hello", {
type: S.String,
resolve: () => Effect.succeed("Hello, World!"),
}),
)
const schema = builder.buildSchema()
const extensions = builder.getExtensions()
// Execute
const result = await Effect.runPromise(
execute(schema, Layer.empty, extensions)("{ hello }")
)
console.log(JSON.stringify(result, null, 2))
// {
// "data": { "hello": "Hello, World!" },
// "extensions": {
// "tracing": {
// "startTime": 1703001234567,
// "endTime": 1703001234589,
// "durationMs": 22
// }
// }
// }