graph_weaver 0.7.0 → 0.7.2

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +4 -4
  3. data/README.md +40 -88
  4. data/docs/alternatives.md +1 -7
  5. data/docs/cassettes.md +54 -59
  6. data/docs/editors.md +32 -47
  7. data/docs/errors.md +261 -369
  8. data/docs/federation.md +650 -837
  9. data/docs/generated_modules.md +380 -463
  10. data/docs/getting_started.md +211 -428
  11. data/docs/i18n.md +114 -177
  12. data/docs/logging.md +127 -116
  13. data/docs/real_world.md +26 -39
  14. data/docs/scalars.md +277 -310
  15. data/docs/testing.md +343 -486
  16. data/docs/transports.md +203 -268
  17. data/docs/upgrading.md +211 -560
  18. data/examples/README.md +38 -0
  19. data/examples/countries.rb +39 -0
  20. data/examples/federation.rb +62 -0
  21. data/examples/github/generate.rb +20 -0
  22. data/examples/github/generated/star_mutation.rb +126 -0
  23. data/examples/github/generated/stargazers_query.rb +232 -0
  24. data/examples/github/generated/starred_query.rb +151 -0
  25. data/examples/github/queries/star.graphql +8 -0
  26. data/examples/github/queries/stargazers.graphql +22 -0
  27. data/examples/github/queries/starred.graphql +11 -0
  28. data/examples/github/run.rb +43 -0
  29. data/examples/github/setup.rb +18 -0
  30. data/examples/rick_and_morty.rb +57 -0
  31. data/graph_weaver.gemspec +12 -3
  32. data/lib/graph_weaver/client.rb +30 -1
  33. data/lib/graph_weaver/codegen/emit.rb +5 -11
  34. data/lib/graph_weaver/codegen.rb +23 -55
  35. data/lib/graph_weaver/context_seam.rb +54 -0
  36. data/lib/graph_weaver/errors.rb +23 -15
  37. data/lib/graph_weaver/federation.rb +11 -2
  38. data/lib/graph_weaver/graph.rb +39 -29
  39. data/lib/graph_weaver/in_process.rb +15 -9
  40. data/lib/graph_weaver/internal/endpoint.rb +7 -5
  41. data/lib/graph_weaver/internal/headers.rb +19 -0
  42. data/lib/graph_weaver/internal/test_clients.rb +7 -11
  43. data/lib/graph_weaver/internal.rb +81 -13
  44. data/lib/graph_weaver/log_subscriber.rb +10 -2
  45. data/lib/graph_weaver/logging.rb +33 -13
  46. data/lib/graph_weaver/query_module.rb +44 -23
  47. data/lib/graph_weaver/retry.rb +12 -8
  48. data/lib/graph_weaver/rspec.rb +13 -24
  49. data/lib/graph_weaver/schema_loader.rb +52 -14
  50. data/lib/graph_weaver/tasks.rb +10 -2
  51. data/lib/graph_weaver/testing/cassette.rb +28 -5
  52. data/lib/graph_weaver/testing/endpoint.rb +14 -13
  53. data/lib/graph_weaver/testing/fake_client.rb +33 -3
  54. data/lib/graph_weaver/testing/router.rb +7 -3
  55. data/lib/graph_weaver/testing.rb +12 -4
  56. data/lib/graph_weaver/transport/http.rb +2 -2
  57. data/lib/graph_weaver/transport.rb +47 -23
  58. data/lib/graph_weaver/version.rb +1 -1
  59. data/lib/graph_weaver.rb +32 -10
  60. metadata +16 -3
  61. data/CHANGELOG.md +0 -3801
@@ -65,7 +65,24 @@ module GraphWeaver
65
65
 
66
66
  def queries = @queries || GraphWeaver.queries_paths
67
67
  def output = @output || GraphWeaver.generated_paths.first
