logtail 0.1.20 → 0.1.21

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.
@@ -15,25 +15,30 @@ module Logtail
15
15
  #
16
16
  # @example Logging an event
17
17
  # logger.info "Payment rejected", payment_rejected: {customer_id: customer_id, amount: 100}
18
+ #
19
+ # @example Adding context to one log line (merged into the gem's context, whose own keys win)
20
+ # logger.info "Payment rejected", context: {tenant_id: tenant_id}
18
21
  class Logger < ::Logger
19
22
 
20
23
  # @private
21
24
  class Formatter
22
- # Formatters get the formatted level from the logger.
25
+ # Formatters get the formatted level from the logger. Ruby's Logger formats UNKNOWN, and any
26
+ # severity it doesn't know, as "ANY".
23
27
  SEVERITY_MAP = {
24
28
  "DEBUG" => :debug,
25
29
  "INFO" => :info,
26
30
  "WARN" => :warn,
27
31
  "ERROR" => :error,
28
32
  "FATAL" => :fatal,
29
- "UNKNOWN" => :unknown
33
+ "UNKNOWN" => :unknown,
34
+ "ANY" => :unknown
30
35
  }
31
36
  EMPTY_ARRAY = []
32
37
 
33
38
  private
34
39
  def build_log_entry(severity, time, progname, logged_obj)
35
40
  context_snapshot = CurrentContext.instance.snapshot
36
- level = SEVERITY_MAP.fetch(severity)
41
+ level = SEVERITY_MAP.fetch(severity, :unknown)
37
42
  tags = extract_active_support_tagged_logging_tags.clone
38
43
 
39
44
  if logged_obj.is_a?(Event)
@@ -41,8 +46,9 @@ module Logtail
41
46
  tags: tags)
42
47
  elsif logged_obj.is_a?(Hash)
43
48
  # Extract the tags
44
- tags.push(logged_obj[:tag]) if logged_obj.key?(:tag)
45
- tags.concat(logged_obj[:tags]) if logged_obj.key?(:tags)
49
+ tags.concat(Array(logged_obj[:tag])) if logged_obj.key?(:tag)
50
+ tags.concat(Array(logged_obj[:tags])) if logged_obj.key?(:tags)
51
+ tags.compact!
46
52
  tags.uniq!
47
53
 
48
54
  message = logged_obj[:message]
@@ -192,8 +198,6 @@ module Logtail
192
198
  Logtail::Config.instance.debug { "Logtail::Logger instantiated, level: #{level}, formatter: #{formatter.class}" }
193
199
 
194
200
  @initialized = true
195
-
196
- at_exit { self.close }
197
201
  end
198
202
 
199
203
  # Sets a new formatted on the logger.
@@ -216,6 +220,21 @@ module Logtail
216
220
  super
217
221
  end
218
222
 
223
+ # Delivers what was logged so far before it returns, waiting about 5 seconds at most with
224
+ # the HTTP log device. Call it before a process ends with `exit!`, which skips the at_exit
225
+ # hook that delivers the rest, as Resque's forked job processes do.
226
+ #
227
+ # Rails calls `flush` on Rails.logger after every request (ActiveSupport::LogSubscriber.flush_all!).
228
+ # Waiting there would hold up every request, so that call returns right away and the lines
229
+ # are delivered in the background as usual.
230
+ def flush
231
+ return true if caller_locations(1, 10).any? { |location| location.base_label == "flush_all!" }
232
+
233
+ @logdev.dev.flush if @logdev && @logdev.dev.respond_to?(:flush)
234
+ @extra_loggers.each { |logger| logger.flush if logger.respond_to?(:flush) }
235
+ true
236
+ end
237
+
219
238
  # @private
220
239
  def with_context(context, &block)
221
240
  Logtail::CurrentContext.with(context, &block)
@@ -233,6 +252,12 @@ module Logtail
233
252
  super
234
253
  end
235
254
 
