graph_weaver 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +153 -4
  4. data/README.md +45 -79
  5. data/docs/alternatives.md +195 -0
  6. data/docs/cassettes.md +61 -50
  7. data/docs/editors.md +32 -47
  8. data/docs/errors.md +360 -103
  9. data/docs/federation.md +692 -473
  10. data/docs/generated_modules.md +441 -314
  11. data/docs/getting_started.md +370 -194
  12. data/docs/i18n.md +171 -0
  13. data/docs/logging.md +197 -50
  14. data/docs/real_world.md +42 -27
  15. data/docs/scalars.md +307 -176
  16. data/docs/testing.md +473 -220
  17. data/docs/transports.md +224 -151
  18. data/docs/upgrading.md +258 -305
  19. data/examples/README.md +38 -0
  20. data/examples/countries.rb +39 -0
  21. data/examples/federation.rb +62 -0
  22. data/examples/github/generate.rb +20 -0
  23. data/examples/github/generated/star_mutation.rb +126 -0
  24. data/examples/github/generated/stargazers_query.rb +232 -0
  25. data/examples/github/generated/starred_query.rb +151 -0
  26. data/examples/github/queries/star.graphql +8 -0
  27. data/examples/github/queries/stargazers.graphql +22 -0
  28. data/examples/github/queries/starred.graphql +11 -0
  29. data/examples/github/run.rb +43 -0
  30. data/examples/github/setup.rb +18 -0
  31. data/examples/rick_and_morty.rb +57 -0
  32. data/graph_weaver.gemspec +19 -3
  33. data/lib/generators/graph_weaver/install_generator.rb +138 -4
  34. data/lib/graph_weaver/client.rb +69 -11
  35. data/lib/graph_weaver/codegen/aliases.rb +7 -5
  36. data/lib/graph_weaver/codegen/emit.rb +98 -29
  37. data/lib/graph_weaver/codegen/enum_type.rb +2 -1
  38. data/lib/graph_weaver/codegen/nodes.rb +39 -6
  39. data/lib/graph_weaver/codegen/registry.rb +175 -0
  40. data/lib/graph_weaver/codegen/scalar_type.rb +123 -30
  41. data/lib/graph_weaver/codegen/type_helpers.rb +56 -11
  42. data/lib/graph_weaver/codegen.rb +406 -195
  43. data/lib/graph_weaver/coerce.rb +155 -26
  44. data/lib/graph_weaver/context_seam.rb +54 -0
  45. data/lib/graph_weaver/errors.rb +284 -46
  46. data/lib/graph_weaver/federation.rb +129 -27
  47. data/lib/graph_weaver/graph.rb +315 -0
  48. data/lib/graph_weaver/hints.rb +100 -24
  49. data/lib/graph_weaver/in_process.rb +27 -15
  50. data/lib/graph_weaver/input_struct.rb +119 -32
  51. data/lib/graph_weaver/internal/endpoint.rb +80 -0
  52. data/lib/graph_weaver/internal/headers.rb +70 -0
  53. data/lib/graph_weaver/internal/overrides.rb +67 -5
  54. data/lib/graph_weaver/internal/planner.rb +45 -15
  55. data/lib/graph_weaver/internal/refusal.rb +49 -0
  56. data/lib/graph_weaver/internal/schemas.rb +23 -9
  57. data/lib/graph_weaver/internal/selection.rb +34 -0
  58. data/lib/graph_weaver/internal/server_input.rb +251 -0
  59. data/lib/graph_weaver/internal/test_clients.rb +276 -0
  60. data/lib/graph_weaver/internal/unused.rb +287 -0
  61. data/lib/graph_weaver/internal/values.rb +40 -4
  62. data/lib/graph_weaver/internal.rb +249 -14
  63. data/lib/graph_weaver/log_subscriber.rb +74 -0
  64. data/lib/graph_weaver/logging.rb +163 -19
  65. data/lib/graph_weaver/query_module.rb +44 -3
  66. data/lib/graph_weaver/railtie.rb +237 -17
  67. data/lib/graph_weaver/representation.rb +55 -17
  68. data/lib/graph_weaver/result_struct.rb +90 -0
  69. data/lib/graph_weaver/retry.rb +45 -13
  70. data/lib/graph_weaver/rspec.rb +404 -93
  71. data/lib/graph_weaver/schema_loader.rb +266 -56
  72. data/lib/graph_weaver/tasks.rb +380 -89
  73. data/lib/graph_weaver/testing/cassette.rb +34 -10
  74. data/lib/graph_weaver/testing/endpoint.rb +107 -0
  75. data/lib/graph_weaver/testing/failure.rb +69 -12
  76. data/lib/graph_weaver/testing/fake_client.rb +164 -45
  77. data/lib/graph_weaver/testing/router.rb +64 -13
  78. data/lib/graph_weaver/testing.rb +200 -58
  79. data/lib/graph_weaver/transport/faraday.rb +41 -8
  80. data/lib/graph_weaver/transport/http.rb +48 -6
  81. data/lib/graph_weaver/transport.rb +134 -27
  82. data/lib/graph_weaver/version.rb +1 -1
  83. data/lib/graph_weaver.rb +495 -106
  84. metadata +71 -3
  85. data/CHANGELOG.md +0 -2355