68
- def client = @client
68
+
69
+ # The client this graph's modules call: the object `client` named, or the
70
+ # constant its name spells. nil when the graph names none — its modules
71
+ # go to GraphWeaver.client, like every other module.
72
+ #
73
+ # Resolved here, at call time, rather than spelled into generated source:
74
+ # renaming the constant is then an initializer edit and not a regeneration
75
+ # of every module, and a graph can name a live object.
76
+ def client
77
+ return @client unless @client.is_a?(String)
78
+
79
+ Object.const_get(@client)
80
+ rescue NameError
81
+ raise GraphWeaver::Error, "the client#{described} names #{@client.inspect} and nothing " \
82
+ "defines that constant, so its modules have no server to reach. Define it where the graph " \
83
+ "block can see it (config/initializers), or name the object itself: client " \
84
+ "GraphWeaver.new(\"https://api.example.com/graphql\")"
85
+ end
69
86
 
70
87
  # Every constant this graph generates lives under `namespace:` — the query
71
88
  # modules and the shared types module alike. Two schemas that each have a
@@ -95,7 +112,7 @@ module GraphWeaver
95
112
 
96
113
  # The dump this graph's schema was named by, when it was named by a file —
97
114
  # what a validation error's subgraph branding is read off. nil for a live
98
- # class, a Client, or inline SDL.
115
+ # class, inline SDL, or a Client built from any of those.
99
116
  def dump_path
100
117
  path = named_dump_path
101
118
  path if path && File.exist?(path)
@@ -109,6 +126,9 @@ module GraphWeaver
109
126
  return GraphWeaver::SchemaLoader.locate_path unless @schema
110
127
 
111
128
  source = named_source
129
+ # a client built from a dump names that dump: the file is where a
130
+ # supergraph's routing table is, and the loaded schema is not
131
+ source = source.schema_source if source.respond_to?(:schema_source) && source.schema_source
112
132
  path = source.respond_to?(:to_path) ? source.to_path : source
113
133
  path if path.is_a?(String) && GraphWeaver::SchemaLoader.dump_path?(path)
114
134
  end
@@ -133,26 +153,16 @@ module GraphWeaver
133
153
  url || live_schema
134
154
  end
135
155
 
136
- # The url this graph's modules post to, or nil. `client:` holds a constant
137
- # or its name codegen spells it into source so a name is resolved here
138
- # the way the generated DEFAULT_CLIENT lambda resolves it; a graph baking
139
- # none posts to the app default, which is where its modules go too.
156
+ # The url this graph's modules post to, or nil the graph's own client,
157
+ # else the app default, which is where its modules go too. What
158
+ # `schema:refresh` bootstraps a missing dump from, and what `rake
159
+ # graph_weaver:graphs` reports; a client with no url (a schema class
160
+ # running in-process) has none to report.
140
161
  def client_url
141
- client = @client.is_a?(String) ? resolve_client! : @client
142
- client ||= GraphWeaver.client
143
- target = (client.transport if client.respond_to?(:transport)) || client
162
+ target = client || GraphWeaver.client
163
+ target = (target.transport if target.respond_to?(:transport)) || target
144
164
  target.url if target.respond_to?(:url)
145
165
  end
146
- private :client_url
147
-
148
- def resolve_client!
149
- Object.const_get(@client)
150
- rescue NameError
151
- raise GraphWeaver::Error, "graph #{name.inspect} bakes client #{@client.inspect} into its " \
152
- "modules and nothing defines that constant, so there is no endpoint to introspect " \
153
- "#{named_dump_path} from"
154
- end
155
- private :resolve_client!
156
166
 
157
167
  # The composed supergraph this graph plans against, or nil — the dump it
158
168
  # names (for the default graph, the conventional one) when that dump
@@ -222,13 +232,12 @@ module GraphWeaver
222
232
  # The same three calls an app already writes at the top level, scoped here
223
233
  # to this graph alone.
224
234
  REGISTRATIONS = %i[register_scalar register_enum extend_type].freeze
225
- # These three end up spelled in generated source, so each takes the
226
- # constant or its name and stores the name.
227
- CONSTANT_SETTINGS = %i[client namespace types_module].freeze
228
- # …and these two are spelled as a `module` DEFINITION rather than a
229
- # reference, which is why a root anchor is refused on them below.
235
+ # These two are spelled in generated source, as a `module` DEFINITION,
236
+ # so each takes the constant or its name and stores the name — and a
237
+ # root anchor is refused on them below. `client` isn't spelled anywhere:
238
+ # the graph resolves it at call time, so it takes the object.
230
239
  MODULE_SETTINGS = %i[namespace types_module].freeze
