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.
- checksums.yaml +4 -4
- data/Gemfile +8 -0
- data/Gemfile.lock +153 -4
- data/README.md +45 -79
- data/docs/alternatives.md +195 -0
- data/docs/cassettes.md +61 -50
- data/docs/editors.md +32 -47
- data/docs/errors.md +360 -103
- data/docs/federation.md +692 -473
- data/docs/generated_modules.md +441 -314
- data/docs/getting_started.md +370 -194
- data/docs/i18n.md +171 -0
- data/docs/logging.md +197 -50
- data/docs/real_world.md +42 -27
- data/docs/scalars.md +307 -176
- data/docs/testing.md +473 -220
- data/docs/transports.md +224 -151
- data/docs/upgrading.md +258 -305
- data/examples/README.md +38 -0
- data/examples/countries.rb +39 -0
- data/examples/federation.rb +62 -0
- data/examples/github/generate.rb +20 -0
- data/examples/github/generated/star_mutation.rb +126 -0
- data/examples/github/generated/stargazers_query.rb +232 -0
- data/examples/github/generated/starred_query.rb +151 -0
- data/examples/github/queries/star.graphql +8 -0
- data/examples/github/queries/stargazers.graphql +22 -0
- data/examples/github/queries/starred.graphql +11 -0
- data/examples/github/run.rb +43 -0
- data/examples/github/setup.rb +18 -0
- data/examples/rick_and_morty.rb +57 -0
- data/graph_weaver.gemspec +19 -3
- data/lib/generators/graph_weaver/install_generator.rb +138 -4
- data/lib/graph_weaver/client.rb +69 -11
- 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 +406 -195
- data/lib/graph_weaver/coerce.rb +155 -26
- data/lib/graph_weaver/context_seam.rb +54 -0
- data/lib/graph_weaver/errors.rb +284 -46
- data/lib/graph_weaver/federation.rb +129 -27
- data/lib/graph_weaver/graph.rb +315 -0
- data/lib/graph_weaver/hints.rb +100 -24
- data/lib/graph_weaver/in_process.rb +27 -15
- data/lib/graph_weaver/input_struct.rb +119 -32
- data/lib/graph_weaver/internal/endpoint.rb +80 -0
- data/lib/graph_weaver/internal/headers.rb +70 -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 +249 -14
- data/lib/graph_weaver/log_subscriber.rb +74 -0
- data/lib/graph_weaver/logging.rb +163 -19
- data/lib/graph_weaver/query_module.rb +44 -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 +45 -13
- data/lib/graph_weaver/rspec.rb +404 -93
- data/lib/graph_weaver/schema_loader.rb +266 -56
- data/lib/graph_weaver/tasks.rb +380 -89
- data/lib/graph_weaver/testing/cassette.rb +34 -10
- data/lib/graph_weaver/testing/endpoint.rb +107 -0
- data/lib/graph_weaver/testing/failure.rb +69 -12
- data/lib/graph_weaver/testing/fake_client.rb +164 -45
- data/lib/graph_weaver/testing/router.rb +64 -13
- data/lib/graph_weaver/testing.rb +200 -58
- data/lib/graph_weaver/transport/faraday.rb +41 -8
- data/lib/graph_weaver/transport/http.rb +48 -6
- data/lib/graph_weaver/transport.rb +134 -27
- data/lib/graph_weaver/version.rb +1 -1
- data/lib/graph_weaver.rb +495 -106
- metadata +71 -3
- 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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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`, `
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
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
|
|
23
|
-
#
|
|
24
|
-
#
|
|
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 # =>
|
|
37
|
+
repo&.created_at # => a real Time
|
|
40
38
|
repo&.stargazer_count # => Integer
|
|
41
39
|
```
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
is
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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`).
|