@@ -47,26 +47,63 @@ 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
- # word.
55
+ # word. Every run of non-alphanumerics in the name is a word boundary,
56
+ # and a trailing extension naming the document's own operation kind is
57
+ # dropped rather than doubled.
53
58
  #
54
59
  # person.graphql => PersonQuery (person_query.rb)
55
60
  # save_list_entry.graphql => SaveListEntryMutation
56
61
  # (save_list_entry_mutation.rb)
62
+ # get-hello.graphql => GetHelloQuery (get_hello_query.rb)
63
+ # hello.query.graphql => HelloQuery (hello_query.rb)
57
64
  #
58
65
  # Every naming site goes through here — generate!, parse(path), and
59
66
  # load_queries! — so the constant a file produces is the same one
60
67
  # whichever door you came in by, and the file it lands in matches it.
61
68
  def generated_names(path, source)
62
- base = File.basename(path, ".*")
63
- suffix = operation_suffix(source)
64
- ["#{Inflect.camelize(base)}#{suffix}", "#{base}_#{suffix.downcase}.rb"]
69
+ kind = operation_kind(source)
70
+ base = strip_kind_extension(File.basename(path, ".*"), kind, path)
71
+ stem = base.gsub(/[^A-Za-z0-9]+/, "_")
72
+ suffix = (kind == "mutation") ? "Mutation" : "Query"
73
+ name = Inflect.camelize(stem)
74
+ # all punctuation camelizes to nothing, which would leave the suffix
75
+ # standing alone as the whole name — keep the base so it stays refusable
76
+ name = base if name.empty?
77
+ ["#{name}#{suffix}", "#{stem}_#{suffix.downcase}.rb"]
65
78
  end
66
79
 
67
80
  # just the module name — see generated_names
68
81
  def module_name(path, source) = generated_names(path, source).first
69
82
 
83
+ # The one sentence about scalars nothing registered — said on the
84
+ # logger per parse and once per run by the build, and worth saying
85
+ # identically in both. Keyed by graph name (nil for an app with no
86
+ # declared graphs): a registration is scoped to one graph, so merging
87
+ # the names across several would read as "forgotten everywhere" for a
88
+ # scalar registered for one of them and forgotten for the next.
89
+ def untyped_scalars_report(by_graph)
90
+ found = by_graph.reject { |_, names| names.empty? }
91
+ return if found.empty?
92
+
93
+ advice = "(register with GraphWeaver.register_scalar)"
94
+ # One graph ran, so there is nothing to attribute — including when it
95
+ # is the only one with findings is what made a forgetful graph read
96
+ # as a forgetful app.
97
+ if by_graph.one?
98
+ names = found.values.first.sort
99
+ return "#{names.size} unregistered custom scalar#{"s" unless names.one?} → T.untyped: " \
100
+ "#{names.join(", ")} #{advice}"
101
+ end
102
+
103
+ ["unregistered custom scalars → T.untyped #{advice}:",
104
+ *found.map { |graph, names| " graph #{graph.inspect}: #{names.sort.join(", ")}" }].join("\n")
105
+ end
106
+
70
107
  # A path setting, as a real path: relative to GraphWeaver.root, which
71
108
  # is the app root and not wherever the process was started. Every
72
109
  # filesystem access on a configured path goes through here; the
@@ -85,12 +122,95 @@ module GraphWeaver
85
122
  path.start_with?(prefix) ? path.delete_prefix(prefix) : path
86
123
  end
87
124
 
