Skip to content

12. Observability ​

Lifecycle events ​

Laragraph fires ordinary Laravel events, so you can listen with Event::listen(), queued listeners, or tools that already consume events (Telescope, Pulse recorders, Sentry breadcrumbs).

EventWhenProperties
Events\QueryExecutingBefore an operation runs (also before cache lookups)query, variables, operationName, schemaName
Events\QueryExecutedAfter every operation, including response-cache hitsthe above plus result, executionMs, hasErrors, cached
Events\QueryErrorAfter an operation whose response contains errorsthe above plus errors (the formatted errors)
Events\SchemaBuiltWhen a schema is compiled (once per process)schemaName, schema

All are in the Ayimdomnic\Laragraph\Events namespace. Every operation of a batch fires its own events.

operationName is the name the client sent in the request. A document such as query Dashboard { … } sent without "operationName": "Dashboard" reports null. Ask your clients to send it (Apollo and Relay do) if you group metrics by operation.

Example: logging slow operations ​

php
// example/app/Listeners/LogSlowGraphQLOperations.php
use Ayimdomnic\Laragraph\Events\QueryExecuted;
use Illuminate\Support\Facades\Log;

class LogSlowGraphQLOperations
{
    public function handle(QueryExecuted $event): void
    {
        if ($event->executionMs < (float) config('app.graphql_slow_ms', 500)) {
            return;
        }

        Log::warning('Slow GraphQL operation', [
            'operation' => $event->operationName ?? '(anonymous)',
            'schema'    => $event->schemaName,
            'ms'        => $event->executionMs,
            'cached'    => $event->cached,
        ]);
    }
}
php
// AppServiceProvider::boot()
Event::listen(QueryExecuted::class, LogSlowGraphQLOperations::class);

Other common uses: counting operations and errors per operationName for your metrics system, auditing mutations (Operation::isMutation($event->query, $event->operationName)), reporting QueryErrors with category internal to your error tracker, and flushing the response cache.

variables may contain passwords and tokens (login(password: …)). Filter them before logging.

Response extensions ​

Extensions add metadata under the response's top-level extensions key. Three are built in, all off by default:

php
'extensions' => [
    'request_id'       => true,   // extensions.requestId.id
    'query_timing'     => true,   // extensions.timing.execution_ms
    'query_complexity' => true,   // extensions.queryComplexity.{cost,maxCost}
],
json
{
  "data": { "me": { "name": "Ada" } },
  "extensions": {
    "requestId": { "id": "5f0c6f9e-3b4c-4a8e-9d0f-2b1e6c7a8d90" },
    "timing": { "execution_ms": 4.12 },
    "queryComplexity": { "cost": 14, "maxCost": 500 }
  }
}
  • requestId reuses the client's X-Request-ID header when it looks like an id (letters, digits, ., _, -, up to 128 characters). Otherwise it generates a UUID. It's the same for every operation in a batch, so you can correlate client reports with server logs.
  • timing is the wall-clock time of the operation, in milliseconds.
  • queryComplexity is the query's computed cost against security.query_max_complexity — empty ({}) when that limit isn't configured. Still populated when the query is rejected for exceeding the limit, so a client can see exactly how far over budget it was, the same idea as GitHub's or Shopify's GraphQL APIs exposing a rate-limit cost for clients to self-throttle against.

Custom extensions ​

Implement GraphQLExtensionInterface and register it:

php
// example/app/GraphQL/Extensions/ApiVersionExtension.php
use Ayimdomnic\Laragraph\Extensions\GraphQLExtensionInterface;

class ApiVersionExtension implements GraphQLExtensionInterface
{
    public function key(): string
    {
        return 'apiVersion';
    }

    /** @param array{execution_ms?: float} $context */
    public function get(array $context = []): array
    {
        return ['version' => config('app.api_version', '2026-09')];
    }
}
php
// AppServiceProvider::boot()
$this->app->make(ExtensionRegistry::class)->add(new ApiVersionExtension);