231
- private_constant :CONSTANT_SETTINGS, :MODULE_SETTINGS
240
+ private_constant :MODULE_SETTINGS
232
241
 
233
242
  attr_reader :settings, :registrations
234
243
 
@@ -283,17 +292,18 @@ module GraphWeaver
283
292
  def respond_to_missing?(name, _private = false) = false
284
293
 
285
294
  # A Module where a constant's name goes says the same thing, and is what
286
- # `client Billing::CLIENT` reads like. Anything else passes through:
287
- # a schema is a path, SDL, a class, a Client, or a callable.
295
+ # `namespace Billing` reads like. Anything else passes through: a schema
296
+ # is a path, SDL, a class, a Client, or a callable, and a client is
297
+ # whatever object answers #execute.
288
298
  # On the singleton so the define_method setters above can reach it — srb
289
299
  # reads a define_method block's self as the class.
290
300
  def self.constant_name(setting, value)
291
- return value unless CONSTANT_SETTINGS.include?(setting)
301
+ return value unless MODULE_SETTINGS.include?(setting)
292
302
 
293
303
  # Generated modules are defined at the top level, where a root anchor
294
304
  # says nothing — and `module ::A::B` is not a name const_get can spell,
295
305
  # so it used to surface as a verdict on the .graphql file's name.
296
- if MODULE_SETTINGS.include?(setting) && value.is_a?(String) && value.start_with?("::")
306
+ if value.is_a?(String) && value.start_with?("::")
297
307
  raise ArgumentError, "#{setting} #{value.inspect}: drop the leading `::` — #{setting} " \
298
308
  "names a module generated source defines, and it defines it at the top level either way"
299
309
  end
@@ -5,6 +5,7 @@ require "json"
5
5
 
6
6
  require_relative "errors"
7
7
  require_relative "internal"
8
+ require_relative "context_seam"
8
9
  require_relative "parsing"
9
10
  require_relative "transport"
10
11
 
@@ -32,13 +33,13 @@ require_relative "transport"
32
33
  # is usually the whole reason you're running in-process.
33
34
  class GraphWeaver::InProcess
34
35
  include GraphWeaver::Parsing
36
+ # #context/#context= plus the lock over them: Testing::Endpoint answers a
37
+ # `context:` proc from one request's headers by writing this field, so the
38
+ # field's owner owns the lock
39
+ include GraphWeaver::ContextSeam
35
40
 
36
- # the schema queries run against, and the context handed to every one
37
- attr_reader :schema, :context
38
-
39
- # settable so Testing::Endpoint can answer a `context:` proc from the
40
- # request's headers and put it back — the same seam Router#context= is
41
- attr_writer :context
41
+ # the schema queries run against
42
+ attr_reader :schema
42
43
 
43
44
  def initialize(schema, context: {})
44
45
  unless schema.respond_to?(:execute)
@@ -46,12 +47,13 @@ class GraphWeaver::InProcess
46
47
  end
47
48
 
48
49
  @schema = schema
49
- @context = context
50
+ init_context_seam(context)
50
51
  end
51
52
 
52
53
  def execute(query, variables: {}, operation_name: nil)
53
54
  operation_name ||= GraphWeaver::Internal::Wire.operation_name(query)
54
- payload = { url: nil, schema: schema_label, operation: operation_name, client: self.class }
55
+ payload = { url: nil, schema: schema_label, operation: operation_name, client: self.class,
56
+ kind: GraphWeaver::Internal::Wire.kind(query) }
55
57
 
56
58
  GraphWeaver::Internal::Log.instrument(GraphWeaver::EXECUTE_EVENT, payload) do
57
59
  perform(query, variables, operation_name)
@@ -84,7 +86,11 @@ class GraphWeaver::InProcess
84
86
  # a resolver blew up. The same failure over HTTP arrives as a 500, so
85
87
  # raise what HTTP would — code that rescues GraphWeaver::Error, or
86
88
  # branches on ServerError#status, behaves the same either side.