125
+ # The registrations a schema generates with: the graph that named it,
126
+ # or the default graph's. The testing fakes ask, so a fabricated
127
+ # scalar is the shape the module generated against that schema will
128
+ # cast — a `Money` registered for one graph is not a `Money` for the
129
+ # next one along. Matched on the schema class a graph runs in-process,
130
+ # which is the only identity cheap enough to ask per fake; anything
131
+ # else falls back to the default, which is where a single-schema app
132
+ # has always read from.
133
+ def registry_for(schema) = graph_for(schema)&.registry || Codegen.registry
134
+
135
+ # The declared graph that runs `schema` in-process, or nil. Matched on
136
+ # the schema class a graph runs, which is the only identity cheap
137
+ # enough to ask per call — a dump would have to be re-read, and
138
+ # re-reading it gives a different object every time. GraphWeaver.parse
139
+ # asks too, to bake the GRAPH a generated file would have carried.
140
+ def graph_for(schema)
141
+ schema && GraphWeaver.graphs.find { |candidate| candidate.live_schema.equal?(schema) }
142
+ end
143
+
144
+ # Where generated modules are READ from: the configured patterns, plus
145
+ # any graph writing somewhere they don't already cover. generated_paths'
146
+ # default glob (app/graphql/*/generated) covers the conventional layout,
147
+ # so listing a graph's output as well would name the same directory
148
+ # twice — in the log, and in the globbing.
149
+ #
150
+ # FNM_PATHNAME because Dir.glob is what expands these patterns
151
+ # everywhere else (Zeitwerk's ignore, load_generated!) and its * stops
152
+ # at a /. Without it app/graphql/*/generated "covered"
153
+ # app/graphql/a/b/generated, which was then neither ignored nor loaded.
154
+ def generated_dirs
155
+ extra = GraphWeaver.graphs.map(&:output).reject do |dir|
156
+ GraphWeaver.generated_paths.any? do |pattern|
157
+ File.fnmatch?(resolve(pattern), resolve(dir), File::FNM_PATHNAME)
158
+ end
159
+ end
160
+ GraphWeaver.generated_paths | extra
161
+ end
162
+
88
163
  # Every query document under these directories, sorted — the files
89
164
  # generate!, verify_generated!, check_queries and load_queries! read.
90
165
  def query_files(paths = GraphWeaver.queries_paths)
91
166
  Array(paths).flat_map { |dir| Dir[File.join(resolve(dir), Codegen::DOCUMENT_GLOB)].sort }
92
167
  end
93
168
 
169
+ # Anywhere GraphWeaver takes schema:, a Client stands for its schema — so
170
+ # the console object and the rake task point at the same thing. A path
171
+ # (String or Pathname) or SDL loads like it does everywhere else in the
172
+ # library; without that it reached `schema.validate` as itself and failed
173
+ # as `undefined method 'validate' for an instance of String`.
174
+ def schema_for(source)
175
+ return source.schema if source.is_a?(Client)
176
+ return SchemaLoader.load(source) if source.is_a?(String) || source.respond_to?(:to_path)
177
+
178
+ source
179
+ end
180
+
181
+ # Whether this source carries the @join__* routing table, i.e. is a
182
+ # composed supergraph rather than an API schema. The one place that
183
+ # asks: Graph#supergraph reads it per graph, Testing::Config for the
184
+ # app-wide fallbacks, and the federation rake tasks through both.
185
+ #
186
+ # Kept per source for the life of the process — parsing a supergraph
187
+ # is milliseconds and :wire asks per example — and keyed on what the
188
+ # file IS, since the answer is a property of its content. A supergraph
189
+ # recomposed at a stable path (a `before` hook, chained rake tasks)
190
+ # used to be answered from the previous composition, which routed a
191
+ # graph into the wrong plan or refused it as being in none.
192
+ def composed?(source)
193
+ @composed ||= {}
194
+ key = composed_key(source)
195
+ return @composed[key] if @composed.key?(key)
196
+
197
+ @composed[key] = begin
198
+ SchemaLoader.routing_table?(source)
199
+ rescue GraphWeaver::Error
200
+ # a source that can't even be read is in no supergraph either —
201
+ # and that Error is worth its warn line, where "not federated"
202
+ # never was
203
+ false
204
+ end
205
+ end
206
+
207
+ # the conventional schema dump, required
208
+ def locate_schema!
209
+ SchemaLoader.locate or raise GraphWeaver::Error,
210
+ "no schema dump at #{GraphWeaver.schema_path} (.json/.graphql/.gql) — pass schema:, " \
211
+ "or cache one: GraphWeaver.new(url, cache: true).schema"
212
+ end
213
+
94
214
  # The graphql-ruby schema class the app default executes against,
95
215
  # when it runs in-process — a Client wrapping one, or the class in
96
216
  # the slot bare. nil for every network client. Not memoized: in dev
@@ -104,15 +224,62 @@ module GraphWeaver
104
224
  target if target.is_a?(Class) && target <= GraphQL::Schema
105
225
  end
106
226
 
