Repository navigation
Add Apollo Federation inline tracing (ftv1) - #14
Merged
magnusnordlander merged 2 commits intoAug 19, 2026
Merged
magnusnordlander merged 2 commits into
magnusnordlander merged 2 commits into
Conversation
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
force-pushed
the
feature/apollo-inline-trace
branch
from
August 19, 2026 05:35
2ea1ff4 to
4745f5a
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ports Apollo Server's
ApolloServerPluginInlineTrace. When the gateway sendsapollo-federation-include-trace: ftv1, the subgraph now answers withextensions.ftv1— a base64-encoded protobufTraceof the resolver tree, sooperations show a real field-level breakdown in Apollo Studio instead of
gateway-side timings only.
Spec
There is no published FTV1 specification.
docs/federation/inline-trace.mdderives one from Apollo's
reports.protoand the two referenceimplementations (Apollo Server's
inlineTraceplugin +traceTreeBuilder, andfederation-jvm's
FederatedTracingInstrumentation).Configuration
Setting only
include_errors/transformer_servicedoes not enable thefeature —
enabledstill inheritsfederation. Configuring options shouldn'tsilently switch tracing on for a non-federated schema.
How it hangs together
requestDidStartInlineTraceListener::onPreExecutor(graphql.pre_executor)willResolveFieldTracingReferenceExecutor::resolveFieldValueOrError()didEncounterErrors+willSendResponseInlineTraceListener::onPostExecutor(priority 100)Priority 100 is required:
ErrorHandlerListenerrebuilds errors that have agetPrevious()without passing the extensions argument, so the trace has tosee them first.
The in-progress trace lives in the per-execution context
ArrayObject, not onthe listener service — that's what makes batched queries each get their own.
This is the same mechanism
@cacheControlalready uses for its recorder. Thetwo 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
ReferenceExecutor::getPromise()rather than the promise adapter.
DataLoader::load()returns aGraphQL\Executor\Promise\Promise, whichSyncPromiseAdapter::isThenable()rejects —
convertThenable()would throwInvariantViolationon the mostcommon async path in this bundle. The derived promise is deliberately
discarded; the original value is always returned unchanged.
ResolveInfo.completeListValue()mutates the sharedResolveInfo::$pathper 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: unmodifieddoes not mean "raw." This bundle'sErrorHandlermasks at response-format time, not on theErrorobject, so$error->getMessage()is still the raw exception text (SQL, DSNs, filepaths) while the trace is built.
unmodifiedtherefore goes throughFormattedError::createFromException(..., DebugFlag::NONE, $internalErrorMessage).Tested both at unit level and end to end.
federationdefault is resolved in a rootvalidate()callback.Symfony normalizes each config fragment individually before merging, so a
beforeNormalizationwould not see both keys — the test app usesimports:heavily, and there's a two-fragment test covering it.
Verification
95 new tests, plus three checks that are independent of our own code:
subgraph (
tests/Federation/Tracing/fixtures/, with a README on how it wasproduced), parsed by our generated classes. The only test that proves
interop rather than self-consistency.
transposition that round-tripping cannot.
protoc --decode_raw(no schemainvolved). Structurally identical to Apollo's, including index nodes,
original_field_nameon aliases,field_execution_weight1.0, and adeferred 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 removedSecurity\Core\Security).That includes the base branch's cache-control tests, which pass alongside this.
Things to be aware of
composer static-analysisandcomposer check-cscould not be run as CIwould. 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.
proto/gen/, outside the paths php-cs-fixerand 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.
timings. Same exposure Apollo Server has; called out in the docs.
google/protobuf: ^5.35.1is a new runtime dependency, and the PHP floormoves 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\RepeatedFieldrather thanGoogle\Protobuf\Internal\RepeatedFieldand adds native parameter types tosetters — so
proto/genis generated with protoc 35.1, the release matchingthe 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-protobufis suggested for speed.