thtp 0.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.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +190 -0
- data/.travis.yml +5 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +6 -0
- data/bin/console +7 -0
- data/bin/setup +8 -0
- data/lib/thtp.rb +10 -0
- data/lib/thtp/client.rb +123 -0
- data/lib/thtp/client/instrumentation.rb +46 -0
- data/lib/thtp/client/middleware.rb +27 -0
- data/lib/thtp/encoding.rb +32 -0
- data/lib/thtp/errors.rb +143 -0
- data/lib/thtp/middleware_stack.rb +54 -0
- data/lib/thtp/server.rb +122 -0
- data/lib/thtp/server/instrumentation.rb +241 -0
- data/lib/thtp/server/middleware.rb +63 -0
- data/lib/thtp/server/null_route.rb +22 -0
- data/lib/thtp/server/pub_sub.rb +25 -0
- data/lib/thtp/status.rb +10 -0
- data/lib/thtp/utils.rb +120 -0
- data/lib/thtp/version.rb +3 -0
- data/spec/spec_helper.rb +36 -0
- data/spec/support/helpers/rack_test.rb +3 -0
- data/spec/support/helpers/thrift.rb +163 -0
- data/spec/thtp/server/null_route_spec.rb +27 -0
- data/thtp.gemspec +36 -0
- metadata +233 -0
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'thtp/utils'
|
4
|
+
|
5
|
+
module THTP
|
6
|
+
class Client
|
7
|
+
module Instrumentation
|
8
|
+
# Automagic instrumentation for all outbound RPCs as a THTP::Client middleware
|
9
|
+
class Metrics
|
10
|
+
OUTBOUND_RPC_STAT = 'rpc.outgoing'
|
11
|
+
|
12
|
+
SUCCESS_TAG = 'rpc.status:success' # everything is ok
|
13
|
+
EXCEPTION_TAG = 'rpc.status:exception' # schema-defined (expected) exception
|
14
|
+
ERROR_TAG = 'rpc.status:error' # unexpected error
|
15
|
+
INTERNAL_ERROR_TAG = 'rpc.status:internal_error'
|
16
|
+
|
17
|
+
def initialize(app, from:, to:, statsd:)
|
18
|
+
unless defined?(Datadog::Statsd) && statsd.is_a?(Datadog::Statsd)
|
19
|
+
raise ArgumentError, "Only dogstatsd is supported, not #{statsd.class.name}"
|
20
|
+
end
|
21
|
+
@app = app
|
22
|
+
@statsd = statsd
|
23
|
+
@base_tags = ["rpc.from:#{from}", "rpc.to:#{to}"]
|
24
|
+
end
|
25
|
+
|
26
|
+
def call(rpc, *rpc_args, **rpc_opts)
|
27
|
+
start = Utils.get_time
|
28
|
+
status_tag = SUCCESS_TAG
|
29
|
+
error_tag = nil
|
30
|
+
@app.call(rpc, *rpc_args, **rpc_opts)
|
31
|
+
rescue Thrift::Exception => e
|
32
|
+
status_tag = EXCEPTION_TAG
|
33
|
+
error_tag = "rpc.exception:#{e.class.name.underscore}"
|
34
|
+
raise
|
35
|
+
rescue => e
|
36
|
+
status_tag = ERROR_TAG
|
37
|
+
error_tag = "rpc.error:#{e.class.name.underscore}"
|
38
|
+
raise
|
39
|
+
ensure
|
40
|
+
tags = ["rpc:#{rpc}", status_tag, error_tag, *@base_tags].compact
|
41
|
+
@statsd.timing(OUTBOUND_RPC_STAT, Utils.elapsed_ms(start), tags: tags)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'thrift'
|
2
|
+
require 'thtp/errors'
|
3
|
+
|
4
|
+
module THTP
|
5
|
+
class Client
|
6
|
+
module Middleware
|
7
|
+
# Raise Thrift validation issues as their own detectable error type, rather
|
8
|
+
# than just ProtocolException.
|
9
|
+
class SchemaValidation
|
10
|
+
def initialize(app)
|
11
|
+
require 'thrift/validator' # if you don't have it, you'll need it
|
12
|
+
@app = app
|
13
|
+
@validator = Thrift::Validator.new
|
14
|
+
end
|
15
|
+
|
16
|
+
# Raises a ValidationError if any part of the request or response did not
|
17
|
+
# match the schema
|
18
|
+
def call(rpc, *rpc_args, **rpc_opts)
|
19
|
+
@validator.validate(rpc_args)
|
20
|
+
@app.call(rpc, *rpc_args, **rpc_opts).tap { |resp| @validator.validate(resp) }
|
21
|
+
rescue Thrift::ProtocolException => e
|
22
|
+
raise ClientValidationError, e.message
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'thrift'
|
2
|
+
|
3
|
+
module THTP
|
4
|
+
# Handling of registered MIME types and protocols
|
5
|
+
module Encoding
|
6
|
+
BINARY = 'application/vnd.apache.thrift.binary'.freeze
|
7
|
+
COMPACT = 'application/vnd.apache.thrift.compact'.freeze
|
8
|
+
JSON = 'application/vnd.apache.thrift.json'.freeze
|
9
|
+
|
10
|
+
def self.protocol(content_type)
|
11
|
+
case content_type
|
12
|
+
when BINARY
|
13
|
+
Thrift::BinaryProtocol
|
14
|
+
when COMPACT
|
15
|
+
Thrift::CompactProtocol
|
16
|
+
when JSON
|
17
|
+
Thrift::JsonProtocol
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.content_type(protocol)
|
22
|
+
# this can't be a case/when because Class !=== Class
|
23
|
+
if protocol == Thrift::BinaryProtocol
|
24
|
+
BINARY
|
25
|
+
elsif protocol == Thrift::CompactProtocol
|
26
|
+
COMPACT
|
27
|
+
elsif protocol == Thrift::JsonProtocol
|
28
|
+
JSON
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
data/lib/thtp/errors.rb
ADDED
@@ -0,0 +1,143 @@
|
|
1
|
+
require 'thrift'
|
2
|
+
|
3
|
+
module THTP
|
4
|
+
# Parent class of all THTP errors
|
5
|
+
class Error < StandardError; end
|
6
|
+
|
7
|
+
# parent class for all errors during RPC execution;
|
8
|
+
# serializable as a Thrift::ApplicationException
|
9
|
+
class ServerError < Error
|
10
|
+
# @return [Thrift::ApplicationExceptionType]
|
11
|
+
def self.type
|
12
|
+
Thrift::ApplicationException::UNKNOWN
|
13
|
+
end
|
14
|
+
|
15
|
+
# @return [Thrift::ApplicationException] a serialisable Thrift exception
|
16
|
+
def to_thrift
|
17
|
+
Thrift::ApplicationException.new(self.class.type, message)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# parent class for all errors during RPC calls
|
22
|
+
class ClientError < Error; end
|
23
|
+
|
24
|
+
# Indicates an unrecognised or inappropriate RPC request format
|
25
|
+
class BadRequestError < ServerError
|
26
|
+
def self.type
|
27
|
+
Thrift::ApplicationException::UNKNOWN_METHOD
|
28
|
+
end
|
29
|
+
|
30
|
+
def initialize
|
31
|
+
super 'Calls must be made as POSTs to /:service/:rpc'
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Indicates a well-formatted request for an RPC that does not exist
|
36
|
+
class UnknownRpcError < ServerError
|
37
|
+
def self.type
|
38
|
+
Thrift::ApplicationException::WRONG_METHOD_NAME
|
39
|
+
end
|
40
|
+
|
41
|
+
# @param rpc [String] the RPC requested
|
42
|
+
def initialize(rpc)
|
43
|
+
super "Unknown RPC '#{rpc}'"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# We don't recognise the response (client & server schemas don't match, or it's just invalid)
|
48
|
+
class BadResponseError < ServerError
|
49
|
+
def self.type
|
50
|
+
Thrift::ApplicationException::MISSING_RESULT
|
51
|
+
end
|
52
|
+
|
53
|
+
def initialize(rpc, response = nil)
|
54
|
+
super "#{rpc} failed: unknown result#{": '#{response.inspect}'" if response}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Indicates a failure to turn a value into Thrift bytes according to schema
|
59
|
+
class SerializationError < ServerError
|
60
|
+
def self.type
|
61
|
+
Thrift::ApplicationException::PROTOCOL_ERROR
|
62
|
+
end
|
63
|
+
|
64
|
+
# @param error [StandardError] the exception encountered while serialising
|
65
|
+
def initialize(error)
|
66
|
+
super friendly_message(error)
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def friendly_type(error)
|
72
|
+
return :other unless error.respond_to?(:type)
|
73
|
+
{
|
74
|
+
1 => :invalid_data,
|
75
|
+
2 => :negative_size,
|
76
|
+
3 => :size_limit,
|
77
|
+
4 => :bad_version,
|
78
|
+
5 => :not_implemented,
|
79
|
+
6 => :depth_limit,
|
80
|
+
}[error.type] || :unknown
|
81
|
+
end
|
82
|
+
|
83
|
+
def friendly_message(error)
|
84
|
+
"Serialization error (#{friendly_type(error)}): #{error.message}"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Indicates a failure to parse Thrift according to schema
|
89
|
+
class DeserializationError < SerializationError
|
90
|
+
# @param error [StandardError] the exception encountered while deserialising
|
91
|
+
def friendly_message(error)
|
92
|
+
"Deserialization error (#{friendly_type(error)}): #{error.message}"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Indicates that some Thrift struct failed cursory schema validation on the server
|
97
|
+
class ServerValidationError < ServerError
|
98
|
+
def initialize(validation_message)
|
99
|
+
super validation_message, Thrift::ApplicationException::UNKNOWN
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Indicates an uncategorised exception -- an error unrelated to Thrift,
|
104
|
+
# somewhere in application code.
|
105
|
+
class InternalError < ServerError
|
106
|
+
def self.type
|
107
|
+
Thrift::ApplicationException::INTERNAL_ERROR
|
108
|
+
end
|
109
|
+
|
110
|
+
# @param error [StandardError]
|
111
|
+
def initialize(error)
|
112
|
+
super "Internal error (#{error.class}): #{error.message}"
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Indicates an unexpected and unknown message type (HTTP status)
|
117
|
+
class UnknownMessageType < ClientError
|
118
|
+
def self.type
|
119
|
+
Thrift::ApplicationException::INVALID_MESSAGE_TYPE
|
120
|
+
end
|
121
|
+
|
122
|
+
def initialize(rpc, status, message)
|
123
|
+
super "#{rpc} returned unknown response code #{status}: #{message}"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Indicates that the remote server could not be found
|
128
|
+
class ServerUnreachableError < ClientError
|
129
|
+
def initialize
|
130
|
+
super 'Failed to open connection to host'
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Indicates that RPC execution took too long
|
135
|
+
class RpcTimeoutError < ClientError
|
136
|
+
def initialize(rpc)
|
137
|
+
super "Host did not respond to #{rpc} in the allotted time"
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# Indicates that some Thrift struct failed cursory schema validation on the client
|
142
|
+
class ClientValidationError < ClientError; end
|
143
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'thtp/utils'
|
2
|
+
|
3
|
+
module THTP
|
4
|
+
# An implementation of the middleware pattern (a la Rack) for RPC handling.
|
5
|
+
# Extracts RPCs from a Thrift service and passes requests to a handler via
|
6
|
+
# a middleware stack. Note, NOT threadsafe -- mount all desired middlewares
|
7
|
+
# before calling.
|
8
|
+
class MiddlewareStack
|
9
|
+
# @param thrift_service [Class] The Thrift service from which to extract RPCs
|
10
|
+
# @param handlers [Object,Array<Object>] An object or objects responding to
|
11
|
+
# each defined RPC; if multiple respond, the first will be used
|
12
|
+
def initialize(thrift_service, handlers)
|
13
|
+
# initialise middleware stack as empty with a generic dispatcher at the bottom
|
14
|
+
@stack = []
|
15
|
+
@dispatcher = ->(rpc, *rpc_args, **_rpc_opts) do
|
16
|
+
handler = Array(handlers).find { |h| h.respond_to?(rpc) }
|
17
|
+
raise NoMethodError, "No handler for rpc #{rpc}" unless handler
|
18
|
+
handler.public_send(rpc, *rpc_args) # opts are for middleware use only
|
19
|
+
end
|
20
|
+
# define instance methods for each RPC
|
21
|
+
Utils.extract_rpcs(thrift_service).each do |rpc|
|
22
|
+
define_singleton_method(rpc) { |*rpc_args_and_opts| call(rpc, *rpc_args_and_opts) }
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Nests a middleware at the bottom of the stack (closest to the function
|
27
|
+
# call). A middleware is any class implementing #call and calling app.call
|
28
|
+
# in turn., i.e.,
|
29
|
+
# class PassthroughMiddleware
|
30
|
+
# def initialize(app, *opts)
|
31
|
+
# @app = app
|
32
|
+
# end
|
33
|
+
# def call(rpc, *rpc_args, **rpc_opts)
|
34
|
+
# @app.call(rpc, *rpc_args, **rpc_opts)
|
35
|
+
# end
|
36
|
+
# end
|
37
|
+
def use(middleware_class, *middleware_args)
|
38
|
+
@stack << [middleware_class, middleware_args]
|
39
|
+
end
|
40
|
+
|
41
|
+
# Freezes and execute the middleware stack culminating in the RPC itself
|
42
|
+
def call(rpc, *rpc_args, **rpc_opts)
|
43
|
+
compose.call(rpc, *rpc_args, **rpc_opts)
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
# compose stack functions into one callable: [f, g, h] => f . g . h
|
49
|
+
def compose
|
50
|
+
@app ||= @stack.freeze.reverse_each. # rubocop:disable Naming/MemoizedInstanceVariableName
|
51
|
+
reduce(@dispatcher) { |app, (mw, mw_args)| mw.new(app, *mw_args) }
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
data/lib/thtp/server.rb
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
require 'rack'
|
2
|
+
require 'thrift'
|
3
|
+
|
4
|
+
require 'thtp/encoding'
|
5
|
+
require 'thtp/errors'
|
6
|
+
require 'thtp/middleware_stack'
|
7
|
+
require 'thtp/status'
|
8
|
+
require 'thtp/utils'
|
9
|
+
|
10
|
+
require 'thtp/server/instrumentation'
|
11
|
+
require 'thtp/server/middleware'
|
12
|
+
require 'thtp/server/pub_sub'
|
13
|
+
require 'thtp/server/null_route'
|
14
|
+
|
15
|
+
module THTP
|
16
|
+
# An HTTP (Rack middleware) implementation of Thrift-RPC
|
17
|
+
class Server
|
18
|
+
include PubSub
|
19
|
+
include Utils
|
20
|
+
|
21
|
+
attr_reader :service
|
22
|
+
|
23
|
+
# @param app [Object?] The Rack application underneath, if used as middleware
|
24
|
+
# @param service [Thrift::Service] The service class handled by this server
|
25
|
+
# @param handlers [Object,Array<Object>] The object(s) handling RPC requests
|
26
|
+
def initialize(app = NullRoute.new, service:, handlers: [])
|
27
|
+
@app = app
|
28
|
+
@service = service
|
29
|
+
@handler = MiddlewareStack.new(service, handlers)
|
30
|
+
@route = %r{^/#{canonical_name(service)}/(?<rpc>[\w.]+)/?$} # /:service/:rpc
|
31
|
+
end
|
32
|
+
|
33
|
+
# delegate to RPC handler stack
|
34
|
+
def use(middleware_class, *middleware_args)
|
35
|
+
@handler.use(middleware_class, *middleware_args)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Rack implementation entrypoint
|
39
|
+
def call(rack_env)
|
40
|
+
start_time = get_time
|
41
|
+
# verify routing
|
42
|
+
request = Rack::Request.new(rack_env)
|
43
|
+
protocol = Encoding.protocol(request.media_type) || Thrift::JsonProtocol
|
44
|
+
return @app.call(rack_env) unless request.post? && @route.match(request.path_info)
|
45
|
+
# get RPC name from route
|
46
|
+
rpc = Regexp.last_match[:rpc]
|
47
|
+
raise UnknownRpcError, rpc unless @handler.respond_to?(rpc)
|
48
|
+
# read, perform, write
|
49
|
+
args = read_args(request.body, rpc, protocol)
|
50
|
+
result = @handler.public_send(rpc, *args)
|
51
|
+
write_reply(result, rpc, protocol).tap do
|
52
|
+
publish :rpc_success,
|
53
|
+
request: request, rpc: rpc, args: args, result: result, time: elapsed_ms(start_time)
|
54
|
+
end
|
55
|
+
rescue Thrift::Exception => e # known schema-defined Thrift errors
|
56
|
+
write_reply(e, rpc, protocol).tap do
|
57
|
+
publish :rpc_exception,
|
58
|
+
request: request, rpc: rpc, args: args, exception: e, time: elapsed_ms(start_time)
|
59
|
+
end
|
60
|
+
rescue ServerError => e # known server/communication errors
|
61
|
+
write_error(e, protocol).tap do
|
62
|
+
publish :rpc_error,
|
63
|
+
request: request, rpc: rpc, args: args, error: e, time: elapsed_ms(start_time)
|
64
|
+
end
|
65
|
+
rescue => e # a non-Thrift exception occurred; translate to Thrift as best we can
|
66
|
+
write_error(InternalError.new(e), protocol).tap do
|
67
|
+
publish :internal_error, request: request, error: e, time: elapsed_ms(start_time)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
# fetches args from a request
|
74
|
+
def read_args(request_body, rpc, protocol)
|
75
|
+
args_struct = args_class(service, rpc).new
|
76
|
+
# read off the request body into a Thrift args struct
|
77
|
+
deserialize_stream(request_body, args_struct, protocol)
|
78
|
+
# args are named methods, but handler signatures use positional arguments;
|
79
|
+
# convert between the two using struct_fields, which is an ordered hash.
|
80
|
+
args_struct.struct_fields.values.map { |f| args_struct.public_send(f[:name]) }
|
81
|
+
end
|
82
|
+
|
83
|
+
# given any schema-defined response (success or exception), write it to the HTTP response
|
84
|
+
def write_reply(reply, rpc, protocol)
|
85
|
+
result_struct = result_class(service, rpc).new
|
86
|
+
# void return types have no spot in the result struct
|
87
|
+
unless reply.nil?
|
88
|
+
if reply.is_a?(Thrift::Exception)
|
89
|
+
# detect the correct exception field, if it exists, and set its value
|
90
|
+
field = result_struct.struct_fields.values.find do |f|
|
91
|
+
f.key?(:class) && reply.instance_of?(f[:class])
|
92
|
+
end
|
93
|
+
raise BadResponseError, rpc, reply unless field
|
94
|
+
result_struct.public_send("#{field[:name]}=", reply)
|
95
|
+
else
|
96
|
+
# if it's not an exception, it must be the "success" value
|
97
|
+
result_struct.success = reply
|
98
|
+
end
|
99
|
+
end
|
100
|
+
# write to the response as a REPLY message
|
101
|
+
[
|
102
|
+
Status::REPLY,
|
103
|
+
{ Rack::CONTENT_TYPE => Encoding.content_type(protocol) },
|
104
|
+
[serialize_buffer(result_struct, protocol)],
|
105
|
+
]
|
106
|
+
end
|
107
|
+
|
108
|
+
# Given an unexpected error (non-schema), try to write it schemaless. The
|
109
|
+
# status code indicate to clients that an error occurred and should be
|
110
|
+
# deserialised. The implicit schema for a non-schema exception is:
|
111
|
+
# struct exception { 1: string message, 2: i32 type }
|
112
|
+
# @param exception [Errors::ServerError]
|
113
|
+
def write_error(exception, protocol)
|
114
|
+
# write to the response as an EXCEPTION message
|
115
|
+
[
|
116
|
+
Status::EXCEPTION,
|
117
|
+
{ Rack::CONTENT_TYPE => Encoding.content_type(protocol) },
|
118
|
+
[serialize_buffer(exception.to_thrift, protocol)],
|
119
|
+
]
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,241 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'thrift'
|
4
|
+
require 'thtp/errors'
|
5
|
+
require 'thtp/utils'
|
6
|
+
|
7
|
+
module THTP
|
8
|
+
class Server
|
9
|
+
module Instrumentation
|
10
|
+
# A THTP::Server Server subscriber for RPC metrics reporting
|
11
|
+
class Metrics
|
12
|
+
include Utils
|
13
|
+
|
14
|
+
INBOUND_RPC_STAT = 'rpc.incoming'
|
15
|
+
|
16
|
+
SUCCESS_TAG = 'rpc.status:success' # everything is ok
|
17
|
+
EXCEPTION_TAG = 'rpc.status:exception' # schema-defined (expected) exception
|
18
|
+
ERROR_TAG = 'rpc.status:error' # unexpected error
|
19
|
+
INTERNAL_ERROR_TAG = 'rpc.status:internal_error'
|
20
|
+
|
21
|
+
def initialize(statsd)
|
22
|
+
unless defined?(Datadog::Statsd) && statsd.is_a?(Datadog::Statsd)
|
23
|
+
raise ArgumentError, 'Only dogstatsd is supported'
|
24
|
+
end
|
25
|
+
@statsd = statsd
|
26
|
+
end
|
27
|
+
|
28
|
+
# Everything went according to plan
|
29
|
+
# @param request [Rack::Request] The inbound HTTP request
|
30
|
+
# @param rpc [Symbol] The name of the RPC
|
31
|
+
# @param args [Thrift::Struct] The deserialized thrift args
|
32
|
+
# @param result [Thrift::Struct] The to-be-serialized thrift response
|
33
|
+
# @param time [Integer] Milliseconds of execution wall time
|
34
|
+
def rpc_success(request:, rpc:, args:, result:, time:)
|
35
|
+
tags = ["rpc:#{rpc}", SUCCESS_TAG]
|
36
|
+
@statsd.timing(INBOUND_RPC_STAT, time, tags: tags)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Handler raised an exception defined in the schema
|
40
|
+
# @param request [Rack::Request] The inbound HTTP request
|
41
|
+
# @param rpc [Symbol] The name of the RPC
|
42
|
+
# @param args [Thrift::Struct] The deserialized thrift args
|
43
|
+
# @param exception [Thrift::Struct] The to-be-serialized thrift exception
|
44
|
+
# @param time [Integer] Milliseconds of execution wall time
|
45
|
+
def rpc_exception(request:, rpc:, args:, exception:, time:)
|
46
|
+
tags = ["rpc:#{rpc}", EXCEPTION_TAG, "rpc.error:#{canonical_name(exception.class)}"]
|
47
|
+
@statsd.timing(INBOUND_RPC_STAT, time, tags: tags)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Handler raised an unexpected error
|
51
|
+
# @param request [Rack::Request] The inbound HTTP request
|
52
|
+
# @param rpc [Symbol] The name of the RPC
|
53
|
+
# @param args [Thrift::Struct?] The deserialized thrift args
|
54
|
+
# @param error [THTP::ServerError] The to-be-serialized exception
|
55
|
+
# @param time [Integer] Milliseconds of execution wall time
|
56
|
+
def rpc_error(request:, rpc:, args:, error:, time:)
|
57
|
+
tags = ["rpc:#{rpc}", ERROR_TAG, "rpc.error:#{canonical_name(error.class)}"]
|
58
|
+
@statsd.timing(INBOUND_RPC_STAT, time, tags: tags)
|
59
|
+
end
|
60
|
+
|
61
|
+
# An unknown error occurred
|
62
|
+
# @param request [Rack::Request] The inbound HTTP request
|
63
|
+
# @param error [Exception] The to-be-serialized exception
|
64
|
+
# @param time [Integer] Milliseconds of execution wall time
|
65
|
+
def internal_error(request:, error:, time:)
|
66
|
+
tags = [INTERNAL_ERROR_TAG, "rpc.error:#{canonical_name(error.class)}"]
|
67
|
+
@statsd.timing(INBOUND_RPC_STAT, time, tags: tags)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# A THTP::Server subscriber for RPC logging and exception recording
|
72
|
+
class Logging
|
73
|
+
include Utils
|
74
|
+
|
75
|
+
def initialize(logger, backtrace_lines: 10)
|
76
|
+
@logger = logger
|
77
|
+
@backtrace_lines = backtrace_lines
|
78
|
+
end
|
79
|
+
|
80
|
+
# Everything went according to plan
|
81
|
+
# @param request [Rack::Request] The inbound HTTP request
|
82
|
+
# @param rpc [Symbol] The name of the RPC
|
83
|
+
# @param args [Thrift::Struct] The deserialized thrift args
|
84
|
+
# @param result [Thrift::Struct] The to-be-serialized thrift response
|
85
|
+
# @param time [Integer] Milliseconds of execution wall time
|
86
|
+
def rpc_success(request:, rpc:, args:, result:, time:)
|
87
|
+
@logger.info :rpc do
|
88
|
+
{
|
89
|
+
rpc: rpc,
|
90
|
+
http: http_request_to_hash(request),
|
91
|
+
request: args_to_hash(args),
|
92
|
+
result: result_to_hash(result),
|
93
|
+
elapsed_ms: time,
|
94
|
+
}
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Handler raised an exception defined in the schema
|
99
|
+
# @param request [Rack::Request] The inbound HTTP request
|
100
|
+
# @param rpc [Symbol] The name of the RPC
|
101
|
+
# @param args [Thrift::Struct] The deserialized thrift args
|
102
|
+
# @param exception [Thrift::Struct] The to-be-serialized thrift exception
|
103
|
+
# @param time [Integer] Milliseconds of execution wall time
|
104
|
+
def rpc_exception(request:, rpc:, args:, exception:, time:)
|
105
|
+
@logger.info :rpc do
|
106
|
+
{
|
107
|
+
rpc: rpc,
|
108
|
+
http: http_request_to_hash(request),
|
109
|
+
request: args_to_hash(args),
|
110
|
+
exception: result_to_hash(exception),
|
111
|
+
elapsed_ms: time,
|
112
|
+
}
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Handler raised an unexpected error
|
117
|
+
# @param request [Rack::Request] The inbound HTTP request
|
118
|
+
# @param rpc [Symbol] The name of the RPC
|
119
|
+
# @param args [Thrift::Struct?] The deserialized thrift args
|
120
|
+
# @param error [THTP::ServerError] The to-be-serialized exception
|
121
|
+
# @param time [Integer] Milliseconds of execution wall time
|
122
|
+
def rpc_error(request:, rpc:, args:, error:, time:)
|
123
|
+
@logger.error :rpc do
|
124
|
+
{
|
125
|
+
rpc: rpc,
|
126
|
+
http: http_request_to_hash(request),
|
127
|
+
request: args_to_hash(args),
|
128
|
+
error: error_to_hash(error),
|
129
|
+
elapsed_ms: time,
|
130
|
+
}
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# An unknown error occurred
|
135
|
+
# @param request [Rack::Request] The inbound HTTP request
|
136
|
+
# @param error [Exception] The to-be-serialized exception
|
137
|
+
# @param time [Integer] Milliseconds of execution wall time
|
138
|
+
def internal_error(request:, error:, time:)
|
139
|
+
@logger.error :server do
|
140
|
+
{
|
141
|
+
http: http_request_to_hash(request),
|
142
|
+
internal_error: error_to_hash(error),
|
143
|
+
elapsed_ms: time,
|
144
|
+
}
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
private
|
149
|
+
|
150
|
+
def http_request_to_hash(rack_request)
|
151
|
+
{
|
152
|
+
user_agent: rack_request.user_agent,
|
153
|
+
content_type: rack_request.content_type,
|
154
|
+
verb: rack_request.request_method,
|
155
|
+
path: rack_request.path_info,
|
156
|
+
ssl: rack_request.ssl?,
|
157
|
+
}
|
158
|
+
end
|
159
|
+
|
160
|
+
def args_to_hash(rpc_args)
|
161
|
+
jsonify(rpc_args)
|
162
|
+
end
|
163
|
+
|
164
|
+
# converts all possible Thrift result types to JSON, inferring types
|
165
|
+
# from collections, with the intent of producing ELK-compatible output
|
166
|
+
# (i.e., no multiple-type-mapped fields)
|
167
|
+
def result_to_hash(result)
|
168
|
+
case result
|
169
|
+
when nil
|
170
|
+
nil # nulls are ok in ELK land
|
171
|
+
when Array
|
172
|
+
{ list: { canonical_name(result.first.class) => jsonify(result) } }
|
173
|
+
when Set
|
174
|
+
{ set: { canonical_name(result.first.class) => jsonify(result) } }
|
175
|
+
when Hash
|
176
|
+
ktype, vtype = result.first.map { |obj| canonical_name(obj.class) }
|
177
|
+
{ hash: { ktype => { vtype => jsonify(result) } } }
|
178
|
+
when StandardError
|
179
|
+
error_to_hash(result, backtrace: false)
|
180
|
+
else # primitives
|
181
|
+
{ canonical_name(result.class) => jsonify(result) }
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
# converts non-schema errors to an ELK-compatible format (JSON-serialisable hash)
|
186
|
+
def error_to_hash(error, backtrace: true)
|
187
|
+
error_info = { message: error.message, repr: error.inspect }
|
188
|
+
error_info[:backtrace] = error.backtrace.first(@backtrace_lines) if backtrace
|
189
|
+
{ canonical_name(error.class) => error_info }
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
# Captures exceptions to Sentry
|
194
|
+
class Sentry
|
195
|
+
# @param raven [Raven] A Sentry client instance, or something that acts like one
|
196
|
+
def initialize(raven)
|
197
|
+
@raven = raven
|
198
|
+
end
|
199
|
+
|
200
|
+
# Handler raised an unexpected error
|
201
|
+
# @param request [Rack::Request] The inbound HTTP request
|
202
|
+
# @param rpc [Symbol] The name of the RPC
|
203
|
+
# @param args [Thrift::Struct?] The deserialized thrift args
|
204
|
+
# @param error [THTP::ServerError] The to-be-serialized exception
|
205
|
+
# @param time [Integer] Milliseconds of execution wall time
|
206
|
+
def rpc_error(request:, rpc:, args:, error:, time:)
|
207
|
+
@raven.capture_exception(error, **rpc_context(request, rpc, args))
|
208
|
+
end
|
209
|
+
|
210
|
+
# An unknown error occurred
|
211
|
+
# @param request [Rack::Request] The inbound HTTP request
|
212
|
+
# @param error [Exception] The to-be-serialized exception
|
213
|
+
# @param time [Integer] Milliseconds of execution wall time
|
214
|
+
def internal_error(request:, error:, time:)
|
215
|
+
@raven.capture_exception(error, **http_context(request))
|
216
|
+
end
|
217
|
+
|
218
|
+
private
|
219
|
+
|
220
|
+
# subclass and override if desired
|
221
|
+
# @param rack_request [Rack::Request] The inbound HTTP request
|
222
|
+
def http_context(rack_request)
|
223
|
+
{
|
224
|
+
user: { ip: rack_request.ip, user_agent: rack_request.user_agent },
|
225
|
+
extra: {},
|
226
|
+
}
|
227
|
+
end
|
228
|
+
|
229
|
+
# subclass and override if desired
|
230
|
+
# @param rack_request [Rack::Request] The inbound HTTP request
|
231
|
+
# @param rpc [Symbol] The name of the RPC
|
232
|
+
# @param args [Thrift::Struct] The deserialized thrift args
|
233
|
+
def rpc_context(rack_request, rpc, _args)
|
234
|
+
http_context(rack_request).tap do |context|
|
235
|
+
context[:extra].merge!(rpc: rpc)
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|