227
+ # The context to hand resolvers. A proc is answered from a request's
228
+ # headers (Testing::Endpoint resolves it), so off the wire there is
229
+ # nothing to answer it with — and a Proc reaching graphql-ruby as a
230
+ # context fails far from the line that set it.
231
+ def context!(context)
232
+ return context unless context.respond_to?(:call)
233
+
234
+ raise GraphWeaver::Error, "context: is a proc, so it is answered from a request's " \
235
+ "headers — and nothing here made a request. Tag the example graphql: :wire, which " \
236
+ "serves your resolvers at your client's endpoint so your transport's headers reach " \
237
+ "them; off the wire, pass the hash."
238
+ end
239
+
107
240
  private
108
241
 
109
- # "Mutation" for a mutation document, "Query" for everything else.
110
- def operation_suffix(source)
242
+ # What makes a composed? answer stale. Both callers pass a path that
243
+ # exists, so the file's identity is its stat — size as well as mtime,
244
+ # because a coarse mtime can miss two writes in one tick. Anything
245
+ # that isn't a path (SDL, a class) is its own key.
246
+ def composed_key(source)
247
+ stat = File.stat(source.to_s)
248
+ [source.to_s, stat.mtime, stat.size]
249
+ rescue SystemCallError
250
+ source
251
+ end
252
+
253
+ # The document's operation kind — "query", "mutation" or
254
+ # "subscription" — or nil when it holds no operation or won't parse.
255
+ # The one source of truth for the word a module name ends in AND for
256
+ # the file-name extension that word makes redundant.
257
+ def operation_kind(source)
111
258
  operation = GraphQL.parse(source).definitions
112
259
  .grep(GraphQL::Language::Nodes::OperationDefinition).first
113
- (operation&.operation_type == "mutation") ? "Mutation" : "Query"
260
+ operation && (operation.operation_type || "query") # `{ hello }` is shorthand for a query
114
261
  rescue GraphQL::ParseError
115
- "Query" # unparseable: codegen brands the real error a moment later
262
+ nil # unparseable: codegen brands the real error a moment later
263
+ end
264
+
265
+ # GraphQL's operation kinds, as a file name spells them. Apollo, Relay
266
+ # and GitLab's frontend all name a query file for its operation, so
267
+ # `blob_content.query.graphql` says in the extension exactly what the
268
+ # module's own suffix says — drop it rather than emit BlobContentQueryQuery.
269
+ # `_query` inside a snake_case name is a word OF the name, not this, so
270
+ # nothing that generates today is renamed.
271
+ OPERATION_EXTENSION = /\.(query|mutation|subscription)\z/i
272
+ private_constant :OPERATION_EXTENSION
273
+
274
+ def strip_kind_extension(base, kind, path)
275
+ declared = base[OPERATION_EXTENSION, 1]&.downcase
276
+ stem = declared && base[0...-(declared.length + 1)]
277
+ return base if stem.nil? || stem.empty?
278
+ return stem if kind.nil? || kind == declared
279
+
280
+ raise GraphWeaver::Error, "#{relative(path)}: the file name ends .#{declared}, but the " \
281
+ "document defines a #{kind} — rename it #{stem}.#{kind}#{File.extname(path)} " \
282
+ "or drop the .#{declared}"
116
283
  end
117
284
  end
118
285
  end
@@ -142,7 +309,7 @@ module GraphWeaver
142
309
  # JSON round-trip so symbol keys become strings — otherwise
143
310
  # YAML.dump writes Ruby symbols the safe loader rejects on the next
144
311
  # run, and lookup keys stay stable across processes
145
- def normalize_variables(variables) = JSON.parse(JSON.generate(variables || {}))
312
+ def normalize_variables(variables) = JSON.parse(Wire.json(variables || {}))
146
313
 
147
314
  # one readable line: an error naming a 60-line query is a wall, not a hint
148
315
  def summarize(query, limit: 160)
@@ -157,6 +324,54 @@ module GraphWeaver
157
324
  # carries an error in. Lived on Transport and Router, both of which
158
325
  # users touch — the worst place for it.
159
326
  module Wire
