lines 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,18 @@
1
+ Lines - structured logs for humans
2
+ ==================================
3
+
4
+ An oppinionated logging library that implement the
5
+ [lines](https://github.com/zimbatm/lines) format.
6
+
7
+ Status: work in progress
8
+
9
+ TODO
10
+ ====
11
+
12
+ Add context to the library
13
+
14
+ Check performances
15
+
16
+ Exception formatting
17
+
18
+ Fix the UniqueID algorithm
@@ -0,0 +1,343 @@
1
+ require 'date'
2
+ require 'time'
3
+ require 'forwardable'
4
+
5
+ # Lines is an opinionated structured log format and a library
6
+ # inspired by Slogger.
7
+ #
8
+ # Don't use log levels. They limit the reasoning of the developer.
9
+ # Log everything in development AND production.
10
+ # Logs should be easy to read, grep and parse.
11
+ # Logging something should never fail.
12
+ # Use syslog.
13
+ #
14
+ # Example:
15
+ #
16
+ # log(msg: "Oops !")
17
+ # #outputs:
18
+ # # at=2013-03-07T09:21:39+00:00 pid=3242 app=some-process msg="Oops !" foo={} g=[]
19
+ #
20
+ # Usage:
21
+ #
22
+ # Lines.use(Syslog, $stderr)
23
+ # Lines.log(foo: 3, msg: "This")
24
+ #
25
+ # ctx = Lines.context(encoding_id: Log.id)
26
+ # ctx.log({})
27
+ #
28
+ # Lines.context(:foo => :bar) do |l|
29
+ # l.log(:sadfasdf => 3)
30
+ # end
31
+ module Lines
32
+ # New lines in Lines
33
+ NL = "\n".freeze
34
+
35
+ @global = {}
36
+ @outputters = []
37
+
38
+ class << self
39
+ def dumper; @dumper ||= Dumper.new end
40
+ attr_reader :global
41
+ attr_reader :outputters
42
+
43
+ # Used to select what output the lines will be put on.
44
+ #
45
+ # outputs - allows any kind of IO or Syslog
46
+ #
47
+ # Usage:
48
+ #
49
+ # Lines.use(Syslog, $stderr)
50
+ def use(*outputs)
51
+ outputters.replace(outputs.map{|o| to_outputter o})
52
+ end
53
+
54
+ # The main function. Used to record objects in the logs as lines.
55
+ #
56
+ # obj - a ruby hash
57
+ # args -
58
+ def log(obj, args={})
59
+ obj = sanitize_obj(obj, args)
60
+ obj = global.merge(obj)
61
+ outputters.each{|out| out.output(dumper, obj) }
62
+ obj
63
+ end
64
+
65
+ # Add data to the logs
66
+ #
67
+ # data - a ruby hash
68
+ def context(data={})
69
+ new_context = Context.new global.merge(data)
70
+ yield new_context if block_given?
71
+ new_context
72
+ end
73
+
74
+ class Context
75
+ attr_reader :data
76
+
77
+ def initialize(data)
78
+ @data = data
79
+ end
80
+
81
+ def log(obj, args={})
82
+ Lines.log(obj, args.merge(data))
83
+ end
84
+ end
85
+
86
+ # A backward-compatibile logger
87
+ def logger
88
+ @logger ||= (
89
+ require "lines/logger"
90
+ Logger.new(self)
91
+ )
92
+ end
93
+
94
+ protected
95
+
96
+ def sanitize_obj(obj, args={})
97
+ if obj.kind_of?(Exception)
98
+ ex = obj
99
+ obj = {ex: ex.class, msg: ex.to_s}
100
+ if ex.respond_to?(:backtrace) && ex.backtrace
101
+ obj[:backtrace] = ex.backtrace
102
+ end
103
+ elsif !obj.kind_of?(Hash)
104
+ if obj.respond_to?(:to_h)
105
+ obj = obj.to_h
106
+ else
107
+ obj = {msg: obj}
108
+ end
109
+ end
110
+ obj.merge(args)
111
+ end
112
+
113
+ def to_outputter(out)
114
+ return out if out.respond_to?(:output)
115
+ return StreamOutputter.new(out) if out.respond_to?(:write)
116
+
117
+ case out
118
+ when IO
119
+ StreamOutputter.new(out)
120
+ when Syslog
121
+ SyslogOutputter.new
122
+ else
123
+ raise ArgumentError, "unknown outputter #{out.inspect}"
124
+ end
125
+ end
126
+ end
127
+
128
+ class StreamOutputter
129
+ # stream must accept a #write(str) message
130
+ def initialize(stream = $stderr)
131
+ @stream = stream
132
+ # Is this needed ?
133
+ @stream.sync = true if @stream.respond_to?(:sync)
134
+ end
135
+
136
+ def output(dumper, obj)
137
+ str = dumper.dump(obj) + NL
138
+ stream.write str
139
+ end
140
+
141
+ protected
142
+
143
+ attr_reader :stream
144
+ end
145
+
146
+ require 'syslog'
147
+ class SyslogOutputter
148
+ PRI2SYSLOG = {
149
+ debug: Syslog::LOG_DEBUG,
150
+ info: Syslog::LOG_INFO,
151
+ warn: Syslog::LOG_WARNING,
152
+ warning: Syslog::LOG_WARNING,
153
+ err: Syslog::LOG_ERR,
154
+ error: Syslog::LOG_ERR,
155
+ crit: Syslog::LOG_CRIT,
156
+ critical: Syslog::LOG_CRIT,
157
+ }
158
+
159
+ def initialize(syslog = Syslog, app_name=nil)
160
+ @app_name = app_name
161
+ @syslog = syslog
162
+ prepare_syslog
163
+ end
164
+
165
+ def output(dumper, obj)
166
+ obj = obj.dup
167
+ obj.delete(:pid) # It's going to be part of the message
168
+ obj.delete(:at) # Also part of the message
169
+ obj.delete(:app) # And again
170
+
171
+ level = extract_pri(obj)
172
+ str = dumper.dump(obj)
173
+
174
+ syslog.log(level, str)
175
+ end
176
+
177
+ protected
178
+
179
+ attr_reader :app_name
180
+ attr_reader :syslog
181
+
182
+ def prepare_syslog
183
+ unless syslog.opened?
184
+ # Did you know ? app_name is detected by syslog if nil
185
+ syslog.open(app_name,
186
+ Syslog::LOG_PID & Syslog::LOG_CONS & Syslog::LOG_NDELAY,
187
+ Syslog::LOG_USER)
188
+ end
189
+ end
190
+
191
+ def extract_pri(h)
192
+ pri = h.delete(:pri).to_s.downcase
193
+ PRI2SYSLOG[pri] || PRI2SYSLOG[:info]
194
+ end
195
+ end
196
+
197
+ # Some opinions here as well on the format:
198
+ #
199
+ # We really want to never fail at dumping because you know, they're logs.
200
+ # It's better to get a slightly less readable log that no logs at all.
201
+ #
202
+ # We're trying to be helpful for humans. It means that if possible we want
203
+ # to make things shorter and more readable. It also means that ideally
204
+ # we would like the parsing to be isomorphic but approximations are alright.
205
+ # For example a symbol might become a string.
206
+ #
207
+ # Basically, values are either composite (dictionaries and arrays), quoted
208
+ # strings or litterals. Litterals are strings that can be parsed to
209
+ # something else depending if the language supports it or not.
210
+ # Litterals never contain white-spaces or other weird (very precise !) characters.
211
+ #
212
+ # the true litteral is written as "#t"
213
+ # the false litteral is written as "#f"
214
+ # the nil / null litteral is written as "nil"
215
+ #
216
+ # dictionary keys are always strings or litterals.
217
+ #
218
+ # Pleaaase, keep units with numbers. And we provide a way for this:
219
+ # a tuple of (number, litteral) can be concatenated. Eg: (3, 'ms') => 3ms
220
+ # alternatively if your language supports a time range it could be serialized
221
+ # to the same value (and parsed back as well).
222
+ #
223
+ # if we don't know how to serialize something we provide a language-specific
224
+ # string of it and encode is at such.
225
+ #
226
+ # The output ought to use the UTF-8 encoding.
227
+ #
228
+ # This dumper has been inspired by the OkJSON gem (both formats look alike
229
+ # after all).
230
+ class Dumper
231
+ def dump(obj) #=> String
232
+ objenc_internal(obj)
233
+ end
234
+
235
+ # Used to introduce new ruby litterals.
236
+ def map(klass, &rule)
237
+ @mapping[klass] = rule
238
+ end
239
+
240
+ protected
241
+
242
+ attr_reader :mapping
243
+
244
+ def initialize
245
+ @mapping = {}
246
+ end
247
+
248
+ def objenc_internal(x)
249
+ x.map{|k,v| "#{keyenc(k)}=#{valenc(v)}" }.join(' ')
250
+ end
251
+
252
+ def keyenc(k)
253
+ case k
254
+ when String, Symbol then strenc(k)
255
+ else
256
+ strenc(k.inspect)
257
+ end
258
+ end
259
+
260
+ def valenc(x)
261
+ case x
262
+ when Hash then objenc(x)
263
+ when Array then arrenc(x)
264
+ when String, Symbol then strenc(x)
265
+ when Numeric then numenc(x)
266
+ when Time, Date then timeenc(x)
267
+ when true then "#t"
268
+ when false then "#f"
269
+ when nil then "nil"
270
+ else
271
+ litenc(x)
272
+ end
273
+ end
274
+
275
+ def objenc(x)
276
+ '{' + objenc_internal(x) + '}'
277
+ end
278
+
279
+ def arrenc(a)
280
+ # num + unit. Eg: 3ms
281
+ if a.size == 2 && a.first.kind_of?(Numeric) && is_literal?(a.last.to_s)
282
+ numenc(a.first) + strenc(a.last)
283
+ else
284
+ '[' + a.map{|x| valenc(x)}.join(' ') + ']'
285
+ end
286
+ end
287
+
288
+ # TODO: Single-quote espace if possible
289
+ def strenc(s)
290
+ s = s.to_s
291
+ s = s.inspect unless is_literal?(s)
292
+ s
293
+ end
294
+
295
+ def numenc(n)
296
+ #case n
297
+ # when Float
298
+ # "%.3f" % n
299
+ #else
300
+ n.to_s
301
+ #end
302
+ end
303
+
304
+ def litenc(x)
305
+ klass = (x.class.ancestors & mapping.keys).first
306
+ if klass
307
+ mapping[klass].call(x)
308
+ else
309
+ strenc(x.inspect)
310
+ end
311
+ rescue
312
+ klass = (class << x; self; end).ancestors.first
313
+ strenc("#<#{klass}:0x#{x.__id__.to_s(16)}>")
314
+ end
315
+
316
+ def timeenc(t)
317
+ t.iso8601
318
+ end
319
+
320
+ def is_literal?(s)
321
+ !s.index(/[\s'"]/)
322
+ end
323
+
324
+ end
325
+
326
+ require 'securerandom'
327
+ module UniqueIDs
328
+ # A small utility to generate unique IDs that are as short as possible.
329
+ #
330
+ # It's useful to link contextes together
331
+ #
332
+ # See http://preshing.com/20110504/hash-collision-probabilities
333
+ def id(collision_chance=1.0/10e9, over_x_messages=10e3)
334
+ # Assuming that the distribution is perfectly random
335
+ # how many bits do we need so that the chance of collision over_x_messages
336
+ # is lower thant collision_chance ?
337
+ number_of_possible_numbers = (over_x_messages ** 2) / (2 * collision_chance)
338
+ num_bytes = (Math.log2(number_of_possible_numbers) / 8).ceil
339
+ SecureRandom.urlsafe_base64(num_bytes)
340
+ end
341
+ end
342
+ extend UniqueIDs
343
+ end
@@ -0,0 +1,36 @@
1
+ require 'active_record'
2
+ require 'lines'
3
+
4
+ module Lines
5
+ class ActiveRecordSubscriber < ActiveSupport::LogSubscriber
6
+ def sql(event)
7
+ payload = event.payload
8
+
9
+ return if payload[:name] == "SCHEMA"
10
+
11
+ args = {}
12
+
13
+ args[:name] = payload[:name] if payload[:name]
14
+ args[:sql] = payload[:sql].squeeze(' ')
15
+
16
+ if payload[:binds] && payload[:binds].any?
17
+ args[:binds] = payload[:binds].inject({}) do |hash,(col, v)|
18
+ hash[col.name] = v
19
+ hash
20
+ end
21
+ end
22
+
23
+ args[:elapsed] = [event.duration, 's']
24
+
25
+ Lines.log(args)
26
+ end
27
+
28
+ def identity(event)
29
+ Lines.log(name: event.payload[:name], line: event.payload[:line])
30
+ end
31
+
32
+ def logger; true; end
33
+ end
34
+ end
35
+
36
+ Lines::ActiveRecordSubscriber.attach_to :active_record
@@ -0,0 +1,166 @@
1
+ begin
2
+ require 'parslet'
3
+ rescue LoadError
4
+ warn "lines/loader depends on parslet"
5
+ raise
6
+ end
7
+
8
+ # http://zerowidth.com/2013/02/24/parsing-toml-in-ruby-with-parslet.html
9
+ module Lines
10
+ module Error; end
11
+ module ParseError; include Error; end
12
+ module Loader; extend self
13
+ def load(s)
14
+ parser = Parser.new
15
+ transformer = Transformer.new
16
+
17
+ tree = parser.parse(s)
18
+ #puts; p tree; puts
19
+ transformer.apply(tree)
20
+ rescue Parslet::ParseFailed => ex
21
+ # Mark as being part of the Lines library
22
+ ex.extend ParseError
23
+ raise
24
+ end
25
+ end
26
+
27
+ # Mostly copied over from the JSON example:
28
+ # https://github.com/kschiess/parslet/blob/master/example/json.rb
29
+ #
30
+ # TODO:
31
+ # ISO8601 dates
32
+ class Parser < Parslet::Parser
33
+
34
+ rule(:spaces) { match(' ').repeat(1) }
35
+ rule(:spaces?) { spaces.maybe }
36
+
37
+ rule(:digit) { match['0-9'] }
38
+
39
+ rule(:number) {
40
+ (
41
+ str('-').maybe >> (
42
+ str('0') | (match['1-9'] >> digit.repeat)
43
+ ) >> (
44
+ str('.') >> digit.repeat(1)
45
+ ).maybe >> (
46
+ match('[eE]') >> (str('+') | str('-')).maybe >> digit.repeat(1)
47
+ ).maybe
48
+ ).as(:number)
49
+ }
50
+
51
+ rule(:time) {
52
+ digit.repeat(4) >> str('-') >>
53
+ digit.repeat(2) >> str('-') >>
54
+ digit.repeat(2) >> str('T') >>
55
+ digit.repeat(2) >> str(':') >>
56
+ digit.repeat(2) >> str(':') >>
57
+ digit.repeat(2) >> str('Z')
58
+ }
59
+
60
+ rule(:singlequoted_string) {
61
+ str("'") >> (
62
+ str('\\') >> any | str("'").absent? >> any
63
+ ).repeat.as(:string) >> str("'")
64
+ }
65
+
66
+ rule(:doublequoted_string) {
67
+ str('"') >> (
68
+ str('\\') >> any | str('"').absent? >> any
69
+ ).repeat.as(:string) >> str('"')
70
+ }
71
+
72
+ rule(:simple_string) {
73
+ match['a-zA-Z_\-:'].repeat.as(:string)
74
+ }
75
+
76
+ rule(:string) {
77
+ singlequoted_string | doublequoted_string | simple_string
78
+ }
79
+
80
+ rule(:array) {
81
+ str('[') >> spaces? >>
82
+ (value >> (spaces >> value).repeat).maybe.as(:array) >>
83
+ spaces? >> str(']')
84
+ }
85
+
86
+ rule(:object) {
87
+ str('{') >> spaces? >>
88
+ (entry >> (spaces >> entry).repeat).maybe.as(:object) >>
89
+ spaces? >> str('}')
90
+ }
91
+
92
+ rule(:key) {
93
+ match['a-zA-Z0-9_'].repeat
94
+ }
95
+
96
+ rule(:value) {
97
+ str('#t').as(:true) | str('#f').as(:false) |
98
+ str('nil').as(:nil) |
99
+ object | array |
100
+ number | time |
101
+ string
102
+ }
103
+
104
+ rule(:entry) {
105
+ (
106
+ key.as(:key) >>
107
+ str('=') >>
108
+ value.as(:val)
109
+ ).as(:entry)
110
+ }
111
+
112
+ rule(:top) { spaces? >> (entry >> (spaces >> entry).repeat).maybe.as(:object) >> spaces? }
113
+ #rule(:top) { (digit >> digit).as(:digit) }
114
+ #rule(:top) { time }
115
+
116
+ root(:top)
117
+ end
118
+
119
+ class Transformer < Parslet::Transform
120
+
121
+ class Entry < Struct.new(:key, :val); end
122
+
123
+ rule(array: subtree(:ar)) {
124
+ case ar
125
+ when nil
126
+ []
127
+ when Array
128
+ ar
129
+ else
130
+ [ar]
131
+ end
132
+ }
133
+ rule(object: subtree(:ob)) {
134
+ case ob
135
+ when nil
136
+ []
137
+ when Array
138
+ ob
139
+ else
140
+ [ob]
141
+ end.inject({}) { |h, e|
142
+ h[e[:entry][:key].to_s] = e[:entry][:val]; h
143
+ }
144
+ }
145
+
146
+ # rule(entry: { key: simple(:ke), val: simple(:va) }) {
147
+ # Entry.new(ke.to_s, va)
148
+ # }
149
+
150
+ rule(time: { year: simple(:ye), month: simple(:mo), day: simple(:da), hour: simple(:ho), minute: simple(:min), second: simple(:sec)}) {
151
+ Time.new(ye.to_i, mo.to_i, da.to_i, ho.to_i, min.to_i, sec.to_i, "+00:00")
152
+ }
153
+
154
+ rule(string: simple(:st)) {
155
+ st.to_s
156
+ }
157
+
158
+ rule(number: simple(:nb)) {
159
+ nb.match(/[eE\.]/) ? Float(nb) : Integer(nb)
160
+ }
161
+
162
+ rule(nil: simple(:ni)) { nil }
163
+ rule(true: simple(:tr)) { true }
164
+ rule(false: simple(:fa)) { false }
165
+ end
166
+ end
@@ -0,0 +1,61 @@
1
+ module Lines
2
+ # Backward-compatible logger
3
+ # http://ruby-doc.org/stdlib-2.0/libdoc/logger/rdoc/Logger.html#method-i-log
4
+ class Logger
5
+ LEVELS = {
6
+ 0 => :debug,
7
+ 1 => :info,
8
+ 2 => :warn,
9
+ 3 => :error,
10
+ 4 => :fatal,
11
+ 5 => :unknown,
12
+ }
13
+
14
+ def initialize(line)
15
+ @line = line
16
+ end
17
+
18
+ def log(severity, message = nil, progname = nil, &block)
19
+ pri = LEVELS[severity] || severity
20
+ if block_given?
21
+ progname = message
22
+ message = yield.to_s rescue $!.to_s
23
+ end
24
+
25
+ data = { pri: pri }
26
+ data[:app] = progname if progname
27
+ data[:msg] = message if message
28
+
29
+ @line.log(data)
30
+ end
31
+
32
+ LEVELS.values.each do |level|
33
+ define_method(level) do |message=nil, &block|
34
+ log(level, message, &block)
35
+ end
36
+ end
37
+
38
+ alias << info
39
+ alias unknown info
40
+
41
+ def noop(*a); true end
42
+ %w[add
43
+ clone
44
+ datetime_format
45
+ datetime_format=
46
+ debug?
47
+ info?
48
+ error?
49
+ fatal?
50
+ warn?
51
+ level
52
+ level=
53
+ progname
54
+ progname=
55
+ sev_threshold
56
+ sev_threshold=
57
+ ].each do |op|
58
+ alias_method(op, :noop)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,38 @@
1
+ require 'rack/commonlogger'
2
+ require 'lines'
3
+
4
+ module Lines
5
+ class RackLogger < Rack::CommonLogger
6
+ # In development mode the common logger is always inserted
7
+ def self.silence_common_logger!
8
+ Rack::CommonLogger.module_eval("def call(env); @app.call(env); end")
9
+ end
10
+
11
+ def initialize(app)
12
+ @app = app
13
+ end
14
+
15
+ def call(env)
16
+ began_at = Time.now
17
+ status, header, body = @app.call(env)
18
+ header = Utils::HeaderHash.new(header)
19
+ body = BodyProxy.new(body) { log(env, status, header, began_at) }
20
+ [status, header, body]
21
+ end
22
+
23
+ protected
24
+
25
+ def log(env, status, header, began_at)
26
+ Lines.log(
27
+ remote_addr: env['HTTP_X_FORWARDED_FOR'] || env["REMOTE_ADDR"],
28
+ remote_user: env['REMOTE_USER'] || '',
29
+ method: env['REQUEST_METHOD'],
30
+ path: env['PATH_INFO'],
31
+ query: env["QUERY_STRING"],
32
+ status: status.to_s[0..3],
33
+ length: extract_content_length(header),
34
+ elapsed: [Time.now - began_at, 's'],
35
+ )
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,3 @@
1
+ module Lines
2
+ VERSION = "0.1.0"
3
+ end
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lines
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Jonas Pfenniger
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-04-03 00:00:00.000000000 Z
13
+ dependencies: []
14
+ description: Lines is a cross-language logging format
15
+ email: jonas@pfenniger.name
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - lib/lines/active_record.rb
22
+ - lib/lines/loader.rb
23
+ - lib/lines/logger.rb
24
+ - lib/lines/rack_logger.rb
25
+ - lib/lines/version.rb
26
+ - lib/lines.rb
27
+ homepage: https://github.com/zimbatm/lines
28
+ licenses: []
29
+ post_install_message:
30
+ rdoc_options: []
31
+ require_paths:
32
+ - lib
33
+ required_ruby_version: !ruby/object:Gem::Requirement
34
+ none: false
35
+ requirements:
36
+ - - ! '>='
37
+ - !ruby/object:Gem::Version
38
+ version: '0'
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ none: false
41
+ requirements:
42
+ - - ! '>='
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ requirements: []
46
+ rubyforge_project:
47
+ rubygems_version: 1.8.23
48
+ signing_key:
49
+ specification_version: 3
50
+ summary: Logging revisited
51
+ test_files: []