graph_weaver 0.6.0 → 0.7.0

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1470 -1
  3. data/Gemfile +8 -0
  4. data/Gemfile.lock +151 -2
  5. data/README.md +21 -7
  6. data/docs/alternatives.md +201 -0
  7. data/docs/cassettes.md +17 -1
  8. data/docs/errors.md +382 -17
  9. data/docs/federation.md +469 -63
  10. data/docs/generated_modules.md +231 -15
  11. data/docs/getting_started.md +498 -105
  12. data/docs/i18n.md +234 -0
  13. data/docs/logging.md +160 -24
  14. data/docs/real_world.md +32 -4
  15. data/docs/scalars.md +286 -57
  16. data/docs/testing.md +458 -59
  17. data/docs/transports.md +164 -19
  18. data/docs/upgrading.md +330 -5
  19. data/graph_weaver.gemspec +7 -0
  20. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  21. data/lib/graph_weaver/client.rb +47 -10
  22. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  23. data/lib/graph_weaver/codegen/emit.rb +98 -29
  24. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  25. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  26. data/lib/graph_weaver/codegen/registry.rb +175 -0
  27. data/lib/graph_weaver/codegen/scalar_type.rb +218 -59
  28. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  29. data/lib/graph_weaver/codegen.rb +408 -206
  30. data/lib/graph_weaver/coerce.rb +155 -26
  31. data/lib/graph_weaver/errors.rb +264 -34
  32. data/lib/graph_weaver/federation.rb +119 -26
  33. data/lib/graph_weaver/graph.rb +315 -0
  34. data/lib/graph_weaver/hints.rb +100 -24
  35. data/lib/graph_weaver/in_process.rb +17 -11
  36. data/lib/graph_weaver/input_struct.rb +119 -32
  37. data/lib/graph_weaver/internal/endpoint.rb +78 -0
  38. data/lib/graph_weaver/internal/headers.rb +51 -0
  39. data/lib/graph_weaver/internal/overrides.rb +67 -5
  40. data/lib/graph_weaver/internal/planner.rb +45 -15
  41. data/lib/graph_weaver/internal/refusal.rb +49 -0
  42. data/lib/graph_weaver/internal/schemas.rb +23 -9
  43. data/lib/graph_weaver/internal/selection.rb +34 -0
  44. data/lib/graph_weaver/internal/server_input.rb +251 -0
  45. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  46. data/lib/graph_weaver/internal/unused.rb +287 -0
  47. data/lib/graph_weaver/internal/values.rb +43 -4
  48. data/lib/graph_weaver/internal.rb +183 -1
  49. data/lib/graph_weaver/log_subscriber.rb +66 -0
  50. data/lib/graph_weaver/logging.rb +136 -12
  51. data/lib/graph_weaver/query_module.rb +36 -3
  52. data/lib/graph_weaver/railtie.rb +237 -17
  53. data/lib/graph_weaver/representation.rb +55 -17
  54. data/lib/graph_weaver/result_struct.rb +90 -0
  55. data/lib/graph_weaver/retry.rb +33 -5
  56. data/lib/graph_weaver/rspec.rb +404 -93
  57. data/lib/graph_weaver/schema_loader.rb +221 -49
  58. data/lib/graph_weaver/tasks.rb +380 -89
  59. data/lib/graph_weaver/testing/cassette.rb +6 -5
  60. data/lib/graph_weaver/testing/endpoint.rb +106 -0
  61. data/lib/graph_weaver/testing/failure.rb +69 -12
  62. data/lib/graph_weaver/testing/fake_client.rb +133 -44
  63. data/lib/graph_weaver/testing/router.rb +58 -11
  64. data/lib/graph_weaver/testing.rb +200 -58
  65. data/lib/graph_weaver/transport/faraday.rb +41 -8
  66. data/lib/graph_weaver/transport/http.rb +46 -4
  67. data/lib/graph_weaver/transport.rb +109 -26
  68. data/lib/graph_weaver/version.rb +1 -1
  69. data/lib/graph_weaver.rb +490 -116
  70. metadata +56 -1
