Skip to content

Commit 65e05e3

Browse files
authored
feat: add query plan hash field for expressions (#2738)
1 parent 4feb4ec commit 65e05e3

File tree

6 files changed

+225
-4
lines changed

6 files changed

+225
-4
lines changed

docs-website/router/configuration/template-expressions.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ The `request` object is a read-only entity that provides details about the incom
6464
- `request.operation`
6565
- `request.operation.name`
6666
- `request.operation.type` (possible values: `mutation` or `query` )
67-
- `request.operation.hash`
68-
- `request.operation.sha256Hash`
67+
- `request.operation.hash` - Hash of the normalized operation. Computed before variable remapping, so queries with different variable names produce different hashes. Identical queries with different skip/include variable values also produce different hashes because normalization inlines those directives.
68+
- `request.operation.queryPlanHash` - The Hash used as the query plan cache key. Computed after variable remapping (e.g. `$myId` and `$eid` both become `$0`) and includes skip/include variable values. Two requests share the same `queryPlanHash` when they resolve to the same query plan.
69+
- `request.operation.sha256Hash` - SHA-256 hash of the original operation query string sent by the client. Identical for all requests that send the same raw query body, regardless of variable values.
6970
- `request.operation.parsingTime` (time.Duration)
7071
- `request.operation.persistedId`
7172
- `request.operation.normalizationTime` (time.Duration)

router-tests/observability/structured_logging_test.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4200,6 +4200,202 @@ func TestAccessLogs(t *testing.T) {
42004200
},
42014201
)
42024202
})
4203+
4204+
t.Run("validate queryPlanHash differs with skip/include variations", func(t *testing.T) {
4205+
t.Parallel()
4206+
4207+
testenv.Run(t,
4208+
&testenv.Config{
4209+
AccessLogFields: []config.CustomAttribute{
4210+
{
4211+
Key: "sha256_hash",
4212+
ValueFrom: &config.CustomDynamicAttribute{
4213+
Expression: "request.operation.sha256Hash",
4214+
},
4215+
},
4216+
{
4217+
Key: "operation_hash",
4218+
ValueFrom: &config.CustomDynamicAttribute{
4219+
Expression: "request.operation.hash",
4220+
},
4221+
},
4222+
{
4223+
Key: "query_plan_hash",
4224+
ValueFrom: &config.CustomDynamicAttribute{
4225+
Expression: "request.operation.queryPlanHash",
4226+
},
4227+
},
4228+
{
4229+
Key: "plan_cache_hit",
4230+
ValueFrom: &config.CustomDynamicAttribute{
4231+
Expression: "request.operation.planCacheHit",
4232+
},
4233+
},
4234+
},
4235+
LogObservation: testenv.LogObservationConfig{
4236+
Enabled: true,
4237+
LogLevel: zapcore.InfoLevel,
4238+
},
4239+
}, func(t *testing.T, xEnv *testenv.Environment) {
4240+
query := `query Employee( $id: Int! = 4 $withAligators: Boolean! $withCats: Boolean! $skipDogs:Boolean! $skipMouses:Boolean! ) { employee(id: $id) { details { pets { name __typename ...AlligatorFields @include(if: $withAligators) ...CatFields @include(if: $withCats) ...DogFields @skip(if: $skipDogs) ...MouseFields @skip(if: $skipMouses) ...PonyFields @include(if: false) } } } } fragment AlligatorFields on Alligator { __typename class dangerous gender name } fragment CatFields on Cat { __typename class gender name type } fragment DogFields on Dog { __typename breed class gender name } fragment MouseFields on Mouse { __typename class gender name } fragment PonyFields on Pony { __typename class gender name }`
4241+
4242+
// First request: skipMouses=true, id=4
4243+
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
4244+
OperationName: []byte(`"Employee"`),
4245+
Query: query,
4246+
Variables: []byte(`{"id": 4,"withAligators": true,"withCats": true,"skipDogs": false,"skipMouses": true}`),
4247+
})
4248+
4249+
// Second request: skipMouses=false (same query body, different skip/include variable)
4250+
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
4251+
OperationName: []byte(`"Employee"`),
4252+
Query: query,
4253+
Variables: []byte(`{"id": 4,"withAligators": true,"withCats": true,"skipDogs": false,"skipMouses": false}`),
4254+
})
4255+
4256+
// Third request: same as first (should be plan cache hit)
4257+
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
4258+
OperationName: []byte(`"Employee"`),
4259+
Query: query,
4260+
Variables: []byte(`{"id": 4,"withAligators": true,"withCats": true,"skipDogs": false,"skipMouses": true}`),
4261+
})
4262+
4263+
// Fourth request: different id (same queryPlanHash as first, different non-skip/include variable)
4264+
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
4265+
OperationName: []byte(`"Employee"`),
4266+
Query: query,
4267+
Variables: []byte(`{"id": 3,"withAligators": true,"withCats": true,"skipDogs": false,"skipMouses": true}`),
4268+
})
4269+
4270+
requestLogs := xEnv.Observer().FilterMessage("/graphql").All()
4271+
require.Len(t, requestLogs, 4)
4272+
4273+
ctx1 := requestLogs[0].ContextMap()
4274+
ctx2 := requestLogs[1].ContextMap()
4275+
ctx3 := requestLogs[2].ContextMap()
4276+
ctx4 := requestLogs[3].ContextMap()
4277+
4278+
// All four requests use the same query body, so sha256Hash must be identical
4279+
sha256First, ok := ctx1["sha256_hash"].(string)
4280+
require.True(t, ok)
4281+
require.NotEmpty(t, sha256First)
4282+
sha256Second, ok := ctx2["sha256_hash"].(string)
4283+
require.True(t, ok)
4284+
require.Equal(t, sha256First, sha256Second, "sha256Hash should be the same for identical query bodies")
4285+
sha256Third, ok := ctx3["sha256_hash"].(string)
4286+
require.True(t, ok)
4287+
require.Equal(t, sha256First, sha256Third, "sha256Hash should be the same for identical query bodies")
4288+
sha256Fourth, ok := ctx4["sha256_hash"].(string)
4289+
require.True(t, ok)
4290+
require.Equal(t, sha256First, sha256Fourth, "sha256Hash should be the same for identical query bodies")
4291+
4292+
// hash (analytics hash of normalized operation) differs with skip/include
4293+
// because normalization produces different operations for different skip/include values
4294+
hash1, ok := ctx1["operation_hash"].(string)
4295+
require.True(t, ok)
4296+
require.NotEmpty(t, hash1)
4297+
hash2, ok := ctx2["operation_hash"].(string)
4298+
require.True(t, ok)
4299+
require.NotEqual(t, hash1, hash2, "hash should differ when skip/include variables change")
4300+
hash3, ok := ctx3["operation_hash"].(string)
4301+
require.True(t, ok)
4302+
require.Equal(t, hash1, hash3, "hash should be the same for identical skip/include values")
4303+
// hash should be the same for request 1 and 4 (same skip/include, different id)
4304+
hash4, ok := ctx4["operation_hash"].(string)
4305+
require.True(t, ok)
4306+
require.Equal(t, hash1, hash4, "hash should be the same when only non-skip/include variables change")
4307+
4308+
// queryPlanHash should differ between request 1 and 2 (different skip/include values)
4309+
queryPlanHash1, ok := ctx1["query_plan_hash"].(string)
4310+
require.True(t, ok)
4311+
require.NotEmpty(t, queryPlanHash1)
4312+
queryPlanHash2, ok := ctx2["query_plan_hash"].(string)
4313+
require.True(t, ok)
4314+
require.NotEmpty(t, queryPlanHash2)
4315+
require.NotEqual(t, queryPlanHash1, queryPlanHash2, "queryPlanHash should differ when skip/include variables change")
4316+
4317+
// queryPlanHash should be the same for request 1 and 3 (same skip/include values)
4318+
queryPlanHash3, ok := ctx3["query_plan_hash"].(string)
4319+
require.True(t, ok)
4320+
require.Equal(t, queryPlanHash1, queryPlanHash3, "queryPlanHash should be the same for identical skip/include values")
4321+
4322+
// queryPlanHash should be the same for request 1 and 4 (same skip/include, different id)
4323+
queryPlanHash4, ok := ctx4["query_plan_hash"].(string)
4324+
require.True(t, ok)
4325+
require.Equal(t, queryPlanHash1, queryPlanHash4, "queryPlanHash should be the same when only non-skip/include variables change")
4326+
4327+
// Third request should be a plan cache hit (same queryPlanHash as first)
4328+
planCacheHit3, ok := ctx3["plan_cache_hit"].(bool)
4329+
require.True(t, ok)
4330+
require.True(t, planCacheHit3, "third request should be a plan cache hit")
4331+
})
4332+
})
4333+
4334+
t.Run("validate hash differs but queryPlanHash matches for queries with different variable names", func(t *testing.T) {
4335+
t.Parallel()
4336+
4337+
testenv.Run(t,
4338+
&testenv.Config{
4339+
AccessLogFields: []config.CustomAttribute{
4340+
{
4341+
Key: "operation_hash",
4342+
ValueFrom: &config.CustomDynamicAttribute{
4343+
Expression: "request.operation.hash",
4344+
},
4345+
},
4346+
{
4347+
Key: "query_plan_hash",
4348+
ValueFrom: &config.CustomDynamicAttribute{
4349+
Expression: "request.operation.queryPlanHash",
4350+
},
4351+
},
4352+
},
4353+
LogObservation: testenv.LogObservationConfig{
4354+
Enabled: true,
4355+
LogLevel: zapcore.InfoLevel,
4356+
},
4357+
}, func(t *testing.T, xEnv *testenv.Environment) {
4358+
// Two queries with different variable names but identical structure.
4359+
// The operation names differ but hash is computed with a static empty name,
4360+
// so operation name does not affect hash — only the variable names ($myId vs $eid) do.
4361+
// hash is computed before variable remapping, so different variable names produce different hashes.
4362+
// queryPlanHash is computed after variable remapping ($myId/$eid both become $0), so they match.
4363+
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
4364+
OperationName: []byte(`"EmployeeA"`),
4365+
Query: `query EmployeeA($myId: Int!) { employee(id: $myId) { id } }`,
4366+
Variables: []byte(`{"myId": 4}`),
4367+
})
4368+
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
4369+
OperationName: []byte(`"EmployeeB"`),
4370+
Query: `query EmployeeB($eid: Int!) { employee(id: $eid) { id } }`,
4371+
Variables: []byte(`{"eid": 4}`),
4372+
})
4373+
4374+
requestLogs := xEnv.Observer().FilterMessage("/graphql").All()
4375+
require.Len(t, requestLogs, 2)
4376+
4377+
ctxA := requestLogs[0].ContextMap()
4378+
ctxB := requestLogs[1].ContextMap()
4379+
4380+
// hash should differ (pre-remapping: different variable names $myId vs $eid)
4381+
hashA, ok := ctxA["operation_hash"].(string)
4382+
require.True(t, ok)
4383+
require.NotEmpty(t, hashA)
4384+
hashB, ok := ctxB["operation_hash"].(string)
4385+
require.True(t, ok)
4386+
require.NotEmpty(t, hashB)
4387+
require.NotEqual(t, hashA, hashB, "hash should differ because variable names differ before remapping")
4388+
4389+
// queryPlanHash should match (post-remapping: both $myId and $eid become $0)
4390+
queryPlanHashA, ok := ctxA["query_plan_hash"].(string)
4391+
require.True(t, ok)
4392+
require.NotEmpty(t, queryPlanHashA)
4393+
queryPlanHashB, ok := ctxB["query_plan_hash"].(string)
4394+
require.True(t, ok)
4395+
require.NotEmpty(t, queryPlanHashB)
4396+
require.Equal(t, queryPlanHashA, queryPlanHashB, "queryPlanHash should match because variable names are remapped to canonical names")
4397+
})
4398+
})
42034399
})
42044400
}
42054401

