graph_weaver 0.6.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1447 -1
- data/Gemfile +8 -0
- data/Gemfile.lock +151 -2
- data/README.md +20 -6
- data/docs/alternatives.md +201 -0
- data/docs/cassettes.md +17 -1
- data/docs/errors.md +382 -17
- data/docs/federation.md +469 -63
- data/docs/generated_modules.md +231 -15
- data/docs/getting_started.md +497 -104
- data/docs/i18n.md +234 -0
- data/docs/logging.md +160 -24
- data/docs/real_world.md +28 -0
- data/docs/scalars.md +190 -26
- data/docs/testing.md +457 -58
- data/docs/transports.md +164 -19
- data/docs/upgrading.md +328 -3
- data/graph_weaver.gemspec +7 -0
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +47 -10
- data/lib/graph_weaver/codegen/aliases.rb +7 -5
- data/lib/graph_weaver/codegen/emit.rb +98 -29
- data/lib/graph_weaver/codegen/enum_type.rb +2 -1
- data/lib/graph_weaver/codegen/nodes.rb +39 -6
- data/lib/graph_weaver/codegen/registry.rb +175 -0
- data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
- data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
- data/lib/graph_weaver/codegen.rb +404 -197
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/errors.rb +264 -34
- data/lib/graph_weaver/federation.rb +119 -26
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +17 -11
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +78 -0
- data/lib/graph_weaver/internal/headers.rb +51 -0
- data/lib/graph_weaver/internal/overrides.rb +67 -5
- data/lib/graph_weaver/internal/planner.rb +45 -15
- data/lib/graph_weaver/internal/refusal.rb +49 -0
- data/lib/graph_weaver/internal/schemas.rb +23 -9
- data/lib/graph_weaver/internal/selection.rb +34 -0
- data/lib/graph_weaver/internal/server_input.rb +251 -0
- data/lib/graph_weaver/internal/test_clients.rb +276 -0
- data/lib/graph_weaver/internal/unused.rb +287 -0
- data/lib/graph_weaver/internal/values.rb +40 -4
- data/lib/graph_weaver/internal.rb +183 -1
- data/lib/graph_weaver/log_subscriber.rb +66 -0
- data/lib/graph_weaver/logging.rb +136 -12
- data/lib/graph_weaver/query_module.rb +36 -3
- data/lib/graph_weaver/railtie.rb +237 -17
- data/lib/graph_weaver/representation.rb +55 -17
- data/lib/graph_weaver/result_struct.rb +90 -0
- data/lib/graph_weaver/retry.rb +33 -5
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +221 -49
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +6 -5
- data/lib/graph_weaver/testing/endpoint.rb +106 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +133 -44
- data/lib/graph_weaver/testing/router.rb +58 -11
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +46 -4
- data/lib/graph_weaver/transport.rb +109 -26
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +474 -106
- 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`, `
|
|
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
|
|
58
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
@@ -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
|