logtail-ruby 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +33 -0
  3. data/.gitignore +24 -0
  4. data/.rspec +2 -0
  5. data/CHANGELOG.md +12 -0
  6. data/Gemfile +10 -0
  7. data/LICENSE.md +15 -0
  8. data/README.md +4 -0
  9. data/Rakefile +72 -0
  10. data/lib/logtail.rb +36 -0
  11. data/lib/logtail/config.rb +154 -0
  12. data/lib/logtail/config/integrations.rb +17 -0
  13. data/lib/logtail/context.rb +9 -0
  14. data/lib/logtail/contexts.rb +12 -0
  15. data/lib/logtail/contexts/http.rb +31 -0
  16. data/lib/logtail/contexts/release.rb +52 -0
  17. data/lib/logtail/contexts/runtime.rb +23 -0
  18. data/lib/logtail/contexts/session.rb +24 -0
  19. data/lib/logtail/contexts/system.rb +29 -0
  20. data/lib/logtail/contexts/user.rb +28 -0
  21. data/lib/logtail/current_context.rb +168 -0
  22. data/lib/logtail/event.rb +36 -0
  23. data/lib/logtail/events.rb +10 -0
  24. data/lib/logtail/events/controller_call.rb +44 -0
  25. data/lib/logtail/events/error.rb +40 -0
  26. data/lib/logtail/events/exception.rb +10 -0
  27. data/lib/logtail/events/sql_query.rb +26 -0
  28. data/lib/logtail/events/template_render.rb +25 -0
  29. data/lib/logtail/integration.rb +40 -0
  30. data/lib/logtail/integrator.rb +50 -0
  31. data/lib/logtail/log_devices.rb +8 -0
  32. data/lib/logtail/log_devices/http.rb +368 -0
  33. data/lib/logtail/log_devices/http/flushable_dropping_sized_queue.rb +52 -0
  34. data/lib/logtail/log_devices/http/request_attempt.rb +20 -0
  35. data/lib/logtail/log_entry.rb +110 -0
  36. data/lib/logtail/logger.rb +270 -0
  37. data/lib/logtail/logtail.rb +36 -0
  38. data/lib/logtail/timer.rb +21 -0
  39. data/lib/logtail/util.rb +7 -0
  40. data/lib/logtail/util/non_nil_hash_builder.rb +40 -0
  41. data/lib/logtail/version.rb +3 -0
  42. data/logtail-ruby.gemspec +43 -0
  43. data/spec/README.md +13 -0
  44. data/spec/logtail/current_context_spec.rb +113 -0
  45. data/spec/logtail/events/controller_call_spec.rb +12 -0
  46. data/spec/logtail/events/error_spec.rb +15 -0
  47. data/spec/logtail/log_devices/http_spec.rb +185 -0
  48. data/spec/logtail/log_entry_spec.rb +22 -0
  49. data/spec/logtail/logger_spec.rb +227 -0
  50. data/spec/spec_helper.rb +22 -0
  51. data/spec/support/logtail.rb +5 -0
  52. data/spec/support/socket_hostname.rb +12 -0
  53. data/spec/support/timecop.rb +3 -0
  54. data/spec/support/webmock.rb +3 -0
  55. metadata +238 -0