255
+ # Logs the message as an info line. ::Logger#<< writes it to the log device as it is, which
256
+ # the HTTP device can't deliver. Rack::CommonLogger, for example, logs requests this way.
257
+ def <<(msg)
258
+ info(msg.to_s.chomp)
259
+ end
260
+
236
261
  # Backwards compatibility with older ActiveSupport::Logger versions
237
262
  Logger::Severity.constants.each do |severity|
238
263
  class_eval(<<-EOT, __FILE__, __LINE__ + 1)
@@ -25,7 +25,7 @@ module Logtail
25
25
  def add(k, v, options = {})
26
26
  if !v.nil?
27
27
  if options[:json_encode]
28
- v = v.to_json
28
+ v = Util.generate_json(v)
29
29
  end
30
30
 
31
31
  if options[:limit]
data/lib/logtail/util.rb CHANGED
@@ -1,7 +1,15 @@
1
+ require "json"
2
+
1
3
  require "logtail/util/non_nil_hash_builder"
2
4
 
3
5
  module Logtail
4
6
  # @private
5
7
  module Util
8
+ # Encodes data as JSON. With ActiveSupport loaded, the data is converted with `as_json` first,
9
+ # so values come out as ActiveSupport's `to_json` encoded them (times in ISO 8601, NaN as
10
+ # null, ...), but without its encoder: with json 3, ActiveSupport 8.0 and older raise from it.
11
+ def self.generate_json(data)
12
+ ::JSON.generate(data.respond_to?(:as_json) ? data.as_json : data)
13
+ end
6
14
  end
7
15
  end
@@ -1,3 +1,3 @@
1
1
  module Logtail
2
- VERSION = "0.1.20"
2
+ VERSION = "0.1.21"
3
3
  end
