Skip to content

Add Apollo Federation inline tracing (ftv1) - #14

Merged
magnusnordlander merged 2 commits into
feature/cache-control-directivesfrom
feature/apollo-inline-trace
Aug 19, 2026
Merged

magnusnordlander merged 2 commits into
feature/cache-control-directivesfrom
feature/apollo-inline-trace

Conversation

@magnusnordlander

@magnusnordlander magnusnordlander commented Aug 18, 2026 •

Copy link
Copy Markdown
Member

Ports Apollo Server's ApolloServerPluginInlineTrace. When the gateway sends
apollo-federation-include-trace: ftv1, the subgraph now answers with
extensions.ftv1 — a base64-encoded protobuf Trace of the resolver tree, so
operations show a real field-level breakdown in Apollo Studio instead of
gateway-side timings only.

Stacked on feature/cache-control-directives. That branch is the base, so
the diff here is just this feature. Merge it first.

Spec

There is no published FTV1 specification. docs/federation/inline-trace.md
derives one from Apollo's reports.proto and the two reference
implementations (Apollo Server's inlineTrace plugin + traceTreeBuilder, and
federation-jvm's FederatedTracingInstrumentation).

Configuration

redeye_graphql:
    federation: true
    # inline_trace is enabled by default when federation is true
redeye_graphql:
    inline_trace: false                 # disable
    inline_trace:
        include_errors: masked          # masked (default) | unmodified
        transformer_service: App\GraphQL\MyTraceErrorTransformer

Setting only include_errors/transformer_service does not enable the
feature — enabled still inherits federation. Configuring options shouldn't
silently switch tracing on for a non-federated schema.

How it hangs together

Apollo Server Here
requestDidStart InlineTraceListener::onPreExecutor (graphql.pre_executor)
willResolveField TracingReferenceExecutor::resolveFieldValueOrError()
didEncounterErrors + willSendResponse InlineTraceListener::onPostExecutor (priority 100)

Priority 100 is required: ErrorHandlerListener rebuilds errors that have a
getPrevious() without passing the extensions argument, so the trace has to
see them first.

The in-progress trace lives in the per-execution context ArrayObject, not on
the listener service — that's what makes batched queries each get their own.
This is the same mechanism @cacheControl already uses for its recorder. The
two hooks sit side by side in resolveFieldValueOrError: cache-control first
(it only reads the field), inline trace last (it starts a clock, so it
shouldn't be charged for the Sentry setup above it).

Details worth a second look during review

  • Promise handling uses the inherited ReferenceExecutor::getPromise()
    rather than the promise adapter. DataLoader::load() returns a
    GraphQL\Executor\Promise\Promise, which SyncPromiseAdapter::isThenable()
    rejects — convertThenable() would throw InvariantViolation on the most
    common async path in this bundle. The derived promise is deliberately
    discarded; the original value is always returned unchanged.
  • The end callback captures the trace node, not the ResolveInfo.
    completeListValue() mutates the shared ResolveInfo::$path per list item,
    so a callback that re-derived the path on settle would write into the wrong
    node. There's a regression test for exactly this.
  • include_errors: unmodified does not mean "raw." This bundle's
    ErrorHandler masks at response-format time, not on the Error object, so
    $error->getMessage() is still the raw exception text (SQL, DSNs, file
    paths) while the trace is built. unmodified therefore goes through
    FormattedError::createFromException(..., DebugFlag::NONE, $internalErrorMessage).
    Tested both at unit level and end to end.
  • The federation default is resolved in a root validate() callback.
    Symfony normalizes each config fragment individually before merging, so a
    beforeNormalization would not see both keys — the test app uses imports:
    heavily, and there's a two-fragment test covering it.

Verification

95 new tests, plus three checks that are independent of our own code:

  1. Golden fixture — a real ftv1 payload captured from a real Apollo Server
    subgraph (tests/Federation/Tracing/fixtures/, with a README on how it was
    produced), parsed by our generated classes. The only test that proves
    interop rather than self-consistency.
  2. Raw protobuf tag-byte assertions on every field number — catches a
    transposition that round-tripping cannot.
  3. This bundle's own output decoded with protoc --decode_raw (no schema
    involved). Structurally identical to Apollo's, including index nodes,
    original_field_name on aliases, field_execution_weight 1.0, and a
    deferred field measured at ~4.7 ms against ~2 µs for a sync sibling —
    confirming end times are recorded on settle, not on return.

Full suite on the rebased branch: 897 tests, 5 errors — all 5 pre-existing
(GetUserTest, which fails because Symfony 7 removed Security\Core\Security).
That includes the base branch's cache-control tests, which pass alongside this.

Things to be aware of

  • composer static-analysis and composer check-cs could not be run as CI
    would.
    PHPStan 0.12 and php-cs-fixer 3.3.2 are 2020-era pins that cannot
    parse the Symfony 7 vendor code under PHP 8.4 locally. I verified none of
    PHPStan's project-file errors are in files this PR touches, and ran cs-fixer
    scoped to the new/changed files only (clean). Worth confirming on CI.
  • Generated protobuf lives in proto/gen/, outside the paths php-cs-fixer
    and PHPStan scan, so neither needs exclusion config. A new CI job regenerates
    with a pinned protoc 29.3 and fails on drift — pinned because protoc's PHP
    backend changes its output between releases. That job has never executed;
    it's unproven until CI runs it.
  • Security: with this on, any client sending the header gets field-level
    timings. Same exposure Apollo Server has; called out in the docs.
  • google/protobuf: ^5.35.1 is a new runtime dependency, and the PHP floor
    moves to 8.2
    (second commit). The 5.x runtime is not a drop-in for 3.x —
    protoc's PHP backend now emits Google\Protobuf\RepeatedField rather than
    Google\Protobuf\Internal\RepeatedField and adds native parameter types to
    setters — so proto/gen is generated with protoc 35.1, the release matching
    the 5.35.1 package, and the CI pin moves with it. Dropping 8.0/8.1 shrinks the
    test matrix and retires the two 8.0-with-Symfony-6.2/6.4 excludes.
    ext-protobuf is suggested for speed.

Port ApolloServerPluginInlineTrace: when the gateway sends the header
`apollo-federation-include-trace: ftv1`, respond with `extensions.ftv1`
carrying a base64-encoded protobuf Trace of the resolver tree.

There is no published FTV1 spec, so docs/federation/inline-trace.md derives
one from Apollo's reports.proto and the Apollo Server / federation-jvm
implementations.

The trace is assembled at three points, mirroring Apollo's plugin lifecycle:
InlineTraceListener starts it on graphql.pre_executor, TracingReferenceExecutor
records per-field timings, and the listener encodes it on graphql.post_executor
at priority 100 -- ahead of ErrorHandlerListener, which rebuilds errors and
drops their extensions.

State lives in the per-execution context ArrayObject rather than on the
listener service, so batched queries each get their own trace. This is the same
approach @CacheControl already uses for its recorder; the two hooks sit side by
side in resolveFieldValueOrError, cache-control first because it only reads the
field, inline trace last because it starts a clock and should not be charged for
the Sentry setup above it.

Notable details:

- Field end times use ReferenceExecutor::getPromise(), not the promise
  adapter: DataLoader returns a GraphQL\Executor\Promise\Promise, which
  SyncPromiseAdapter::isThenable() rejects, so convertThenable() would throw
  on the most common async path in this bundle.
- The end callback captures the trace node, never the ResolveInfo:
  completeListValue() mutates the shared ResolveInfo::path per list item.
- `include_errors: unmodified` reports the client-visible message via
  FormattedError, not $error->getMessage(). The bundle's ErrorHandler masks
  at response-format time, so the raw exception text (SQL, DSNs, paths) is
  still on the Error object while the trace is built.
- The federation default is resolved in a root validate() callback. Symfony
  normalizes config fragments individually before merging, so a
  beforeNormalization would not see both keys.

Generated protobuf classes live in proto/gen/, outside the paths php-cs-fixer
and PHPStan scan, so neither needs exclusion config. A CI job regenerates with
a pinned protoc and fails on drift.

Verified against a golden ftv1 payload captured from a real Apollo Server
subgraph, against raw protobuf tag bytes, and by decoding this bundle's own
output with `protoc --decode_raw`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@magnusnordlander
magnusnordlander force-pushed the feature/apollo-inline-trace branch from 2ea1ff4 to 4745f5a Compare August 19, 2026 05:35
@magnusnordlander
magnusnordlander changed the base branch from main to feature/cache-control-directives August 19, 2026 05:38
The 5.x PHP runtime is not a drop-in for 3.x: protoc's PHP backend now emits
Google\Protobuf\RepeatedField instead of Google\Protobuf\Internal\RepeatedField
and adds native parameter types to setters, so the generated code only works
against a matching runtime. proto/gen is therefore regenerated with protoc 35.1,
the release that corresponds to the 5.35.1 PHP package, and the CI drift job's
pin moves with it.

Raising the floor to 8.2 drops PHP 8.0 and 8.1 from the test matrix, which also
retires the two 8.0-with-Symfony-6.2/6.4 excludes. The two `include` legs (the
optional-dependency check and the coverage run) move to 8.2. The coding-standard
and static-analysis jobs, the AppVeyor build, and the docs version table follow.

CacheScope's comment claimed a native enum was impossible because the bundle
supports PHP 8.0. That is no longer true, so the comment says so; the class is
otherwise left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@magnusnordlander
magnusnordlander merged commit 39cdf84 into feature/cache-control-directives Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant