scout_agent 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. data/AUTHORS +4 -0
  2. data/CHANGELOG +3 -0
  3. data/COPYING +340 -0
  4. data/INSTALL +17 -0
  5. data/LICENSE +6 -0
  6. data/README +3 -0
  7. data/Rakefile +123 -0
  8. data/TODO +3 -0
  9. data/bin/scout_agent +11 -0
  10. data/lib/scout_agent.rb +73 -0
  11. data/lib/scout_agent/agent.rb +42 -0
  12. data/lib/scout_agent/agent/communication_agent.rb +85 -0
  13. data/lib/scout_agent/agent/master_agent.rb +301 -0
  14. data/lib/scout_agent/api.rb +241 -0
  15. data/lib/scout_agent/assignment.rb +105 -0
  16. data/lib/scout_agent/assignment/configuration.rb +30 -0
  17. data/lib/scout_agent/assignment/identify.rb +110 -0
  18. data/lib/scout_agent/assignment/queue.rb +95 -0
  19. data/lib/scout_agent/assignment/reset.rb +91 -0
  20. data/lib/scout_agent/assignment/snapshot.rb +92 -0
  21. data/lib/scout_agent/assignment/start.rb +149 -0
  22. data/lib/scout_agent/assignment/status.rb +44 -0
  23. data/lib/scout_agent/assignment/stop.rb +60 -0
  24. data/lib/scout_agent/assignment/upload_log.rb +61 -0
  25. data/lib/scout_agent/core_extensions.rb +260 -0
  26. data/lib/scout_agent/database.rb +386 -0
  27. data/lib/scout_agent/database/mission_log.rb +282 -0
  28. data/lib/scout_agent/database/queue.rb +126 -0
  29. data/lib/scout_agent/database/snapshots.rb +187 -0
  30. data/lib/scout_agent/database/statuses.rb +65 -0
  31. data/lib/scout_agent/dispatcher.rb +157 -0
  32. data/lib/scout_agent/id_card.rb +143 -0
  33. data/lib/scout_agent/lifeline.rb +243 -0
  34. data/lib/scout_agent/mission.rb +212 -0
  35. data/lib/scout_agent/order.rb +58 -0
  36. data/lib/scout_agent/order/check_in_order.rb +32 -0
  37. data/lib/scout_agent/order/snapshot_order.rb +33 -0
  38. data/lib/scout_agent/plan.rb +306 -0
  39. data/lib/scout_agent/server.rb +123 -0
  40. data/lib/scout_agent/tracked.rb +59 -0
  41. data/lib/scout_agent/wire_tap.rb +513 -0
  42. data/setup.rb +1360 -0
  43. data/test/tc_core_extensions.rb +89 -0
  44. data/test/tc_id_card.rb +115 -0
  45. data/test/tc_plan.rb +285 -0
  46. data/test/test_helper.rb +22 -0
  47. data/test/ts_all.rb +7 -0
  48. metadata +171 -0
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env ruby -wKU
2
+
3
+ module ScoutAgent
4
+ #
5
+ # This class is a thin wrapper over RestClient for Scout's check-in API.
6
+ # Public methods are provided for each action you can perform againt the API.
7
+ #
8
+ class Server
9
+ #
10
+ # Create a new API wrapper, optionally with a +log+ to write connection
11
+ # details to.
12
+ #
13
+ def initialize(log = WireTap.new(nil))
14
+ @log = log
15
+ @rest_client = RestClient::Resource.new(
16
+ Plan.agent_url,
17
+ :headers => { :client_version => ScoutAgent::VERSION,
18
+ :accept_encoding => "gzip" }
19
+ )
20
+ end
21
+
22
+ # The log connection notes will be written to.
23
+ attr_reader :log
24
+
25
+ #
26
+ # This method fetches the current plan for this agent. You can pass any
27
+ # +additional_headers+ for the request if needed (mainly useful for
28
+ # <tt>:if_modified_since</tt>).
29
+ #
30
+ # This method will return the raw plan when it is successfully fetched. An
31
+ # empty String is returned if the plan is malformed or unchanged. Finally,
32
+ # +nil+ is returned if the plan cannot be retrieved for some reason.
33
+ #
34
+ def get_plan(additional_headers = { })
35
+ no_warnings { # keep OpenSSL quiet
36
+ @rest_client["plan.scout"].get(additional_headers)
37
+ }
38
+ rescue Zlib::Error # could not decompress response
39
+ log.warn("Plan was malformed zipped data.")
40
+ "" # replace bad plan with empty plan
41
+ rescue RestClient::RequestFailed => error # RestClient bug workaround
42
+ # all success codes are OK, but other codes are not
43
+ if error.http_code.between? 200, 299
44
+ error.response.body # the returned plan
45
+ else
46
+ log.warn("Plan was returned as a failure code: #{error.http_code}.")
47
+ nil # failed to retrieve plan
48
+ end
49
+ rescue RestClient::NotModified
50
+ log.info("Plan was not modified.")
51
+ "" # empty plan
52
+ rescue Errno::ECONNREFUSED, RestClient::Exception => error # bad networking
53
+ log.warn("Plan could not be retrieved: #{error.class}.")
54
+ nil # failed to retrieve plan
55
+ end
56
+
57
+ #
58
+ # This method can be used to send +data+ to the Scout API as a check-in.
59
+ # The data is zipped before it is sent to reduce the cost duplicated reports
60
+ # as much as possible.
61
+ #
62
+ # If the ckeck-in succeeds, +true+ is returned. However, +false+ doesn't
63
+ # ensure that we failed. RestClient may time out a long connection attempt,
64
+ # but the server may still complete it eventually.
65
+ #
66
+ def post_checkin(data)
67
+ io = StringIO.new
68
+ gzip = Zlib::GzipWriter.new(io)
69
+ gzip << data.to_json
70
+ gzip.close
71
+ no_warnings do # keep OpenSSL quiet
72
+ @rest_client["checkin.scout"].post(
73
+ io.string,
74
+ :content_type => "application/json",
75
+ :content_encoding => "gzip"
76
+ )
77
+ end
78
+ true
79
+ rescue Zlib::Error # could not compress data for sending
80
+ log.error("Check-in could not be zipped.")
81
+ false
82
+ rescue RestClient::RequestFailed => error # RestClient bug workaround
83
+ # all success codes are OK, but other codes are not
84
+ if error.http_code.between? 200, 299
85
+ true
86
+ else
87
+ log.warn( "Check-in was returned as a failure code: " +
88
+ "#{error.http_code}." )
89
+ false
90
+ end
91
+ rescue Errno::ECONNREFUSED, RestClient::Exception # networking problem
92
+ log.warn("Check-in could not be sent: #{error.class}.")
93
+ false # we failed to send and will retry later
94
+ end
95
+
96
+ #
97
+ # Uploads +log_file+ to the server for troubleshooting. Returns +true+ if
98
+ # the upload succeeded.
99
+ #
100
+ def post_log(log_file)
101
+ no_warnings do # keep OpenSSL quiet
102
+ @rest_client["log.scout"].post(
103
+ log_file.read,
104
+ :content_type => "text/plain",
105
+ :content_encoding => "gzip"
106
+ )
107
+ end
108
+ true
109
+ rescue RestClient::RequestFailed => error # RestClient bug workaround
110
+ # all success codes are OK, but other codes are not
111
+ if error.http_code.between? 200, 299
112
+ true
113
+ else
114
+ log.warn( "Log upload was returned as a failure code: " +
115
+ "#{error.http_code}." )
116
+ false
117
+ end
118
+ rescue Errno::ECONNREFUSED, RestClient::Exception # networking problem
119
+ log.warn("Log could not be sent: #{error.class}.")
120
+ false # could not send log
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env ruby -wKU
2
+
3
+ module ScoutAgent
4
+ #
5
+ # A mix-in for adding status tracking to various objects. Methods are
6
+ # provided to set your current status and clear your status.
7
+ #
8
+ module Tracked
9
+ #
10
+ # Returns the <tt>log()</tt> for this object, if supported and set, or a bit
11
+ # bucket log object.
12
+ #
13
+ def status_log
14
+ (respond_to?(:log) and log) or WireTap.new(nil)
15
+ end
16
+
17
+ #
18
+ # Returns the status database this object this object can use to update
19
+ # its status.
20
+ #
21
+ # This value is cached after the first call to speed up future interactions
22
+ # with the database. You'll need to reset the cached value with a call to
23
+ # force_status_database_reload() after doing something that would invalidate
24
+ # a SQLite database handle like <tt>fork()</tt>.
25
+ #
26
+ def status_database(log = nil)
27
+ @status_database ||= Database.load(:statuses, status_log)
28
+ end
29
+
30
+ # Call this to clear the cached database handle after a <tt>fork()</tt>.
31
+ def force_status_database_reload
32
+ @status_database = nil
33
+ end
34
+
35
+ #
36
+ # :call-seq:
37
+ # status(status, name = IDCard.me && IDCard.me.process_name)
38
+ #
39
+ # Sets the +status+ of this process. You can pass +name+ explicitly if it
40
+ # cannot be properly determined from <tt>IDCard.me()</tt>.
41
+ #
42
+ def status(*args)
43
+ status_database.update_status(*args) if status_database
44
+ end
45
+
46
+ #
47
+ # :call-seq:
48
+ # clear_status(name = IDCard.me && IDCard.me.process_name)
49
+ #
50
+ # Clears any status currently set for this process. This should be called
51
+ # for any process setting status messages <tt>at_exit()</tt>. You can pass
52
+ # +name+ explicitly if it cannot be properly determined from
53
+ # <tt>IDCard.me()</tt>.
54
+ #
55
+ def clear_status(*args)
56
+ status_database.clear_status(*args) if status_database
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,513 @@
1
+ #!/usr/bin/env ruby -wKU
2
+
3
+ module ScoutAgent
4
+ #
5
+ # WireTap is a complete replacement for Ruby's standard Logger class, with a
6
+ # few changes:
7
+ #
8
+ # * The code is multi-Process safe
9
+ # * Log can be duplicated on a second device
10
+ # * The default formatter produces a trimmed down output
11
+ # * Severity levels can be dynamically altered (not used in the agent)
12
+ #
13
+ # The main change was to make the code work for multiple processes. This is
14
+ # done by forcing code to aquire the proper locks during all file
15
+ # interactions. Log rotation also had to be changed to avoid moving an active
16
+ # log file out from under another process. Instead, files are copied and
17
+ # truncated. This also required a seek() to the end of the file before each
18
+ # write to be added to defeat some pos() caching that seemed to be occurring.
19
+ # The end results of all of this is that WireTap is safer than Logger, but
20
+ # slower because of the extra precausions it must take.
21
+ #
22
+ # The tap=() method on instances of this class, allows you to set a second
23
+ # device input can be copied to.
24
+ #
25
+ class WireTap
26
+ # A base Class for all WireTap failure conditions.
27
+ class Error < RuntimeError; end
28
+ # The Error thrown when WireTap is unable to shift logs for whatever reason.
29
+ class ShiftingError < Error; end
30
+
31
+ #
32
+ # Instances of this Class are used to format all outgoing log messages.
33
+ # WireTap will pass messages to call() which is expected to return a String
34
+ # ready for output.
35
+ #
36
+ # This class works very similar to Logger's default formatter (supporting
37
+ # things like logging Exception backtraces and Ruby objects with inspect())
38
+ # with the following changes:
39
+ #
40
+ # * The default message format is simplified
41
+ # * The default timestamp format is more readable
42
+ # * All lines after the first in a logged message are indented
43
+ # * Program names are shown with an attached PID
44
+ #
45
+ class Formatter
46
+ #
47
+ # The sprintf() style format used to format messages:
48
+ #
49
+ # SEVERITY [TIME PROCESS]: MESSAGE
50
+ #
51
+ FORMAT = "%s [%s %s]: %s\n"
52
+
53
+ # Creates a new formatter without a timestamp format.
54
+ def initialize
55
+ @datetime_format = nil
56
+ end
57
+
58
+ # Used to manually set the timestamp format, overriding the default.
59
+ attr_accessor :datetime_format
60
+
61
+ #
62
+ # The main interface required by Formatter instances. Turns +severity+,
63
+ # +time+, +progname+, and +message+ into a String for writing to a log
64
+ # file.
65
+ #
66
+ def call(severity, time, progname, message)
67
+ FORMAT % [ severity,
68
+ format_time(time),
69
+ format_progname(progname),
70
+ format_message(message) ]
71
+ end
72
+
73
+ #######
74
+ private
75
+ #######
76
+
77
+ # Formats +time+ using the set strftime() pattern, or a readable default.
78
+ def format_time(time)
79
+ if @datetime_format.nil?
80
+ time.strftime("%Y-%m-%d %H:%M:%S")
81
+ else
82
+ time.strftime(@datetime_format)
83
+ end
84
+ end
85
+
86
+ #
87
+ # Formats +progname+ by adding on a PID, or replacing it with a PID when
88
+ # +nil+.
89
+ #
90
+ def format_progname(progname)
91
+ pid = Process.pid
92
+ if progname
93
+ "%s(%d)" % [progname, pid]
94
+ else
95
+ pid
96
+ end
97
+ end
98
+
99
+ #
100
+ # Formats the +message+ object into a String. String objects are
101
+ # unchanged, Exception objects are converted to a message, type, and
102
+ # backtrace, and all other objects have inspect() called on them.
103
+ #
104
+ # If this process results in more than one line, all other lines are
105
+ # indented two spaces to make the resulting file trivial to parse.
106
+ #
107
+ def format_message(message)
108
+ output = case message
109
+ when String
110
+ message
111
+ when Exception
112
+ "%s (%s)\n%s" % [ message.message,
113
+ message.class,
114
+ message.backtrace.join("\n") ]
115
+ else
116
+ message.inspect
117
+ end
118
+ output.strip.gsub("\n", "\n ")
119
+ end
120
+ end
121
+
122
+ #
123
+ # This Class is used to wrap a device passed to WireTap and then manage
124
+ # interactions with that device. These interactions are handled in a
125
+ # multi-Thread and multi-Process safe way.
126
+ #
127
+ # WireTap's LogDevice requires a bigger underlying File-like interface than
128
+ # Logger's version do to the extra locking and rotation requirements. It
129
+ # skips these steps when the device does not support them though, to make it
130
+ # possible to log to something like <tt>$stdout</tt>.
131
+ #
132
+ class LogDevice
133
+ # Used in log rotation calculations.
134
+ SECONDS_IN_A_DAY = 60 * 60 * 24
135
+
136
+ #
137
+ # Wraps +log+ with +options+, which may include <tt>:shift_age</tt> and
138
+ # <tt>:shift_size</tt>. See WireTap::new() for details on these settings.
139
+ #
140
+ def initialize(log = nil, options = Hash.new)
141
+ if log.respond_to?(:write) and log.respond_to?(:close)
142
+ @dev = log
143
+ @filename = nil
144
+ else
145
+ @dev = open_log(log)
146
+ @filename = log
147
+ end
148
+ @shift_age = options[:shift_age] || 7
149
+ @shift_size = options[:shift_size] || 1048576
150
+ @lock = Mutex.new
151
+ end
152
+
153
+ # The underlying device being managed.
154
+ attr_reader :dev
155
+ # A base name for files created by this device.
156
+ attr_reader :filename
157
+
158
+ #
159
+ # Writes +message+ to +dev+ in a multi-Thread and multi-Process safe
160
+ # manner.
161
+ #
162
+ # Before any write occurs, a check is made to see if the content should be
163
+ # shifted. That process will be trigger, if needed.
164
+ #
165
+ def write(message)
166
+ @lock.synchronize do
167
+ not @dev.respond_to?(:flock) or @dev.flock(File::LOCK_EX)
168
+ begin
169
+ begin
170
+ shift_log if @filename and @dev.respond_to?(:stat)
171
+ rescue Exception => error # shifting failed for whatever reason
172
+ raise ShiftingError, "Shifting failed: #{error.message}"
173
+ end
174
+ begin
175
+ #
176
+ # make sure we are in the right place, even if the data has been
177
+ # truncated() by another process as part of rotation
178
+ #
179
+ @dev.seek(0, IO::SEEK_END)
180
+ rescue Errno::ESPIPE # illegal seek, probably $stdout or $stderr
181
+ # do nothing: such streams are always at the end
182
+ end
183
+ @dev.write(message)
184
+ ensure
185
+ not @dev.respond_to?(:flock) or @dev.flock(File::LOCK_UN)
186
+ end
187
+ end
188
+ end
189
+
190
+ # Closes the underlying +dev+ in a multi-Thread safe manner.
191
+ def close
192
+ @lock.synchronize do
193
+ @dev.close
194
+ end
195
+ end
196
+
197
+ #######
198
+ private
199
+ #######
200
+
201
+ #
202
+ # Opens a new log file in a multi-Process safe manner, adding a header
203
+ # message, if this Process was the one to create the file.
204
+ #
205
+ def open_log(filename)
206
+ dev = open(filename, File::CREAT | File::EXCL | File::WRONLY)
207
+ dev.chmod(0777) # make sure this file is available to all processes
208
+ write_header(dev, :created)
209
+ dev
210
+ rescue Errno::EEXIST # file already exists
211
+ dev = open(filename, File::CREAT | File::APPEND | File::WRONLY)
212
+ ensure
213
+ dev.sync = true if dev
214
+ end
215
+
216
+ #
217
+ # Sends a simple header to +dev+ saying when it was created or rotated.
218
+ #
219
+ # Technically, this method is not perfectly multi-Process safe. It's
220
+ # possible for one Process to create the file, but have another Process
221
+ # log one or more messages before this header can be added. This only
222
+ # affects the position of this message though and thus is deemed and
223
+ # acceptable risk. The agent is careful never to trigger this scenario.
224
+ #
225
+ def write_header(dev, verb)
226
+ dev.write "# Log %s at %s by %s\n" %
227
+ [verb, Time.now.strftime("%Y-%m-%d %H:%M:%S"), Process.pid]
228
+ end
229
+
230
+ #
231
+ # Forwards to shift_log_by_size() or shift_log_by_period() as dictated by
232
+ # the settings for this LogDevice.
233
+ #
234
+ def shift_log
235
+ if @shift_age.is_a? Integer
236
+ shift_log_by_size
237
+ else
238
+ shift_log_by_period
239
+ end
240
+ end
241
+
242
+ # Shifts log files if the file size has been exceeded.
243
+ def shift_log_by_size
244
+ return false unless @shift_age > 0 and @dev.stat.size > @shift_size
245
+ (@shift_age - 3).downto(0) do |i|
246
+ if File.exist? shifted_filename(i)
247
+ FileUtils.mv(shifted_filename(i), shifted_filename(i + 1))
248
+ end
249
+ end
250
+ multiprocessing_safe_shift(shifted_filename(0))
251
+ end
252
+
253
+ # Shifts log files if the period length has been exceeded.
254
+ def shift_log_by_period
255
+ now = Time.now
256
+ period_end = previous_period_end(now)
257
+ return false unless @dev.stat.mtime <= period_end
258
+ shifted_file = shifted_filename(period_end.strftime("%Y%m%d"))
259
+ if File.exist? shifted_file
260
+ raise "'%s' already exists." % shifted_file
261
+ end
262
+ multiprocessing_safe_shift(shifted_file)
263
+ end
264
+
265
+ # Returns a modified name for the file including +shifted_extension+.
266
+ def shifted_filename(shifted_extension)
267
+ "#{@filename}.#{shifted_extension}"
268
+ end
269
+
270
+ # Shifts the log file with a copy, truncate(), and rewind() process.
271
+ def multiprocessing_safe_shift(to_file)
272
+ FileUtils.cp(@filename, to_file)
273
+ @dev.truncate(0)
274
+ @dev.rewind
275
+ write_header(@dev, :rotated)
276
+ true
277
+ end
278
+
279
+ # A helper used to determine the previous period end from +now+.
280
+ def previous_period_end(now)
281
+ case @shift_age.to_s.downcase
282
+ when "daily"
283
+ end_of_day(now - SECONDS_IN_A_DAY)
284
+ when "weekly"
285
+ end_of_day(now - (now.wday + 1) * SECONDS_IN_A_DAY)
286
+ when "monthly"
287
+ end_of_day(now - now.mday * SECONDS_IN_A_DAY)
288
+ else
289
+ now
290
+ end
291
+ end
292
+
293
+ #
294
+ # A helper that converts a +day+ Time object into a new Time at the end of
295
+ # that +day+.
296
+ #
297
+ def end_of_day(day)
298
+ Time.local(day.year, day.month, day.mday, 23, 59, 59)
299
+ end
300
+ end
301
+
302
+ #
303
+ # This module includes the severity levels used by all WireTap instances.
304
+ # Changing the constants in this module will rework the interface methods
305
+ # in WireTap used to log messages. Any changes made here should be done
306
+ # before logs are created.
307
+ #
308
+ # WireTap instances will have the following methods for each constant in
309
+ # this module (examples are for the +WARN+ constant):
310
+ #
311
+ # # check if a level is currently being logged
312
+ # if wire_tap.warn?
313
+ # # ...
314
+ # end
315
+ #
316
+ # # log a message at the indicated level
317
+ # wire_tap.warn "Message goes here."
318
+ #
319
+ # # log a message, skipping creation if the level is not being logged
320
+ # wire_tap.warn { build_expensive_warning_message() }
321
+ #
322
+ # # log a message, overriding +progname+
323
+ # wire_tap.warn("my_process_name") { "Message goes here." }
324
+ #
325
+ module Severity
326
+ # Low-lever information for developers.
327
+ DEBUG = 0
328
+ # Useful information about normal system operations.
329
+ INFO = 1
330
+ # A warning about something that may not have worked as expected.
331
+ WARN = 2
332
+ # A handled error condition.
333
+ ERROR = 3
334
+ # An unhandleable error.
335
+ FATAL = 4
336
+ #
337
+ # A non-classified message. (Dynamic method dispatch is not done for this
338
+ # special case.)
339
+ #
340
+ UNKNOWN = 5
341
+ end
342
+
343
+ # Make the level constants available at the instance level.
344
+ include Severity
345
+
346
+ #
347
+ # Wraps +logdev+ for logging.
348
+ #
349
+ # You can set +shift_age+ and/or +shift_size+ to setup log rotation. You
350
+ # can pass <tt>:daily</tt>, <tt>:weekly</tt>, or <tt>:monthly</tt> as
351
+ # +shift_age+ to get the named rotation. Alternately, you can set
352
+ # +shift_age+ to the number of log files to keep and +shift_size+ to the
353
+ # byte size at which log files are rotated out.
354
+ #
355
+ def initialize(logdev, shift_age = 0, shift_size = 1048576)
356
+ @logdev = logdev &&
357
+ LogDevice.new( logdev, :shift_age => shift_age,
358
+ :shift_size => shift_size )
359
+ @formatter = Formatter.new
360
+ @level = min_level
361
+ @progname = nil
362
+ end
363
+
364
+ #
365
+ # The Formatter instance being used to format the messages logged by this
366
+ # WireTap instance.
367
+ #
368
+ attr_accessor :formatter
369
+ # The current level below which no messages will be logged.
370
+ attr_accessor :level
371
+ alias_method :sev_threshold, :level
372
+ alias_method :sev_threshold=, :level=
373
+ # The Process name used in messages logged by this instance.
374
+ attr_accessor :progname
375
+
376
+ #
377
+ # Sets a new timestamp format on the Formatter used to format messages for
378
+ # this instance. +datetime_format+ should be a strftime() pattern String.
379
+ #
380
+ def datetime_format=(datetime_format)
381
+ @formatter.datetime_format = datetime_format
382
+ end
383
+
384
+ #
385
+ # Returns the timestamp format currently in use (or +nil+ if the default is
386
+ # being used) to format messages for this instance.
387
+ #
388
+ def datetime_format
389
+ @formatter.datetime_format
390
+ end
391
+
392
+ # Sets a second +logdev+ all messages should be copied to.
393
+ def tap=(logdev)
394
+ @tap = logdev && LogDevice.new(logdev)
395
+ end
396
+
397
+ #
398
+ # Returns the secondary device messages are being logged to (or +nil+ if
399
+ # none is in use).
400
+ #
401
+ def tap
402
+ @tap ||= nil
403
+ end
404
+
405
+ # Returns +true+ if a +level+ message would currently be written.
406
+ def level?(level)
407
+ @level <= level_by_name(level)
408
+ end
409
+
410
+ #
411
+ # A low-level way to log a +message+ with +severity+. You can optionally
412
+ # pass a +progname+ override. You can also choose to pass the message in a
413
+ # +block+ that will be run to build the message only if it would be written.
414
+ #
415
+ def add(severity, message = nil, progname = nil, &block)
416
+ severity ||= max_level
417
+ return true if @logdev.nil? or severity < @level
418
+ progname ||= @progname
419
+ if message.nil?
420
+ if block.nil?
421
+ message, progname = progname, @progname
422
+ else
423
+ message = block.call
424
+ end
425
+ end
426
+ output = @formatter.call( level_names[severity] || "ANY",
427
+ Time.now,
428
+ progname,
429
+ message )
430
+ @logdev.write(output)
431
+ tap.write(output) if tap
432
+ true
433
+ end
434
+ alias_method :log, :add
435
+
436
+ #
437
+ # Write's a raw +message+ to the log without any formatting or
438
+ # preprocessing.
439
+ #
440
+ def <<(message)
441
+ @logdev.write(message) unless @logdev.nil?
442
+ end
443
+
444
+ # An override to show the dynamic level methods. See Severity for details.
445
+ def respond_to?(message, include_private = false)
446
+ if level_methods.include? message.to_s
447
+ true
448
+ else
449
+ super
450
+ end
451
+ end
452
+
453
+ # Dynamic level method dispatch. See Severity for details.
454
+ def method_missing(method, *args, &block)
455
+ case method.to_s
456
+ when /\A(#{level_re})\?\z/i
457
+ level?($1, *args, &block)
458
+ when /\A(#{level_re})\z/i
459
+ add(levels[$1.upcase], nil, *args, &block)
460
+ else
461
+ super
462
+ end
463
+ end
464
+
465
+ # Closes the underlying log.
466
+ def close
467
+ @logdev.close unless @logdev.nil?
468
+ end
469
+
470
+ #######
471
+ private
472
+ #######
473
+
474
+ # Fetches a level from Severity by +name+.
475
+ def level_by_name(name)
476
+ Severity.const_get(name.to_s.upcase)
477
+ end
478
+
479
+ # Builds a Hash of all levels used.
480
+ def levels
481
+ @levels ||= Hash[ *Severity.constants.
482
+ map { |name| [name, level_by_name(name)] }.
483
+ flatten ]
484
+ end
485
+
486
+ # Build the inverse Hash of levels by name.
487
+ def level_names
488
+ @level_names ||= levels.invert.merge(levels["UNKNOWN"] => "ANY")
489
+ end
490
+
491
+ # Builds an Array of level method names.
492
+ def level_methods
493
+ @level_methods ||= (levels.keys - ["UNKNOWN"]).map { |l| [l, "#{l}"] }.
494
+ flatten.
495
+ map { |l| l.downcase }
496
+ end
497
+
498
+ # Builds a Regexp that will match level method names.
499
+ def level_re
500
+ @level_re ||= levels.keys.map { |name| Regexp.escape(name) }.join("|")
501
+ end
502
+
503
+ # Returns the minimum level.
504
+ def min_level
505
+ @min_level ||= levels.values.min
506
+ end
507
+
508
+ # Returns the maximum level.
509
+ def max_level
510
+ @max_level ||= levels.values.min
511
+ end
512
+ end
513
+ end