scout_agent 3.0.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.
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