87
- raise GraphWeaver::ServerError.new(status: 500, body: "#{e.class}: #{e.message}")
89
+ # detail:, not body: there was no response, so there are no bytes to
90
+ # hold, and the diagnosis is this process's own exception
91
+ raise GraphWeaver::ServerError.new(
92
+ status: 500, detail: "#{e.class}: #{GraphWeaver::Internal::Redact.cap(e.message)}",
93
+ )
88
94
  end
89
95
 
90
96
  # never leak the context (session tokens, current_user) through logs or
@@ -54,20 +54,22 @@ module GraphWeaver
54
54
  def drop_secrets(query)
55
55
  kept = query.split("&").reject do |pair|
56
56
  name, value = pair.split("=", 2)
57
- value && Redact.filtered?(URI.decode_www_form_component(name))
57
+ value && Redact.credential?(URI.decode_www_form_component(name))
58
58
  end
59
59
  kept.join("&") unless kept.empty?
60
60
  end
61
61
 
62
62
  # Which query parameters are secret is the same question
63
63
  # GraphWeaver.filter_parameters already answers for variables, so a
64
- # scrubbed log reads the same either side of the seam. Split rather
65
- # than decoded and re-encoded: every parameter that stays is printed
66
- # exactly as it was sent.
64
+ # scrubbed log reads the same either side of the seam widened by the
65
+ # default names, which apply here even when the app has emptied its
66
+ # list (see Redact.credential?). Split rather than decoded and
67
+ # re-encoded: every parameter that stays is printed exactly as it was
68
+ # sent.
67
69
  def scrub_query(query)
68
70
  query.split("&").map do |pair|
69
71
  name, value = pair.split("=", 2)
70
- next pair if value.nil? || !Redact.filtered?(URI.decode_www_form_component(name))
72
+ next pair if value.nil? || !Redact.credential?(URI.decode_www_form_component(name))
71
73
 
72
74
  "#{name}=#{GraphWeaver::FILTERED}"
73
75
  end.join("&")
@@ -1,6 +1,8 @@
1
1
  # typed: true
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "time" # Time.httpdate, for Retry-After
5
+
4
6
  module GraphWeaver
5
7
  module Internal
6
8
  # Response headers, as ServerError carries them. HTTP field names are
@@ -42,6 +44,23 @@ module GraphWeaver
42
44
  rest.empty? ? value : value&.dig(*rest)
43
45
  end
44
46
 
47
+ # Seconds to wait per the server's Retry-After, which is either a delay
48
+ # in seconds or an HTTP-date. nil when absent or unparseable; a date
49
+ # already past clamps to 0. See RFC 9110 §10.2.3.
50
+ #
51
+ # Here rather than on ServerError because a rate limit reaches a caller
52
+ # two ways — raised, and returned as the envelope a 4xx/5xx WITH a
53
+ # GraphQL errors body makes — and both have to read the one rule.
54
+ def retry_after
55
+ value = self["retry-after"]&.strip
56
+ return if value.nil? || value.empty?
57
+ return value.to_f if value.match?(/\A\d+(\.\d+)?\z/)
58
+
59
+ [Time.httpdate(value) - Time.now, 0.0].max
60
+ rescue ArgumentError
61
+ nil
62
+ end
63
+
45
64
  def key?(name) = super(Headers.fold(name))
46
65
  alias_method :has_key?, :key?
47
66
  alias_method :include?, :key?
@@ -8,13 +8,12 @@ module GraphWeaver
8
8
  # a `graphql_*` helper writes to.
9
9
  #
10
10
  # A tag used to work by swapping GraphWeaver.client, which is the LAST
11
- # place a module looks: one generated with `client:` reads its baked
12
- # DEFAULT_CLIENT first and never got there, so the tag quietly didn't
13
- # apply. The mode installs itself here instead, and QueryModule asks
14
- # before it reads that constant — so a tag reaches every module the
15
- # example runs, bound or not.
11
+ # place a module looks: one whose graph names a client of its own reads
12
+ # that first and never got there, so the tag quietly didn't apply. The
13
+ # mode installs itself here instead, and QueryModule asks before it reads
14
+ # the graph — so a tag reaches every module the example runs.
16
15
  #
17
- # Keyed by the graph a module was generated from (its baked GRAPH), since
16
+ # Keyed by the graph a module was generated from (its GRAPH), since
18
17
  # the honest answer varies: :fake for a billing module has to fabricate
