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.
@@ -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
@@ -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
@@ -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