router/core/graphql_prehandler.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,9 @@ func (h *PreHandler) handleOperation(req *http.Request, httpOperation *httpOpera
978978
requestContext.expressionContext.Request.Operation.Hash = operationHash
979979
setTelemetryAttributes(normalizeCtx, requestContext, expr.BucketHash)
980980

981+
requestContext.expressionContext.Request.Operation.QueryPlanHash = strconv.FormatUint(requestContext.operation.internalHash, 10)
982+
setTelemetryAttributes(normalizeCtx, requestContext, expr.BucketQueryPlanHash)
983+
981984
if !requestContext.operation.traceOptions.ExcludeNormalizeStats {
982985
httpOperation.traceTimings.EndNormalize()
983986
}

router/internal/expr/expr.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ type Operation struct {
8686
PersistedID string `expr:"persistedId"`
8787
NormalizationTime time.Duration `expr:"normalizationTime"`
8888
Hash string `expr:"hash"`
89+
QueryPlanHash string `expr:"queryPlanHash"`
8990
ValidationTime time.Duration `expr:"validationTime"`
9091
PlanningTime time.Duration `expr:"planningTime"`
9192

router/internal/expr/request_operation_bucket_visitor.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ const (
1616
BucketPersistedID
1717
BucketNormalizationTime
1818
BucketHash
19+
BucketQueryPlanHash
1920
BucketValidationTime
2021
BucketPlanningTime
2122
BucketSubgraph
2223
)
2324

2425
// RequestOperationBucketVisitor inspects nodes and sets Bucket to the highest-priority match
2526
// Priority (low -> high): any, auth, sha256, parsingTime, name/type, persistedId, normalizationTime,
26-
// hash, validationTime, planningTime, subgraph
27+
// hash, queryPlanHash, validationTime, planningTime, subgraph
2728
type RequestOperationBucketVisitor struct {
2829
Bucket AttributeBucket
2930
}
@@ -90,6 +91,8 @@ func (v *RequestOperationBucketVisitor) Visit(baseNode *ast.Node) {
9091
v.setBucketIfHigher(BucketNormalizationTime)
9192
case "hash":
9293
v.setBucketIfHigher(BucketHash)
94+
case "queryPlanHash":
95+
v.setBucketIfHigher(BucketQueryPlanHash)
9396
case "validationTime":
9497
v.setBucketIfHigher(BucketValidationTime)
9598
case "planningTime":

router/internal/expr/request_operation_bucket_visitor_test.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,20 @@ func TestRequestOperationBucketVisitor(t *testing.T) {
152152
description: "Hash with bracket notation should use hash bucket",
153153
},
154154

155+
// BucketQueryPlanHash - request.operation.queryPlanHash
156+
{
157+
name: "operation queryPlanHash",
158+
expression: `request.operation.queryPlanHash == "12345"`,
159+
expectedBucket: BucketQueryPlanHash,
160+
description: "Query plan hash access should use query plan hash bucket",
161+
},
162+
{
163+
name: "queryPlanHash with bracket notation",
164+
expression: `request["operation"]["queryPlanHash"]`,
165+
expectedBucket: BucketQueryPlanHash,
166+
description: "Query plan hash with bracket notation should use query plan hash bucket",
167+
},
168+
155169
// BucketValidationTime - request.operation.validationTime
156170
{
157171
name: "validationTime",
@@ -335,6 +349,8 @@ func bucketName(bucket AttributeBucket) string {
335349
return "BucketNormalizationTime"
336350
case BucketHash:
337351
return "BucketHash"
352+
case BucketQueryPlanHash:
353+
return "BucketQueryPlanHash"
338354
case BucketValidationTime:
339355
return "BucketValidationTime"
340356
case BucketPlanningTime:
@@ -360,7 +376,8 @@ func TestBucketPriority(t *testing.T) {
360376
assert.True(t, BucketNameOrType < BucketPersistedID, "NameOrType should be lower priority than PersistedID")
361377
assert.True(t, BucketPersistedID < BucketNormalizationTime, "PersistedID should be lower priority than NormalizationTime")
362378
assert.True(t, BucketNormalizationTime < BucketHash, "NormalizationTime should be lower priority than Hash")
363-
assert.True(t, BucketHash < BucketValidationTime, "Hash should be lower priority than ValidationTime")
379+
assert.True(t, BucketHash < BucketQueryPlanHash, "Hash should be lower priority than QueryPlanHash")
380+
assert.True(t, BucketQueryPlanHash < BucketValidationTime, "QueryPlanHash should be lower priority than ValidationTime")
364381
assert.True(t, BucketValidationTime < BucketPlanningTime, "ValidationTime should be lower priority than PlanningTime")
365382
assert.True(t, BucketPlanningTime < BucketSubgraph, "PlanningTime should be lower priority than Subgraph")
366383
}

0 commit comments

Comments
 (0)