Return [] from get() to leave the extension out of a particular response. Extensions are computed for every response, including cache hits, and are never cached.

Tracing ​

Tracing times every resolver, root fields and nested type fields alike, and reports the results in the Apollo Tracing format:

php
'tracing' => [
    'enabled' => env('LARAGRAPH_TRACING', false),
],
json
"extensions": {
  "tracing": {
    "version": 1,
    "startTime": "2026-09-24T10:15:30.123Z",
    "endTime": "2026-09-24T10:15:30.141Z",
    "duration": 18234567,
    "execution": {
      "resolvers": [
        { "path": ["posts"], "parentType": "Query", "fieldName": "posts",
          "returnType": "PostConnection", "startOffset": 51234, "duration": 9123456 }
      ]
    }
  }
}

Durations are in nanoseconds. Tracing wraps every resolver, so it has a cost and exposes timing details. Enable it in development, or temporarily in staging, not on a public production API.

The otel driver ​

Apollo deprecated the Apollo Tracing format years ago in favor of OpenTelemetry; 'otel' is the recommended choice for new projects.

open-telemetry/api — the lightweight interfaces-plus-no-op package this driver calls into — is a suggested, not required, dependency: the default 'apollo' driver needs none of it, so it isn't installed for you automatically. Add it once, then enable the driver:

bash
composer require open-telemetry/api
php
'tracing' => [
    'enabled' => true,
    'driver'  => 'otel',
    'otel'    => ['tracer_name' => 'laragraph'],
],

Enabling 'otel' without installing the package throws a clear MissingOptionalDependencyException naming the exact composer require to run, rather than a raw autoload error.

This exports real OpenTelemetry spans instead — one root span per GraphQL operation (graphql.operation.name/.type, graphql.document, the schema name as attributes; Error status when the response has errors), and one child span per resolver (graphql.field.name/.path, graphql.type.name, graphql.field.return_type). Laragraph calls OpenTelemetry\API\Globals::tracerProvider(); your app wires up its own OTel SDK and exporter the standard way, e.g. in a service provider:

php
use OpenTelemetry\API\Globals;
use OpenTelemetry\SDK\Trace\SpanExporter\ConsoleSpanExporter; // or any real OTLP/Zipkin/etc. exporter
use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor;
use OpenTelemetry\SDK\Trace\TracerProvider;

Globals::registerInitializer(fn ($configurator) => $configurator->withTracerProvider(
    new TracerProvider(new SimpleSpanProcessor(new ConsoleSpanExporter())),
));

With no SDK configured, every call is a cheap no-op (the API package's default no-op tracer). On 'otel', extensions.tracing is not added to the response — span data leaves through the OTel pipeline, not the response body, so a client expecting the Apollo Tracing shape needs the 'apollo' driver instead. Switching drivers doesn't touch the actual per-resolver timing instrumentation: both replay the same already-collected span data, just into a different shape.

Field logging ​

LoggingMiddleware logs each root field's resolution and its duration at debug level:

php
'middleware' => [\Ayimdomnic\Laragraph\Middleware\LoggingMiddleware::class],   // every root field
'logging'    => ['channel' => 'graphql'],                                     // null = default channel
[debug] GraphQL: [posts] resolved in 12.4ms {"field":"posts","elapsed_ms":12.4}

Or add it to a single field's middleware(). The logging.channel also receives subscription updates when subscriptions.driver is log.

php artisan about ​

Laragraph adds a section to php artisan about, a quick way to check a server's configuration:

  Laragraph ..........................................................
  Version .................................................... 3.1.1
  Endpoint ................................................ /graphql
  Schemas ............................................ default, admin
  Discovery ................................................. CACHED
  GraphiQL ..................................................... OFF
  Introspection ................................................ OFF
  Response cache ........................................... ENABLED
  Persisted queries ........................................ ENABLED
  Subscriptions ............................................ ENABLED
  Tracing ...................................................... OFF

Released under the MIT License.