327
+ # JSON for the wire, or the caller's bug named under the umbrella: a
328
+ # value with no JSON form (NaN, Infinity, binary) raised a raw JSON::
329
+ # error from wherever it was first encoded — the transport, a cassette
330
+ # key, a log line — so every encoder goes through here.
331
+ def self.json(value)
332
+ JSON.generate(value)
333
+ rescue JSON::GeneratorError => e
334
+ raise GraphWeaver::Error, "variables are not JSON-serializable: #{e.message}"
335
+ end
336
+
337
+ # The half of that discipline JSON doesn't raise for. JSON.generate
338
+ # carries a String, a number, a boolean, null, a list and an object;
339
+ # anything else it renders as the value's #to_s — right for a Date or
340
+ # a Symbol, a memory address for a File, which then sits in the
341
+ # server's database looking like it meant something. So a variable
342
+ # whose #to_s is Ruby's debug form, or that is a stream whose bytes
343
+ # JSON can't carry at all, is refused before the body is built.
344
+ def self.check_variables!(variables)
345
+ variables&.each { |name, value| check_variable!(name.to_s, value) }
346
+ end
347
+
348
+ def self.check_variable!(path, value)
349
+ case value
350
+ when Hash then value.each { |key, nested| check_variable!("#{path}.#{key}", nested) }
351
+ when Array then value.each_with_index { |nested, i| check_variable!("#{path}[#{i}]", nested) }
352
+ when String, Symbol, Numeric, true, false, nil then nil
353
+ else
354
+ raise GraphWeaver::Error, variable_refusal(path, value) if value.respond_to?(:read) ||
355
+ value.to_s.start_with?("#<")
356
+ end
357
+ end
358
+ private_class_method :check_variable!
359
+
360
+ # A stream and an anonymous object fail the same way and need different
361
+ # next steps: one is a feature this client doesn't have, the other is a
362
+ # value that never said what it is.
363
+ def self.variable_refusal(path, value)
364
+ if value.respond_to?(:read)
365
+ "$#{path} is a #{value.class} — graph_weaver posts application/json and doesn't implement " \
366
+ "the GraphQL multipart request spec, so a file can't ride along; send what the server " \
367
+ "expects as JSON, or POST the upload with your own transport"
368
+ else
369
+ "$#{path} is a #{value.class}, which has no JSON form — it would go on the wire as " \
370
+ "#{value.to_s.inspect}; send a String, a number, a boolean, a list, or an object"
371
+ end
372
+ end
373
+ private_class_method :variable_refusal
374
+
160
375
  # The name of the document's FIRST operation, nil when anonymous. Only
161
376
  # the fallback for a raw query string handed straight to a transport —
162
377
  # generated modules pass their OPERATION_NAME, parsed properly.
@@ -168,7 +383,8 @@ module GraphWeaver
168
383
  # every request, and the only way to be wrong (a field literally named
169
384
  # `mutation` opening a line) errs toward not retrying.
170
385
  MUTATION_PATTERN = /^[ \t]*mutation\b/
171
- private_constant :MUTATION_PATTERN
386
+ SUBSCRIPTION_PATTERN = /^[ \t]*subscription\b/
387
+ private_constant :MUTATION_PATTERN, :SUBSCRIPTION_PATTERN
172
388
 
173
389
  REQUEST_MUTEX = Mutex.new
174
390
  private_constant :REQUEST_MUTEX
@@ -183,16 +399,35 @@ module GraphWeaver
183
399
 
184
400
  def mutation?(query) = MUTATION_PATTERN.match?(query)
185
401
 
402
+ # What this document runs, for the instrumentation payload — :query
403
+ # for the shorthand `{ ... }` document too, which is what it is.
404
+ # Built on mutation? rather than beside it, so an APM's write-failure
405
+ # rate and the decision not to retry can't come to disagree.
406
+ def kind(query)
407
+ return :mutation if mutation?(query)
408
+
409
+ SUBSCRIPTION_PATTERN.match?(query) ? :subscription : :query
410
+ end
411
+
186
412
  # one error in the shape a GraphQL response carries them
187
413
  def graphql_error(message, code)
188
414
  { "message" => message, "extensions" => { "code" => code } }
189
415
  end
190
416
 
191
- # "[req 3 FilteredPokemon]" — a per-process request id plus the
192
- # operation name, when there is one
417
+ # "[req 4123-3 FilteredPokemon]" — the pid, this process's own
418
+ # request count, and the operation name when there is one.
419
+ #
420
+ # Both halves, because a Puma cluster forks: the counter is inherited
421
+ # with everything else, so without the reset every worker continues
422
+ # the master's sequence, and without the pid two workers' "[req 3]"
423
+ # are two unrelated requests in one aggregated log.
193
424
  def log_tag(operation_name = nil)
194
- id = REQUEST_MUTEX.synchronize { @request_count = (@request_count || 0) + 1 }
195
- "[req #{id}#{" #{operation_name}" if operation_name}]"
425
+ pid = Process.pid
426
+ id = REQUEST_MUTEX.synchronize do
427
+ @request_pid, @request_count = pid, 0 unless @request_pid == pid
428
+ @request_count += 1
429
+ end
430
+ "[req #{pid}-#{id}#{" #{operation_name}" if operation_name}]"
196
431
  end
197
432
 
198
433
  def truncate_for_log(query)