data/docs/i18n.md ADDED
@@ -0,0 +1,234 @@
1
+ # Translating input errors
2
+
3
+ The keys and the data an app translates "that input was wrong" from, without
4
+ reading English sentences. See [errors](errors.md) for `InputError` itself and
5
+ for what a server has to send.
6
+
7
+ ## The one idea
8
+
9
+ A library that hands you a sentence has already decided what your user reads, in
10
+ one language, in its own voice. So graph_weaver should hand you **a stable key
11
+ and the facts**, and let the app own the sentence:
12
+
13
+ - **stable** — a `kind` from a short closed vocabulary, and the key derived from
14
+ it, `graph_weaver.input.<kind>`. These are API: they change only with a MAJOR
15
+ bump, and a new one arrives only as an addition.
16
+ - **data** — the field, the value, the enum's members, the bounds. Never baked
17
+ into the message only, and never pre-formatted (`members` is an Array, not
18
+ "CAT or DOG" — joining it is a language decision).
19
+ - **not stable** — `#message`. It is the developer's line, in English, and it
20
+ will be reworded.
21
+
22
+ ## The vocabulary
23
+
24
+ Eight kinds, each one sentence. A ninth arrives only when a real input failure
25
+ fits none of them — `:refused` is the honest home for everything else.
26
+
27
+ The last four columns are **who can say it**, which is the other half of the
28
+ story — a `kind` is worth only as much as the producer that can state it. The
29
+ last of them is any server at all, through the
30
+ [convention](#what-the-server-has-to-do).
31
+
32
+ | `kind` | means | `details` carries | client | graphql-ruby | Hasura | convention |
33
+ |---|---|---|---|---|---|---|
34
+ | `:type_mismatch` | the value is not the type the schema declares, and no conversion applies | `type` | yes | yes | **no** | yes |
35
+ | `:unparseable` | the right kind of thing, but the text doesn't parse as that scalar | `type` | yes | yes | **no** | yes |
36
+ | `:not_a_member` | not one of the values an enum (or an inclusion rule) allows | `members` | yes | yes | yes | yes |
37
+ | `:missing` | a required field or argument wasn't supplied, or was null | — | yes | yes | yes | yes |
38
+ | `:unknown` | a key the input type doesn't define — a typo | `suggestion` | yes | yes | yes | yes |
39
+ | `:out_of_range` | a number, length or date outside the bounds the schema states | `min`, `max` | **no** | **no** | **no** | yes |
40
+ | `:invalid_format` | parses, right type, but fails a stated semantic rule — a regex, an email | `pattern` | **no** | **no** | **no** | yes |
41
+ | `:refused` | rejected, with nothing more than a message — the fallback, never a guess | — | yes | yes | yes | yes |
42
+
43
+ Those are the servers graph_weaver reads by name. **Apollo** is `:refused` and
44
+ the server's own sentence, nothing finer — `BAD_USER_INPUT` says the input was
45
+ bad and not what about it. **Anything else** says nothing graph_weaver will
46
+ claim: `#input_errors` comes back `[]`, and the rejection stays an ordinary
47
+ error to render as the server wrote it. Hasura's two extra blanks are one
48
+ sentence doing two jobs: the same "expected a non-negative 32-bit integer" answers
49
+ `limit: -5` and `limit: "lots"`, so both arrive `:refused` on the right field
50
+ rather than one of them arriving confidently wrong. Which shape each server is
51
+ read off is in [errors](errors.md#when-the-server-rejects-the-input).
52
+
53
+ `field` and `value` ride on every kind. `value` is the offending value passed
54
+ through [`filter_parameters`](logging.md#filtered-variables), so it reads
55
+ `"[FILTERED]"` under a sensitive key and `nil` when the producer never said what
56
+ the value was.
57
+
58
+ Two rows carry the headline: **`:out_of_range` and `:invalid_format` — the
59
+ customer's "right type but out of range" and "bad semantic format" — cannot be
60
+ produced from either side without the
61
+ [server convention](errors.md#what-your-server-can-send).** The client doesn't
62
+ know the schema's bounds, and a graphql-ruby `validates:` failure reaches the
63
+ wire as a bare sentence with no `extensions` at all.
64
+
65
+ `@oneOf` violations map to `:refused`, with one exception that isn't really
66
+ one: exactly one field, explicitly null, is `:missing` on that field — the
67
+ count is right and the value isn't, which is the same thing `:missing` means
68
+ everywhere else, and it gives a form the one slot to highlight. The wrong
69
+ *count* has no single field to name, so it stays a message ("supply exactly one
70
+ field, non-null, got a, b"); one rule in twenty schemas doesn't earn a ninth
71
+ kind.
72
+
73
+ ## The shape
74
+
75
+ One class, carried by both halves. `GraphWeaver::InputError` is *raised* before
76
+ the request leaves — rescuing it is a control-flow decision (render a 422) — and
77
+ it is also the **value** a server's rejection becomes, which has precedent:
78
+ `Response#errors` already holds unraised `GraphQLError`s. One class, zero new
79
+ nouns, one renderer for both halves.
80
+
81
+ ```ruby
82
+ GraphWeaver::InputError # kind, path, coordinate, value, details, field, struct
83
+ GraphWeaver::GraphQLError#input_errors # [] when the error isn't about input
84
+ GraphWeaver::Response#input_errors # and QueryError#input_errors
85
+ ```
86
+
87
+ Plural on all three: one variable-coercion error routinely carries several
88
+ problems about different fields, and keeping only the first would lose the rest
89
+ silently.
90
+
91
+ - `path` — rooted at the **variable**: `["where", "_and", 0, "_not", "species"]`,
92
+ so `$where._and.0._not.species`. Every named segment is the **schema's**
93
+ spelling (`issuedOn`, not the `issued_on` you type in Ruby), whichever side
94
+ refused — [why](errors.md#which-spelling-a-path-is-in). `[]` when the
95
+ producer named no slot; `#field` is its last named segment.
96
+ - `coordinate` — the GraphQL [schema coordinate](https://github.com/graphql/graphql-spec/pull/794):
97
+ `"PetFilter.species"`. `nil` when unknown — a variable names no schema
98
+ element, and a graphql-ruby coercion error rarely names the nested input type.
99
+
100
+ ## Translating it
101
+
102
+ ```ruby
103
+ def render_input_error(error)
104
+ I18n.t(
105
+ "graph_weaver.input.#{error.kind}",
106
+ field: error.field,
107
+ value: error.value,
108
+ **error.details,
109
+ default: error.message, # untranslated kinds fall back, never "translation missing"
110
+ )
111
+ end
112
+ ```
113
+
114
+ ```ruby
115
+ # before the wire
116
+ rescue GraphWeaver::InputError => e
117
+ form.errors.add(e.field&.underscore || :base, render_input_error(e))
118
+
119
+ # and after it, the same call
120
+ response.input_errors.each { |e| form.errors.add(e.field&.underscore || :base, render_input_error(e)) }
121
+ ```
122
+
123
+ `#field` is the schema's spelling on both sides, so one `underscore` is the
124
+ whole mapping to a Rails form field — and the two calls above really are the
125
+ same call. **Plan for `nil`**: nothing named a slot, which is exactly what a
126
+ server that states no input path gives you, and that is the default until your
127
+ server adopts [the convention](errors.md#what-your-server-can-send).
128
+
129
+ **`field:` can't say which row.** A list index lives in `#path` and nowhere
130
+ else, so every element of `lines: [LineInput!]!` translates to the same
131
+ sentence. Where a form lists rows, label it from the path:
132
+
133
+ ```ruby
134
+ # "input.lines.0.qty", where #field alone is "qty" for every line at once
135
+ I18n.t("graph_weaver.input.#{error.kind}", field: error.path.join("."), **error.details)
136
+ ```
137
+
138
+ `default:` is the load-bearing half: `:refused` is untranslatable by definition —
139
+ it *is* whatever the message said — so every app needs the fallback, and having
140
+ it means an app can translate the four kinds it cares about and leave the rest.
141
+
142
+ Arrays stay arrays: `details[:members]` is `["CAT", "DOG"]`, so join it where you
143
+ know the language — `members: e.details[:members].to_sentence`.
144
+
145
+ `details[:type]` is the **GraphQL** name for the type — `Int`, `Money` — so a
146
+ sentence translated from it still says "Int" in the middle of the French. Name
147
+ the scalars your forms use, and translate the name before it is interpolated:
148
+ `render_input_error` splats `details`, so a `type:` passed after the splat wins.
149
+
150
+ ```yaml
151
+ fr:
152
+ types:
153
+ Int: "numérique"
154
+ String: "textuelle"
155
+ ```
156
+
157
+ ```ruby
158
+ type = I18n.t("types.#{error.details[:type]}", default: error.details[:type])
159
+ ```
160
+
161
+ ## No locale file ships
162
+
163
+ graph_weaver ships the **keys and the data, and no `en.yml`**. Three
164
+ reasons, in order:
165
+
166
+ 1. The strings are app-voice, not library-voice. "Species must be one of: cat,
167
+ dog" belongs to whoever owns the form.
168
+ 2. A shipped default applies by merely adding the gem — behavior that follows
169
+ from the Gemfile rather than from the code in front of you, which is the one
170
+ thing this library refuses to do everywhere else.
171
+ 3. Owning a user-visible English string means every reword is a breaking change
172
+ for apps that translated around it.
173
+
174
+ Paste this into your own `config/locales/en.yml` instead:
175
+
176
+ ```yaml
177
+ en:
178
+ graph_weaver:
179
+ input:
180
+ type_mismatch: "%{field} must be a %{type}."
181
+ unparseable: "%{field} isn't a valid %{type}."
182
+ not_a_member: "%{field} must be one of: %{members}."
183
+ missing: "%{field} is required."
184
+ unknown: "%{field} isn't a field we recognize."
185
+ out_of_range: "%{field} is out of range."
186
+ invalid_format: "%{field} isn't in the right format."
187
+ refused: "%{field} was rejected."
188
+ ```
189
+
190
+ ## What the server has to do
191
+
192
+ For the two kinds nothing can produce on its own, and for every column the
193
+ table leaves blank, the server states it — under one key, `extensions.input`, with
194
+ the `kind` taken from the table above. The shape, the graphql-ruby recipes for
195
+ it, and what a server that sends none of it gets instead are in
196
+ [errors](errors.md#what-your-server-can-send).
197
+
198
+ Two rules matter to a translator. **A `kind` outside the table becomes
199
+ `:refused`** rather than being passed through, because a wrong key is worse
200
+ than no key: the app will have translated it into a confident sentence. And
201
+ **a key outside `type`/`members`/`min`/`max`/`pattern`/`suggestion` is dropped**
202
+ before it reaches `details`, so `I18n.t(..., **details)` can never be handed an
203
+ interpolation your locale file has no slot for. None of the six is a name in
204
+ `I18n::RESERVED_KEYS` either — `:pattern` rather than `:format` for that reason,
205
+ since splatting a `:format` key into `I18n.t` raises
206
+ `I18n::ReservedInterpolationKey` instead of translating.
207
+
208
+ ## Transport and server failures
209
+
210
+ `kind` exists for input errors and nothing else. The failures beside them — the
211
+ endpoint was unreachable, the server answered 502, the server answered 200 and
212
+ complained — name no field and carry nothing to interpolate. Key those on the
213
+ **exception class**:
214
+
215
+ | what you rescued | what the user is told |
216
+ |---|---|
217
+ | `GraphWeaver::TransportError` | we couldn't reach the service — try again |
218
+ | `GraphWeaver::ServerError` | the service is having trouble — try later (`#throttled?` earns its own sentence) |
219
+ | `GraphWeaver::QueryError` whose `#input_errors` is empty | something went wrong with that request |
220
+
221
+ One static translated sentence each, and log what the server said rather than
222
+ showing it. The [class hierarchy](errors.md) is the only closed vocabulary on
223
+ this side: a `code` is a per-server invention — Apollo sends `BAD_USER_INPUT`,
224
+ graphql-js sends none — so a translation keyed on one degrades to English
225
+ against the next server, and `#message` is the developer's line, which moves.
226
+
227
+ ## Where translation stops
228
+
229
+ graph_weaver's own refusals are for developers and stay English, with no keys:
230
+ `QueryValidationError` and the codegen errors (a rake task's output), the schema-drift
231
+ hint, `ConfigurationError`, `Testing::*`. Same for `InputError#message` — it is
232
+ the line in the log and the stack trace; the translated line is the one your app
233
+ builds from `kind` and `details`. And a message the *server* wrote arrives in
234
+ whatever language the server chose, passed through untouched.
data/docs/logging.md CHANGED
@@ -14,8 +14,8 @@ What logs at which level — pick the level, get the story:
14
14
  | Level | What you see |
15
15
  |-------|--------------|
16
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 |
17
+ | `info` | one line per operation in Rails (see 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
21
  Every line carries `graph_weaver` as the progname, so formatter-based
@@ -36,7 +36,9 @@ written — at any 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 — including Rails' own default list, which has `:email` on it, so
40
+ an ordinary field named `email` reads `[FILTERED]` in `#value` and in the
41
+ message the day you add the gem.
40
42
 
41
43
  Everywhere else, one list:
42
44
 
@@ -54,39 +56,173 @@ as-is, which is how the railtie hands over an
54
56
 
55
57
  The same list scrubs error messages, which reach the log at `warn` rather
56
58
  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.
59
+ is rejected with `[FILTERED]` in place of the value, and a value a message
60
+ *quotes* is scrubbed at every depth, so a filtered key one level in reads
61
+ `got {"token" => "[FILTERED]"}`. Everything else keeps quoting the value,
62
+ since `expected an Int, got "lots"` is the whole diagnosis.
63
+
64
+ **It reaches what GraphWeaver composes, and nothing else.** That is
65
+ `InputError#message` and `#value` on both halves — a server's sentence
66
+ included, once it has been read back into an `InputError` — plus the variables
67
+ line at debug. A `GraphQLError#message` is the server's or a resolver's own
68
+ words and is **passed through untouched**, in `response.errors`, in
69
+ `QueryError`'s summary, and in the `warn` line that summary writes. A server
70
+ that quotes a rejected password in its message has to be fixed at the server.
59
71
 
60
72
  ## Instrumentation
61
73
 
62
74
  A logger tells a human what happened; an APM needs to time it and count
63
75
  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:
76
+ over the wire *and* in-process, one seam for both paths. It's a no-op until you
77
+ set one — and **in Rails you set nothing**: the railtie installs the adapter
78
+ below and attaches `GraphWeaver::LogSubscriber` on top of it, so the snippet is
79
+ what the framework already did. Everywhere else, it is the line to write:
67
80
 
68
81
  ```ruby
69
82
  GraphWeaver.instrumenter = lambda do |event, payload, &block|
70
83
  ActiveSupport::Notifications.instrument(event, payload, &block)
71
84
  end
85
+ ```
86
+
87
+ An instrumenter you set yourself is never replaced — including
88
+ `GraphWeaver.instrumenter = nil` in an initializer, which opts out the
89
+ way `GraphWeaver.logger = nil` does. Yours **must** call the block and
90
+ return its value; a failure propagates through it, so the hook sees the
91
+ exception and can record it.
72
92
 
93
+ The one event is `GraphWeaver::EXECUTE_EVENT` (`"execute.graph_weaver"`)
94
+ — one request, start to parsed response, whichever client slot served
95
+ it, so a single subscriber covers both sides of the seam:
96
+
97
+ ```ruby
73
98
  ActiveSupport::Notifications.subscribe(GraphWeaver::EXECUTE_EVENT) do |*, payload|
74
- StatsD.timing("graphql.#{payload[:operation] || "anonymous"}", ...)
99
+ StatsD.timing("graphql.#{payload[:operation] || "anonymous"}", payload[:duration_ms],
100
+ tags: ["status:#{payload[:status]}", "code:#{payload[:code]}"])
101
+ end
102
+ ```
103
+
104
+ **A subscriber that raises takes the request down with it** — the response was
105
+ computed and is then thrown away. That is `ActiveSupport::Notifications`' own
106
+ semantics, the same on `sql.active_record`, so rescue inside the block. A
107
+ `GraphWeaver::LogSubscriber` subclass is the exception: it already rescues, and
108
+ reports the failure instead of raising it.
109
+
110
+ ### The payload
111
+
112
+ | Key | When | |
113
+ |-----|------|--|
114
+ | `: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) |
115
+ | `:client` | always | the class that ran it: `GraphWeaver::Transport::HTTP`, `GraphWeaver::InProcess`, your own |
116
+ | `:status` | always | `:ok`, `:errors` (a response carrying GraphQL errors), or `:failed` (it raised) |
117
+ | `:duration_ms` | always | start to parsed response, for **this attempt** — under a [`Retry`](transports.md#retries) the backoff sleep between attempts is in none of them, so no event reports the wall clock the caller waited |
118
+ | `:url` | over the wire | the endpoint; nil in-process |
119
+ | `:http_status` | over the wire | what the server answered with, success or not; nil in-process |
120
+ | `:schema` | in-process | the schema class's name, as a String, so a payload logs as it stands |
121
+ | `:code` | on `:errors`, and on a `ServerError` | the machine-readable reason — the first `code` *any* of the errors carries, not the first error's, or a `ServerError`'s status. A code that exists beats the absence of one at position 0, which is what an alert groups by. Present and `nil` when the errors carry none |
122
+ | `:error` | on `:failed` | the exception's class name |
123
+ | `: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 |
124
+ | `: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 |
125
+
126
+ Every key is filled in before your callable's block returns, so a
127
+ subscriber reads a complete payload. `ActiveSupport::Notifications` adds
128
+ `:exception` and `:exception_object` of its own when the block raises.
129
+
130
+ **`:graph` labels one request.** A generated module's `execute` labels the
131
+ request *it* makes — each of them, when a federated operation fans out to
132
+ several subgraphs. It never labels what a *server* does while answering
133
+ one: an in-process resolver that calls out produces an event of its own,
134
+ carrying its own graph or `nil`. So `:graph` always reads "this request
135
+ went to that graph", which is the only claim a dashboard can group by.
136
+
137
+ One edge: the label is fiber-local, so a dispatch that crosses a `Fiber` —
138
+ graphql-ruby's `Dataloader` does — arrives with `:graph` unset. No label
139
+ rather than a wrong one, which is the same trade as the paragraph above.
140
+
141
+ **Never the query text or the variables.** `filter_parameters` scrubs
142
+ what reaches the log, which GraphWeaver writes itself; the payload fans
143
+ out to subscribers that know none of those rules, so here the rule isn't
144
+ "scrub it", it's that it was never there. Queries and variables stay at
145
+ debug on the logger, where the level gates them.
146
+
147
+ `:url` is the one thing on the payload that *is* scrubbed, because a url can
148
+ itself be a credential: its userinfo, and any query parameter
149
+ `filter_parameters` filters, are folded to `[FILTERED]` — the same list, the
150
+ same spelling — before the payload, a log line or a `TransportError` says it.
151
+
152
+ ### One line per operation
153
+
154
+ In Rails the railtie also attaches `GraphWeaver::LogSubscriber`, which
155
+ turns each event into one line — the shape ActiveRecord uses for a query:
156
+
157
+ ```
158
+ GraphWeaver PersonQuery (12.3ms) ok
159
+ GraphWeaver PersonQuery (8.1ms) errors [THROTTLED]
160
+ GraphWeaver PersonQuery (31.2ms) failed GraphWeaver::TransportError
161
+ GraphWeaver PersonQuery (5.0ms) ok (retry 2)
162
+ GraphWeaver billing/InvoicesQuery (12.3ms) ok
163
+ ```
164
+
165
+ The operation is prefixed by its graph when the request carried one, so an
166
+ app with several graphs sorts its own log and an app with one never sees
167
+ the prefix.
168
+
169
+ **One rule: the summary is info, the wire is debug.** This is the only
170
+ GraphWeaver line at info, so a production log gets one per operation and
171
+ nothing that can carry PII; turning the logger up to debug adds the
172
+ query, the variables and the response *beneath* it rather than repeating
173
+ it. It writes through `GraphWeaver.logger`, so `GraphWeaver.logger = nil`
174
+ silences this along with everything else.
175
+
176
+ Outside Rails there's no railtie to attach it, so the same line is an
177
+ instrumenter of your own — read the payload in an `ensure`, since it is only
178
+ complete once the block has returned:
179
+
180
+ ```ruby
181
+ log = Logger.new("log/graphql.log")
182
+
183
+ GraphWeaver.instrumenter = lambda do |_event, payload, &block|
184
+ block.call
185
+ ensure
186
+ log.info { "#{payload[:operation] || "anonymous"} (#{payload[:duration_ms]}ms) #{payload[:status]}" }
187
+ end
188
+ ```
189
+
190
+ ```
191
+ I, [2026-09-12T18:40:33.032236 #97490] INFO -- : PersonQuery (19.57ms) ok
192
+ I, [2026-09-12T18:40:33.037186 #97490] INFO -- : DraftsQuery (4.67ms) errors
193
+ ```
194
+
195
+ ### OpenTelemetry
196
+
197
+ ```ruby
198
+ tracer = OpenTelemetry.tracer_provider.tracer("graph_weaver")
199
+
200
+ GraphWeaver.instrumenter = lambda do |_event, payload, &block|
201
+ tracer.in_span("graphql #{payload[:operation] || "query"}") do |span|
202
+ block.call
203
+ ensure
204
+ span.add_attributes(payload.compact.transform_keys { "graphql.#{_1}" }.transform_values(&:to_s))
205
+ end
206
+ end
207
+ ```
208
+
209
+ `ensure` rather than after the call: the payload is only complete once
210
+ the block has returned, and a failed span needs the attributes most.
211
+ `in_span` records the exception and sets the span status itself.
212
+
213
+ ### Datadog
214
+
215
+ ```ruby
216
+ GraphWeaver.instrumenter = lambda do |event, payload, &block|
217
+ Datadog::Tracing.trace(event, resource: payload[:operation], service: "graphql") do |span|
218
+ block.call
219
+ ensure
220
+ payload.compact.each { |key, value| span.set_tag("graphql.#{key}", value.to_s) }
221
+ end
75
222
  end
76
223
  ```
77
224
 
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.
225
+ Datadog's Net::HTTP and Faraday contribs already trace the transport
226
+ layer, so with them on you have a span for the POST. This adds the span
227
+ *above* it, named for the operation — the one that means anything, since
228
+ every GraphQL call is a POST to the same url.
data/docs/real_world.md CHANGED
@@ -19,10 +19,10 @@ require "graph_weaver"
19
19
  # in a header, so a stale dump says where it came from.
20
20
  github = GraphWeaver.new("https://api.github.com/graphql", auth: `gh auth token`.strip, cache: true)
21
21
 
22
- # map GitHub's DateTime scalar onto Time (cast inferred from Time.parse).
23
- # Registrations are global and codegen-time, so this line types your
24
- # console and your checked-in code identically (docs/scalars.md).
25
- GraphWeaver.register_scalar("DateTime", Time, serialize: :iso8601, requires: "time")
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.
26
26
 
27
27
  RepoQuery = github.parse(<<~GRAPHQL)
28
28
  query($owner: String!, $name: String!) {
@@ -48,6 +48,34 @@ is the schema `generate!` wants:
48
48
  GraphWeaver.generate!(schema: github) # no dump on disk needed
49
49
  ```
50
50
 
51
+ Keeping a second server's modules checked in alongside your own is a
52
+ [graph](getting_started.md#more-than-one-schema) — its own queries, output and
53
+ namespace, declared once and generated by the same
54
+ `rake graph_weaver:generate` as everything else.
55
+
56
+ ## Browsing the schema
57
+
58
+ `client.schema` is an ordinary graphql-ruby `Schema` class, so "what can I even
59
+ ask for" is answered in the console with nothing else installed:
60
+
61
+ ```ruby
62
+ schema = github.schema
63
+
64
+ schema.query.fields.keys # => ["repository", "search", ...]
65
+ field = schema.query.fields["repository"]
66
+ field.arguments.keys # => ["owner", "name", ...]
67
+ field.type.to_type_signature # => "Repository"
68
+
69
+ repo = field.type.unwrap # past the ! and [] wrappers
70
+ repo.graphql_name # => "Repository"
71
+ repo.fields.keys.sort # => ["createdAt", "description", ...]
72
+ ```
73
+
74
+ `graphql_name` is the one to reach for, not `name`: a schema built from
75
+ introspection or SDL has no Ruby class behind its types, so `.name` is `nil`
76
+ there — and where the schema *is* a class you wrote, `.name` gives you the Ruby
77
+ constant, never the GraphQL name.
78
+
51
79
  The introspection step (seconds on a big API) happens lazily on first
52
80
  `schema`/`parse` and caches per `cache:`/`ttl:`; for finer control the
53
81
  pieces are all public