@@ -0,0 +1,10 @@
1
+ require "logtail/events/error"
2
+
3
+ module Logtail
4
+ module Events
5
+ # DEPRECATION: This class is deprecated in favor of using {Logtail:Events:Error}.
6
+ # @private
7
+ class Exception < Error
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,26 @@
1
+ require 'logtail/util'
2
+ require 'logtail/event'
3
+
4
+ module Logtail
5
+ module Events
6
+ # @private
7
+ class SQLQuery < Logtail::Event
8
+ attr_reader :sql, :duration_ms, :message
9
+
10
+ def initialize(attributes)
11
+ @sql = attributes[:sql]
12
+ @duration_ms = attributes[:duration_ms]
13
+ @message = attributes[:message]
14
+ end
15
+
16
+ def to_hash
17
+ {
18
+ sql_query_executed: Util::NonNilHashBuilder.build do |h|
19
+ h.add(:sql, sql)
20
+ h.add(:duration_ms, duration_ms)
21
+ end
22
+ }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,25 @@
1
+ require "logtail/util"
2
+ require "logtail/event"
3
+ module Logtail
4
+ module Events
5
+ # @private
6
+ class TemplateRender < Logtail::Event
7
+ attr_reader :message, :name, :duration_ms
8
+
9
+ def initialize(attributes)
10
+ @name = attributes[:name]
11
+ @duration_ms = attributes[:duration_ms]
12
+ @message = attributes[:message]
13
+ end
14
+
15
+ def to_hash
16
+ {
17
+ template_rendered: Util::NonNilHashBuilder.build do |h|
18
+ h.add(:name, name)
19
+ h.add(:duration_ms, duration_ms)
20
+ end
21
+ }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,40 @@
1
+ module Logtail
2
+ # An integration represent an integration for an entire library. For example, `Rack`.
3
+ # While the Logtail `Rack` integration is comprised of multiple middlewares, the
4
+ # `Logtail::Integrations::Rack` module is an entire integration that extends this module.
5
+ module Integration
6
+ # Easily sisable entire library integrations. This is like removing the code from
7
+ # Logtail. It will not touch this library and the library will function as it would
8
+ # without Logtail.
9
+ #
10
+ # @example
11
+ # Logtail::Integrations::ActiveRecord.enabled = false
12
+ def enabled=(value)
13
+ @enabled = value
14
+ end
15
+
16
+ # Accessor method for {#enabled=}
17
+ def enabled?
18
+ @enabled != false
19
+ end
20
+
21
+ # Silences a library's logs. This ensures that logs are not generated at all
22
+ # from this library.
23
+ #
24
+ # @example
25
+ # Logtail::Integrations::ActiveRecord.silence = true
26
+ def silence=(value)
27
+ @silence = value
28
+ end
29
+
30
+ # Accessor method for {#silence=}
31
+ def silence?
32
+ @silence == true
33
+ end
34
+
35
+ # Abstract method that each integration must implement.
36
+ def integrate!
37
+ raise NotImplementedError.new
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,50 @@
1
+ module Logtail
2
+ # Base class for `Logtail::Integrations::*`. Provides a common interface for all integrators.
3
+ # An integrator is a single specific integration into a part of a library. See
4
+ # {Integration} for higher library level integration settings.
5
+ class Integrator
6
+ # Raised when an integrators requirements are not met. For example, this will be raised
7
+ # in the ActiveRecord integration if ActiveRecord is not available as a dependency in
8
+ # the current application.
9
+ class RequirementNotMetError < StandardError; end
10
+
11
+ class << self
12
+ attr_writer :enabled
13
+
14
+ # Allows you to enable / disable specific integrations.
15
+ #
16
+ # @note Disabling specific low level integrations should only be needed for edge cases.
17
+ # If you want to disable integration with an entire library, we recommend doing so
18
+ # at a higher level. Ex: `Logtail::Integrations::ActiveRecord.enabled = false`.
19
+ #
20
+ # @example
21
+ # Logtail::Integrations::ActiveRecord::LogSubscriber.enabled = false
22
+ def enabled?
23
+ @enabled != false
24
+ end
25
+
26
+ # Convenience class level method that runs the integrator by instantiating a new
27
+ # object and calling {#integrate!}. It also takes care to look at the if the integrator
28
+ # is enabled, skipping it if not.
29
+ def integrate!(*args)
30
+ if !enabled?
31
+ Config.instance.debug_logger.debug("#{name} integration disabled, skipping") if Config.instance.debug_logger
32
+ return false
33
+ end
34
+
35
+ new(*args).integrate!
36
+ Config.instance.debug_logger.debug("Integrated #{name}") if Config.instance.debug_logger
37
+ true
38
+ # RequirementUnsatisfiedError is the only silent failure we support
39
+ rescue RequirementNotMetError => e
40
+ Config.instance.debug_logger.debug("Failed integrating #{name}: #{e.message}") if Config.instance.debug_logger
41
+ false
42
+ end
43
+ end
44
+
45
+ # Abstract method that each integration must implement.
46
+ def integrate!
47
+ raise NotImplementedError.new
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,8 @@
1
+ require "logtail/log_devices/http"
2
+
3
+ module Logtail
4
+ # Namespace for all log devices.
5
+ # @private
6
+ module LogDevices
7
+ end
8
+ end
@@ -0,0 +1,368 @@
1
+ require "base64"
2
+ require "msgpack"
3
+ require "net/https"
4
+
5
+ require "logtail/config"
6
+ require "logtail/log_devices/http/flushable_dropping_sized_queue"
7
+ require "logtail/log_devices/http/request_attempt"
8
+ require "logtail/version"
9
+
10
+ module Logtail
11
+ module LogDevices
12
+ # A highly efficient log device that buffers and delivers log messages over HTTPS to
13
+ # the Logtail API. It uses batches, keep-alive connections, and msgpack to deliver logs with
14
+ # high-throughput and little overhead. All log preparation and delivery is done asynchronously
15
+ # in a thread as not to block application execution and efficiently deliver logs for
16
+ # multi-threaded environments.
17
+ #
18
+ # See {#initialize} for options and more details.
19
+ class HTTP
20
+ LOGTAIL_STAGING_HOST = "logs-staging.logtail.com".freeze
21
+ LOGTAIL_PRODUCTION_HOST = "logs.logtail.com".freeze
22
+ LOGTAIL_HOST = ENV['LOGTAIL_STAGING'] ? LOGTAIL_STAGING_HOST : LOGTAIL_PRODUCTION_HOST
23
+ LOGTAIL_PORT = 443
24
+ LOGTAIL_SCHEME = "https".freeze
25
+ CONTENT_TYPE = "application/msgpack".freeze
26
+ USER_AGENT = "Logtail Ruby/#{Logtail::VERSION} (HTTP)".freeze
27
+
28
+ # Instantiates a new HTTP log device that can be passed to {Logtail::Logger#initialize}.
29
+ #
30
+ # The class maintains a buffer which is flushed in batches to the Logtail API. 2
31
+ # options control when the flush happens, `:batch_byte_size` and `:flush_interval`.
32
+ # If either of these are surpassed, the buffer will be flushed.
33
+ #
34
+ # By default, the buffer will apply back pressure when the rate of log messages exceeds
35
+ # the maximum delivery rate. If you don't want to sacrifice app performance in this case
36
+ # you can drop the log messages instead by passing a {DroppingSizedQueue} via the
37
+ # `:request_queue` option.
38
+ #
39
+ # @param api_key [String] The API key provided to you after you add your application to
40
+ # [Logtail](https://logtail.com).
41
+ # @param [Hash] options the options to create a HTTP log device with.
42
+ # @option attributes [Symbol] :batch_size (1000) Determines the maximum of log lines in
43
+ # each HTTP payload. If the queue exceeds this limit an HTTP request will be issued. Bigger
44
+ # payloads mean higher throughput, but also use more memory. Logtail will not accept
45
+ # payloads larger than 1mb.
46
+ # @option attributes [Symbol] :flush_continuously (true) This should only be disabled under
47
+ # special circumstsances (like test suites). Setting this to `false` disables the
48
+ # continuous flushing of log message. As a result, flushing must be handled externally
49
+ # via the #flush method.
50
+ # @option attributes [Symbol] :flush_interval (1) How often the client should
51
+ # attempt to deliver logs to the Logtail API in fractional seconds. The HTTP client buffers
52
+ # logs and this options represents how often that will happen, assuming `:batch_byte_size`
53
+ # is not met.
54
+ # @option attributes [Symbol] :requests_per_conn (2500) The number of requests to send over a
55
+ # single persistent connection. After this number is met, the connection will be closed
56
+ # and a new one will be opened.
57
+ # @option attributes [Symbol] :request_queue (FlushableDroppingSizedQueue.new(25)) The request
58
+ # queue object that queues Net::HTTP requests for delivery. By deafult this is a
59
+ # `FlushableDroppingSizedQueue` of size `25`. Meaning once the queue fills up to 25
60
+ # requests new requests will be dropped. If you'd prefer to apply back pressure,
61
+ # ensuring you do not lose log data, pass a standard {SizedQueue}. See examples for
62
+ # an example.
63
+ # @option attributes [Symbol] :logtail_host The Logtail host to delivery the log lines to.
64
+ # The default is set via {LOGTAIL_HOST}.
65
+ #
66
+ # @example Basic usage
67
+ # Logtail::Logger.new(Logtail::LogDevices::HTTP.new("my_logtail_api_key"))
68
+ #
69
+ # @example Apply back pressure instead of dropping messages
70
+ # http_log_device = Logtail::LogDevices::HTTP.new("my_logtail_api_key", request_queue: SizedQueue.new(25))
71
+ # Logtail::Logger.new(http_log_device)
72
+ def initialize(api_key, options = {})
73
+ @api_key = api_key || raise(ArgumentError.new("The api_key parameter cannot be blank"))
74
+ @logtail_host = options[:logtail_host] || ENV['LOGTAIL_HOST'] || LOGTAIL_HOST
75
+ @logtail_port = options[:logtail_port] || ENV['LOGTAIL_PORT'] || LOGTAIL_PORT
76
+ @logtail_scheme = options[:logtail_scheme] || ENV['LOGTAIL_SCHEME'] || LOGTAIL_SCHEME
77
+ @batch_size = options[:batch_size] || 1_000
78
+ @flush_continuously = options[:flush_continuously] != false
79
+ @flush_interval = options[:flush_interval] || 2 # 2 seconds
80
+ @requests_per_conn = options[:requests_per_conn] || 2_500
81
+ @msg_queue = FlushableDroppingSizedQueue.new(@batch_size)
82
+ @request_queue = options[:request_queue] || FlushableDroppingSizedQueue.new(25)
83
+ @successive_error_count = 0
84
+ @requests_in_flight = 0
85
+ end
86
+
87
+ # Write a new log line message to the buffer, and flush asynchronously if the
88
+ # message queue is full. We flush asynchronously because the maximum message batch
89
+ # size is constricted by the Logtail API. The actual application limit is a multiple
90
+ # of this. Hence the `@request_queue`.
91
+ def write(msg)
92
+ @msg_queue.enq(msg)
93
+
94
+ # Lazily start flush threads to ensure threads are alive after forking processes.
95
+ # If the threads are started during instantiation they will not be copied when
96
+ # the current process is forked. This is the case with various web servers,
97
+ # such as phusion passenger.
98
+ ensure_flush_threads_are_started
99
+
100
+ if @msg_queue.full?
101
+ Logtail::Config.instance.debug { "Flushing HTTP buffer via write" }
102
+ flush_async
103
+ end
104
+ true
105
+ end
106
+
107
+ # Flush all log messages in the buffer synchronously. This method will not return
108
+ # until delivery of the messages has been successful. If you want to flush
109
+ # asynchronously see {#flush_async}.
110
+ def flush
111
+ flush_async
112
+ wait_on_request_queue
113
+ true
114
+ end
115
+
116
+ # Closes the log device, cleans up, and attempts one last delivery.
117
+ def close
118
+ # Kill the flush thread immediately since we are about to flush again.
119
+ @flush_thread.kill if @flush_thread
120
+
121
+ # Flush all remaining messages
122
+ flush
123
+
124
+ # Kill the request queue thread. Flushing ensures that no requests are pending.
125
+ @request_outlet_thread.kill if @request_outlet_thread
126
+ end
127
+
128
+ def deliver_one(msg)
129
+ http = build_http
130
+
131
+ begin
132
+ resp = http.start do |conn|
133
+ req = build_request([msg])
134
+ @requests_in_flight += 1
135
+ conn.request(req)
136
+ end
137
+ return resp
138
+ rescue => e
139
+ Logtail::Config.instance.debug { "error: #{e.message}" }
140
+ return e
141
+ ensure
142
+ http.finish if http.started?
143
+ @requests_in_flight -= 1
144
+ end
145
+ end
146
+
147
+ def verify_delivery!
148
+ 5.times do |i|
149
+ sleep(2)
150
+
151
+ if @last_resp.nil?
152
+ print "."
153
+ elsif @last_resp.code == "202"
154
+ puts "Log delivery successful! View your logs at https://logtail.com"
155
+ else
156
+ raise <<-MESSAGE
157
+
158
+ Log delivery failed!
159
+
160
+ Status: #{@last_resp.code}
161
+ Body: #{@last_resp.body}
162
+
163
+ You can enable internal Logtail debug logging with the following:
164
+
165
+ Logtail::Config.instance.debug_logger = ::Logger.new(STDOUT)
166
+ MESSAGE
167
+ end
168
+ end
169
+
170
+ raise <<-MESSAGE
171
+
172
+ Log delivery failed! No request was made.
173
+
174
+ You can enable internal debug logging with the following:
175
+
176
+ Logtail::Config.instance.debug_logger = ::Logger.new(STDOUT)
177
+ MESSAGE
178
+ end
179
+
180
+ private
181
+ # This is a convenience method to ensure the flush thread are
182
+ # started. This is called lazily from {#write} so that we
183
+ # only start the threads as needed, but it also ensures
184
+ # threads are started after process forking.
185
+ def ensure_flush_threads_are_started
186
+ if @flush_continuously
187
+ if @request_outlet_thread.nil? || !@request_outlet_thread.alive?
188
+ @request_outlet_thread = Thread.new { request_outlet }
189
+ end
190
+
191
+ if @flush_thread.nil? || !@flush_thread.alive?
192
+ @flush_thread = Thread.new { intervaled_flush }
193
+ end
194
+ end
195
+ end
196
+
197
+ # Builds an HTTP request based on the current messages queued.
198
+ def build_request(msgs)
199
+ path = '/'
200
+ req = Net::HTTP::Post.new(path)
201
+ req['Authorization'] = authorization_payload
202
+ req['Content-Type'] = CONTENT_TYPE
203
+ req['User-Agent'] = USER_AGENT
204
+ req.body = msgs.to_msgpack
205
+ req
206
+ end
207
+
208
+ # Flushes the message buffer asynchronously. The reason we provide this
209
+ # method is because the message buffer limit is constricted by the
210
+ # Logtail API. The application limit is multiples of the buffer limit,
211
+ # hence the `@request_queue`, allowing us to buffer beyond the Logtail API
212
+ # imposed limit.
213
+ def flush_async
214
+ @last_async_flush = Time.now
215
+ msgs = @msg_queue.flush
216
+ return if msgs.empty?
217
+
218
+ req = build_request(msgs)
219
+ if !req.nil?
220
+ Logtail::Config.instance.debug { "New request placed on queue" }
221
+ request_attempt = RequestAttempt.new(req)
222
+ @request_queue.enq(request_attempt)
223
+ end
224
+ end
225
+
226
+ # Waits on the request queue. This is used in {#flush} to ensure
227
+ # the log data has been delivered before returning.
228
+ def wait_on_request_queue
229
+ # Wait 20 seconds
230
+ 40.times do |i|
231
+ if @request_queue.size == 0 && @requests_in_flight == 0
232
+ Logtail::Config.instance.debug { "Request queue is empty and no requests are in flight, finish waiting" }
233
+ return true
234
+ end
235
+ Logtail::Config.instance.debug do
236
+ "Request size #{@request_queue.size}, reqs in-flight #{@requests_in_flight}, " \
237
+ "continue waiting (iteration #{i + 1})"
238
+ end
239
+ sleep 0.5
240
+ end
241
+ end
242
+
243
+ # Flushes the message queue on an interval. You will notice that {#write} also
244
+ # flushes the buffer if it is full. This method takes note of this via the
245
+ # `@last_async_flush` variable as to not flush immediately after a write flush.
246
+ def intervaled_flush
247
+ # Wait specified time period before starting
248
+ sleep @flush_interval
249
+
250
+ loop do
251
+ begin
252
+ if intervaled_flush_ready?
253
+ Logtail::Config.instance.debug { "Flushing HTTP buffer via the interval" }
254
+ flush_async
255
+ end
256
+
257
+ sleep(0.5)
258
+ rescue Exception => e
259
+ Logtail::Config.instance.debug { "Intervaled HTTP flush failed: #{e.inspect}\n\n#{e.backtrace}" }
260
+ end
261
+ end
262
+ end
263
+
264
+ # Determines if the loop in {#intervaled_flush} is ready to be flushed again. It
265
+ # uses the `@last_async_flush` variable to ensure that a flush does not happen
266
+ # too rapidly ({#write} also triggers a flush).
267
+ def intervaled_flush_ready?
268
+ @last_async_flush.nil? || (Time.now.to_f - @last_async_flush.to_f).abs >= @flush_interval
269
+ end
270
+
271
+ # Builds an `Net::HTTP` object to deliver requests over.
272
+ def build_http
273
+ http = Net::HTTP.new(@logtail_host, @logtail_port)
274
+ http.set_debug_output(Config.instance.debug_logger) if Config.instance.debug_logger
275
+ if @logtail_scheme == 'https'
276
+ http.use_ssl = true
277
+ # Verification on Windows fails despite having a valid certificate.
278
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
279
+ end
280
+ http.read_timeout = 30
281
+ http.ssl_timeout = 10
282
+ http.open_timeout = 10
283
+ http
284
+ end
285
+
286
+ # Creates a loop that processes the `@request_queue` on an interval.
287
+ def request_outlet
288
+ loop do
289
+ http = build_http
290
+
291
+ begin
292
+ Logtail::Config.instance.debug { "Starting HTTP connection" }
293
+
294
+ http.start do |conn|
295
+ deliver_requests(conn)
296
+ end
297
+ rescue => e
298
+ Logtail::Config.instance.debug { "#request_outlet error: #{e.message}" }
299
+ ensure
300
+ Logtail::Config.instance.debug { "Finishing HTTP connection" }
301
+ http.finish if http.started?
302
+ end
303
+ end
304
+ end
305
+
306
+ # Creates a loop that delivers requests over an open (kept alive) HTTP connection.
307
+ # If the connection dies, the request is thrown back onto the queue and
308
+ # the method returns. It is the responsibility of the caller to implement retries
309
+ # and establish a new connection.
310
+ def deliver_requests(conn)
311
+ num_reqs = 0
312
+
313
+ while num_reqs < @requests_per_conn
314
+ if @request_queue.size > 0
315
+ Logtail::Config.instance.debug { "Waiting on next request, threads waiting: #{@request_queue.size}" }
316
+ end
317
+
318
+ request_attempt = @request_queue.deq
319
+
320
+ if request_attempt.nil?
321
+ sleep(1)
322
+ else
323
+ request_attempt.attempted!
324
+ @requests_in_flight += 1
325
+
326
+ begin
327
+ resp = conn.request(request_attempt.request)
328
+ rescue => e
329
+ Logtail::Config.instance.debug { "#deliver_requests error: #{e.message}" }
330
+
331
+ # Throw the request back on the queue for a retry if it has been attempted less
332
+ # than 3 times
333
+ if request_attempt.attempts < 3
334
+ Logtail::Config.instance.debug { "Request is being retried, #{request_attempt.attempts} previous attempts" }
335
+ @request_queue.enq(request_attempt)
336
+ else
337
+ Logtail::Config.instance.debug { "Request is being dropped, #{request_attempt.attempts} previous attempts" }
338
+ end
339
+
340
+ return false
341
+ ensure
342
+ @requests_in_flight -= 1
343
+ end
344
+
345
+ num_reqs += 1
346
+
347
+ @last_resp = resp
348
+
349
+ Logtail::Config.instance.debug do
350
+ if resp.code == "202"
351
+ "Logs successfully sent! View your logs at https://logtail.com"
352
+ else
353
+ "Log delivery failed! status: #{resp.code}, body: #{resp.body}"
354
+ end
355
+ end
356
+ end
357
+ end
358
+
359
+ true
360
+ end
361
+
362
+ # Builds the `Authorization` header value for HTTP delivery to the Logtail API.
363
+ def authorization_payload
364
+ "Bearer #{@api_key}"
365
+ end
366
+ end
367
+ end
368
+ end