19
18
  # billing's shapes, not the other schema's. A helper names its graphs the
20
19
  # same way and lands in the same table, so what an example says applies to
@@ -254,11 +253,8 @@ module GraphWeaver
254
253
  # generated before its graph was declared, or by an older release —
255
254
  # and guessing would fake one schema's shapes at another's module.
256
255
  def graph_for!(mod)
257
- graphs = GraphWeaver.graphs
258
- return graphs.first if graphs.one?
259
-
260
256
  name = mod.const_defined?(:GRAPH, false) ? mod.const_get(:GRAPH) : nil
261
- found = graphs.find { |graph| graph.name == name }
257
+ found = Util.graph_named(name)
262
258
  return found if found
263
259
 
264
260
  # Two doors produce a module, so the fix has two spellings: a file
@@ -268,7 +264,7 @@ module GraphWeaver
268
264
  raise GraphWeaver::Error, "#{mod} doesn't say which of this app's graphs " \
269
265
  "(#{declared_names}) it was generated from, so #{@mode.inspect} has nothing to run " \
270
266
  "it against — regenerate it (rake graph_weaver:generate), or, if it came from " \
271
- "GraphWeaver.parse, say which there (graph: #{graphs.first.name.inspect})."
267
+ "GraphWeaver.parse, say which there (graph: #{GraphWeaver.graphs.first.name.inspect})."
272
268
  end
273
269
  end
274
270
  end
@@ -52,19 +52,29 @@ module GraphWeaver
52
52
 
53
53
  # The module a .graphql file generates, and the basename of the file
54
54
  # it generates into: the camelized file name plus the operation's own
55
- # 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.
56
58
  #
57
59
  # person.graphql => PersonQuery (person_query.rb)
58
60
  # save_list_entry.graphql => SaveListEntryMutation
59
61
  # (save_list_entry_mutation.rb)
62
+ # get-hello.graphql => GetHelloQuery (get_hello_query.rb)
63
+ # hello.query.graphql => HelloQuery (hello_query.rb)
60
64
  #
61
65
  # Every naming site goes through here — generate!, parse(path), and
62
66
  # load_queries! — so the constant a file produces is the same one
63
67
  # whichever door you came in by, and the file it lands in matches it.
64
68
  def generated_names(path, source)
65
- base = File.basename(path, ".*")
66
- suffix = operation_suffix(source)
67
- ["#{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"]
68
78
  end
69
79
 
70
80
  # just the module name — see generated_names
@@ -131,6 +141,21 @@ module GraphWeaver
131
141
  schema && GraphWeaver.graphs.find { |candidate| candidate.live_schema.equal?(schema) }
132
142
  end
133
143
 
144
+ # The declared graph `name` names, or nil — how a generated module
145
+ # finds the graph whose client it runs against, and whose schema a
146
+ # test mode fabricates from.
147
+ #
148
+ # One graph in an app is the answer whatever a module calls it: a
149
+ # module generated before its graph was named, or by an older release,
150
+ # still belongs to the only graph there is. With several, guessing
151
+ # would send one schema's query to another's endpoint.
152
+ def graph_named(name)
153
+ graphs = GraphWeaver.graphs
154
+ return graphs.first if graphs.one?
155
+
156
+ graphs.find { |graph| graph.name == name }
157
+ end
158
+
134
159
  # Where generated modules are READ from: the configured patterns, plus
135
160
  # any graph writing somewhere they don't already cover. generated_paths'
136
161
  # default glob (app/graphql/*/generated) covers the conventional layout,
@@ -240,13 +265,36 @@ module GraphWeaver
240
265
  source
241
266
  end
242
267
 
243
- # "Mutation" for a mutation document, "Query" for everything else.
244
- def operation_suffix(source)
268
+ # The document's operation kind — "query", "mutation" or
269
+ # "subscription" — or nil when it holds no operation or won't parse.
270
+ # The one source of truth for the word a module name ends in AND for
271
+ # the file-name extension that word makes redundant.
272
+ def operation_kind(source)
245
273
  operation = GraphQL.parse(source).definitions
246
274
  .grep(GraphQL::Language::Nodes::OperationDefinition).first
