honeycomb-beeline 1.1.0 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -15,7 +15,7 @@ module Honeycomb
15
15
 
16
16
  @client.start_span(name: "http_client") do |span|
17
17
  span.add_field "request.method", env.method.upcase
18
- span.add_field "request.protocol", env.url.scheme
18
+ span.add_field "request.scheme", env.url.scheme
19
19
  span.add_field "request.host", env.url.host
20
20
  span.add_field "request.path", env.url.path
21
21
  span.add_field "meta.type", "http_client"
@@ -13,8 +13,14 @@ module Honeycomb
13
13
  ["HTTP_VERSION", "request.http_version"],
14
14
  ["HTTP_HOST", "request.host"],
15
15
  ["REMOTE_ADDR", "request.remote_addr"],
16
+ ["HTTP_X_FORWARDED_FOR", "request.header.x_forwarded_for"],
17
+ ["HTTP_X_FORWARDED_PROTO", "request.header.x_forwarded_proto"],
18
+ ["HTTP_X_FORWARDED_PORT", "request.header.x_forwarded_port"],
19
+ ["HTTP_ACCEPT", "request.header.accept"],
20
+ ["HTTP_ACCEPT_LANGUAGE", "request.header.accept_language"],
21
+ ["CONTENT_TYPE", "request.header.content_type"],
16
22
  ["HTTP_USER_AGENT", "request.header.user_agent"],
17
- ["rack.url_scheme", "request.protocol"],
23
+ ["rack.url_scheme", "request.scheme"],
18
24
  ].freeze
19
25
 
20
26
  attr_reader :app, :client
@@ -25,16 +31,20 @@ module Honeycomb
25
31
  end
26
32
 
27
33
  def call(env)
34
+ req = ::Rack::Request.new(env)
28
35
  hny = env["HTTP_X_HONEYCOMB_TRACE"]
29
36
  client.start_span(name: "http_request", serialized_trace: hny) do |span|
30
37
  add_field = lambda do |key, value|
31
- next unless value && !value.empty?
32
-
33
- span.add_field(key, value)
38
+ unless value.nil? || (value.respond_to?(:empty?) && value.empty?)
39
+ span.add_field(key, value)
40
+ end
34
41
  end
35
42
 
36
43
  extract_fields(env, RACK_FIELDS, &add_field)
37
44
 
45
+ span.add_field("request.secure", req.ssl?)
46
+ span.add_field("request.xhr", req.xhr?)
47
+
38
48
  status, headers, body = app.call(env)
39
49
 
40
50
  add_package_information(env, &add_field)
@@ -42,6 +52,7 @@ module Honeycomb
42
52
  extract_user_information(env, &add_field)
43
53
 
44
54
  span.add_field("response.status_code", status)
55
+ span.add_field("response.content_type", headers["Content-Type"])
45
56
 
46
57
  [status, headers, body]
47
58
  end
@@ -11,23 +11,79 @@ module Honeycomb
11
11
  yield "meta.package", "rails"
12
12
  yield "meta.package_version", ::Rails::VERSION::STRING
13
13
 
14
- ::ActionDispatch::Request.new(env).tap do |request|
15
- yield "request.controller", request.params[:controller]
16
- yield "request.action", request.params[:action]
14
+ request = ::ActionDispatch::Request.new(env)
17
15
 
18
- break unless request.respond_to? :routes
19
- break unless request.routes.respond_to? :router
16
+ yield "request.controller", request.path_parameters[:controller]
17
+ yield "request.action", request.path_parameters[:action]
18
+ yield "request.route", route_for(request)
19
+ end
20
+
21
+ private
22
+
23
+ def route_for(request)
24
+ router = router_for(request)
25
+ routing = routing_for(request)
20
26
 
21
- found_route = false
22
- request.routes.router.recognize(request) do |route, _|
23
- break if found_route
27
+ return unless router && routing
24
28
 
25
- found_route = true
26
- yield "request.route", "#{env['REQUEST_METHOD']} #{route.path.spec}"
27
- end
29
+ router.recognize(routing) do |route, _|
30
+ return "#{request.method} #{route.path.spec}"
28
31
  end
