You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A GraphQL API rides the exact same event pipeline as a REST API — it gets everything REST gets for free, plus one extra graphqlAnalytics properties bucket for the parts a GraphQL API's single POST route can't express through request.path/request.method the way REST does.
1. Currently supported fields
1.1 Common fields (shared with REST and every other API kind)
These are populated identically regardless of apiType — a GraphQL API gets them exactly as a REST API
does, with no GraphQL-specific code involved.
Attribute
Source
Why is it useful?
api.apiType / api.subType
API CRD kind (RestApi, GraphQLApi, Mcp, …)
Routes events into the right per-kind dashboard/bucket; also the switch this doc's GraphQL enrichment keys off of.
api.apiId, apiName, apiVersion, apiContext
Control-plane API model
Groups events per API/version for adoption, chargeback, and per-API dashboards.
api.apiCreator, apiCreatorTenantDomain
Control-plane API model
Attributes usage/errors to the owning team/tenant.
organizationId, projectId, environmentId
Control-plane API model
Segments analytics per org/project/environment in a multi-tenant deployment.
Standard performance/SLO reporting; splits gateway overhead from backend time.
proxyResponseCode
Envoy access log
The outward-facing HTTP status. For GraphQL this is exactly the field that does not reveal a body-level failure (GraphQL errors ride inside an HTTP 200) — the gap graphqlAnalytics.isError exists to close.
requestSize / responseSize
Envoy access log body byte counts
Standard payload-size telemetry, comparable across API kinds.
responseContentType
Captured response Content-Type header (system policy — the Envoy access log itself carries no response headers)
Confirms the client actually got application/json (or application/graphql-response+json) as expected.
Captured only when request_headers/response_headers/request_body/response_body policy params are enabled
Opt-in deep-dive/debug capture — same gating as every other API kind.
userAgentHeader, userName, userIp
Envoy access log
Standard client identification.
metaInfo.correlationId/regionId/gatewayType
Gateway-wide event metadata
Cross-gateway/cross-region correlation in multi-region deployments.
1.2 GraphQL-specific fields (implemented)
Extracted by the analytics system policy — request-side in OnRequestBody (case policy.APIKindGraphQL),
response-side in OnResponseBody — and merged into event.Properties["graphqlAnalytics"] in the
policy-engine's prepareAnalyticEvent. GraphQLRequestAnalyticsProperties/GraphQLResponseAnalyticsProperties
in analytics.go are the source of truth for field names and json tags.
Correlates repeated calls to the same named operation for chargeback, support, and audit lookups. GraphQL has no separate message/request id, so this is the closest thing to one.
operationType
Leading keyword in request.body.query — regex match on mutation/subscription, default query
Closed set per the GraphQL spec (query/mutation/subscription). Shows read/write/subscribe traffic mix; supports operation-type-level adoption, chargeback, capacity, and reliability views.
requestType
Derived: query text matches \b__schema\b or \b__type\b (word-boundary regex, so __typename doesn't false-positive) → "introspection", else "operation". Unset for a batched request (no single query text).
Keeps IDE/codegen schema-discovery traffic out of real invocation volume while still measuring it separately — the GraphQL analogue of A2A's preflight/agentCard split.
transport
Fixed constant "HTTP" (graphqlTransportHTTP) — this gateway only delivers GraphQL over HTTP POST today
Reserved for "WebSocket"/SSE once subscription delivery ships; until then, a subscription-typed operation still arriving as "HTTP" is itself a signal (see §3).
variableCount
len(request.body.variables) from the same generic-map decode used for operationName/query. Unset for a batched request.
Content-free request-complexity proxy for comparing volume, cost, latency, and errors across operations — GraphQL's analogue of A2A's inputPartCount.
variableNames
Keys of request.body.variables (e.g. ["title"]), sorted for determinism — never the values. Only present when variableCount > 0; unset for a batched request.
Lets a dashboard break down usage/latency by which variable shapes an operation is called with, without ever capturing user-submitted variable content — the same structural-not-content principle already applied to operationName.
isBatched
Request body's first non-whitespace byte is [ rather than { (the common GraphQL batching convention)
Flags batched submissions so per-operation volume/latency isn't undercounted or misattributed to whichever operation happened to be first in the array. Always emitted (true/false), never omitted.
isError
Response body's top-level errors array is present and non-empty
Per GraphQL-over-HTTP, an error rides inside an HTTP 200 body — without this it would be silently counted as a success.
errorCount
len(response.body.errors)
Measures partial-failure severity — a resolver can fail alongside others that succeed — beyond a single boolean.
errorCode
First error entry's extensions.code (Apollo convention, not spec-mandated)
Supports failure investigation and downstream mapping into bounded error categories, without publishing resolver-authored error text (message) into analytics.
isPartialSuccess
Derived: response data is present/non-null anderrors is present. Only set when isError is true — a fully successful response has no partial/full distinction to make.
Unlike JSON-RPC (all-or-nothing), GraphQL can return usable data alongside resolver errors. Distinguishes that case from a fully-failed operation, which a binary isError can't express on its own.
Request- and response-side properties are flattened into one graphqlAnalytics object (maps.Copy), not
nested under request/response sub-keys the way the A2A example does — matching the existing mcpAnalytics convention. gateway/it/features/graphql-analytics.feature asserts flat dotted paths
(graphqlAnalytics.operationName, graphqlAnalytics.isBatched, …) against exactly this shape.
Batched request (isBatched: true): operationName, operationType, requestType, variableCount, and variableNames are all omitted, not zero-valued — none of them has a well-defined
single value across several operations, and reporting one operation's values as if they represented the
whole batch would misattribute data. transport is still emitted (batch-independent):
Query field/selector names (e.g. countries.code, createPost.id, including nested selections,
aliases, and fragments) — this is the one attribute Moesif's own native GraphQL parsing gives you that
our implementation structurally cannot: Moesif detects a GraphQL request and parses the query into an
AST, exposed as Request.GraphqlQuery, with auto-generated filters over the actual selected fields (see Moesif's GraphQL support docs). operationType/ requestType here are regex/prefix heuristics over raw query text (§3) — correctly walking a selection
set (nesting, aliases, fragments, comments) needs a real GraphQL parser, which is a dependency decision,
not a field to add incrementally the way variableNames was. Two independent paths get you this,
and they are not mutually exclusive: (a) adopt a GraphQL parsing library in analytics.go and compute
our own selector-level field, or (b) start populating the Moesif event's Request.Body/Response.Body
(currently always nil in moesif.go — payload capture today only reaches a metadata.request_payload
string field, which Moesif's AST parser doesn't read from) so Moesif's own native parser does this work
for you, for GraphQL traffic specifically, whenever payload capture is enabled. Path (b) is a
cross-API-kind change to the Moesif publisher (Body isn't GraphQL-specific), so it needs its own
design/decision independent of this doc.
outcome / failureOrigin — modeled on the A2A table, but there is no precedent for this taxonomy
anywhere in this codebase, for any API kind. dto.EventCategory/FaultCategory/FaultSubCategory are
defined in gateway/gateway-runtime/policy-engine/internal/analytics/dto/ but are never populated — GetFaultType() hardcodes return FaultCategoryOther, and dto.Error{} is constructed nowhere in the
policy-engine. Building outcome/failureOrigin for GraphQL alone would introduce a new cross-cutting
concept inconsistently (present for GraphQL, absent for REST/MCP/LLM). If this is wanted, it should be
designed once at the shared prepareAnalyticEvent level, driven from proxyResponseCode +
API-kind-specific isError signals uniformly, not bolted onto one API kind's properties bucket.
Operation depth/complexity (selection-set depth, field count) — needs a real GraphQL parser; the
existing regex-based extraction is deliberately lightweight and dependency-free (see §3).
Persisted-query hash (extensions.persistedQuery.sha256Hash, Apollo's Automatic Persisted Queries
convention) — would give client cache-hit-rate visibility, but isn't read anywhere today.
Per-operation fields for a batched request (e.g. an array of operationTypes, one per batched
operation, or an aggregate errorCount across the batch) — an open design question deferred alongside
response-side batch parsing; see §3.
3. Limitations and future work
No real GraphQL parser.operationType and requestType are both regex/prefix heuristics over the
query text, not an AST walk. Known edge cases: a query document with a leading comment containing the
word mutation/subscription/__schema before the real operation keyword; a document defining multiple
named operations (the spec allows this, operationName picks which one runs) where the current
extraction only inspects the document text, not the selected operation specifically — a
multi-operation document mixing a query and a mutation would be misclassified if the selected one
isn't the first keyword encountered.
Anonymous query field resolution is out of scope. Resolving an anonymous ({ ... }) query's
top-level field name needs a real parser and is deliberately not attempted — anonymous queries only ever
get operationType/requestType, never a synthesized name.
Batching is only handled request-side.isBatched correctly detects a batched request, but OnResponseBody's JSON handling still only accepts a single top-level object (trimmed[0] == '{'); a
batched response (top-level [...]) is not parsed for analytics at all today — no graphql_response_properties are emitted for it. Extending that parsing path, and then deciding how a
batch's several operations map onto the still-singular graphqlAnalytics properties bucket (aggregate errorCount across the batch? one event per batched operation?), is future work — see §2.
No subscription/streaming support. GraphQL responses in this gateway are always a single buffered
JSON object; there is no WebSocket or SSE delivery path for subscription operations. The A2A-style isStreaming/timeToFirstEventMs/streamDurationMs fields have no GraphQL equivalent yet for this
reason — transport stays the constant "HTTP" until subscription delivery is built, at which point
those latency fields would become directly relevant, and a subscription-typed operationType still
paired with transport: "HTTP" is itself a useful signal that the operation could not actually be
fulfilled as a subscription.
errorCode only reflects the first error entry. A response with multiple errors carrying different extensions.code values only surfaces one code; errorCount is the only signal for the rest. Widening
this (e.g. an array of codes) is a straightforward follow-up but changes the current flat-value contract
tested in moesif_test.go/graphql-analytics.feature.
Introspection detection is a regex, not schema-aware.requestType: "introspection" triggers on the
literal substrings __schema/__type appearing anywhere in the query text with a word boundary — it
cannot tell an introspection-only request apart from a (unusual, but legal) document mixing introspection
fields alongside real business fields in the same query; such a mixed document is classified as "introspection".
outcome/failureOrigin need a platform-wide design, not a GraphQL-only one — see §2.
variableNames is a name list, not a schema. It reports which variables were declared, not their
GraphQL types or whether they were actually referenced inside the query body — a mismatched/unused
variable declaration isn't distinguishable from one that's used. This is intentionally shallow (no
parser dependency); see the "query field/selector names" entry in §2 for what a real parser would add.
Resolver-authored error text is intentionally not captured by any field in this doc — only the
categorical extensions.code is. Raw error message text only reaches analytics if response_body
payload capture is separately enabled (an existing opt-in shared with every other API kind), which is a
deliberate scope boundary, not an oversight.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
A GraphQL API rides the exact same event pipeline as a REST API — it gets everything REST gets for free, plus one extra graphqlAnalytics properties bucket for the parts a GraphQL API's single POST route can't express through request.path/request.method the way REST does.
1. Currently supported fields
1.1 Common fields (shared with REST and every other API kind)
These are populated identically regardless of
apiType— a GraphQL API gets them exactly as a REST APIdoes, with no GraphQL-specific code involved.
api.apiType/api.subTypeRestApi,GraphQLApi,Mcp, …)api.apiId,apiName,apiVersion,apiContextapi.apiCreator,apiCreatorTenantDomainorganizationId,projectId,environmentIdoperation.apiMethod/apiResourceTemplatePOST /<context>— exactly why GraphQL needs its ownoperationName/operationType(§1.2).target.targetResponseCode,responseCacheHit,destination,responseCodeDetailapplication.applicationId/applicationName/applicationOwner/keyTypex-wso2-application-*headers set by auth/subscription policiessubscription.billingCustomerId/billingSubscriptionId/status/planNameSharedContext.Metadata, written by subscription-validation policyx-wso2-user-id,-auth-type,-auth-issuer,-auth-credential-id,-auth-token-id,-auth-audience,-auth-scopes,-auth-properties,-auth-authorizedSharedContext.AuthContext, populated generically by whichever auth policy ran (jwt-auth, api-key-auth, …)latencies.responseLatency/backendLatency/requestMediationLatency/responseMediationLatency/durationCommonPropertiestimepointsproxyResponseCodegraphqlAnalytics.isErrorexists to close.requestSize/responseSizeresponseContentTypeContent-Typeheader (system policy — the Envoy access log itself carries no response headers)application/json(orapplication/graphql-response+json) as expected.requestHeaders/responseHeaders,request_payload/response_payloadrequest_headers/response_headers/request_body/response_bodypolicy params are enableduserAgentHeader,userName,userIpmetaInfo.correlationId/regionId/gatewayType1.2 GraphQL-specific fields (implemented)
Extracted by the
analyticssystem policy — request-side inOnRequestBody(case policy.APIKindGraphQL),response-side in
OnResponseBody— and merged intoevent.Properties["graphqlAnalytics"]in thepolicy-engine's
prepareAnalyticEvent.GraphQLRequestAnalyticsProperties/GraphQLResponseAnalyticsPropertiesin
analytics.goare the source of truth for field names and json tags.operationNamerequest.body.operationName(client-supplied; Apollo/Relay-style clients always send one)operationTyperequest.body.query— regex match onmutation/subscription, defaultqueryquery/mutation/subscription). Shows read/write/subscribe traffic mix; supports operation-type-level adoption, chargeback, capacity, and reliability views.requestType\b__schema\bor\b__type\b(word-boundary regex, so__typenamedoesn't false-positive) →"introspection", else"operation". Unset for a batched request (no single query text).preflight/agentCardsplit.transport"HTTP"(graphqlTransportHTTP) — this gateway only delivers GraphQL over HTTP POST today"WebSocket"/SSE once subscription delivery ships; until then, asubscription-typed operation still arriving as"HTTP"is itself a signal (see §3).variableCountlen(request.body.variables)from the same generic-map decode used foroperationName/query. Unset for a batched request.inputPartCount.variableNamesrequest.body.variables(e.g.["title"]), sorted for determinism — never the values. Only present whenvariableCount > 0; unset for a batched request.operationName.isBatched[rather than{(the common GraphQL batching convention)true/false), never omitted.isErrorerrorsarray is present and non-emptyerrorCountlen(response.body.errors)errorCodeextensions.code(Apollo convention, not spec-mandated)message) into analytics.isPartialSuccessdatais present/non-null anderrorsis present. Only set whenisErroris true — a fully successful response has no partial/full distinction to make.dataalongside resolvererrors. Distinguishes that case from a fully-failed operation, which a binaryisErrorcan't express on its own.Analytics event structure (current)
{ "api": { "apiType": "GraphQLApi", "apiName": "countries-api", "apiVersion": "v1", "...": "..." }, "operation": { "apiMethod": "POST", "apiResourceTemplate": "/graphql" }, "target": { "targetResponseCode": 200, "destination": "http://countries-backend:8080", "...": "..." }, "application": { "applicationId": "app-123", "applicationName": "web-console" }, "subscription": { "planName": "gold", "status": "ACTIVE" }, "latencies": { "responseLatency": 42, "backendLatency": 30, "duration": 42 }, "proxyResponseCode": 200, "requestSize": 128, "responseSize": 512, "properties": { "apiContext": "/graphql-analytics-mutation-e2e", "responseContentType": "application/json", "graphqlAnalytics": { "operationName": "CreatePost", "operationType": "mutation", "requestType": "operation", "transport": "HTTP", "variableCount": 1, "variableNames": ["title"], "isBatched": false, "isError": true, "errorCount": 1, "errorCode": "FORBIDDEN", "isPartialSuccess": false } } }Request- and response-side properties are flattened into one
graphqlAnalyticsobject (maps.Copy), notnested under
request/responsesub-keys the way the A2A example does — matching the existingmcpAnalyticsconvention.gateway/it/features/graphql-analytics.featureasserts flat dotted paths(
graphqlAnalytics.operationName,graphqlAnalytics.isBatched, …) against exactly this shape.Batched request (
isBatched: true):operationName,operationType,requestType,variableCount, andvariableNamesare all omitted, not zero-valued — none of them has a well-definedsingle value across several operations, and reporting one operation's values as if they represented the
whole batch would misattribute data.
transportis still emitted (batch-independent):{ "graphqlAnalytics": { "transport": "HTTP", "isBatched": true } }2. Fields considered but not implemented
countries.code,createPost.id, including nested selections,aliases, and fragments) — this is the one attribute Moesif's own native GraphQL parsing gives you that
our implementation structurally cannot: Moesif detects a GraphQL request and parses the query into an
AST, exposed as
Request.GraphqlQuery, with auto-generated filters over the actual selected fields (seeMoesif's GraphQL support docs).
operationType/requestTypehere are regex/prefix heuristics over raw query text (§3) — correctly walking a selectionset (nesting, aliases, fragments, comments) needs a real GraphQL parser, which is a dependency decision,
not a field to add incrementally the way
variableNameswas. Two independent paths get you this,and they are not mutually exclusive: (a) adopt a GraphQL parsing library in
analytics.goand computeour own selector-level field, or (b) start populating the Moesif event's
Request.Body/Response.Body(currently always
nilinmoesif.go— payload capture today only reaches ametadata.request_payloadstring field, which Moesif's AST parser doesn't read from) so Moesif's own native parser does this work
for you, for GraphQL traffic specifically, whenever payload capture is enabled. Path (b) is a
cross-API-kind change to the Moesif publisher (
Bodyisn't GraphQL-specific), so it needs its owndesign/decision independent of this doc.
outcome/failureOrigin— modeled on the A2A table, but there is no precedent for this taxonomyanywhere in this codebase, for any API kind.
dto.EventCategory/FaultCategory/FaultSubCategoryaredefined in
gateway/gateway-runtime/policy-engine/internal/analytics/dto/but are never populated —GetFaultType()hardcodesreturn FaultCategoryOther, anddto.Error{}is constructed nowhere in thepolicy-engine. Building
outcome/failureOriginfor GraphQL alone would introduce a new cross-cuttingconcept inconsistently (present for GraphQL, absent for REST/MCP/LLM). If this is wanted, it should be
designed once at the shared
prepareAnalyticEventlevel, driven fromproxyResponseCode+API-kind-specific
isErrorsignals uniformly, not bolted onto one API kind's properties bucket.existing regex-based extraction is deliberately lightweight and dependency-free (see §3).
extensions.persistedQuery.sha256Hash, Apollo's Automatic Persisted Queriesconvention) — would give client cache-hit-rate visibility, but isn't read anywhere today.
operationTypes, one per batchedoperation, or an aggregate
errorCountacross the batch) — an open design question deferred alongsideresponse-side batch parsing; see §3.
3. Limitations and future work
operationTypeandrequestTypeare both regex/prefix heuristics over thequery text, not an AST walk. Known edge cases: a query document with a leading comment containing the
word
mutation/subscription/__schemabefore the real operation keyword; a document defining multiplenamed operations (the spec allows this,
operationNamepicks which one runs) where the currentextraction only inspects the document text, not the selected operation specifically — a
multi-operation document mixing a
queryand amutationwould be misclassified if the selected oneisn't the first keyword encountered.
{ ... }) query'stop-level field name needs a real parser and is deliberately not attempted — anonymous queries only ever
get
operationType/requestType, never a synthesized name.isBatchedcorrectly detects a batched request, butOnResponseBody's JSON handling still only accepts a single top-level object (trimmed[0] == '{'); abatched response (top-level
[...]) is not parsed for analytics at all today — nographql_response_propertiesare emitted for it. Extending that parsing path, and then deciding how abatch's several operations map onto the still-singular
graphqlAnalyticsproperties bucket (aggregateerrorCountacross the batch? one event per batched operation?), is future work — see §2.JSON object; there is no WebSocket or SSE delivery path for
subscriptionoperations. The A2A-styleisStreaming/timeToFirstEventMs/streamDurationMsfields have no GraphQL equivalent yet for thisreason —
transportstays the constant"HTTP"until subscription delivery is built, at which pointthose latency fields would become directly relevant, and a
subscription-typedoperationTypestillpaired with
transport: "HTTP"is itself a useful signal that the operation could not actually befulfilled as a subscription.
errorCodeonly reflects the first error entry. A response with multiple errors carrying differentextensions.codevalues only surfaces one code;errorCountis the only signal for the rest. Wideningthis (e.g. an array of codes) is a straightforward follow-up but changes the current flat-value contract
tested in
moesif_test.go/graphql-analytics.feature.requestType: "introspection"triggers on theliteral substrings
__schema/__typeappearing anywhere in the query text with a word boundary — itcannot tell an introspection-only request apart from a (unusual, but legal) document mixing introspection
fields alongside real business fields in the same query; such a mixed document is classified as
"introspection".outcome/failureOriginneed a platform-wide design, not a GraphQL-only one — see §2.variableNamesis a name list, not a schema. It reports which variables were declared, not theirGraphQL types or whether they were actually referenced inside the query body — a mismatched/unused
variable declaration isn't distinguishable from one that's used. This is intentionally shallow (no
parser dependency); see the "query field/selector names" entry in §2 for what a real parser would add.
categorical
extensions.codeis. Raw errormessagetext only reaches analytics ifresponse_bodypayload capture is separately enabled (an existing opt-in shared with every other API kind), which is a
deliberate scope boundary, not an oversight.
All reactions