@@ -0,0 +1,74 @@
1
+ # typed: ignore — ActiveSupport::LogSubscriber, which sorbet can't resolve here
2
+ # frozen_string_literal: true
3
+
4
+ # The superclass, so this file stands alone: requiring it by hand is the
5
+ # documented way to subscribe outside Rails, and there is no railtie out there
6
+ # to have loaded ActiveSupport first. Skipped when the constant already
7
+ # exists, which is how a stand-in can take its place.
8
+ require "active_support/log_subscriber" unless defined?(ActiveSupport::LogSubscriber)
9
+
10
+ module GraphWeaver
11
+ # One line per GraphQL operation in a Rails log, the shape ActiveRecord
12
+ # uses for a query:
13
+ #
14
+ # GraphWeaver PersonQuery (12.3ms) ok
15
+ # GraphWeaver PersonQuery (8.1ms) errors [THROTTLED]
16
+ # GraphWeaver PersonQuery (31.2ms) failed GraphWeaver::TransportError
17
+ # GraphWeaver billing/InvoicesQuery (12.3ms) ok
18
+ #
19
+ # Attached by the railtie wherever ActiveSupport is, and fed by the
20
+ # instrumenter it sets. Requires ActiveSupport — `require` this yourself
21
+ # only if you subscribe by hand.
22
+ #
23
+ # **One rule decides which line you get: the summary is info, the wire is
24
+ # debug.** This is the only GraphWeaver line at info, so a production log
25
+ # gets one per operation and nothing that could carry PII; turning
26
+ # GraphWeaver.logger up to debug adds the query, the variables and the
27
+ # response *beneath* it rather than repeating it.
28
+ #
29
+ # It writes through GraphWeaver.logger rather than Rails.logger, so
30
+ # `GraphWeaver.logger = nil` — the documented way to silence the gem —
31
+ # silences this too, and the line carries the same `graph_weaver`
32
+ # progname as every other one.
33
+ class LogSubscriber < ActiveSupport::LogSubscriber
34
+ # attach_to(:graph_weaver) subscribes "#{method}.graph_weaver" and
35
+ # ActiveSupport::Subscriber#call dispatches on the name up to the first
36
+ # dot — so this method name is EXECUTE_EVENT's first half, both ways.
37
+ def execute(event)
38
+ payload = event.payload
39
+
40
+ GraphWeaver::Internal::Log.log(:info) do
41
+ # duration_ms is the instrumenter's own measurement; event.duration
42
+ # covers a subscriber attached to something that didn't set it
43
+ ms = payload[:duration_ms] || event.duration
44
+ "GraphWeaver #{subject(payload)} (#{format("%.1f", ms)}ms) #{outcome(payload)}"
45
+ end
46
+ end
47
+
48
+ # GraphWeaver's logger, not Rails' — LogSubscriber#call skips a
49
+ # subscriber whose logger is nil, which is what makes the gem's own
50
+ # opt-out reach this line too.
51
+ def logger = GraphWeaver.logger
52
+
53
+ private
54
+
55
+ # What ran: the operation, prefixed by its graph when the request carried
56
+ # one — an app with several graphs reads `billing/InvoicesQuery` without
57
+ # a second line shape to learn, and one with a single graph never sees it.
58
+ def subject(payload)
59
+ operation = payload[:operation] || "query"
60
+ payload[:graph] ? "#{payload[:graph]}/#{operation}" : operation
61
+ end
62
+
63
+ # status, then whatever narrows it: the error class, the reason an alert
64
+ # groups by, and which attempt this was when a Retry is in the stack.
65
+ def outcome(payload)
66
+ parts = [payload[:status], payload[:error]]
67
+ # the GraphQL code, or the HTTP status where the request never got one
68
+ reason = payload[:code] || (payload[:http_status] if payload[:status] == :failed)
69
+ parts << "[#{reason}]" if reason
70
+ parts << "(retry #{payload[:retries]})" if payload[:retries].to_i.positive?
71
+ parts.compact.join(" ")
72
+ end
73
+ end
74
+ end
@@ -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
- # is EXECUTE_EVENT; its payload carries :url (nil in-process),
54
- # :schema (in-process only), :operation (the document's operation
55
- # name, nil for an anonymous one), and added after the response
56
- # lands :status. Never the query text or the variables: those
57
- # carry PII and belong at debug on the logger, where they're gated.
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, :kind, :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
 
@@ -73,10 +77,49 @@ module GraphWeaver
73
77
  !key.nil? && Log.filter_variables({ key.to_s => nil })[key.to_s] == FILTERED
74
78
  end
75
79
 
80
+ # True when this key names a credential — asked of a url's query
81
+ # parameters, which are scrubbed whatever the app's logging appetite.
82
+ # filter_parameters is a knob about log verbosity; emptying it must
83
+ # not un-scrub a token in an endpoint, any more than it un-scrubs the
84
+ # url's userinfo. The app's list widens this; it can't narrow it.
85
+ def credential?(key)
86
+ filtered?(key) || Log.filtered?(key, GraphWeaver::DEFAULT_FILTER_PARAMETERS)
87
+ end
88
+
76
89
  # `detail` unless the key is filtered — free text a coercer or sorbet
