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
|
@@ -47,6 +47,9 @@ module GraphWeaver
|
|
|
47
47
|
DidYouMean::SpellChecker.new(dictionary: dictionary).correct(term).first
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# "a" or "an" for a word an error message is about to name.
|
|
51
|
+
def article(word) = word.downcase.start_with?(/[aeiou]/) ? "an" : "a"
|
|
52
|
+
|
|
50
53
|
# The module a .graphql file generates, and the basename of the file
|
|
51
54
|
# it generates into: the camelized file name plus the operation's own
|
|
52
55
|
# word.
|
|
@@ -67,6 +70,30 @@ module GraphWeaver
|
|
|
67
70
|
# just the module name — see generated_names
|
|
68
71
|
def module_name(path, source) = generated_names(path, source).first
|
|
69
72
|
|
|
73
|
+
# The one sentence about scalars nothing registered — said on the
|
|
74
|
+
# logger per parse and once per run by the build, and worth saying
|
|
75
|
+
# identically in both. Keyed by graph name (nil for an app with no
|
|
76
|
+
# declared graphs): a registration is scoped to one graph, so merging
|
|
77
|
+
# the names across several would read as "forgotten everywhere" for a
|
|
78
|
+
# scalar registered for one of them and forgotten for the next.
|
|
79
|
+
def untyped_scalars_report(by_graph)
|
|
80
|
+
found = by_graph.reject { |_, names| names.empty? }
|
|
81
|
+
return if found.empty?
|
|
82
|
+
|
|
83
|
+
advice = "(register with GraphWeaver.register_scalar)"
|
|
84
|
+
# One graph ran, so there is nothing to attribute — including when it
|
|
85
|
+
# is the only one with findings is what made a forgetful graph read
|
|
86
|
+
# as a forgetful app.
|
|
87
|
+
if by_graph.one?
|
|
88
|
+
names = found.values.first.sort
|
|
89
|
+
return "#{names.size} unregistered custom scalar#{"s" unless names.one?} → T.untyped: " \
|
|
90
|
+
"#{names.join(", ")} #{advice}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
["unregistered custom scalars → T.untyped #{advice}:",
|
|
94
|
+
*found.map { |graph, names| " graph #{graph.inspect}: #{names.sort.join(", ")}" }].join("\n")
|
|
95
|
+
end
|
|
96
|
+
|
|
70
97
|
# A path setting, as a real path: relative to GraphWeaver.root, which
|
|
71
98
|
# is the app root and not wherever the process was started. Every
|
|
72
99
|
# filesystem access on a configured path goes through here; the
|
|
@@ -85,12 +112,95 @@ module GraphWeaver
|
|
|
85
112
|
path.start_with?(prefix) ? path.delete_prefix(prefix) : path
|
|
86
113
|
end
|
|
87
114
|
|
|
115
|
+
# The registrations a schema generates with: the graph that named it,
|
|
116
|
+
# or the default graph's. The testing fakes ask, so a fabricated
|
|
117
|
+
# scalar is the shape the module generated against that schema will
|
|
118
|
+
# cast — a `Money` registered for one graph is not a `Money` for the
|
|
119
|
+
# next one along. Matched on the schema class a graph runs in-process,
|
|
120
|
+
# which is the only identity cheap enough to ask per fake; anything
|
|
121
|
+
# else falls back to the default, which is where a single-schema app
|
|
122
|
+
# has always read from.
|
|
123
|
+
def registry_for(schema) = graph_for(schema)&.registry || Codegen.registry
|
|
124
|
+
|
|
125
|
+
# The declared graph that runs `schema` in-process, or nil. Matched on
|
|
126
|
+
# the schema class a graph runs, which is the only identity cheap
|
|
127
|
+
# enough to ask per call — a dump would have to be re-read, and
|
|
128
|
+
# re-reading it gives a different object every time. GraphWeaver.parse
|
|
129
|
+
# asks too, to bake the GRAPH a generated file would have carried.
|
|
130
|
+
def graph_for(schema)
|
|
131
|
+
schema && GraphWeaver.graphs.find { |candidate| candidate.live_schema.equal?(schema) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Where generated modules are READ from: the configured patterns, plus
|
|
135
|
+
# any graph writing somewhere they don't already cover. generated_paths'
|
|
136
|
+
# default glob (app/graphql/*/generated) covers the conventional layout,
|
|
137
|
+
# so listing a graph's output as well would name the same directory
|
|
138
|
+
# twice — in the log, and in the globbing.
|
|
139
|
+
#
|
|
140
|
+
# FNM_PATHNAME because Dir.glob is what expands these patterns
|
|
141
|
+
# everywhere else (Zeitwerk's ignore, load_generated!) and its * stops
|
|
142
|
+
# at a /. Without it app/graphql/*/generated "covered"
|
|
143
|
+
# app/graphql/a/b/generated, which was then neither ignored nor loaded.
|
|
144
|
+
def generated_dirs
|
|
145
|
+
extra = GraphWeaver.graphs.map(&:output).reject do |dir|
|
|
146
|
+
GraphWeaver.generated_paths.any? do |pattern|
|
|
147
|
+
File.fnmatch?(resolve(pattern), resolve(dir), File::FNM_PATHNAME)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
GraphWeaver.generated_paths | extra
|
|
151
|
+
end
|
|
152
|
+
|
|
88
153
|
# Every query document under these directories, sorted — the files
|
|
89
154
|
# generate!, verify_generated!, check_queries and load_queries! read.
|
|
90
155
|
def query_files(paths = GraphWeaver.queries_paths)
|
|
91
156
|
Array(paths).flat_map { |dir| Dir[File.join(resolve(dir), Codegen::DOCUMENT_GLOB)].sort }
|
|
92
157
|
end
|
|
93
158
|
|
|
159
|
+
# Anywhere GraphWeaver takes schema:, a Client stands for its schema — so
|
|
160
|
+
# the console object and the rake task point at the same thing. A path
|
|
161
|
+
# (String or Pathname) or SDL loads like it does everywhere else in the
|
|
162
|
+
# library; without that it reached `schema.validate` as itself and failed
|
|
163
|
+
# as `undefined method 'validate' for an instance of String`.
|
|
164
|
+
def schema_for(source)
|
|
165
|
+
return source.schema if source.is_a?(Client)
|
|
166
|
+
return SchemaLoader.load(source) if source.is_a?(String) || source.respond_to?(:to_path)
|
|
167
|
+
|
|
168
|
+
source
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Whether this source carries the @join__* routing table, i.e. is a
|
|
172
|
+
# composed supergraph rather than an API schema. The one place that
|
|
173
|
+
# asks: Graph#supergraph reads it per graph, Testing::Config for the
|
|
174
|
+
# app-wide fallbacks, and the federation rake tasks through both.
|
|
175
|
+
#
|
|
176
|
+
# Kept per source for the life of the process — parsing a supergraph
|
|
177
|
+
# is milliseconds and :wire asks per example — and keyed on what the
|
|
178
|
+
# file IS, since the answer is a property of its content. A supergraph
|
|
179
|
+
# recomposed at a stable path (a `before` hook, chained rake tasks)
|
|
180
|
+
# used to be answered from the previous composition, which routed a
|
|
181
|
+
# graph into the wrong plan or refused it as being in none.
|
|
182
|
+
def composed?(source)
|
|
183
|
+
@composed ||= {}
|
|
184
|
+
key = composed_key(source)
|
|
185
|
+
return @composed[key] if @composed.key?(key)
|
|
186
|
+
|
|
187
|
+
@composed[key] = begin
|
|
188
|
+
SchemaLoader.routing_table?(source)
|
|
189
|
+
rescue GraphWeaver::Error
|
|
190
|
+
# a source that can't even be read is in no supergraph either —
|
|
191
|
+
# and that Error is worth its warn line, where "not federated"
|
|
192
|
+
# never was
|
|
193
|
+
false
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# the conventional schema dump, required
|
|
198
|
+
def locate_schema!
|
|
199
|
+
SchemaLoader.locate or raise GraphWeaver::Error,
|
|
200
|
+
"no schema dump at #{GraphWeaver.schema_path} (.json/.graphql/.gql) — pass schema:, " \
|
|
201
|
+
"or cache one: GraphWeaver.new(url, cache: true).schema"
|
|
202
|
+
end
|
|
203
|
+
|
|
94
204
|
# The graphql-ruby schema class the app default executes against,
|
|
95
205
|
# when it runs in-process — a Client wrapping one, or the class in
|
|
96
206
|
# the slot bare. nil for every network client. Not memoized: in dev
|
|
@@ -104,8 +214,32 @@ module GraphWeaver
|
|
|
104
214
|
target if target.is_a?(Class) && target <= GraphQL::Schema
|
|
105
215
|
end
|
|
106
216
|
|
|
217
|
+
# The context to hand resolvers. A proc is answered from a request's
|
|
218
|
+
# headers (Testing::Endpoint resolves it), so off the wire there is
|
|
219
|
+
# nothing to answer it with — and a Proc reaching graphql-ruby as a
|
|
220
|
+
# context fails far from the line that set it.
|
|
221
|
+
def context!(context)
|
|
222
|
+
return context unless context.respond_to?(:call)
|
|
223
|
+
|
|
224
|
+
raise GraphWeaver::Error, "context: is a proc, so it is answered from a request's " \
|
|
225
|
+
"headers — and nothing here made a request. Tag the example graphql: :wire, which " \
|
|
226
|
+
"serves your resolvers at your client's endpoint so your transport's headers reach " \
|
|
227
|
+
"them; off the wire, pass the hash."
|
|
228
|
+
end
|
|
229
|
+
|
|
107
230
|
private
|
|
108
231
|
|
|
232
|
+
# What makes a composed? answer stale. Both callers pass a path that
|
|
233
|
+
# exists, so the file's identity is its stat — size as well as mtime,
|
|
234
|
+
# because a coarse mtime can miss two writes in one tick. Anything
|
|
235
|
+
# that isn't a path (SDL, a class) is its own key.
|
|
236
|
+
def composed_key(source)
|
|
237
|
+
stat = File.stat(source.to_s)
|
|
238
|
+
[source.to_s, stat.mtime, stat.size]
|
|
239
|
+
rescue SystemCallError
|
|
240
|
+
source
|
|
241
|
+
end
|
|
242
|
+
|
|
109
243
|
# "Mutation" for a mutation document, "Query" for everything else.
|
|
110
244
|
def operation_suffix(source)
|
|
111
245
|
operation = GraphQL.parse(source).definitions
|
|
@@ -142,7 +276,7 @@ module GraphWeaver
|
|
|
142
276
|
# JSON round-trip so symbol keys become strings — otherwise
|
|
143
277
|
# YAML.dump writes Ruby symbols the safe loader rejects on the next
|
|
144
278
|
# run, and lookup keys stay stable across processes
|
|
145
|
-
def normalize_variables(variables) = JSON.parse(
|
|
279
|
+
def normalize_variables(variables) = JSON.parse(Wire.json(variables || {}))
|
|
146
280
|
|
|
147
281
|
# one readable line: an error naming a 60-line query is a wall, not a hint
|
|
148
282
|
def summarize(query, limit: 160)
|
|
@@ -157,6 +291,54 @@ module GraphWeaver
|
|
|
157
291
|
# carries an error in. Lived on Transport and Router, both of which
|
|
158
292
|
# users touch — the worst place for it.
|
|
159
293
|
module Wire
|
|
294
|
+
# JSON for the wire, or the caller's bug named under the umbrella: a
|
|
295
|
+
# value with no JSON form (NaN, Infinity, binary) raised a raw JSON::
|
|
296
|
+
# error from wherever it was first encoded — the transport, a cassette
|
|
297
|
+
# key, a log line — so every encoder goes through here.
|
|
298
|
+
def self.json(value)
|
|
299
|
+
JSON.generate(value)
|
|
300
|
+
rescue JSON::GeneratorError => e
|
|
301
|
+
raise GraphWeaver::Error, "variables are not JSON-serializable: #{e.message}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# The half of that discipline JSON doesn't raise for. JSON.generate
|
|
305
|
+
# carries a String, a number, a boolean, null, a list and an object;
|
|
306
|
+
# anything else it renders as the value's #to_s — right for a Date or
|
|
307
|
+
# a Symbol, a memory address for a File, which then sits in the
|
|
308
|
+
# server's database looking like it meant something. So a variable
|
|
309
|
+
# whose #to_s is Ruby's debug form, or that is a stream whose bytes
|
|
310
|
+
# JSON can't carry at all, is refused before the body is built.
|
|
311
|
+
def self.check_variables!(variables)
|
|
312
|
+
variables&.each { |name, value| check_variable!(name.to_s, value) }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def self.check_variable!(path, value)
|
|
316
|
+
case value
|
|
317
|
+
when Hash then value.each { |key, nested| check_variable!("#{path}.#{key}", nested) }
|
|
318
|
+
when Array then value.each_with_index { |nested, i| check_variable!("#{path}[#{i}]", nested) }
|
|
319
|
+
when String, Symbol, Numeric, true, false, nil then nil
|
|
320
|
+
else
|
|
321
|
+
raise GraphWeaver::Error, variable_refusal(path, value) if value.respond_to?(:read) ||
|
|
322
|
+
value.to_s.start_with?("#<")
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
private_class_method :check_variable!
|
|
326
|
+
|
|
327
|
+
# A stream and an anonymous object fail the same way and need different
|
|
328
|
+
# next steps: one is a feature this client doesn't have, the other is a
|
|
329
|
+
# value that never said what it is.
|
|
330
|
+
def self.variable_refusal(path, value)
|
|
331
|
+
if value.respond_to?(:read)
|
|
332
|
+
"$#{path} is a #{value.class} — graph_weaver posts application/json and doesn't implement " \
|
|
333
|
+
"the GraphQL multipart request spec, so a file can't ride along; send what the server " \
|
|
334
|
+
"expects as JSON, or POST the upload with your own transport"
|
|
335
|
+
else
|
|
336
|
+
"$#{path} is a #{value.class}, which has no JSON form — it would go on the wire as " \
|
|
337
|
+
"#{value.to_s.inspect}; send a String, a number, a boolean, a list, or an object"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
private_class_method :variable_refusal
|
|
341
|
+
|
|
160
342
|
# The name of the document's FIRST operation, nil when anonymous. Only
|
|
161
343
|
# the fallback for a raw query string handed straight to a transport —
|
|
162
344
|
# generated modules pass their OPERATION_NAME, parsed properly.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# typed: ignore — ActiveSupport::LogSubscriber, which sorbet can't resolve here
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GraphWeaver
|
|
5
|
+
# One line per GraphQL operation in a Rails log, the shape ActiveRecord
|
|
6
|
+
# uses for a query:
|
|
7
|
+
#
|
|
8
|
+
# GraphWeaver PersonQuery (12.3ms) ok
|
|
9
|
+
# GraphWeaver PersonQuery (8.1ms) errors [THROTTLED]
|
|
10
|
+
# GraphWeaver PersonQuery (31.2ms) failed GraphWeaver::TransportError
|
|
11
|
+
# GraphWeaver billing/InvoicesQuery (12.3ms) ok
|
|
12
|
+
#
|
|
13
|
+
# Attached by the railtie wherever ActiveSupport is, and fed by the
|
|
14
|
+
# instrumenter it sets. Requires ActiveSupport — `require` this yourself
|
|
15
|
+
# only if you subscribe by hand.
|
|
16
|
+
#
|
|
17
|
+
# **One rule decides which line you get: the summary is info, the wire is
|
|
18
|
+
# debug.** This is the only GraphWeaver line at info, so a production log
|
|
19
|
+
# gets one per operation and nothing that could carry PII; turning
|
|
20
|
+
# GraphWeaver.logger up to debug adds the query, the variables and the
|
|
21
|
+
# response *beneath* it rather than repeating it.
|
|
22
|
+
#
|
|
23
|
+
# It writes through GraphWeaver.logger rather than Rails.logger, so
|
|
24
|
+
# `GraphWeaver.logger = nil` — the documented way to silence the gem —
|
|
25
|
+
# silences this too, and the line carries the same `graph_weaver`
|
|
26
|
+
# progname as every other one.
|
|
27
|
+
class LogSubscriber < ActiveSupport::LogSubscriber
|
|
28
|
+
# attach_to(:graph_weaver) subscribes "#{method}.graph_weaver" and
|
|
29
|
+
# ActiveSupport::Subscriber#call dispatches on the name up to the first
|
|
30
|
+
# dot — so this method name is EXECUTE_EVENT's first half, both ways.
|
|
31
|
+
def execute(event)
|
|
32
|
+
payload = event.payload
|
|
33
|
+
|
|
34
|
+
GraphWeaver::Internal::Log.log(:info) do
|
|
35
|
+
# duration_ms is the instrumenter's own measurement; event.duration
|
|
36
|
+
# covers a subscriber attached to something that didn't set it
|
|
37
|
+
ms = payload[:duration_ms] || event.duration
|
|
38
|
+
"GraphWeaver #{subject(payload)} (#{format("%.1f", ms)}ms) #{outcome(payload)}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# GraphWeaver's logger, not Rails' — LogSubscriber#call skips a
|
|
43
|
+
# subscriber whose logger is nil, which is what makes the gem's own
|
|
44
|
+
# opt-out reach this line too.
|
|
45
|
+
def logger = GraphWeaver.logger
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# What ran: the operation, prefixed by its graph when the request carried
|
|
50
|
+
# one — an app with several graphs reads `billing/InvoicesQuery` without
|
|
51
|
+
# a second line shape to learn, and one with a single graph never sees it.
|
|
52
|
+
def subject(payload)
|
|
53
|
+
operation = payload[:operation] || "query"
|
|
54
|
+
payload[:graph] ? "#{payload[:graph]}/#{operation}" : operation
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# status, then whatever narrows it: the error class, the code an alert
|
|
58
|
+
# groups by, and which attempt this was when a Retry is in the stack.
|
|
59
|
+
def outcome(payload)
|
|
60
|
+
parts = [payload[:status], payload[:error]]
|
|
61
|
+
parts << "[#{payload[:code]}]" if payload[:code]
|
|
62
|
+
parts << "(retry #{payload[:retries]})" if payload[:retries].to_i.positive?
|
|
63
|
+
parts.compact.join(" ")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/graph_weaver/logging.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# typed: true
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
4
6
|
module GraphWeaver
|
|
5
7
|
class << self
|
|
6
8
|
# Where GraphWeaver narrates what it's doing — anything
|
|
@@ -43,18 +45,20 @@ module GraphWeaver
|
|
|
43
45
|
|
|
44
46
|
# One callable wrapping every request GraphWeaver makes — over the
|
|
45
47
|
# wire or in-process — so an APM can time it and count errors. A
|
|
46
|
-
# no-op until you set one:
|
|
48
|
+
# no-op until you set one (Rails sets this one for you):
|
|
47
49
|
#
|
|
48
50
|
# GraphWeaver.instrumenter = lambda do |event, payload, &block|
|
|
49
51
|
# ActiveSupport::Notifications.instrument(event, payload, &block)
|
|
50
52
|
# end
|
|
51
53
|
#
|
|
52
|
-
# It must call the block and return its value. The only event today
|
|
53
|
-
#
|
|
54
|
-
# :
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
54
|
+
# It must call the block and return its value. The only event today is
|
|
55
|
+
# EXECUTE_EVENT; its payload is the contract in docs/logging.md —
|
|
56
|
+
# :operation, :client, :status, :duration_ms, :graph always;
|
|
57
|
+
# :url/:http_status over the wire, :schema in-process, :error/:code on a
|
|
58
|
+
# failure, :retries when a Retry wrapped it. Never the query text or the
|
|
59
|
+
# variables: the payload fans out to subscribers that know none of the
|
|
60
|
+
# filtering rules, so PII belongs at debug on the logger, where the
|
|
61
|
+
# level gates it and filter_parameters scrubs it.
|
|
58
62
|
attr_accessor :instrumenter
|
|
59
63
|
end
|
|
60
64
|
|
|
@@ -77,6 +81,30 @@ module GraphWeaver
|
|
|
77
81
|
# wrote can spell a value any way, so for a filtered key none of it
|
|
78
82
|
# survives, not the parts that would have been safe.
|
|
79
83
|
def detail(key, detail) = filtered?(key) ? FILTERED : detail
|
|
84
|
+
|
|
85
|
+
# A value the library reports as DATA rather than inside a sentence —
|
|
86
|
+
# InputError#value. Scrubbed at every depth, so a filtered key nested
|
|
87
|
+
# inside an input object is covered too, and the same list decides it
|
|
88
|
+
# as decides the debug log's variables line.
|
|
89
|
+
def value(key, value) = Log.filter_variables({ key.to_s => value })[key.to_s]
|
|
90
|
+
|
|
91
|
+
# A value the library spells INTO a sentence. `detail` can only ask
|
|
92
|
+
# about the key the value arrived under, so it reads a filtered key one
|
|
93
|
+
# level in as safe; this scrubs at every depth, like #value. The key is
|
|
94
|
+
# optional because a coercer refusing a value hasn't been told one.
|
|
95
|
+
def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(value(key, raw).inspect)
|
|
96
|
+
|
|
97
|
+
# Text the library didn't author — a value a caller sent, a sentence a
|
|
98
|
+
# server wrote — cut to what an error may carry. The number lives on
|
|
99
|
+
# InputError, which is the class that documents it and the one every
|
|
100
|
+
# capped string reaches.
|
|
101
|
+
def cap(text)
|
|
102
|
+
limit = GraphWeaver::InputError::VALUE_LIMIT
|
|
103
|
+
return text if text.bytesize <= limit
|
|
104
|
+
|
|
105
|
+
# byteslice can land mid-character; scrub drops the partial tail
|
|
106
|
+
"#{text.byteslice(0, limit).scrub("")}…(#{text.bytesize - limit} more bytes)"
|
|
107
|
+
end
|
|
80
108
|
end
|
|
81
109
|
end
|
|
82
110
|
end
|
|
@@ -91,14 +119,23 @@ module GraphWeaver
|
|
|
91
119
|
self.filter_parameters = DEFAULT_FILTER_PARAMETERS
|
|
92
120
|
|
|
93
121
|
# The one instrumentation event: a single GraphQL request, start to
|
|
94
|
-
# parsed response, whichever client slot served it.
|
|
95
|
-
|
|
122
|
+
# parsed response, whichever client slot served it. `<event>.<namespace>`
|
|
123
|
+
# is how every notification in this ecosystem is spelled
|
|
124
|
+
# (sql.active_record, execute_multiplex.graphql) — it's what
|
|
125
|
+
# ActiveSupport::LogSubscriber.attach_to and an APM's namespace routing
|
|
126
|
+
# key on, so a backwards name made both of them a puzzle.
|
|
127
|
+
EXECUTE_EVENT = "execute.graph_weaver"
|
|
96
128
|
|
|
97
129
|
module Internal
|
|
98
130
|
# The emitting half of the narration the three accessors above
|
|
99
131
|
# configure. Setting a logger is API; writing to it is not, and the
|
|
100
132
|
# two read as a pair when they sit on the same object.
|
|
101
133
|
module Log
|
|
134
|
+
# fiber-local, set only for the duration of one attempt (with_retries)
|
|
135
|
+
RETRIES = :graph_weaver_retries
|
|
136
|
+
# fiber-local, set only for the duration of one dispatch (with_graph)
|
|
137
|
+
GRAPH = :graph_weaver_graph
|
|
138
|
+
|
|
102
139
|
class << self
|
|
103
140
|
# Level-gated and lazy — the block only runs when a logger is
|
|
104
141
|
# listening. Messages carry "graph_weaver" as progname.
|
|
@@ -118,13 +155,89 @@ module GraphWeaver
|
|
|
118
155
|
result
|
|
119
156
|
end
|
|
120
157
|
|
|
121
|
-
# Wrap the block in the instrumenter, if one is set. The
|
|
122
|
-
#
|
|
158
|
+
# Wrap the block in the instrumenter, if one is set. The caller
|
|
159
|
+
# supplies what only it knows (:url, :schema, :client); this fills
|
|
160
|
+
# in the half every path shares — how it ended, how long it took,
|
|
161
|
+
# what a Retry had already spent — so one subscriber reads one
|
|
162
|
+
# shape whichever client slot served the request.
|
|
123
163
|
def instrument(event, payload)
|
|
124
164
|
hook = GraphWeaver.instrumenter
|
|
125
165
|
return yield unless hook
|
|
126
166
|
|
|
127
|
-
|
|
167
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
168
|
+
retries = Thread.current[RETRIES]
|
|
169
|
+
payload[:retries] = retries if retries
|
|
170
|
+
payload[:graph] = Thread.current[GRAPH]
|
|
171
|
+
# pessimistic, so :status is set even for what a rescue can't
|
|
172
|
+
# see — an Interrupt, a killed thread — and never silently absent
|
|
173
|
+
payload[:status] = :failed
|
|
174
|
+
|
|
175
|
+
# One dispatch labels one request. Whatever THIS request reaches —
|
|
176
|
+
# a resolver serving it that calls out — is a request of its own,
|
|
177
|
+
# and the caller's graph would be a wrong label on it.
|
|
178
|
+
with_graph(nil) do
|
|
179
|
+
hook.call(event, payload) do
|
|
180
|
+
result = yield
|
|
181
|
+
errors = response_errors(result)
|
|
182
|
+
if errors.empty?
|
|
183
|
+
payload[:status] = :ok
|
|
184
|
+
else
|
|
185
|
+
payload[:status] = :errors
|
|
186
|
+
payload[:code] = errors.grep(Hash).filter_map { |e| GraphWeaver::GraphQLError.from_h(e).code }.first
|
|
187
|
+
end
|
|
188
|
+
result
|
|
189
|
+
rescue => e
|
|
190
|
+
payload[:error] = e.class.name
|
|
191
|
+
# the one key an alert groups by, whichever kind of failure it was
|
|
192
|
+
payload[:code] = e.status if e.is_a?(GraphWeaver::ServerError)
|
|
193
|
+
raise
|
|
194
|
+
ensure
|
|
195
|
+
payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# What a Retry has already spent, read by the attempt it is about
|
|
201
|
+
# to make. A dynamic extent rather than a global: the count is only
|
|
202
|
+
# visible while the call it describes is on the stack, so a client
|
|
203
|
+
# that never reaches instrument can't leave a stale one behind.
|
|
204
|
+
def with_retries(count)
|
|
205
|
+
return yield unless GraphWeaver.instrumenter
|
|
206
|
+
|
|
207
|
+
previous = Thread.current[RETRIES]
|
|
208
|
+
Thread.current[RETRIES] = count
|
|
209
|
+
begin
|
|
210
|
+
yield
|
|
211
|
+
ensure
|
|
212
|
+
Thread.current[RETRIES] = previous
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The graph a generated module is dispatching, read by the request it
|
|
217
|
+
# is about to make. Same dynamic extent as with_retries, for the same
|
|
218
|
+
# reason — and instrument clears it for the duration of the request it
|
|
219
|
+
# labels, so exactly one request wears the label.
|
|
220
|
+
def with_graph(name)
|
|
221
|
+
return yield unless GraphWeaver.instrumenter
|
|
222
|
+
|
|
223
|
+
previous = Thread.current[GRAPH]
|
|
224
|
+
Thread.current[GRAPH] = name
|
|
225
|
+
begin
|
|
226
|
+
yield
|
|
227
|
+
ensure
|
|
228
|
+
Thread.current[GRAPH] = previous
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The variables as one JSON line for a log: filtered, and unable to
|
|
233
|
+
# raise. A value with no JSON form (NaN, binary) is the caller's bug
|
|
234
|
+
# and the transport refuses it a few lines later — but a logger that
|
|
235
|
+
# decides WHICH exception a caller sees, or whether one is raised at
|
|
236
|
+
# all, is worse than a log line that says it couldn't render.
|
|
237
|
+
def variables_for_log(variables)
|
|
238
|
+
JSON.generate(filter_variables(variables))
|
|
239
|
+
rescue StandardError => e
|
|
240
|
+
"<unloggable: #{e.class}>"
|
|
128
241
|
end
|
|
129
242
|
|
|
130
243
|
# variables with the filtered keys blanked out
|
|
@@ -138,6 +251,17 @@ module GraphWeaver
|
|
|
138
251
|
|
|
139
252
|
private
|
|
140
253
|
|
|
254
|
+
# The GraphQL errors a response carries, whatever answered it — a
|
|
255
|
+
# Hash from a transport, a graphql-ruby Result in-process, a fake.
|
|
256
|
+
# Never raises: an instrumenter that decides which exception a
|
|
257
|
+
# caller sees is worse than a missing tag.
|
|
258
|
+
def response_errors(result)
|
|
259
|
+
errors = result.to_h["errors"] if result.respond_to?(:to_h)
|
|
260
|
+
errors.is_a?(Array) ? errors : []
|
|
261
|
+
rescue StandardError
|
|
262
|
+
[]
|
|
263
|
+
end
|
|
264
|
+
|
|
141
265
|
def scrub(value, filters)
|
|
142
266
|
case value
|
|
143
267
|
when Hash then value.to_h { |k, v| [k, filtered?(k, filters) ? FILTERED : scrub(v, filters)] }
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
require "sorbet-runtime"
|
|
5
5
|
|
|
6
|
+
require_relative "internal/test_clients"
|
|
7
|
+
|
|
6
8
|
module GraphWeaver
|
|
7
9
|
# Called by generated code — not semver'd for direct use.
|
|
8
10
|
#
|
|
@@ -14,7 +16,8 @@ module GraphWeaver
|
|
|
14
16
|
# those are the point.
|
|
15
17
|
#
|
|
16
18
|
# Resolution order, per the docs: per call → per module (`MyQuery.client =`)
|
|
17
|
-
# →
|
|
19
|
+
# → a test mode's stand-in (Internal::TestClients) → the module's baked
|
|
20
|
+
# DEFAULT_CLIENT → `GraphWeaver.client`.
|
|
18
21
|
module QueryModule
|
|
19
22
|
extend T::Sig
|
|
20
23
|
|
|
@@ -30,13 +33,36 @@ module GraphWeaver
|
|
|
30
33
|
|
|
31
34
|
private
|
|
32
35
|
|
|
36
|
+
# The one call a generated `execute` makes: resolve the client, run this
|
|
37
|
+
# module's own operation, hand the raw response back for from_response to
|
|
38
|
+
# wrap. Here rather than emitted, so what has to BRACKET a request — the
|
|
39
|
+
# graph label today — costs nothing in every generated file, and one
|
|
40
|
+
# reading of it covers every module in the app.
|
|
41
|
+
#
|
|
42
|
+
# The constants come off the module rather than the caller: a generated
|
|
43
|
+
# `execute` already knows them, but reading them here is what makes this
|
|
44
|
+
# the whole of the call instead of three arguments' worth of it.
|
|
45
|
+
sig { params(variables: T::Hash[String, T.untyped], client: T.untyped).returns(T.untyped) }
|
|
46
|
+
def dispatch(variables, client:)
|
|
47
|
+
mod = T.unsafe(self)
|
|
48
|
+
# the graph codegen baked in, never one inferred from the client — a
|
|
49
|
+
# wrong label on a request is worse than no label
|
|
50
|
+
graph = mod.const_defined?(:GRAPH, false) ? mod.const_get(:GRAPH) : nil
|
|
51
|
+
|
|
52
|
+
GraphWeaver::Internal::Log.with_graph(graph) do
|
|
53
|
+
client_for(client).execute(mod.const_get(:QUERY), variables:,
|
|
54
|
+
operation_name: mod.const_get(:OPERATION_NAME))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
33
58
|
# The client one execute runs through: the per-call `client:`, else the
|
|
34
59
|
# module's, else the app default. Checked here so a wrong one names the
|
|
35
60
|
# contract and the module, rather than surfacing as a NoMethodError from
|
|
36
|
-
# inside the call.
|
|
61
|
+
# inside the call — and put through Client.instrumented, the one place a
|
|
62
|
+
# bare schema class gets the seam it has no way to carry itself.
|
|
37
63
|
sig { params(override: T.untyped).returns(T.untyped) }
|
|
38
64
|
def client_for(override)
|
|
39
|
-
target = override || client
|
|
65
|
+
target = GraphWeaver::Client.instrumented(override || client)
|
|
40
66
|
return target if target.respond_to?(:execute)
|
|
41
67
|
|
|
42
68
|
# Kernel.raise: this module is extended into another, so sorbet can't
|
|
@@ -48,9 +74,16 @@ module GraphWeaver
|
|
|
48
74
|
# Codegen's `client:` constant, emitted as a DEFAULT_CLIENT lambda so the
|
|
49
75
|
# constant it names is resolved on first use rather than at load — a
|
|
50
76
|
# generated file may load before the initializer that builds the client.
|
|
77
|
+
#
|
|
78
|
+
# A test mode stands in for it: what codegen baked in is exactly what a
|
|
79
|
+
# `graphql:` tag means to replace, so a bound module is covered by the
|
|
80
|
+
# tag like every other one.
|
|
51
81
|
sig { returns(T.untyped) }
|
|
52
82
|
def default_client
|
|
53
83
|
mod = T.unsafe(self)
|
|
84
|
+
stand_in = GraphWeaver::Internal::TestClients.for(mod)
|
|
85
|
+
return stand_in if stand_in
|
|
86
|
+
|
|
54
87
|
mod.const_defined?(:DEFAULT_CLIENT, false) ? mod.const_get(:DEFAULT_CLIENT).call : GraphWeaver.client!
|
|
55
88
|
end
|
|
56
89
|
end
|