@@ -0,0 +1,121 @@
1
+ require "spec_helper"
2
+ require "open3"
3
+
4
+ # With json 3, ActiveSupport 8.0 and older break `to_json` called directly: ActiveSupport's
5
+ # encoder passes `quirks_mode:` to JSON.generate, and json 3 raises on the unknown keyword.
6
+ # Calls made by JSON.generate itself pass a JSON::State and keep working. The stub does the same
7
+ # for the Hashes and Arrays the gem encodes (TruffleRuby can't stub it on frozen String keys).
8
+ describe "JSON encoding when to_json raises like json 3 under ActiveSupport 8.0" do
9
+ let(:time) { Time.utc(2016, 9, 1, 12, 0, 0) }
10
+ let(:io) { StringIO.new }
11
+ let(:logger) { Logtail::Logger.new(io) }
12
+
13
+ before(:each) do
14
+ [Hash, Array].each do |klass|
15
+ allow_any_instance_of(klass).to receive(:to_json).and_wrap_original do |original, *args|
16
+ raise ArgumentError, "unknown keyword: :quirks_mode" unless args.first.is_a?(::JSON::State)
17
+ original.call(*args)
18
+ end
19
+ end
20
+ end
21
+
22
+ around(:each) do |example|
23
+ Timecop.freeze(time) { example.run }
24
+ end
25
+
26
+ it "should log with the JSONFormatter" do
27
+ logger.formatter = Logtail::Logger::JSONFormatter.new
28
+ logger.info("log message", payment: {amount: 100, tags: ["a"]})
29
+
30
+ line = JSON.parse(io.string)
31
+ expect(line).to include("level" => "info", "dt" => "2016-09-01T12:00:00.000000Z", "message" => "log message")
32
+ expect(line["payment"]).to eq("amount" => 100, "tags" => ["a"])
33
+ end
34
+
35
+ it "should log with the AugmentedFormatter" do
36
+ logger.formatter = Logtail::Logger::AugmentedFormatter.new
37
+ logger.info("log message", payment: {amount: 100})
38
+
39
+ expect(io.string).to start_with("log message @metadata {\"level\":\"info\",\"dt\":\"2016-09-01T12:00:00.000000Z\"")
40
+ expect(io.string).to include("\"payment\":{\"amount\":100}")
41
+ end
42
+
43
+ it "should encode a log entry" do
44
+ log_entry = Logtail::LogEntry.new("INFO", time, nil, "log message", {custom: {a: "b"}}, nil)
45
+
46
+ expect(JSON.parse(log_entry.to_json)).to include("level" => "INFO", "message" => "log message", "context" => {"custom" => {"a" => "b"}, "runtime" => {}})
47
+ end
48
+
49
+ it "should encode an event" do
50
+ event = Logtail::Event.new("log message", {payment: {amount: 100}})
51
+
52
+ expect(event.to_json).to eq("{\"payment\":{\"amount\":100}}")
53
+ end
54
+
55
+ it "should encode the backtrace of an error event" do
56
+ event = Logtail::Events::Error.new(name: "RuntimeError", error_message: "Boom", backtrace: ["/path/to/file1.rb:26:in `function1'"])
57
+
58
+ expect(event.backtrace_json).to eq("[\"/path/to/file1.rb:26:in `function1'\"]")
59
+ end
60
+
61
+ it "should encode the params of a controller call event" do
62
+ event = Logtail::Events::ControllerCall.new(controller: "OrdersController", action: "show", params: {"id" => "1"})
63
+
64
+ expect(event.params_json).to eq("{\"id\":\"1\"}")
65
+ end
66
+
67
+ it "should encode values added with json_encode" do
68
+ hash = Logtail::Util::NonNilHashBuilder.build { |h| h.add(:headers_json, {"Accept" => "*/*"}, json_encode: true) }
69
+
70
+ expect(hash).to eq(headers_json: "{\"Accept\":\"*/*\"}")
71
+ end
72
+ end
73
+
74
+ # ActiveSupport changes how the whole process encodes JSON, so these examples load it in another
75
+ # process. It prints a log entry encoded by ActiveSupport's encoder, as LogEntry#to_json did
76
+ # before, then by LogEntry#to_json and the JSONFormatter while that encoder raises, as it does
77
+ # with json 3 on ActiveSupport 8.0 and older.
78
+ describe "JSON encoding with ActiveSupport loaded" do
79
+ def encode_with_active_support(fields)
80
+ script = <<~RUBY
81
+ require "logger" # ActiveSupport 6.1 needs it loaded first since concurrent-ruby 1.3.5
82
+ require "active_support"
83
+ require "active_support/core_ext/object/json"
84
+ require "logtail"
85
+
86
+ fields = #{fields}
87
+ log_entry = Logtail::LogEntry.new("INFO", Time.utc(2016, 9, 1, 12), nil, "log message", nil, fields)
88
+ puts ActiveSupport::JSON.encode(log_entry.to_hash)
89
+
90
+ ActiveSupport::JSON.singleton_class.prepend(Module.new do
91
+ def encode(*)
92
+ raise ArgumentError, "unknown keyword: quirks_mode"
93
+ end
94
+ end)
95
+ puts log_entry.to_json
96
+ logger = Logtail::Logger.new($stdout)
97
+ logger.formatter = Logtail::Logger::JSONFormatter.new
98
+ logger.info("log message", fields)
99
+ RUBY
100
+ lib = File.expand_path("../../lib", __dir__)
101
+ output, error, status = Open3.capture3(RbConfig.ruby, "-rbundler/setup", "-I", lib, "-e", script)
102
+ expect(status.success?).to be(true), error
103
+ output.lines.map { |line| JSON.parse(line) }
104
+ end
105
+
106
+ it "should encode times and symbols as ActiveSupport did" do
107
+ encoded_by_active_support, encoded, logged = encode_with_active_support("{time: Time.utc(2026, 1, 2, 3, 4, 5), status: :active}")
108
+
109
+ expect(encoded).to eq(encoded_by_active_support)
110
+ expect(encoded).to include("time" => "2026-01-02T03:04:05.000Z", "status" => "active")
111
+ expect(logged).to include("level" => "info", "time" => "2026-01-02T03:04:05.000Z", "status" => "active")
112
+ end
113
+
114
+ it "should encode NaN and Infinity as null, as ActiveSupport did" do
115
+ encoded_by_active_support, encoded, logged = encode_with_active_support("{nan: Float::NAN, infinity: Float::INFINITY}")
116
+
117
+ expect(encoded).to eq(encoded_by_active_support)
118
+ expect(encoded).to include("nan" => nil, "infinity" => nil)
119
+ expect(logged).to include("level" => "info", "nan" => nil, "infinity" => nil)
120
+ end
121
+ end
@@ -0,0 +1,142 @@
1
+ require "spec_helper"
2
+ require "delegate"
3
+
4
+ # A local ingesting server that takes 3 seconds to answer each request
5
+ class SlowAnsweringIngestServer < LocalIngestServer
6
+ class SlowSocket < SimpleDelegator
7
+ def write(*args)
8
+ sleep 3
9
+ super
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def serve(socket)
16
+ super(SlowSocket.new(socket))
17
+ end
18
+ end
19
+
20
+ # What the HTTP device does while the process that logs with it exits. These run logtail in a
21
+ # real Ruby process, and count what reaches a local ingesting server.
22
+ describe Logtail::LogDevices::HTTP, "when the process exits" do
23
+ let(:ingest) { LocalIngestServer.new }
24
+
25
+ after { ingest.stop }
26
+
27
+ # A local port that nothing listens on, so connecting to it is refused.
28
+ def unused_port
29
+ server = TCPServer.new("127.0.0.1", 0)
30
+ server.addr[1]
31
+ ensure
32
+ server.close
33
+ end
34
+
35
+ it "delivers a line that a thread logs while Ruby kills it at exit, without printing an error" do
36
+ result = run_ruby(<<-RUBY)
37
+ require "logtail"
38
+ logger = Logtail::Logger.new(Logtail::LogDevices::HTTP.new("token", #{ingest.device_options}))
39
+ logger.info("logged before exit")
40
+ started = Queue.new
41
+ Thread.new do
42
+ begin
43
+ started << true
44
+ sleep
45
+ ensure
46
+ logger.info("logged by a thread that Ruby kills at exit")
47
+ end
48
+ end
49
+ started.pop
50
+ RUBY
51
+
52
+ expect(result.stderr).not_to include("log writing failed")
53
+ expect(ingest.messages).to contain_exactly("logged before exit", "logged by a thread that Ruby kills at exit")
54
+ end
55
+
56
+ it "doesn't warn about the instance variables it uses to close, with warnings on" do
57
+ # Ruby 2.7 and older warn about an instance variable that is read before it's set
58
+ result = run_ruby(<<-RUBY)
59
+ $VERBOSE = true
60
+ require "logtail"
61
+ logger = Logtail::Logger.new(Logtail::LogDevices::HTTP.new("token", #{ingest.device_options}))
62
+ logger.info("logged before close")
63
+ logger.close
64
+ logger.info("logged after close")
65
+ RUBY
66
+
67
+ expect(result.status).to be_success, result.stderr
68
+ expect(result.stderr).not_to match(/@(closed|late_delivery_failed|last_resp) not initialized/)
69
+ expect(ingest.messages).to contain_exactly("logged before close", "logged after close")
70
+ end
71
+
72
+ it "delivers a line that an at_exit hook logs after the device closed" do
73
+ result = run_ruby(<<-RUBY)
74
+ require "logtail"
75
+ logger = nil
76
+ at_exit { logger.info("logged by an at_exit hook registered first, which runs last") }
77
+ logger = Logtail::Logger.new(Logtail::LogDevices::HTTP.new("token", #{ingest.device_options}))
78
+ logger.info("logged before exit")
79
+ RUBY
80
+
81
+ expect(result.status).to be_success, result.stderr
82
+ expect(ingest.messages).to contain_exactly("logged before exit", "logged by an at_exit hook registered first, which runs last")
83
+ end
84
+
85
+ it "waits up to 5 seconds for a slow host when it delivers lines logged after closing" do
86
+ slow_ingest = SlowAnsweringIngestServer.new
87
+ result = run_ruby(<<-RUBY)
88
+ require "logtail"
89
+ logger = Logtail::Logger.new(Logtail::LogDevices::HTTP.new("token", #{slow_ingest.device_options}))
90
+ logger.close
91
+ logger.info("first line after close")
92
+ logger.info("second line after close")
93
+ RUBY
94
+
95
+ # A delivery that times out would drop the second line
96
+ expect(result.status).to be_success, result.stderr
97
+ expect(slow_ingest.messages).to contain_exactly("first line after close", "second line after close")
98
+ ensure
99
+ slow_ingest.stop if slow_ingest
100
+ end
101
+
102
+ it "delivers a batch that the outlet takes off the queue while close checks whether it's done" do
103
+ result = run_ruby(<<-RUBY)
104
+ require "logtail"
105
+ # Widens the moment between the outlet taking a batch off the queue and counting it as in flight
106
+ Logtail::LogDevices::HTTP::RequestAttempt.prepend(Module.new { define_method(:attempted!) { sleep 1.5; super() } })
107
+ logger = Logtail::Logger.new(Logtail::LogDevices::HTTP.new("token", #{ingest.device_options}))
108
+ logger.info("logged right before exit")
109
+ RUBY
110
+
111
+ expect(result.status).to be_success, result.stderr
112
+ expect(ingest.messages).to eq(["logged right before exit"])
113
+ end
114
+
115
+ it "closes a device once at exit, however many loggers write to it" do
116
+ result = run_ruby(<<-RUBY)
117
+ require "logtail"
118
+ closes = 0
119
+ at_exit { puts "closes: " + closes.to_s }
120
+ Logtail::LogDevices::HTTP.prepend(Module.new { define_method(:close) { closes += 1; super() } })
121
+ device = Logtail::LogDevices::HTTP.new("token", #{ingest.device_options})
122
+ 3.times { |n| Logtail::Logger.new(device).info("line " + n.to_s) }
123
+ RUBY
124
+
125
+ expect(result.stdout).to eq("closes: 1\n"), result.stderr
126
+ expect(ingest.messages).to contain_exactly("line 0", "line 1", "line 2")
127
+ end
128
+
129
+ it "exits within 5 seconds when the ingesting host can't be reached, however many loggers there are" do
130
+ result = run_ruby(<<-RUBY)
131
+ exiting = nil
132
+ at_exit { puts Process.clock_gettime(Process::CLOCK_MONOTONIC) - exiting }
133
+ require "logtail"
134
+ device = Logtail::LogDevices::HTTP.new("token", ingesting_host: "127.0.0.1", ingesting_port: #{unused_port}, ingesting_scheme: "http")
135
+ 2.times { |n| Logtail::Logger.new(device).info("line " + n.to_s) }
136
+ exiting = Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
+ RUBY
138
+
139
+ expect(result.status).to be_success, result.stderr
140
+ expect(result.stdout.to_f).to be < 5
141
+ end
142
+ end
@@ -0,0 +1,85 @@
1
+ require "spec_helper"
2
+
3
+ # Forking servers and job runners (Puma, Unicorn, Resque) fork after the app logged. These run
4
+ # logtail in a real process that forks, and count what reaches a local ingesting server.
5
+ describe Logtail::LogDevices::HTTP, "after a fork" do
6
+ let(:ingest) { LocalIngestServer.new }
7
+
8
+ before do
9
+ skip "needs fork" if !Process.respond_to?(:fork) || RUBY_ENGINE == "truffleruby"
10
+ end
11
+
12
+ after { ingest.stop }
13
+
14
+ it "delivers the lines the parent logged before forking once, not once per process" do
15
+ result = run_ruby(<<-RUBY)
16
+ require "logtail"
17
+ logger = Logtail::Logger.new(Logtail::LogDevices::HTTP.new("token", flush_interval: 60, #{ingest.device_options}))
18
+ logger.info("parent line before the fork")
19
+ 2.times.map { |n| fork { logger.info("child line " + n.to_s) } }.each { |pid| Process.wait(pid) }
20
+ RUBY
21
+
22
+ expect(result.status).to be_success, result.stderr
23
+ expect(ingest.messages).to contain_exactly("parent line before the fork", "child line 0", "child line 1")
24
+ end
25
+
26
+ it "delivers the lines a forked child logs" do
27
+ result = run_ruby(<<-RUBY)
28
+ require "logtail"
29
+ logger = Logtail::Logger.new(Logtail::LogDevices::HTTP.new("token", flush_interval: 60, #{ingest.device_options}))
30
+ Process.wait(fork { 3.times { |n| logger.info("child line " + n.to_s) } })
31
+ logger.info("parent line after the fork")
32
+ RUBY
33
+
34
+ expect(result.status).to be_success, result.stderr
35
+ expect(ingest.messages).to contain_exactly("child line 0", "child line 1", "child line 2", "parent line after the fork")
36
+ end
37
+
38
+ it "lets a child that hasn't logged exit right away, without waiting on the parent's lines" do
39
+ result = run_ruby(<<-RUBY)
40
+ require "logtail"
41
+ logger = Logtail::Logger.new(Logtail::LogDevices::HTTP.new("token", flush_interval: 60, #{ingest.device_options}))
42
+ logger.info("parent line before the fork")
43
+ forking = Process.clock_gettime(Process::CLOCK_MONOTONIC)
44
+ Process.wait(fork {})
45
+ puts Process.clock_gettime(Process::CLOCK_MONOTONIC) - forking
46
+ RUBY
47
+
48
+ expect(result.stdout.to_f).to be < 5
49
+ expect(ingest.messages).to eq(["parent line before the fork"])
50
+ end
51
+
52
+ it "batches a child's lines as usual when the parent closed the device before forking" do
53
+ result = run_ruby(<<-RUBY)
54
+ require "logtail"
55
+ logger = Logtail::Logger.new(Logtail::LogDevices::HTTP.new("token", flush_interval: 60, #{ingest.device_options}))
56
+ logger.info("parent line")
57
+ logger.close
58
+ Process.wait(fork { 3.times { |n| logger.info("child line " + n.to_s) } })
59
+ RUBY
60
+
61
+ expect(result.status).to be_success, result.stderr
62
+ expect(ingest.messages).to contain_exactly("parent line", "child line 0", "child line 1", "child line 2")
63
+ # Lines written after close are each delivered on their own, see Logtail::LogDevices::HTTP#write
64
+ expect(ingest.batch_sizes).to eq([1, 3])
65
+ end
66
+
67
+ it "delivers a child's lines when it calls flush before leaving with exit!, which skips at_exit hooks" do
68
+ result = run_ruby(<<-RUBY)
69
+ require "logtail"
70
+ logger = Logtail::Logger.new(Logtail::LogDevices::HTTP.new("token", flush_interval: 60, #{ingest.device_options}))
71
+ logger.info("parent line")
72
+ Process.wait(fork do
73
+ begin
74
+ logger.info("child line")
75
+ logger.flush
76
+ ensure
77
+ exit!(0) # the way Resque ends a job's process
78
+ end
79
+ end)
80
+ RUBY
81
+
82
+ expect(result.status).to be_success, result.stderr
83
+ expect(ingest.messages).to contain_exactly("parent line", "child line")
84
+ end
85
+ end