29
32
  end
30
33
 
34
+ # Broadly compatible way of getting the ActionDispatch::Routing::RouteSet.
35
+ #
36
+ # While we'd like to just use ActionDispatch::Request#routes, that method
37
+ # was only added circa Rails 5. To support Rails 4, we have to use direct
38
+ # Rack env access.
39
+ #
40
+ # @see https://github.com/rails/rails/commit/87a75910640b83a677099198ccb3317d9850c204
41
+ def router_for(request)
42
+ routes = request.env["action_dispatch.routes"]
43
+ routes.router if routes.respond_to?(:router)
44
+ end
45
+
46
+ # Constructs a simplified ActionDispatch::Request with the original route.
47
+ #
48
+ # This is based on ActionDispatch::Routing::RouteSet#recognize_path, which
49
+ # reconstructs an ActionDispatch::Request using a given HTTP method + path
50
+ # by making a mock Rack environment. Here, instead of taking the method +
51
+ # path from input parameters, we use the original values from the actual
52
+ # incoming request (prior to any mangling that may have been done by
53
+ # middleware).
54
+ #
55
+ # The resulting ActionDispatch::Request instance is suitable for passing to
56
+ # ActionDispatch::Journey::Router#recognize to get the original Rails
57
+ # routing information corresponding to the incoming request.
58
+ #
59
+ # @param request [ActionDispatch::Request]
60
+ # the actual incoming Rails request
61
+ #
62
+ # @return [ActionDispatch::Request]
63
+ # a simplified version of the incoming request that retains the original
64
+ # routing information, but nothing else (e.g., no HTTP parameters)
65
+ #
66
+ # @return [nil]
67
+ # if the original request's path is invalid
68
+ #
69
+ # @see https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-method
70
+ # @see https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-original_fullpath
71
+ # @see https://github.com/rails/rails/blob/2a44ff12c858d296797963f7aa97abfa0c840a15/actionpack/lib/action_dispatch/journey/router/utils.rb#L7-L27
72
+ # @see https://github.com/rails/rails/blob/2a44ff12c858d296797963f7aa97abfa0c840a15/actionpack/lib/action_dispatch/routing/route_set.rb#L846-L859
73
+ def routing_for(request)
74
+ verb = request.method
75
+ path = request.original_fullpath
76
+ path = normalize(path) unless path =~ %r{://}
77
+ env = ::Rack::MockRequest.env_for(path, method: verb)
78
+ ::ActionDispatch::Request.new(env)
79
+ rescue URI::InvalidURIError
80
+ nil
81
+ end
82
+
83
+ def normalize(path)
84
+ ::ActionDispatch::Journey::Router::Utils.normalize_path(path)
85
+ end
86
+
31
87
  # Rails middleware
32
88
  class Middleware
33
89
  include Rack
@@ -9,9 +9,8 @@ module Honeycomb
9
9
  initializer("honeycomb.install_middleware",
10
10
  after: :load_config_initializers) do |app|
11
11
  if Honeycomb.client
12
- # what location should we insert the middleware at?
13
- app.config.middleware.insert_before(
14
- ::Rails::Rack::Logger,
12
+ app.config.middleware.insert_after(
13
+ ActionDispatch::ShowExceptions,
15
14
  Honeycomb::Rails::Middleware,
16
15
  client: Honeycomb.client,
17
16
  )
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+
5
+ module Honeycomb
6
+ module Redis
7
+ # Patches Redis with the option to configure the Honeycomb client.
8
+ #
9
+ # When you load this integration, each Redis call will be wrapped in a span
10
+ # containing information about the command being invoked.
11
+ #
12
+ # This module automatically gets mixed into the Redis class so you can
13
+ # change the underlying {Honeycomb::Client}. By default, we use the global
14
+ # {Honeycomb.client} to send events. A nil client will disable the
15
+ # integration altogether.
16
+ #
17
+ # @example Custom client
18
+ # Redis.honeycomb_client = Honeycomb::Client.new(...)
19
+ #
20
+ # @example Disabling instrumentation
21
+ # Redis.honeycomb_client = nil
22
+ module Configuration
23
+ attr_writer :honeycomb_client
24
+
25
+ def honeycomb_client
26
+ return @honeycomb_client if defined?(@honeycomb_client)
27
+
28
+ Honeycomb.client
29
+ end
30
+ end
31
+
32
+ # Patches Redis::Client with Honeycomb instrumentation.
33
+ #
34
+ # Circa versions 3.x and 4.x of their gem, the Redis class is backed by an
35
+ # underlying Redis::Client object. The methods used to send commands to the
36
+ # Redis server - namely Redis::Client#call, Redis::Client#call_loop,
37
+ # Redis::Client#call_pipeline, Redis::Client#call_pipelined,
38
+ # Redis::Client#call_with_timeout, and Redis::Client#call_without_timeout -
39
+ # all eventually wind up calling the Redis::Client#process method to do the
40
+ # "dirty work" of writing commands out to an underlying connection. So this
41
+ # gives us a single point of entry that's ideal for introducing the
42
+ # Honeycomb span.
43
+ #
44
+ # An alternative interface provided since at least version 3.0.0 is
45
+ # Redis::Distributed. Underneath, though, it maintains a collection of
46
+ # Redis objects, and each call is forwarded to one or more members of the
47
+ # collection. So patching Redis::Client still captures spans originating
48
+ # from Redis::Distributed. Typical commands (i.e., ones that aren't
49
+ # "global" like `QUIT` or `FLUSHALL`) forward to just a single node anyway,
50
+ # so there's not much use to wrapping everything up in a span for the
51
+ # Redis::Distributed method call.
52
+ #
53
+ # Another alternative interface provided since v4.0.3 is Redis::Cluster,
54
+ # which you can configure the Redis class to use instead of Redis::Client.
55
+ # Again, though, Redis::Cluster maintains a collection of Redis::Client
56
+ # instances underneath. The tracing needs wind up being pretty much the
57
+ # same as Redis::Distributed, even though the actual architecture is
58
+ # significantly different.
59
+ #
60
+ # An implementation detail of pub/sub commands since v2.0.0 (well below our
61
+ # supported version of the redis gem!) is Redis::SubscribedClient, but that
62
+ # still wraps an underlying Redis::Client or Redis::Cluster instance.
63
+ #
64
+ # @see https://github.com/redis/redis-rb/blob/2e8577ad71d0efc32f31fb034f341e1eb10abc18/lib/redis/client.rb#L77-L180
65
+ # Relevant Redis::Client methods circa v3.0.0
66
+ # @see https://github.com/redis/redis-rb/blob/a2c562c002bc8f86d1f47818d63db2da1c5c3d3f/lib/redis/client.rb#L124-L239
67
+ # Relevant Redis::Client methods circa v4.1.3
68
+ # @see https://github.com/redis/redis-rb/commits/master/lib/redis/client.rb
69
+ # History of Redis::Client
70
+ #
71
+ # @see https://redis.io/topics/partitioning
72
+ # Partitioning (the basis for Redis::Distributed)
73
+ # @see https://github.com/redis/redis-rb/blob/2e8577ad71d0efc32f31fb034f341e1eb10abc18/lib/redis/distributed.rb
74
+ # Redis::Distributed circa v3.0.0
75
+ # @see https://github.com/redis/redis-rb/blob/a2c562c002bc8f86d1f47818d63db2da1c5c3d3f/lib/redis/distributed.rb
76
+ # Redis::Distributed circa v4.1.3
77
+ # @see https://github.com/redis/redis-rb/commits/master/lib/redis/distributed.rb
78
+ # History of Redis::Distributed
79
+ #
80
+ # @see https://redis.io/topics/cluster-spec
81
+ # Clustering (the basis for Redis::Cluster)
82
+ # @see https://github.com/redis/redis-rb/commit/7f48c0b02fa89256167bc481a73ce2e0c8cca89a
83
+ # Initial implementation of Redis::Cluster released in v4.0.3
84
+ # @see https://github.com/redis/redis-rb/blob/a2c562c002bc8f86d1f47818d63db2da1c5c3d3f/lib/redis/cluster.rb
85
+ # Redis::Cluster circa v4.1.3
86
+ # @see https://github.com/redis/redis-rb/commits/master/lib/redis/cluster.rb
87
+ # History of Redis::Cluster
88
+ #
89
+ # @see https://redis.io/topics/pubsub
90
+ # Pub/Sub in Redis
91
+ # @see https://github.com/redis/redis-rb/blob/17d40d80388b536ec53a8f19bb1404e93a61650f/lib/redis/subscribe.rb
92
+ # Redis::SubscribedClient circa v2.0.0
93
+ # @see https://github.com/redis/redis-rb/blob/2e8577ad71d0efc32f31fb034f341e1eb10abc18/lib/redis/subscribe.rb
94
+ # Redis::SubscribedClient circa v3.0.0
95
+ # @see https://github.com/redis/redis-rb/blob/a2c562c002bc8f86d1f47818d63db2da1c5c3d3f/lib/redis/subscribe.rb
96
+ # Redis::SubscribedClient circa v4.1.3
97
+ module Client
98
+ def process(commands)
99
+ return super if ::Redis.honeycomb_client.nil?
100
+
101
+ span = ::Redis.honeycomb_client.start_span(name: "redis")
102
+ begin
103
+ fields = Fields.new(self)
104
+ fields.options = @options
105
+ fields.command = commands
106
+ span.add fields
107
+ super
108
+ rescue StandardError => e
109
+ span.add_field "redis.error", e.class.name
110
+ span.add_field "redis.error_detail", e.message
111
+ raise
112
+ ensure
113
+ span.send
114
+ end
115
+ end
116
+ end
117
+
118
+ # This structure contains the fields we'll add to each Redis span.
119
+ #
120
+ # The logic is in this class to avoid monkey-patching extraneous APIs into
121
+ # the Redis::Client via {Client}.
122
+ #
123
+ # @private
124
+ class Fields
125
+ def initialize(client)
126
+ @client = client
127
+ end
128
+
129
+ def options=(options)
130
+ options.each do |option, value|
131
+ values["redis.#{option}"] ||= value unless ignore?(option)
132
+ end
133
+ end
134
+
135
+ def command=(commands)
136
+ commands = Array(commands)
137
+ values["redis.command"] = commands.map { |cmd| format(cmd) }.join("\n")
138
+ end
139
+
140
+ def to_hash
141
+ values
142
+ end
143
+
144
+ private
145
+
146
+ def values
147
+ @values ||= {
148
+ "meta.package" => "redis",
149
+ "meta.package_version" => ::Redis::VERSION,
150
+ "redis.id" => @client.id,
151
+ "redis.location" => @client.location,
152
+ }
153
+ end
154
+
155
+ # Do we ignore this Redis::Client option?
156
+ #
157
+ # * :url - unsafe because it might contain a password
158
+ # * :password - unsafe
159
+ # * :logger - just some Ruby object, not useful
160
+ # * :_parsed - implementation detail
161
+ def ignore?(option)
162
+ %i[url password logger _parsed].include?(option)
163
+ end
164
+
165
+ def format(cmd)
166
+ name, *args = cmd.flatten(1)
167
+ name = resolve(name)
168
+ sanitize(args) if name.casecmp("auth").zero?
169
+ [name.upcase, *args.map { |arg| prettify(arg) }].join(" ")
170
+ end
171
+
172
+ def resolve(name)
173
+ @client.command_map.fetch(name, name).to_s
174
+ end
175
+
176
+ def sanitize(args)
177
+ args.map! { "[sanitized]" }
178
+ end
179
+
180
+ def prettify(arg)
181
+ quotes = false
182
+ pretty = "".dup
183
+ arg.to_s.each_char do |c|
184
+ quotes ||= needs_quotes?(c)
185
+ pretty << escape(c)
186
+ end
187
+ quotes ? "\"#{pretty}\"" : pretty
188
+ end
189
+
190
+ # This aims to replicate the algorithms used by redis-cli.
191
+ #
192
+ # @see https://github.com/antirez/redis/blob/0f026af185e918a9773148f6ceaa1b084662be88/src/sds.c#L940-L1067
193
+ # The redis-cli parsing algorithm
194
+ #
195
+ # @see https://github.com/antirez/redis/blob/0f026af185e918a9773148f6ceaa1b084662be88/src/sds.c#L878-L907
196
+ # The redis-cli printing algorithm
197
+ def escape(char)
198
+ return escape_with_backslash(char) if escape_with_backslash?(char)
199
+ return escape_with_hex_codes(char) if escape_with_hex_codes?(char)
200
+
201
+ char
202
+ end
203
+
204
+ # A lookup table for backslash-escaped characters.
205
+ #
206
+ # This is used by {#escape_with_backslash?} and {#escape_with_backslash}
207
+ # to replicate the hard-coded `case` statements in redis-cli. As of this
208
+ # writing, Redis recognizes a handful of standard C escape sequences,
209
+ # like "\n" for newlines.
210
+ #
211
+ # Because {#prettify} will output double quoted strings if any escaping
212
+ # is needed, this table must additionally consider the double-quote to be
213
+ # a backslash-escaped character. For example, instead of generating
214
+ #
215
+ # '"hello"'
216
+ #
217
+ # we'll generate
218
+ #
219
+ # "\"hello\""
220
+ #
221
+ # even though redis-cli would technically recognize the single-quoted
222
+ # version.
223
+ #
224
+ # @see https://github.com/antirez/redis/blob/0f026af185e918a9773148f6ceaa1b084662be88/src/sds.c#L888-L896
225
+ # The redis-cli algorithm for outputting standard escape sequences
226
+ BACKSLASHES = {
227
+ "\\" => "\\\\",
228
+ '"' => '\\"',
229
+ "\n" => "\\n",
230
+ "\r" => "\\r",
231
+ "\t" => "\\t",
232
+ "\a" => "\\a",
233
+ "\b" => "\\b",
234
+ }.freeze
235
+
236
+ def escape_with_backslash?(char)
237
+ BACKSLASHES.key?(char)
238
+ end
239
+
240
+ def escape_with_backslash(char)
241
+ BACKSLASHES.fetch(char, char)
242
+ end
243
+
244
+ # Do we need to hex-encode this character?
245
+ #
246
+ # This replicates the C isprint() function that redis-cli uses to decide
247
+ # whether to escape a character in hexadecimal notation, "\xhh". Any
248
+ # non-printable character must be represented as a hex escape sequence.
249
+ #
250
+ # Normally, we could match this using a negated POSIX bracket expression:
251
+ #
252
+ # /[^[:print:]]/
253
+ #
254
+ # You can read that as "not printable".
255
+ #
256
+ # However, in Ruby, these character classes also encompass non-ASCII
257
+ # characters. In contrast, since most platforms have 8-bit `char` types,
258
+ # the C isprint() function generally does not recognize any Unicode code
259
+ # points. This effectively limits the redis-cli interpretation of the
260
+ # printable character range to just printable ASCII characters.
261
+ #
262
+ # Thus, we match using a combination of the previous regexp with a
263
+ # non-POSIX character class that Ruby defines:
264
+ #
265
+ # /[^[:print:]&&[:ascii:]]/
266
+ #
267
+ # You can read this like
268
+ #
269
+ # NOT (printable AND ascii)
270
+ #
271
+ # which by DeMorgan's Law is equivalent to
272
+ #
273
+ # (NOT printable) OR (NOT ascii)
274
+ #
275
+ # That is, if the character is not printable (even in Unicode), we'll
276
+ # escape it; if the character is printable but non-ASCII, we'll also
277
+ # escape it.
278
+ #
279
+ # What's more, Ruby's Regexp#=~ method will blow up if the string does
280
+ # not have a valid encoding (e.g., in UTF-8). In this case, though,
281
+ # {#escape_with_hex_codes} can still convert the bytes that make up the
282
+ # invalid character into a hex code. So we preemptively check for
283
+ # invalidly-encoded characters before testing the above match.
284
+ #
285
+ # @see https://ruby-doc.org/core-2.6.5/Regexp.html
286
+ # @see https://github.com/antirez/redis/blob/0f026af185e918a9773148f6ceaa1b084662be88/src/sds.c#L878-L880
287
+ # @see https://github.com/antirez/redis/blob/0f026af185e918a9773148f6ceaa1b084662be88/src/sds.c#L898-L901
288
+ # @see https://www.justinweiss.com/articles/3-steps-to-fix-encoding-problems-in-ruby/
289
+ def escape_with_hex_codes?(char)
290
+ !char.valid_encoding? || char =~ /[^[:print:]&&[:ascii:]]/
291
+ end
292
+
293
+ # Hex-encodes a (presumably non-printable or non-ASCII) character.
294
+ #
295
+ # Aside from standard backslash escape sequences, redis-cli also
296
+ # recognizes "\xhh" notation, where `hh` is a hexadecimal number.
297
+ #
298
+ # Of note is that redis-cli only recognizes *exactly* two-digit
299
+ # hexadecimal numbers. This is in accordance with IEEE Std 1003.1-2001,
300
+ # Chapter 7, Locale:
301
+ #
302
+ # > A character can be represented as a hexadecimal constant. A
303
+ # > hexadecimal constant shall be specified as the escape character
304
+ # > followed by an 'x' followed by two hexadecimal digits. Each constant
305
+ # > shall represent a byte value. Multi-byte values can be represented by
306
+ # > concatenated constants specified in byte order with the last constant
307
+ # > specifying the least significant byte of the character.
308
+ #
309
+ # Unlike the C `char` type, Ruby's conception of a character can span
310
+ # multiple bytes (and possibly bytes that aren't valid in Ruby's string
311
+ # encoding). So we take care to escape the input properly into the
312
+ # redis-cli compatible version by iterating through each byte and
313
+ # formatting it as a (zero-padded) 2-digit hexadecimal number prefixed by
314
+ # `\x`.
315
+ #
316
+ # @see https://pubs.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap07.html
317
+ # @see https://github.com/antirez/redis/blob/0f026af185e918a9773148f6ceaa1b084662be88/src/sds.c#L878-L880
318
+ # @see https://github.com/antirez/redis/blob/0f026af185e918a9773148f6ceaa1b084662be88/src/sds.c#L898-L901
319
+ def escape_with_hex_codes(char)
320
+ char.bytes.map { |b| Kernel.format("\\x%02x", b) }.join
321
+ end
322
+
323
+ def escape?(char)
324
+ escape_with_backslash?(char) || escape_with_hex_codes?(char)
325
+ end
326
+
327
+ # Should this character cause {#prettify} to wrap its output in quotes?
328
+ #
329
+ # The overall string returned by {#prettify} should only be quoted if at
330
+ # least one of the following holds:
331
+ #
332
+ # 1. The string contains a character that needs to be escaped. This
333
+ # includes standard backslash escape sequences (like "\n" and "\t") as
334
+ # well as hex-encoded bytes using the "\x" escape sequence. Since
335
+ # {#prettify} uses double quotes on its output string, we must also
336
+ # force quotes if the string itself contains a literal double quote.
337
+ # This double quote behavior is handled tacitly by {BACKSLASHES}.
338
+ #
339
+ # 2. The string contains a single quote. Since redis-cli recognizes
340
+ # single-quoted strings, we want to wrap the {#prettify} output in
341
+ # double quotes so that the literal single quote character isn't
342
+ # mistaken as the delimiter of a new string.
343
+ #
344
+ # 3. The string contains any whitespace characters. If the {#prettify}
345
+ # output weren't wrapped in quotes, whitespace would act as a
346
+ # separator between arguments to the Redis command. To group things
347
+ # together, we need to quote the string.
348
+ def needs_quotes?(char)
349
+ escape?(char) || char == "'" || char =~ /\s/
350
+ end
351
+ end
352
+ end
353
+ end
354
+
355
+ Redis.extend(Honeycomb::Redis::Configuration)
356
+ Redis::Client.prepend(Honeycomb::Redis::Client)