honeycomb-beeline 1.1.0 → 2.1.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.
@@ -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)