logtail-ruby 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.
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