77
90
  # wrote can spell a value any way, so for a filtered key none of it
78
91
  # survives, not the parts that would have been safe.
79
92
  def detail(key, detail) = filtered?(key) ? FILTERED : detail
93
+
94
+ # A value the library reports as DATA rather than inside a sentence —
95
+ # InputError#value. Scrubbed at every depth, so a filtered key nested
96
+ # inside an input object is covered too, and the same list decides it
97
+ # as decides the debug log's variables line.
98
+ def value(key, value) = Log.filter_variables({ key.to_s => value })[key.to_s]
99
+
100
+ # A value the library spells INTO a sentence. `detail` can only ask
101
+ # about the key the value arrived under, so it reads a filtered key one
102
+ # level in as safe; this scrubs at every depth, like #value. The key is
103
+ # optional because a coercer refusing a value hasn't been told one.
104
+ def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(value(key, raw).inspect)
105
+
106
+ # A short server-chosen string the library republishes inside its own
107
+ # text — the APM's :code, the [CODE] in the one line info writes, a
108
+ # redirect's destination. Control characters are stripped because it
109
+ # lands where the log's own framing lives: a newline in
110
+ # extensions.code forges a second, complete-looking line.
111
+ def tag(value) = value.is_a?(String) ? cap(value.gsub(/[[:cntrl:]]+/, " ")) : value
112
+
113
+ # Text the library didn't author — a value a caller sent, a sentence a
114
+ # server wrote — cut to what an error may carry. The number lives on
115
+ # InputError, which is the class that documents it and the one every
116
+ # capped string reaches.
117
+ def cap(text, limit = GraphWeaver::InputError::VALUE_LIMIT)
118
+ return text if text.bytesize <= limit
119
+
120
+ # byteslice can land mid-character; scrub drops the partial tail
121
+ "#{text.byteslice(0, limit).scrub("")}…(#{text.bytesize - limit} more bytes)"
122
+ end
80
123
  end
81
124
  end
82
125
  end
@@ -91,14 +134,23 @@ module GraphWeaver
91
134
  self.filter_parameters = DEFAULT_FILTER_PARAMETERS
92
135
 
93
136
  # The one instrumentation event: a single GraphQL request, start to
94
- # parsed response, whichever client slot served it.
95
- EXECUTE_EVENT = "graph_weaver.execute"
137
+ # parsed response, whichever client slot served it. `<event>.<namespace>`
138
+ # is how every notification in this ecosystem is spelled
139
+ # (sql.active_record, execute_multiplex.graphql) — it's what
140
+ # ActiveSupport::LogSubscriber.attach_to and an APM's namespace routing
141
+ # key on, so a backwards name made both of them a puzzle.
142
+ EXECUTE_EVENT = "execute.graph_weaver"
96
143
 
97
144
  module Internal
98
145
  # The emitting half of the narration the three accessors above
99
146
  # configure. Setting a logger is API; writing to it is not, and the
100
147
  # two read as a pair when they sit on the same object.
101
148
  module Log
149
+ # fiber-local, set only for the duration of one attempt (with_retries)
150
+ RETRIES = :graph_weaver_retries
151
+ # fiber-local, set only for the duration of one dispatch (with_graph)
152
+ GRAPH = :graph_weaver_graph
153
+
102
154
  class << self
103
155
  # Level-gated and lazy — the block only runs when a logger is
104
156
  # listening. Messages carry "graph_weaver" as progname.
@@ -118,13 +170,91 @@ module GraphWeaver
118
170
  result
119
171
  end
120
172
 
121
- # Wrap the block in the instrumenter, if one is set. The payload is
122
- # a plain Hash the caller may add to inside the block.
173
+ # Wrap the block in the instrumenter, if one is set. The caller
174
+ # supplies what only it knows (:url, :schema, :client); this fills
175
+ # in the half every path shares — how it ended, how long it took,
176
+ # what a Retry had already spent — so one subscriber reads one
177
+ # shape whichever client slot served the request.
123
178
  def instrument(event, payload)
124
179
  hook = GraphWeaver.instrumenter
125
180
  return yield unless hook
126
181
 