247
- (operation&.operation_type == "mutation") ? "Mutation" : "Query"
275
+ operation && (operation.operation_type || "query") # `{ hello }` is shorthand for a query
248
276
  rescue GraphQL::ParseError
249
- "Query" # unparseable: codegen brands the real error a moment later
277
+ nil # unparseable: codegen brands the real error a moment later
278
+ end
279
+
280
+ # GraphQL's operation kinds, as a file name spells them. Apollo, Relay
281
+ # and GitLab's frontend all name a query file for its operation, so
282
+ # `blob_content.query.graphql` says in the extension exactly what the
283
+ # module's own suffix says — drop it rather than emit BlobContentQueryQuery.
284
+ # `_query` inside a snake_case name is a word OF the name, not this, so
285
+ # nothing that generates today is renamed.
286
+ OPERATION_EXTENSION = /\.(query|mutation|subscription)\z/i
287
+ private_constant :OPERATION_EXTENSION
288
+
289
+ def strip_kind_extension(base, kind, path)
290
+ declared = base[OPERATION_EXTENSION, 1]&.downcase
291
+ stem = declared && base[0...-(declared.length + 1)]
292
+ return base if stem.nil? || stem.empty?
293
+ return stem if kind.nil? || kind == declared
294
+
295
+ raise GraphWeaver::Error, "#{relative(path)}: the file name ends .#{declared}, but the " \
296
+ "document defines a #{kind} — rename it #{stem}.#{kind}#{File.extname(path)} " \
297
+ "or drop the .#{declared}"
250
298
  end
251
299
  end
252
300
  end
@@ -350,7 +398,8 @@ module GraphWeaver
350
398
  # every request, and the only way to be wrong (a field literally named
351
399
  # `mutation` opening a line) errs toward not retrying.
352
400
  MUTATION_PATTERN = /^[ \t]*mutation\b/
353
- private_constant :MUTATION_PATTERN
401
+ SUBSCRIPTION_PATTERN = /^[ \t]*subscription\b/
402
+ private_constant :MUTATION_PATTERN, :SUBSCRIPTION_PATTERN
354
403
 
355
404
  REQUEST_MUTEX = Mutex.new
356
405
  private_constant :REQUEST_MUTEX
@@ -365,16 +414,35 @@ module GraphWeaver
365
414
 
366
415
  def mutation?(query) = MUTATION_PATTERN.match?(query)
367
416
 
417
+ # What this document runs, for the instrumentation payload — :query
418
+ # for the shorthand `{ ... }` document too, which is what it is.
419
+ # Built on mutation? rather than beside it, so an APM's write-failure
420
+ # rate and the decision not to retry can't come to disagree.
421
+ def kind(query)
422
+ return :mutation if mutation?(query)
423
+
424
+ SUBSCRIPTION_PATTERN.match?(query) ? :subscription : :query
425
+ end
426
+
368
427
  # one error in the shape a GraphQL response carries them
369
428
  def graphql_error(message, code)
370
429
  { "message" => message, "extensions" => { "code" => code } }
371
430
  end
372
431
 
373
- # "[req 3 FilteredPokemon]" — a per-process request id plus the
374
- # operation name, when there is one
432
+ # "[req 4123-3 FilteredPokemon]" — the pid, this process's own
433
+ # request count, and the operation name when there is one.
434
+ #
435
+ # Both halves, because a Puma cluster forks: the counter is inherited
436
+ # with everything else, so without the reset every worker continues
437
+ # the master's sequence, and without the pid two workers' "[req 3]"
438
+ # are two unrelated requests in one aggregated log.
375
439
  def log_tag(operation_name = nil)
376
- id = REQUEST_MUTEX.synchronize { @request_count = (@request_count || 0) + 1 }
377
- "[req #{id}#{" #{operation_name}" if operation_name}]"
440
+ pid = Process.pid
441
+ id = REQUEST_MUTEX.synchronize do
442
+ @request_pid, @request_count = pid, 0 unless @request_pid == pid
443
+ @request_count += 1
444
+ end
445
+ "[req #{pid}-#{id}#{" #{operation_name}" if operation_name}]"
378
446
  end
379
447
 
380
448
  def truncate_for_log(query)
