graph_weaver 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
data/docs/i18n.md ADDED
@@ -0,0 +1,171 @@
1
+ # Translating input errors
2
+
3
+ graph_weaver hands your app **a stable key and the facts**, and lets the app own
4
+ the sentence. `kind` — a Symbol from a short closed vocabulary — and the key
5
+ derived from it, `graph_weaver.input.<kind>`, are API: they change only with a
6
+ MAJOR bump, and a new one arrives only as an addition. `#message` is the
7
+ developer's line, in English, and it will be reworded. See
8
+ [errors](errors.md) for `InputError` itself and for what a server has to send.
9
+
10
+ ## Translating it
11
+
12
+ No locale file ships — the strings are app-voice, not library-voice, and owning
13
+ a user-visible English string would make every reword a breaking change. Paste
14
+ this into your own `config/locales/en.yml`:
15
+
16
+ ```yaml
17
+ en:
18
+ graph_weaver:
19
+ input:
20
+ type_mismatch: "%{field} must be a %{type}."
21
+ unparseable: "%{field} isn't a valid %{type}."
22
+ not_a_member: "%{field} must be one of: %{members}."
23
+ missing: "%{field} is required."
24
+ unknown: "%{field} isn't a field we recognize."
25
+ out_of_range: "%{field} is out of range."
26
+ invalid_format: "%{field} isn't in the right format."
27
+ refused: "%{field} was rejected."
28
+ ```
29
+
30
+ ```ruby
31
+ def render_input_error(error)
32
+ I18n.t(
33
+ "graph_weaver.input.#{error.kind}",
34
+ field: error.field,
35
+ value: error.value,
36
+ **error.details,
37
+ default: error.message, # untranslated kinds fall back, never "translation missing"
38
+ )
39
+ end
40
+ ```
41
+
42
+ One call serves both halves — a refusal raised before the request left, and one
43
+ the server sent back:
44
+
45
+ ```ruby
46
+ # before the wire
47
+ rescue GraphWeaver::InputError => e
48
+ form.errors.add(e.field&.underscore || :base, render_input_error(e))
49
+
50
+ # and after it, the same call
51
+ response.input_errors.each { |e| form.errors.add(e.field&.underscore || :base, render_input_error(e)) }
52
+ ```
53
+
54
+ `#field` is the schema's spelling on both sides, so one `underscore` is the whole
55
+ mapping to a Rails form field. **Plan for `nil`** — nothing named a slot, which
56
+ is what a server that states no input path gives you, and the default until your
57
+ server adopts [the convention](errors.md#what-your-server-can-send).
58
+
59
+ `default:` is load-bearing: `:refused` is untranslatable by definition, so every
60
+ app needs the fallback — and having it means you can translate the four kinds you
61
+ care about and leave the rest.
62
+
63
+ ## The vocabulary
64
+
65
+ Eight kinds; a ninth arrives only when a real input failure fits none of them.
66
+ The last four columns are **who can say it**, which is the other half of the
67
+ story — a `kind` is worth only as much as the producer that can state it, and the
68
+ last of them is any server at all, through the
69
+ [convention](#what-the-server-has-to-do).
70
+
71
+ | `kind` | means | `details` carries | client | graphql-ruby | Hasura | convention |
72
+ |---|---|---|---|---|---|---|
73
+ | `:type_mismatch` | the value is not the type the schema declares, and no conversion applies | `type` | yes | yes | **no** | yes |
74
+ | `:unparseable` | the right kind of thing, but the text doesn't parse as that scalar | `type` | yes | yes | **no** | yes |
75
+ | `:not_a_member` | not one of the values an enum (or an inclusion rule) allows | `members` | yes | yes | yes | yes |
76
+ | `:missing` | a required field or argument wasn't supplied, or was null | — | yes | yes | yes | yes |
77
+ | `:unknown` | a key the input type doesn't define — a typo | `suggestion` | yes | yes | yes | yes |
78
+ | `:out_of_range` | a number, length or date outside the bounds the schema states | `min`, `max` | **no** | **no** | **no** | yes |
79
+ | `:invalid_format` | parses, right type, but fails a stated semantic rule — a regex, an email | `pattern` | **no** | **no** | **no** | yes |
80
+ | `:refused` | rejected, with nothing more than a message — the fallback, never a guess | — | yes | yes | yes | yes |
81
+
82
+ `field` and `value` ride on every kind. `value` is the offending value passed
83
+ through [`filter_parameters`](logging.md#filtered-variables), so it reads
84
+ `"[FILTERED]"` under a sensitive key and `nil` when the producer never said what
85
+ the value was.
86
+
87
+ Two rows carry the headline: **`:out_of_range` and `:invalid_format` — the
88
+ everyday "right type, wrong value" — cannot be produced from either side without
89
+ the [server convention](errors.md#what-your-server-can-send).** The client
90
+ doesn't know the schema's bounds, and a graphql-ruby `validates:` failure reaches
91
+ the wire as a bare sentence with no `extensions` at all.
92
+
93
+ Those three are the servers graph_weaver reads by name. **Apollo** is `:refused`
94
+ and the server's own sentence, nothing finer. **Anything else** says nothing
95
+ graph_weaver will claim: `#input_errors` comes back `[]`, and the rejection stays
96
+ an ordinary error to render as the server wrote it. Which shape each server is
97
+ read off, and why Hasura's blanks are blank, is in
98
+ [errors](errors.md#when-the-server-rejects-the-input).
99
+
100
+ ## Details
101
+
102
+ One class carries both halves: `GraphWeaver::InputError` is *raised* before the
103
+ request leaves, and it is also the **value** a server's rejection becomes — read
104
+ back by `Response#input_errors`, `QueryError#input_errors` and
105
+ `GraphQLError#input_errors`, all plural, since one variable-coercion error
106
+ routinely carries problems about several fields. What it holds is the table in
107
+ [errors](errors.md#what-an-inputerror-says-without-reading-english); the one
108
+ thing a translator needs from it is that every named segment of `#path` — and so
109
+ `#field` — is the **schema's** spelling, whichever side refused
110
+ ([why](errors.md#which-spelling-a-path-is-in)).
111
+
112
+ **`field:` can't say which row.** A list index lives in `#path` and nowhere else,
113
+ so every element of `lines: [LineInput!]!` translates to the same sentence. Where
114
+ a form lists rows, label it from the path:
115
+
116
+ ```ruby
117
+ # "input.lines.0.qty", where #field alone is "qty" for every line at once
118
+ I18n.t("graph_weaver.input.#{error.kind}", field: error.path.join("."), **error.details)
119
+ ```
120
+
121
+ Arrays stay arrays: `details[:members]` is `["CAT", "DOG"]`, so join it where you
122
+ know the language — `members: e.details[:members].to_sentence`.
123
+
124
+ `details[:type]` is the **GraphQL** name for the type — `Int`, `Money` — so a
125
+ sentence built from it still says "Int" in the middle of the French. Translate
126
+ the name yourself under a key of your own and pass it *after* the splat, which
127
+ wins: `I18n.t(key, **e.details, type: I18n.t("types.#{e.details[:type]}"))`.
128
+
129
+ `@oneOf` violations are `:refused`, except exactly one field explicitly null,
130
+ which is `:missing` on that field — a form gets the one slot to highlight. The
131
+ wrong *count* names no single field, so it stays a message.
132
+
133
+ ### What the server has to do
134
+
135
+ For the two kinds nothing can produce on its own, and for every column the table
136
+ leaves blank, the server states it — under one key, `extensions.input`, with the
137
+ `kind` taken from the table above. The shape, the graphql-ruby recipes for it,
138
+ and what a server that sends none of it gets instead are in
139
+ [errors](errors.md#what-your-server-can-send).
140
+
141
+ Two rules matter to a translator. **A `kind` outside the table becomes
142
+ `:refused`** rather than being passed through, because a wrong key is worse than
143
+ no key: the app will have translated it into a confident sentence. And **a key
144
+ outside `type`/`members`/`min`/`max`/`pattern`/`suggestion` is dropped** before it
145
+ reaches `details`, so `I18n.t(..., **details)` can never be handed an
146
+ interpolation your locale file has no slot for — nor one I18n reserves, which is
147
+ why it is `:pattern` and not `:format`.
148
+
149
+ ### Transport and server failures
150
+
151
+ `kind` exists for input errors and nothing else. The failures beside them name no
152
+ field and carry nothing to interpolate, so key those on the **exception class**,
153
+ which is the only closed vocabulary on that side — a `code` is a per-server
154
+ invention, so a translation keyed on one degrades to English against the next
155
+ server:
156
+
157
+ | what you rescued | what the user is told |
158
+ |---|---|
159
+ | `GraphWeaver::TransportError` | we couldn't reach the service — try again |
160
+ | `GraphWeaver::ServerError` | the service is having trouble — try later (`#throttled?` earns its own sentence) |
161
+ | `GraphWeaver::QueryError` whose `#input_errors` is empty | something went wrong with that request |
162
+
163
+ One static translated sentence each, and log what the server said rather than
164
+ showing it.
165
+
166
+ ### Where translation stops
167
+
168
+ graph_weaver's own refusals are for developers and stay English, with no keys:
169
+ `QueryValidationError`, the codegen errors, the schema-drift hint,
170
+ `ConfigurationError`, `Testing::*`, and `InputError#message`. A message the
171
+ *server* wrote arrives in whatever language the server chose, untouched.
data/docs/logging.md CHANGED
@@ -1,42 +1,43 @@
1
1
  # Logging
2
2
 
3
3
  Silent by default. Point `GraphWeaver.logger` at anything
4
- stdlib-Logger-compatible and the whole flow narrates itself — in Rails
5
- the railtie wires `Rails.logger` automatically (set
6
- `GraphWeaver.logger = nil` in an initializer to opt out):
4
+ stdlib-Logger-compatible and the whole flow narrates itself — in Rails the
5
+ railtie wires `Rails.logger` automatically (set `GraphWeaver.logger = nil` in an
6
+ initializer to opt out):
7
7
 
8
8
  ```ruby
9
9
  GraphWeaver.logger = Logger.new($stdout, level: Logger::INFO)
10
10
  ```
11
11
 
12
- What logs at which level — pick the level, get the story:
12
+ Pick the level, get the story:
13
13
 
14
14
  | Level | What you see |
15
15
  |-------|--------------|
16
- | `debug` | the wire: query + variables per call (long queries truncated), response status/bytes, request timing, connection open/drop, dynamically parsed modules |
17
- | `info` | schema introspection (with timing) and cache hits/misses, the transport a client built, generated files written and any unregistered scalars, query modules loaded, a retry's wait and attempt number — and in development, what's being watched and what a save regenerated |
18
- | `warn` | every GraphWeaver error raised — `TransportError`, `ServerError`, `QueryError`, `ValidationError`, `TypeError` — registrations the schema being generated against can't match, a retry skipped because the operation was a mutation, and every fetch the test router answered with fabricated data |
16
+ | `debug` | the wire: query + variables per call (long queries truncated), response status/bytes/content type, request timing, connection open/drop, dynamically parsed modules |
17
+ | `info` | one line per operation in Rails (see [Instrumentation](#instrumentation)), schema introspection (with timing) and cache hits/misses, the transport a client built, generated files written and any unregistered scalars, query modules loaded, a retry's wait and attempt number — and in development, what's being watched and what a save regenerated |
18
+ | `warn` | every GraphWeaver error raised — `TransportError`, `ServerError`, `QueryError`, `QueryValidationError`, `CastError` — registrations the schema being generated against can't match, a retry skipped because the operation was a mutation, and every fetch the test router answered with fabricated data |
19
19
  | `error` | development only: a `.graphql` edit that won't compile, with its file and position — the modules already loaded keep serving |
20
20
 
21
- Every line carries `graph_weaver` as the progname, so formatter-based
22
- filtering works out of the box. Wire lines are tagged
23
- `[req 3 FilteredPokemon]` — a per-process request id plus the operation
24
- name so a request's lines stay paired when threads interleave.
21
+ Every line carries `graph_weaver` as the progname, so formatter-based filtering
22
+ works out of the box. Wire lines are tagged `[req 4123-3 FilteredPokemon]` — pid,
23
+ that process's own request count, operation name so a request's lines stay
24
+ paired when threads interleave and distinct when a Puma cluster's workers share a
25
+ log.
25
26
 
26
- **PII note**: queries, variables, and response sizes appear at debug
27
- only variables can carry user data, so keep production loggers at
28
- info or above. Auth headers never log at any level.
27
+ **PII note**: queries, variables and response sizes appear at debug only, so keep
28
+ production loggers at info or above. Auth headers never log at any level.
29
29
 
30
30
  ## Filtered variables
31
31
 
32
32
  Debug gets switched on during an incident, which is exactly when a
33
- `login(password:)` mutation's variables must not land in the log. So the
34
- values of sensitive keys are replaced with `[FILTERED]` before the line is
35
- written — at any depth, including inside input objects.
33
+ `login(password:)` mutation's variables must not land in the log. So the values of
34
+ sensitive keys are replaced with `[FILTERED]` before the line is written, at any
35
+ depth, including inside input objects.
36
36
 
37
37
  In Rails you configure nothing: the railtie adopts the app's own
38
38
  `config.filter_parameters`, so GraphWeaver scrubs whatever the request logs
39
- already scrub.
39
+ already scrub — Rails' own default list has `:email` on it, so an ordinary field
40
+ named `email` reads `[FILTERED]` the day you add the gem.
40
41
 
41
42
  Everywhere else, one list:
42
43
 
@@ -45,48 +46,194 @@ GraphWeaver.filter_parameters = [:password, /token/]
45
46
  ```
46
47
 
47
48
  Strings and Symbols match as case-insensitive substrings — `:token` covers
48
- `apiToken` — and Regexps match themselves. The default is `[:password,
49
- :token, :secret, :authorization]`, which covers the usual names before
50
- anyone configures anything; assigning replaces it rather than adding to it,
51
- and `[]` turns filtering off. Anything answering `#filter(hash)` is used
52
- as-is, which is how the railtie hands over an
53
- `ActiveSupport::ParameterFilter`.
54
-
55
- The same list scrubs error messages, which reach the log at `warn` rather
56
- than `debug`: a variable, input field, or entity key whose name is filtered
57
- is rejected with `[FILTERED]` in place of the value — everything else keeps
58
- quoting it, since `expected an Int, got "lots"` is the whole diagnosis.
49
+ `apiToken` — and Regexps match themselves. The default is `[:password, :token,
50
+ :secret, :authorization]`; assigning replaces it rather than adding to it, and
51
+ `[]` turns filtering off for variables and messages. A credential in the *url* is
52
+ scrubbed either way. Anything answering `#filter(hash)` is used as-is, which is
53
+ how the railtie hands over an `ActiveSupport::ParameterFilter`.
54
+
55
+ The same list scrubs error messages, which reach the log at `warn` rather than
56
+ `debug`: a variable, input field or entity key whose name is filtered is rejected
57
+ with `[FILTERED]` in place of the value, at every depth a filtered key one level
58
+ in reads `got {"token" => "[FILTERED]"}`. Everything else keeps quoting the
59
+ value, since `expected an Int, got "lots"` is the whole diagnosis.
59
60
 
60
61
  ## Instrumentation
61
62
 
62
- A logger tells a human what happened; an APM needs to time it and count
63
- it. `GraphWeaver.instrumenter` is one callable wrapping every request —
64
- over the wire *and* in-process, one seam for both paths. It's a no-op
65
- until you set one, and `ActiveSupport::Notifications` is a two-line
66
- adapter:
63
+ A logger tells a human what happened; an APM needs to time it and count it.
64
+ `GraphWeaver.instrumenter` is one callable wrapping every request — over the wire
65
+ *and* in-process. **In Rails you set nothing**: the railtie installs the adapter
66
+ below and attaches `GraphWeaver::LogSubscriber` on top of it. Everywhere else:
67
67
 
68
68
  ```ruby
69
69
  GraphWeaver.instrumenter = lambda do |event, payload, &block|
70
70
  ActiveSupport::Notifications.instrument(event, payload, &block)
71
71
  end
72
+ ```
73
+
74
+ An instrumenter you set yourself is never replaced — including
75
+ `GraphWeaver.instrumenter = nil`, which opts out. Yours **must** call the block
76
+ and return its value; a failure propagates through it, so the hook sees the
77
+ exception and can record it.
72
78
 
79
+ The one event is `GraphWeaver::EXECUTE_EVENT` (`"execute.graph_weaver"`) — one
80
+ request, start to parsed response, whichever client slot served it:
81
+
82
+ ```ruby
73
83
  ActiveSupport::Notifications.subscribe(GraphWeaver::EXECUTE_EVENT) do |*, payload|
74
- StatsD.timing("graphql.#{payload[:operation] || "anonymous"}", ...)
84
+ StatsD.timing("graphql.#{payload[:operation] || "anonymous"}", payload[:duration_ms],
85
+ tags: ["status:#{payload[:status]}", "kind:#{payload[:kind]}", "code:#{payload[:code]}"])
86
+ end
87
+ ```
88
+
89
+ **A subscriber that raises takes the request down with it** — the response was
90
+ computed and is then thrown away. That is `ActiveSupport::Notifications`' own
91
+ semantics, the same on `sql.active_record`, so rescue inside the block. A
92
+ `GraphWeaver::LogSubscriber` subclass already does.
93
+
94
+ ### The payload
95
+
96
+ | Key | When | |
97
+ |-----|------|--|
98
+ | `:operation` | always | the operation name sent with the request, nil for an anonymous document — what a trace keys on (a generated module always has one) |
99
+ | `:client` | always | the class that ran it: `GraphWeaver::Transport::HTTP`, `GraphWeaver::InProcess`, your own |
100
+ | `:kind` | always | `:query`, `:mutation` or `:subscription` — what the document runs, so a write failure rate is a payload question rather than a guess at the operation's name. The shorthand `{ ... }` document is a `:query`. The same reading decides whether [`Retry`](transports.md#retries) may repeat the request |
101
+ | `:status` | always | `:ok`, `:errors` (a response carrying GraphQL errors), or `:failed` (it raised) |
102
+ | `:duration_ms` | always | start to parsed response, for **this attempt** — under a [`Retry`](transports.md#retries) no event covers the backoff sleep, so a tracer recovers the caller's wall clock from the sibling spans instead: the first attempt's start to the last one's end |
103
+ | `:url` | over the wire | the endpoint; nil in-process |
104
+ | `:http_status` | over the wire | what the server answered with, success or not; nil in-process |
105
+ | `:schema` | in-process | the schema class's name, as a String, so a payload logs as it stands |
106
+ | `:code` | on `:errors` | the machine-readable reason, always a String or `nil` — the first `extensions.code` *any* of the errors carries, not the first error's, since a code that exists beats the absence of one at position 0. Present and `nil` when the errors carry none. Never an HTTP status: that is `:http_status` |
107
+ | `:error` | on `:failed` | the exception's class name |
108
+ | `:retries` | under a `Retry` | how many retries this attempt follows. Each attempt is its own event, so one retried call is three events reading 0, 1, 2 — present at 0 rather than absent, so its absence means nothing was retrying |
109
+ | `:graph` | always | the [graph](getting_started.md#more-than-one-schema) the generated module was declared under, as a Symbol — `nil` for a module that names none, and for a client called directly. Never inferred from the client: a wrong graph on a request is worse than no graph |
110
+
111
+ Every key is filled in before your callable's block returns, so a subscriber reads
112
+ a complete payload; `ActiveSupport::Notifications` adds `:exception` and
113
+ `:exception_object` of its own when the block raises.
114
+
115
+ **`:graph` labels one request**, never what a *server* does while answering one:
116
+ an in-process resolver that calls out produces an event of its own. The label is
117
+ fiber-local, so a dispatch that crosses a `Fiber` — graphql-ruby's `Dataloader`
118
+ does — arrives with `:graph` unset. No label rather than a wrong one.
119
+
120
+ **Never the query text or the variables**: the payload fans out to subscribers
121
+ that know none of the filtering rules, so the rule here isn't "scrub it", it's
122
+ that it was never there. `:url` is the one thing on the payload that *is*
123
+ scrubbed, because a url can itself be a credential — and the default names apply
124
+ to its query parameters even when you have emptied or narrowed
125
+ `filter_parameters`. Your list widens that; it can't narrow it.
126
+
127
+ ### One line per operation
128
+
129
+ In Rails the railtie also attaches `GraphWeaver::LogSubscriber`, which turns each
130
+ event into one line — the shape ActiveRecord uses for a query:
131
+
132
+ ```
133
+ GraphWeaver PersonQuery (12.3ms) ok
134
+ GraphWeaver PersonQuery (8.1ms) errors [THROTTLED]
135
+ GraphWeaver PersonQuery (31.2ms) failed GraphWeaver::TransportError
136
+ GraphWeaver PersonQuery (5.0ms) ok (retry 2)
137
+ GraphWeaver billing/InvoicesQuery (12.3ms) ok
138
+ ```
139
+
140
+ The operation is prefixed by its graph when the request carried one, so an app
141
+ with several graphs sorts its own log and an app with one never sees the prefix.
142
+
143
+ **The summary is info, the wire is debug.** This is the only GraphWeaver line at
144
+ info, so a production log gets one per operation and nothing that can carry PII;
145
+ debug adds the query, the variables and the response *beneath* it. It writes
146
+ through `GraphWeaver.logger`.
147
+
148
+ Outside Rails the same line is an instrumenter of your own, writing
149
+ `payload[:operation]`, `[:duration_ms]` and `[:status]` from an `ensure` — the
150
+ shape the two tracing examples below use.
151
+
152
+ ### OpenTelemetry
153
+
154
+ ```ruby
155
+ tracer = OpenTelemetry.tracer_provider.tracer("graph_weaver")
156
+
157
+ GraphWeaver.instrumenter = lambda do |_event, payload, &block|
158
+ tracer.in_span("graphql #{payload[:operation] || "query"}") do |span|
159
+ block.call
160
+ ensure
161
+ span.add_attributes(payload.compact.transform_keys { "graphql.#{_1}" }.transform_values(&:to_s))
162
+ span.status = OpenTelemetry::Trace::Status.error(payload[:code] || "graphql errors") if payload[:status] == :errors
163
+ end
164
+ end
165
+ ```
166
+
167
+ `ensure` rather than after the call: the payload is only complete once the block
168
+ has returned, and a failed span needs the attributes most. `in_span` sets the
169
+ span status itself for a *raise* only, which is the last line's reason to exist.
170
+
171
+ **Span status is not the alerting signal; `payload[:status]` is.** A response
172
+ carrying GraphQL errors is a 200 that returned normally, so nothing raises and a
173
+ span left to itself is `UNSET` — an SLO built on span status alone misses every
174
+ GraphQL-level failure there is. Alert on `:status` and group by `:code`.
175
+
176
+ **Propagating the trace outward** is a header, and a
177
+ [header value may be a callable](transports.md#headers) resolved per request —
178
+ inside the span, which is what makes it work:
179
+ `headers: { "traceparent" => -> { {}.tap { OpenTelemetry.propagation.inject(_1) }["traceparent"] } }`.
180
+
181
+ ### Datadog
182
+
183
+ ```ruby
184
+ GraphWeaver.instrumenter = lambda do |event, payload, &block|
185
+ Datadog::Tracing.trace(event, resource: payload[:operation], service: "graphql") do |span|
186
+ block.call
187
+ ensure
188
+ payload.compact.each { |key, value| span.set_tag("graphql.#{key}", value.to_s) }
189
+ span.set_error([payload[:code], "graphql errors"]) if payload[:status] == :errors
190
+ end
75
191
  end
76
192
  ```
77
193
 
78
- The one event is `GraphWeaver::EXECUTE_EVENT`
79
- (`"graph_weaver.execute"`), a single request from start to parsed
80
- response. Its payload carries:
81
-
82
- | Key | |
83
- |-----|--|
84
- | `:url` | the endpoint — nil in-process |
85
- | `:schema` | the schema class's name, in-process only — a String, so a payload logs as it stands |
86
- | `:operation` | the operation name sent with the request (a generated module always has one) — what a trace keys on |
87
- | `:status` | the HTTP status, added once the response lands |
88
-
89
- Your callable **must** call the block and return its value. A failure
90
- propagates through it, so the hook sees the exception and can record it.
91
- The query text and the variables are deliberately absent: they carry
92
- PII, and belong at debug on the logger where the level gates them.
194
+ Datadog's Net::HTTP and Faraday contribs already trace the transport layer, so
195
+ with them on you have a span for the POST. This adds the span *above* it, named
196
+ for the operation — the one that means anything, since every GraphQL call is a
197
+ POST to the same url.
198
+
199
+ ## Details
200
+
201
+ ### What carries text we didn't author
202
+
203
+ A log line, an exception and an APM tag all outlive the request, and each can
204
+ carry text somebody else wrote. Every such channel has a policy, and there are no
205
+ others:
206
+
207
+ | Channel | Policy |
208
+ |---------|--------|
209
+ | the variables line | scrubbed through `filter_parameters` at every depth, and written at **debug** only |
210
+ | the query text | debug only, truncated |
211
+ | `InputError#message`, `#value`, `#to_h` | `[FILTERED]` under a filtered key, at every depth; capped at 1 KB |
212
+ | `ServerError#message` | the status, what *we* judged wrong, the hint, the safe url — **never the body** |
213
+ | `ServerError#body` | the bytes verbatim. This is the channel that carries them, which is why no other has to |
214
+ | `ServerError#to_h` | status, `retry_after`, url — the body and the headers stay off it (read `#headers`) |
215
+ | a redirect's `Location` | a url the server chose, folded the way we fold our own |
216
+ | `TransportError#message` | the adapter's own sentence, capped, plus the safe url |
217
+ | `GraphQLError#message` | the server's own words, **passed through untouched** — a server that quotes a rejected password has to be fixed at the server |
218
+ | `extensions.code` → the info line, the APM `:code` | control characters stripped, capped — a tag can't forge a line |
219
+ | the endpoint, everywhere it is said | userinfo and credential query parameters folded to `[FILTERED]` |
220
+ | `#inspect` on any public object | its class and its safe url; never a header, a context or a body |
221
+
222
+ The rule behind the `ServerError` rows: every raised error writes its message to
223
+ the log at `warn`, and the commonest non-2xx body in the world is a framework
224
+ error page echoing the request, `Authorization` header included.
225
+
226
+ ### What is process-global, and who owns it
227
+
228
+ Three things outlive a single request and meet more than one writer in a shipped
229
+ configuration. Each has one owner, so none needs a convention on your side:
230
+
231
+ | Resource | Several writers arrive from | Who keeps them apart |
232
+ |----------|-----------------------------|----------------------|
233
+ | the `[req …]` counter | a Puma cluster: forked workers inherit it | the tag carries the pid, and the count restarts in a new process |
234
+ | the schema cache file | two clients both saying `cache: true` | a dump records its source url; a client that didn't write it caches under a name of its own ([getting started](getting_started.md)) |
235
+ | a cassette | `parallel_tests`, one cassette, several processes | a recorder re-reads and rewrites under a `flock` ([testing](testing.md)) |
236
+
237
+ Two things are *not* process-global and shouldn't be made so: a client's GraphQL
238
+ context (per client, guarded by the client — see `GraphWeaver::ContextSeam`), and
239
+ a connection pool (per process, rebuilt after a fork).
data/docs/real_world.md CHANGED
@@ -1,10 +1,9 @@
1
1
  # Against a real API
2
2
 
3
- The exploratory tour: point a client at a live endpoint and go, no build
4
- step — ideal for consoles, spikes, and getting a feel for an API. What
5
- ships is the checked-in codegen path in the [getting started](getting_started.md);
6
- this page is how you get there (the `parse` below becomes a `.graphql`
7
- file plus `rake graph_weaver:generate`, everything else stays).
3
+ Point a client at a live endpoint and go, no build step — the exploratory tour,
4
+ for consoles and spikes. What ships is the checked-in codegen path in
5
+ [getting started](getting_started.md); the `parse` below becomes a `.graphql`
6
+ file plus `rake graph_weaver:generate`, and everything else stays.
8
7
 
9
8
  Everything hangs off a client — transport and schema for one server. GitHub's
10
9
  API, end to end:
@@ -12,17 +11,16 @@ API, end to end:
12
11
  ```ruby
13
12
  require "graph_weaver"
14
13
 
15
- # transport + auth in one object (docs/transports.md for retries and
16
- # advanced setup). cache: true dumps the schema at GraphWeaver.schema_path
17
- # on first introspection — the same file rake graph_weaver:generate reads
18
- # (docs/getting_started.md for the formats), with the source url recorded
19
- # in a header, so a stale dump says where it came from.
14
+ # transport + auth in one object (docs/transports.md for retries and TLS).
15
+ # cache: true writes the schema to GraphWeaver.schema_path on first
16
+ # introspection — the same dump rake graph_weaver:generate reads. It records
17
+ # its source url, so a second client at a second origin caches under a name of
18
+ # its own rather than overwriting this one.
20
19
  github = GraphWeaver.new("https://api.github.com/graphql", auth: `gh auth token`.strip, cache: true)
21
20
 
22
- # GitHub's DateTime needs no registration it is one of the names the
23
- # registry knows (docs/scalars.md). A scalar of your own would go here;
24
- # registrations are global and codegen-time, so one line types your
25
- # console and your checked-in code identically.
21
+ # GitHub's DateTime needs no registration (docs/scalars.md). A scalar of your
22
+ # own goes here: registrations are global and codegen-time, so one line types
23
+ # your console and your checked-in code identically.
26
24
 
27
25
  RepoQuery = github.parse(<<~GRAPHQL)
28
26
  query($owner: String!, $name: String!) {
@@ -36,24 +34,41 @@ GRAPHQL
36
34
 
37
35
  repo = RepoQuery.execute!(owner: "dpep", name: "graph_weaver").repository
38
36
  repo&.name_with_owner # => "dpep/graph_weaver"
39
- repo&.created_at # => 2026-07-07 ... (a real Time)
37
+ repo&.created_at # => a real Time
40
38
  repo&.stargazer_count # => Integer
41
39
  ```
42
40
 
43
- Clients are independent build one per server, each with its own transport
44
- and schema. When you're ready to check the generated code in, the same client
45
- is the schema `generate!` wants:
41
+ Build one client per server; they're independent. That same client is what
42
+ `GraphWeaver.generate!(schema: github)` takes when you check the generated code
43
+ in, so no dump on disk is needed and keeping a second server's modules beside
44
+ your own is a [graph](getting_started.md#more-than-one-schema).
45
+
46
+ ## Browsing the schema
47
+
48
+ `client.schema` is an ordinary graphql-ruby `Schema` class, so "what can I even
49
+ ask for" is answered in the console with nothing else installed:
46
50
 
47
51
  ```ruby
48
- GraphWeaver.generate!(schema: github) # no dump on disk needed
52
+ schema = github.schema
53
+
54
+ schema.query.fields.keys # => ["repository", "search", ...]
55
+ field = schema.query.fields["repository"]
56
+ field.arguments.keys # => ["owner", "name", ...]
57
+ field.type.to_type_signature # => "Repository"
58
+
59
+ repo = field.type.unwrap # past the ! and [] wrappers
60
+ repo.graphql_name # => "Repository"
61
+ repo.fields.keys.sort # => ["createdAt", "description", ...]
49
62
  ```
50
63
 
51
- The introspection step (seconds on a big API) happens lazily on first
52
- `schema`/`parse` and caches per `cache:`/`ttl:`; for finer control the
53
- pieces are all public
54
- (`GraphWeaver::SchemaLoader.introspect(transport, cache:, ttl:)`, or cache
55
- `introspect(transport).to_json` in Rails.cache and `SchemaLoader.load` it).
64
+ Reach for `graphql_name`, not `name`: a schema built from introspection or SDL
65
+ has no Ruby class behind its types, so `.name` is `nil` there, and where the
66
+ schema *is* a class you wrote `.name` gives the Ruby constant.
67
+
68
+ Introspection (seconds on a big API) happens lazily on first `schema`/`parse`
69
+ and caches per `cache:`/`ttl:`; for finer control the pieces are public —
70
+ `GraphWeaver::SchemaLoader.introspect(transport, cache:, ttl:)`, or cache
71
+ `introspect(transport).to_json` yourself and `SchemaLoader.load` it.
56
72
 
57
- The same flow runs as one-off integration specs against the live GitHub
58
- and Countries APIs `make integration` (network; GitHub auth via
59
- `gh auth token` or `GITHUB_TOKEN`).
73
+ `make integration` runs this flow against the live GitHub and Countries APIs
74
+ (network; GitHub auth via `gh auth token` or `GITHUB_TOKEN`).