127
- hook.call(event, payload) { yield }
182
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
183
+ retries = Thread.current[RETRIES]
184
+ payload[:retries] = retries if retries
185
+ payload[:graph] = Thread.current[GRAPH]
186
+ # pessimistic, so :status is set even for what a rescue can't
187
+ # see — an Interrupt, a killed thread — and never silently absent
188
+ payload[:status] = :failed
189
+
190
+ # One dispatch labels one request. Whatever THIS request reaches —
191
+ # a resolver serving it that calls out — is a request of its own,
192
+ # and the caller's graph would be a wrong label on it.
193
+ with_graph(nil) do
194
+ hook.call(event, payload) do
195
+ result = yield
196
+ errors = response_errors(result)
197
+ if errors.empty?
198
+ payload[:status] = :ok
199
+ else
200
+ payload[:status] = :errors
201
+ code = errors.grep(Hash).filter_map { |e| GraphWeaver::GraphQLError.from_h(e).code }.first
202
+ payload[:code] = Redact.tag(code)
203
+ end
204
+ result
205
+ rescue => e
206
+ payload[:error] = e.class.name
207
+ # :code stays the GraphQL error code and nothing else — it used
208
+ # to hold a ServerError's status here, so one tag carried two
209
+ # dimensions ("THROTTLED" and 429). The number is :http_status.
210
+ raise
211
+ ensure
212
+ payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
213
+ end
214
+ end
215
+ end
216
+
217
+ # What a Retry has already spent, read by the attempt it is about
218
+ # to make. A dynamic extent rather than a global: the count is only
219
+ # visible while the call it describes is on the stack, so a client
220
+ # that never reaches instrument can't leave a stale one behind.
221
+ def with_retries(count)
222
+ return yield unless GraphWeaver.instrumenter
223
+
224
+ previous = Thread.current[RETRIES]
225
+ Thread.current[RETRIES] = count
226
+ begin
227
+ yield
228
+ ensure
229
+ Thread.current[RETRIES] = previous
230
+ end
231
+ end
232
+
233
+ # The graph a generated module is dispatching, read by the request it
234
+ # is about to make. Same dynamic extent as with_retries, for the same
235
+ # reason — and instrument clears it for the duration of the request it
236
+ # labels, so exactly one request wears the label.
237
+ def with_graph(name)
238
+ return yield unless GraphWeaver.instrumenter
239
+
240
+ previous = Thread.current[GRAPH]
241
+ Thread.current[GRAPH] = name
242
+ begin
243
+ yield
244
+ ensure
245
+ Thread.current[GRAPH] = previous
246
+ end
247
+ end
248
+
249
+ # The variables as one JSON line for a log: filtered, and unable to
250
+ # raise. A value with no JSON form (NaN, binary) is the caller's bug
251
+ # and the transport refuses it a few lines later — but a logger that
252
+ # decides WHICH exception a caller sees, or whether one is raised at
253
+ # all, is worse than a log line that says it couldn't render.
254
+ def variables_for_log(variables)
255
+ JSON.generate(filter_variables(variables))
256
+ rescue StandardError => e
257
+ "<unloggable: #{e.class}>"
128
258
  end
129
259
 
130
260
  # variables with the filtered keys blanked out
@@ -136,8 +266,29 @@ module GraphWeaver
136
266
  filters.nil? ? variables : filters.filter(variables)
137
267
  end
138
268
 
269
+ # Whether a key matches one of `filters` — the same matching the
270
+ # variables line uses, asked about a list other than the app's so
271
+ # Redact.credential? can hold url parameters to the default names.
272
+ def filtered?(key, filters)
273
+ name = key.to_s
274
+ filters.any? do |filter|
275
+ filter.is_a?(Regexp) ? name.match?(filter) : name.downcase.include?(filter.to_s.downcase)
276
+ end
277
+ end
278
+
139
279
  private
140
280
 
281
+ # The GraphQL errors a response carries, whatever answered it — a
282
+ # Hash from a transport, a graphql-ruby Result in-process, a fake.
283
+ # Never raises: an instrumenter that decides which exception a
284
+ # caller sees is worse than a missing tag.
285
+ def response_errors(result)
286
+ errors = result.to_h["errors"] if result.respond_to?(:to_h)
287
+ errors.is_a?(Array) ? errors : []
288
+ rescue StandardError
289
+ []
290
+ end
291
+
141
292
  def scrub(value, filters)
142
293
  case value
143
294
  when Hash then value.to_h { |k, v| [k, filtered?(k, filters) ? FILTERED : scrub(v, filters)] }
@@ -145,13 +296,6 @@ module GraphWeaver
145
296
  else value
146
297
  end
147
298
  end
148
-
149
- def filtered?(key, filters)
150
- name = key.to_s
151
- filters.any? do |filter|
152
- filter.is_a?(Regexp) ? name.match?(filter) : name.downcase.include?(filter.to_s.downcase)
153
- end
154
- end
155
299
  end
156
300
  end
157
301
  end