@@ -1,6 +1,12 @@
1
1
  # typed: ignore — ActiveSupport::LogSubscriber, which sorbet can't resolve here
2
2
  # frozen_string_literal: true
3
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
+
4
10
  module GraphWeaver
5
11
  # One line per GraphQL operation in a Rails log, the shape ActiveRecord
6
12
  # uses for a query:
@@ -54,11 +60,13 @@ module GraphWeaver
54
60
  payload[:graph] ? "#{payload[:graph]}/#{operation}" : operation
55
61
  end
56
62
 
57
- # status, then whatever narrows it: the error class, the code an alert
63
+ # status, then whatever narrows it: the error class, the reason an alert
58
64
  # groups by, and which attempt this was when a Retry is in the stack.
59
65
  def outcome(payload)
60
66
  parts = [payload[:status], payload[:error]]
61
- parts << "[#{payload[:code]}]" if payload[:code]
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
62
70
  parts << "(retry #{payload[:retries]})" if payload[:retries].to_i.positive?
63
71
  parts.compact.join(" ")
64
72
  end
@@ -53,7 +53,7 @@ module GraphWeaver
53
53
  #
54
54
  # It must call the block and return its value. The only event today is
55
55
  # EXECUTE_EVENT; its payload is the contract in docs/logging.md —
56
- # :operation, :client, :status, :duration_ms, :graph always;
56
+ # :operation, :client, :kind, :status, :duration_ms, :graph always;
57
57
  # :url/:http_status over the wire, :schema in-process, :error/:code on a
58
58
  # failure, :retries when a Retry wrapped it. Never the query text or the
59
59
  # variables: the payload fans out to subscribers that know none of the
@@ -77,6 +77,15 @@ module GraphWeaver
77
77
  !key.nil? && Log.filter_variables({ key.to_s => nil })[key.to_s] == FILTERED
78
78
  end
79
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
+
80
89
  # `detail` unless the key is filtered — free text a coercer or sorbet
81
90
  # wrote can spell a value any way, so for a filtered key none of it
82
91
  # survives, not the parts that would have been safe.
@@ -94,12 +103,18 @@ module GraphWeaver
94
103
  # optional because a coercer refusing a value hasn't been told one.
95
104
  def shown(raw, key = nil) = filtered?(key) ? FILTERED : cap(value(key, raw).inspect)
96
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
+
97
113
  # Text the library didn't author — a value a caller sent, a sentence a
98
114
  # server wrote — cut to what an error may carry. The number lives on
99
115
  # InputError, which is the class that documents it and the one every
100
116
  # capped string reaches.
101
- def cap(text)
102
- limit = GraphWeaver::InputError::VALUE_LIMIT
117
+ def cap(text, limit = GraphWeaver::InputError::VALUE_LIMIT)
103
118
  return text if text.bytesize <= limit
104
119
 
105
120
  # byteslice can land mid-character; scrub drops the partial tail
@@ -183,13 +198,15 @@ module GraphWeaver
183
198
  payload[:status] = :ok
184
199
  else
185
200
  payload[:status] = :errors
186
- payload[:code] = errors.grep(Hash).filter_map { |e| GraphWeaver::GraphQLError.from_h(e).code }.first
201
+ code = errors.grep(Hash).filter_map { |e| GraphWeaver::GraphQLError.from_h(e).code }.first
202
+ payload[:code] = Redact.tag(code)
187
203
  end
188
204
  result
189
205
  rescue => e
190
206
  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)
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.
193
210
  raise
194
211
  ensure
195
212
  payload[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
@@ -249,6 +266,16 @@ module GraphWeaver
249
266
  filters.nil? ? variables : filters.filter(variables)
250
267
  end
251
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
+
252
279
  private
253
280
 
254
281
  # The GraphQL errors a response carries, whatever answered it — a
@@ -269,13 +296,6 @@ module GraphWeaver
269
296
  else value
270
297
  end
271
298
  end
272
-
273
- def filtered?(key, filters)
274
- name = key.to_s
275
- filters.any? do |filter|
276
- filter.is_a?(Regexp) ? name.match?(filter) : name.downcase.include?(filter.to_s.downcase)
277
- end
278
- end
279
299
  end
280
300
  end
281
301
  end