lines 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []