cabin 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.
- data/examples/sample.rb +48 -0
- data/examples/sinatra-logging.rb +27 -0
- data/lib/cabin.rb +1 -0
- data/lib/cabin/channel.rb +142 -0
- data/lib/cabin/context.rb +45 -0
- data/lib/cabin/logger.rb +29 -0
- data/lib/cabin/namespace.rb +3 -0
- data/lib/cabin/outputs/stdlib-logger.rb +20 -0
- data/lib/cabin/timer.rb +20 -0
- data/test/minitest-patch.rb +13 -0
- data/test/test_logging.rb +101 -0
- metadata +70 -0
data/examples/sample.rb
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
require "rubygems"
|
2
|
+
require "cabin"
|
3
|
+
require "logger"
|
4
|
+
|
5
|
+
# Logging::... is something I'm implemented and experimenting with.
|
6
|
+
@logger = Cabin::Channel.new
|
7
|
+
|
8
|
+
# A logging channel can have any number of subscribers.
|
9
|
+
# Any subscriber is simply expected to respond to '<<' and take a single
|
10
|
+
# argument (the event)
|
11
|
+
# Special case of stdlib Logger instances that are wrapped smartly to
|
12
|
+
# log JSON and call the right Logger method (Logger#info, etc).
|
13
|
+
@logger.subscribe(Logger.new(STDOUT))
|
14
|
+
|
15
|
+
# You can store arbitrary key-value pairs in the logging channel.
|
16
|
+
# These are emitted with every event.
|
17
|
+
@logger[:program] = "sample program"
|
18
|
+
|
19
|
+
def foo(val)
|
20
|
+
# A context is something that lets you modify key-value pieces in the
|
21
|
+
# logging channel and gives you a trivial way to undo the changes later.
|
22
|
+
context = @logger.context()
|
23
|
+
context[:foo] = val
|
24
|
+
context[:example] = 100
|
25
|
+
|
26
|
+
# The point of the context above is to save context so that the bar() method
|
27
|
+
# and it's logging efforts can include said context.
|
28
|
+
timer = @logger.time("Timing bar")
|
29
|
+
bar()
|
30
|
+
timer.stop # logs the result.
|
31
|
+
|
32
|
+
@logger.time("Another bar timer") do
|
33
|
+
bar()
|
34
|
+
end
|
35
|
+
|
36
|
+
# Clearing this context will exactly undo the changes made to the logger by
|
37
|
+
# this context.
|
38
|
+
context.clear()
|
39
|
+
end
|
40
|
+
|
41
|
+
def bar
|
42
|
+
@logger.info("bar bar bar!")
|
43
|
+
sleep(rand * 2)
|
44
|
+
end
|
45
|
+
|
46
|
+
foo("Hello")
|
47
|
+
@logger.info("All done.")
|
48
|
+
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require "rubygems"
|
2
|
+
require "sinatra"
|
3
|
+
$: << "./lib"
|
4
|
+
require "cabin"
|
5
|
+
require "logger"
|
6
|
+
|
7
|
+
$logger = Cabin::Channel.new
|
8
|
+
$logger.subscribe(Cabin.new(STDOUT))
|
9
|
+
|
10
|
+
def serve_it_up(arg)
|
11
|
+
$logger.info("Serving it up")
|
12
|
+
sleep 2
|
13
|
+
"Hello, #{arg}!"
|
14
|
+
end
|
15
|
+
|
16
|
+
get "/hello/:name" do
|
17
|
+
context = $logger.context
|
18
|
+
context[:name] = params[:name]
|
19
|
+
context[:verb] = "GET"
|
20
|
+
timer = $logger.time("serve_it_up latency")
|
21
|
+
result = serve_it_up(params[:name])
|
22
|
+
timer.stop
|
23
|
+
|
24
|
+
# Clear the context so that the next request doesn't have tainted context.
|
25
|
+
context.clear
|
26
|
+
return result
|
27
|
+
end
|
data/lib/cabin.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "cabin/channel"
|
@@ -0,0 +1,142 @@
|
|
1
|
+
require "cabin/logger"
|
2
|
+
require "cabin/namespace"
|
3
|
+
require "cabin/timer"
|
4
|
+
require "cabin/context"
|
5
|
+
require "cabin/outputs/stdlib-logger"
|
6
|
+
require "logger"
|
7
|
+
|
8
|
+
# A wonderful channel for logging.
|
9
|
+
#
|
10
|
+
# You can log normal messages through here, but you should be really
|
11
|
+
# shipping structured data. A message is just part of your data.
|
12
|
+
# "An error occurred" - in what? when? why? how?
|
13
|
+
#
|
14
|
+
# Logging channels support the usual 'info' 'warn' and other logger methods
|
15
|
+
# provided by Ruby's stdlib Logger class
|
16
|
+
#
|
17
|
+
# It additionally allows you to store arbitrary pieces of data in it like a
|
18
|
+
# hash, so your call stack can do be this:
|
19
|
+
#
|
20
|
+
# @logger = Cabin::Channel.new
|
21
|
+
# rubylog = Logger.new(STDOUT) # ruby's stlib logger
|
22
|
+
# @logger.subscribe(rubylog)
|
23
|
+
#
|
24
|
+
# def foo(val)
|
25
|
+
# context = @logger.context()
|
26
|
+
# context[:foo] = val
|
27
|
+
# context[:example] = 100
|
28
|
+
# bar()
|
29
|
+
#
|
30
|
+
# # Clear any context we just wanted bar() to know about
|
31
|
+
# context.clear()
|
32
|
+
#
|
33
|
+
# @logger.info("Done in foo")
|
34
|
+
# end
|
35
|
+
#
|
36
|
+
# def bar
|
37
|
+
# @logger.info("Fizzle")
|
38
|
+
# end
|
39
|
+
#
|
40
|
+
# The result:
|
41
|
+
#
|
42
|
+
# I, [2011-10-11T01:00:57.993200 #1209] INFO -- : {:timestamp=>"2011-10-11T01:00:57.992353-0700", :foo=>"Hello", :example=>100, :message=>"Fizzle", :level=>:info}
|
43
|
+
# I, [2011-10-11T01:00:57.993575 #1209] INFO -- : {:timestamp=>"2011-10-11T01:00:57.993517-0700", :message=>"Done in foo", :level=>:info}
|
44
|
+
#
|
45
|
+
class Cabin::Channel
|
46
|
+
include Cabin::Logger
|
47
|
+
|
48
|
+
# Create a new logging channel.
|
49
|
+
# The default log level is 'info'
|
50
|
+
public
|
51
|
+
def initialize
|
52
|
+
@outputs = []
|
53
|
+
@data = {}
|
54
|
+
@level = :info
|
55
|
+
end # def initialize
|
56
|
+
|
57
|
+
# Subscribe a new input
|
58
|
+
public
|
59
|
+
def subscribe(output)
|
60
|
+
# Wrap ruby stdlib Logger if given.
|
61
|
+
if output.is_a?(::Logger)
|
62
|
+
output = Cabin::Outputs::StdlibLogger.new(output)
|
63
|
+
end
|
64
|
+
@outputs << output
|
65
|
+
# TODO(sissel): Return a method or object that allows you to easily
|
66
|
+
# unsubscribe?
|
67
|
+
end # def subscribe
|
68
|
+
|
69
|
+
# Set some contextual map value
|
70
|
+
public
|
71
|
+
def []=(key, value)
|
72
|
+
@data[key] = value
|
73
|
+
end # def []=
|
74
|
+
|
75
|
+
# Get a context value by name.
|
76
|
+
public
|
77
|
+
def [](key)
|
78
|
+
@data[key]
|
79
|
+
end # def []
|
80
|
+
|
81
|
+
# Remove a context value by name.
|
82
|
+
public
|
83
|
+
def remove(key)
|
84
|
+
@data.delete(key)
|
85
|
+
end # def remove
|
86
|
+
|
87
|
+
# Publish data to all outputs. The data is expected to be a hash or a string.
|
88
|
+
#
|
89
|
+
# A new hash is generated based on the data given. If data is a string, then
|
90
|
+
# it will be added to the new event hash with key :message.
|
91
|
+
#
|
92
|
+
# A special key :timestamp is set at the time of this method call. The value
|
93
|
+
# is a string ISO8601 timestamp with microsecond precision.
|
94
|
+
public
|
95
|
+
def publish(data)
|
96
|
+
event = {
|
97
|
+
:timestamp => Time.now.strftime("%Y-%m-%dT%H:%M:%S.%6N%z")
|
98
|
+
}
|
99
|
+
event.merge!(@data)
|
100
|
+
# TODO(sissel): need to refactor string->hash shoving.
|
101
|
+
if data.is_a?(String)
|
102
|
+
event[:message] = data
|
103
|
+
else
|
104
|
+
event.merge!(data)
|
105
|
+
end
|
106
|
+
|
107
|
+
@outputs.each do |out|
|
108
|
+
out << event
|
109
|
+
end
|
110
|
+
end # def publish
|
111
|
+
|
112
|
+
# Start timing something.
|
113
|
+
# Returns an instance of Cabin::Timer bound to this Cabin::Channel.
|
114
|
+
# To stop the timer and immediately emit the result to this channel, invoke
|
115
|
+
# the Cabin::Timer#stop method.
|
116
|
+
public
|
117
|
+
def time(data, &block)
|
118
|
+
# TODO(sissel): need to refactor string->hash shoving.
|
119
|
+
if data.is_a?(String)
|
120
|
+
data = { :message => data }
|
121
|
+
end
|
122
|
+
|
123
|
+
timer = Cabin::Timer.new do |duration|
|
124
|
+
# TODO(sissel): Document this field
|
125
|
+
data[:duration] = duration
|
126
|
+
publish(data)
|
127
|
+
end
|
128
|
+
|
129
|
+
if block_given?
|
130
|
+
block.call
|
131
|
+
return timer.stop
|
132
|
+
else
|
133
|
+
return timer
|
134
|
+
end
|
135
|
+
end # def time
|
136
|
+
|
137
|
+
public
|
138
|
+
def context
|
139
|
+
ctx = Cabin::Context.new(self)
|
140
|
+
return ctx
|
141
|
+
end # def context
|
142
|
+
end # class Cabin::Channel
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require "cabin/namespace"
|
2
|
+
|
3
|
+
# Logging context exists to make it easy to add and later undo any changes made
|
4
|
+
# to the context data associated with a given Logging::Channel
|
5
|
+
#
|
6
|
+
# Usage:
|
7
|
+
#
|
8
|
+
# context = channel.context
|
9
|
+
# context["foo"] = "Hello world!"
|
10
|
+
# channel.info("Sample log") # output includes { "foo" => "Hello world!" }
|
11
|
+
# context.clear
|
12
|
+
# channel.info("Sample log 2") # context cleared, key "foo" removed.
|
13
|
+
#
|
14
|
+
class Cabin::Context
|
15
|
+
def initialize(channel)
|
16
|
+
@changes = []
|
17
|
+
@channel = channel
|
18
|
+
end # def initialize
|
19
|
+
|
20
|
+
def on_clear(&block)
|
21
|
+
@clear_callback = block
|
22
|
+
end # def on_clear
|
23
|
+
|
24
|
+
def []=(key, value)
|
25
|
+
# Maintain a record of what was changed so clear() can undo this context.
|
26
|
+
# This record is in reverse order so it can be undone in reverse later.
|
27
|
+
@changes.unshift([key, value, @channel[key]])
|
28
|
+
@channel[key] = value
|
29
|
+
end # def []=
|
30
|
+
|
31
|
+
def [](key)
|
32
|
+
@channel[key]
|
33
|
+
end # def []
|
34
|
+
|
35
|
+
# Undo any changes made to the channel by this context.
|
36
|
+
def clear
|
37
|
+
@changes.each do |key, value, original|
|
38
|
+
if original.nil?
|
39
|
+
@channel.remove(key)
|
40
|
+
else
|
41
|
+
@channel[key] = original
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end # def clear
|
45
|
+
end # class Cabin::Context
|
data/lib/cabin/logger.rb
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
require "cabin/namespace"
|
2
|
+
|
3
|
+
module Cabin::Logger
|
4
|
+
attr_accessor :level
|
5
|
+
LEVELS = {
|
6
|
+
:fatal => 0,
|
7
|
+
:error => 1,
|
8
|
+
:warn => 2,
|
9
|
+
:info => 3,
|
10
|
+
:debug => 4
|
11
|
+
}
|
12
|
+
|
13
|
+
# Define the usual log methods: info, fatal, etc.
|
14
|
+
# Each level-based method accepts both a message and a hash data.
|
15
|
+
%w(fatal error warn info debug).each do |level|
|
16
|
+
level = level.to_sym
|
17
|
+
# def info, def warn, etc...
|
18
|
+
define_method(level) do |message, data={}|
|
19
|
+
next unless LEVELS[@level] >= LEVELS[level]
|
20
|
+
if message.is_a?(Hash)
|
21
|
+
data.merge!(message)
|
22
|
+
else
|
23
|
+
data[:message] = message
|
24
|
+
end
|
25
|
+
data[:level] = level
|
26
|
+
publish(data)
|
27
|
+
end
|
28
|
+
end # end defining level-based log methods
|
29
|
+
end # module Cabin::Logger
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require "cabin"
|
2
|
+
require "json"
|
3
|
+
|
4
|
+
# Wrap Ruby stdlib's logger. This allows you to output to a normal ruby logger
|
5
|
+
# with Cabin. Since Ruby's Logger has a love for strings alone, this
|
6
|
+
# wrapper will convert the data/event to json before sending it to Logger.
|
7
|
+
class Cabin::Outputs::StdlibLogger
|
8
|
+
public
|
9
|
+
def initialize(logger)
|
10
|
+
@logger = logger
|
11
|
+
end # def initialize
|
12
|
+
|
13
|
+
# Receive an event
|
14
|
+
public
|
15
|
+
def <<(data)
|
16
|
+
method = data[:level] || "info"
|
17
|
+
# This will call @logger.info(data) or something similar.
|
18
|
+
@logger.send(method, data.to_json)
|
19
|
+
end # def <<
|
20
|
+
end # class Cabin::Outputs::StdlibLogger
|
data/lib/cabin/timer.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
require "cabin/namespace"
|
2
|
+
|
3
|
+
# A simple timer class for timing events like a stop watch. Normally you don't
|
4
|
+
# invoke this yourself, but you are welcome to do so.
|
5
|
+
#
|
6
|
+
# See also: Cabin::Channel#time
|
7
|
+
class Cabin::Timer
|
8
|
+
def initialize(&block)
|
9
|
+
@start = Time.now
|
10
|
+
@callback = block if block_given?
|
11
|
+
end # def initialize
|
12
|
+
|
13
|
+
# Stop the clock and call the callback with the duration.
|
14
|
+
# Also returns the duration of this timer.
|
15
|
+
def stop
|
16
|
+
duration = Time.now - @start
|
17
|
+
@callback.call(duration) if @callback
|
18
|
+
return duration
|
19
|
+
end # def stop
|
20
|
+
end # class Cabin::Timer
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require "rubygems"
|
2
|
+
require "minitest/spec"
|
3
|
+
# XXX: This code stolen from logstash's test bits.
|
4
|
+
|
5
|
+
# I don't really like monkeypatching, but whatever, this is probably better
|
6
|
+
# than overriding the 'describe' method.
|
7
|
+
class MiniTest::Spec
|
8
|
+
class << self
|
9
|
+
# 'it' sounds wrong, call it 'test'
|
10
|
+
alias :test :it
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
@@ -0,0 +1,101 @@
|
|
1
|
+
$: << File.dirname(__FILE__)
|
2
|
+
$: << File.join(File.dirname(__FILE__), "..", "lib")
|
3
|
+
|
4
|
+
require "rubygems"
|
5
|
+
require "minitest-patch"
|
6
|
+
require "cabin"
|
7
|
+
require "stringio"
|
8
|
+
require "minitest/autorun" if __FILE__ == $0
|
9
|
+
|
10
|
+
describe Cabin::Channel do
|
11
|
+
class Receiver
|
12
|
+
attr_accessor :data
|
13
|
+
|
14
|
+
public
|
15
|
+
def initialize
|
16
|
+
@data = []
|
17
|
+
end
|
18
|
+
|
19
|
+
public
|
20
|
+
def <<(data)
|
21
|
+
@data << data
|
22
|
+
end
|
23
|
+
end # class Receiver
|
24
|
+
|
25
|
+
before do
|
26
|
+
@logger = Cabin::Channel.new
|
27
|
+
@target = Receiver.new
|
28
|
+
@logger.subscribe(@target)
|
29
|
+
end
|
30
|
+
|
31
|
+
test "simple string publishing" do
|
32
|
+
@logger.publish("Hello world")
|
33
|
+
assert_equal(1, @target.data.length)
|
34
|
+
assert_equal("Hello world", @target.data[0][:message])
|
35
|
+
end
|
36
|
+
|
37
|
+
test "simple context data" do
|
38
|
+
@logger[:foo] = "bar"
|
39
|
+
@logger.publish("Hello world")
|
40
|
+
assert_equal(1, @target.data.length)
|
41
|
+
assert_equal("Hello world", @target.data[0][:message])
|
42
|
+
assert_equal("bar", @target.data[0][:foo])
|
43
|
+
end
|
44
|
+
|
45
|
+
test "time something" do
|
46
|
+
timer = @logger.time("some sample")
|
47
|
+
timer.stop
|
48
|
+
|
49
|
+
event = @target.data[0]
|
50
|
+
assert_equal("some sample", event[:message])
|
51
|
+
assert(event[:duration].is_a?(Numeric))
|
52
|
+
end
|
53
|
+
|
54
|
+
test "double subscription" do
|
55
|
+
@logger.subscribe(@target)
|
56
|
+
@logger.publish("Hello world")
|
57
|
+
assert_equal(2, @target.data.length)
|
58
|
+
assert_equal("Hello world", @target.data[0][:message])
|
59
|
+
assert_equal("Hello world", @target.data[1][:message])
|
60
|
+
end
|
61
|
+
|
62
|
+
test "context values" do
|
63
|
+
context = @logger.context
|
64
|
+
context["foo"] = "hello"
|
65
|
+
@logger.publish("testing")
|
66
|
+
assert_equal(1, @target.data.length)
|
67
|
+
assert_equal("hello", @target.data[0]["foo"])
|
68
|
+
assert_equal("testing", @target.data[0][:message])
|
69
|
+
end
|
70
|
+
|
71
|
+
test "context values clear properly" do
|
72
|
+
context = @logger.context
|
73
|
+
context["foo"] = "hello"
|
74
|
+
context.clear
|
75
|
+
@logger.publish("testing")
|
76
|
+
assert_equal(1, @target.data.length)
|
77
|
+
assert(!@target.data[0].has_key?("foo"))
|
78
|
+
assert_equal("testing", @target.data[0][:message])
|
79
|
+
end
|
80
|
+
|
81
|
+
%w(fatal error warn info debug).each do |level|
|
82
|
+
level = level.to_sym
|
83
|
+
test "standard use case, '#{level}' logging when enabled" do
|
84
|
+
@logger.level = level
|
85
|
+
@logger.send(level, "Hello world")
|
86
|
+
event = @target.data[0]
|
87
|
+
assert_equal("Hello world", event[:message])
|
88
|
+
assert_equal(level, event[:level])
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
%w(error warn info debug).each do |level|
|
93
|
+
level = level.to_sym
|
94
|
+
test "standard use case, '#{level}' logging when wrong level" do
|
95
|
+
@logger.level = :fatal
|
96
|
+
# Should not log since log level is :fatal and we are above that.
|
97
|
+
@logger.send(level, "Hello world")
|
98
|
+
assert_equal(0, @target.data.length)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
metadata
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: cabin
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Jordan Sissel
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2011-10-11 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: json
|
16
|
+
requirement: &10650540 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *10650540
|
25
|
+
description: This is an experiment to try and make logging more flexible and more
|
26
|
+
consumable. Plain text logs are bullshit, let's emit structured and contextual logs.
|
27
|
+
email:
|
28
|
+
- jls@semicomplete.com
|
29
|
+
executables: []
|
30
|
+
extensions: []
|
31
|
+
extra_rdoc_files: []
|
32
|
+
files:
|
33
|
+
- lib/cabin.rb
|
34
|
+
- lib/cabin/outputs/stdlib-logger.rb
|
35
|
+
- lib/cabin/channel.rb
|
36
|
+
- lib/cabin/namespace.rb
|
37
|
+
- lib/cabin/timer.rb
|
38
|
+
- lib/cabin/context.rb
|
39
|
+
- lib/cabin/logger.rb
|
40
|
+
- examples/sinatra-logging.rb
|
41
|
+
- examples/sample.rb
|
42
|
+
- test/minitest-patch.rb
|
43
|
+
- test/test_logging.rb
|
44
|
+
homepage: https://github.com/jordansissel/ruby-cabin
|
45
|
+
licenses:
|
46
|
+
- Apache License (2.0)
|
47
|
+
post_install_message:
|
48
|
+
rdoc_options: []
|
49
|
+
require_paths:
|
50
|
+
- lib
|
51
|
+
- lib
|
52
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
53
|
+
none: false
|
54
|
+
requirements:
|
55
|
+
- - ! '>='
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: '0'
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
59
|
+
none: false
|
60
|
+
requirements:
|
61
|
+
- - ! '>='
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: '0'
|
64
|
+
requirements: []
|
65
|
+
rubyforge_project:
|
66
|
+
rubygems_version: 1.8.10
|
67
|
+
signing_key:
|
68
|
+
specification_version: 3
|
69
|
+
summary: Experiments in structured and contextual logging
|
70
|
+
test_files: []
|