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.
- checksums.yaml +4 -4
- data/{bundler_version.sh → .circleci/bundler_version.sh} +1 -2
- data/.circleci/config.yml +132 -0
- data/.circleci/setup-rubygems.sh +3 -0
- data/.rubocop.yml +2 -0
- data/Appraisals +27 -2
- data/CONTRIBUTORS.md +2 -1
- data/Gemfile.lock +45 -42
- data/README.md +3 -1
- data/honeycomb-beeline.gemspec +3 -2
- data/lib/generators/honeycomb/honeycomb_generator.rb +14 -0
- data/lib/honeycomb-beeline.rb +4 -1
- data/lib/honeycomb/beeline/version.rb +1 -1
- data/lib/honeycomb/client.rb +22 -11
- data/lib/honeycomb/configuration.rb +1 -1
- data/lib/honeycomb/integrations/active_support.rb +14 -2
- data/lib/honeycomb/integrations/aws.rb +400 -0
- data/lib/honeycomb/integrations/faraday.rb +1 -1
- data/lib/honeycomb/integrations/rack.rb +15 -4
- data/lib/honeycomb/integrations/rails.rb +67 -11
- data/lib/honeycomb/integrations/railtie.rb +2 -3
- data/lib/honeycomb/integrations/redis.rb +356 -0
- data/lib/honeycomb/integrations/warden.rb +2 -2
- metadata +34 -12
- data/.travis.yml +0 -57
@@ -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.
|
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.
|
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
|
-
|
32
|
-
|
33
|
-
|
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)
|
15
|
-
yield "request.controller", request.params[:controller]
|
16
|
-
yield "request.action", request.params[:action]
|
14
|
+
request = ::ActionDispatch::Request.new(env)
|
17
15
|
|
18
|
-
|
19
|
-
|
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
|
-
|
22
|
-
request.routes.router.recognize(request) do |route, _|
|
23
|
-
break if found_route
|
27
|
+
return unless router && routing
|
24
28
|
|
25
|
-
|
26
|
-
|
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
|
-
|
13
|
-
|
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)
|