Permalink
Browse files

Initial commit of steno

This breaks out vcap_logging into its own repo. Also includes:
- Code cleanup
- Json formatting of log lines by default
- Thread and fiber local persistent data
- HTTP Handler for querying and setting logger levels.

Test plan:
- Unit tests pass

Change-Id: Idc78251d9e891f197fe4e3a7ff259338d5ee9296
  • Loading branch information...
0 parents commit a1a602a0ae799bf94c31a4369e2f308a38d6ae3a mpage committed Jun 20, 2012
4 Gemfile
@@ -0,0 +1,4 @@
+source 'https://rubygems.org'
+
+# Specify your gem's dependencies in steno.gemspec
+gemspec
48 Gemfile.lock
@@ -0,0 +1,48 @@
+PATH
+ remote: .
+ specs:
+ steno (0.0.1)
+ grape
+ yajl-ruby
+
+GEM
+ remote: https://rubygems.org/
+ specs:
+ builder (3.0.0)
+ ci_reporter (1.7.0)
+ builder (>= 2.1.2)
+ diff-lcs (1.1.3)
+ grape (0.2.0)
+ hashie (~> 1.2)
+ multi_json
+ multi_xml
+ rack
+ rack-mount
+ hashie (1.2.0)
+ multi_json (1.3.6)
+ multi_xml (0.5.1)
+ rack (1.4.1)
+ rack-mount (0.8.3)
+ rack (>= 1.0.0)
+ rack-test (0.6.1)
+ rack (>= 1.0)
+ rake (0.9.2.2)
+ rspec (2.10.0)
+ rspec-core (~> 2.10.0)
+ rspec-expectations (~> 2.10.0)
+ rspec-mocks (~> 2.10.0)
+ rspec-core (2.10.1)
+ rspec-expectations (2.10.0)
+ diff-lcs (~> 1.1.3)
+ rspec-mocks (2.10.1)
+ yajl-ruby (1.1.0)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ ci_reporter
+ rack-test
+ rake
+ rspec
+ steno!
22 LICENSE
@@ -0,0 +1,22 @@
+Copyright (c) 2012 mpage
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
24 README.md
@@ -0,0 +1,24 @@
+# Steno
+Steno is a lightweight, modular logging library written specifically to support
+Cloud Foundry.
+
+## Concepts
+
+Steno is composed of three main classes: loggers, sinks, and formatters. Loggers
+are the main entry point for Steno. They consume user input, create structured
+records, and forward said records to the configured sinks. Sinks are the
+ultimate destination for log records. They transform a structured record into
+a string via a formatter and then typically write the transformed string to
+another transport.
+
+## Getting started
+ config = Steno::Config.new(
+ :sinks => [Steno::Sink::IO.new(STDOUT)],
+ :codec => Steno::Codec::Json.new,
+ :context => Steno::Context::ThreadLocal.new)
+
+ Steno.init(config)
+
+ logger = Steno.logger("test")
+
+ logger.info("Hello world!")
14 Rakefile
@@ -0,0 +1,14 @@
+#!/usr/bin/env rake
+require "ci/reporter/rake/rspec"
+require "rspec/core/rake_task"
+
+desc "Run all specs"
+RSpec::Core::RakeTask.new("spec") do |t|
+ t.rspec_opts = %w[--color --format documentation]
+end
+
+desc "Run all specs and provide output for ci"
+RSpec::Core::RakeTask.new("spec:ci" => "ci:setup:rspec") do |t|
+ t.rspec_opts = %w[--no-color --format documentation]
+end
+
128 lib/steno.rb
@@ -0,0 +1,128 @@
+require "thread"
+
+require "steno/codec"
+require "steno/config"
+require "steno/context"
+require "steno/errors"
+require "steno/log_level"
+require "steno/logger"
+require "steno/record"
+require "steno/sink"
+require "steno/version"
+
+module Steno
+ class << self
+
+ attr_reader :config
+ attr_reader :logger_regexp
+
+ # Initializes the logging system. This must be called exactly once before
+ # attempting to use any Steno class methods.
+ #
+ # @param [Steno::Config]
+ #
+ # @return [nil]
+ def init(config)
+ @config = config
+
+ @loggers = {}
+ @loggers_lock = Mutex.new
+
+ @logger_regexp = nil
+ @logger_regexp_level = nil
+
+ nil
+ end
+
+ # Returns (and memoizes) the logger identified by name.
+ #
+ # @param [String] name
+ #
+ # @return [Steno::Logger]
+ def logger(name)
+ @loggers_lock.synchronize do
+ logger = @loggers[name]
+
+ if logger.nil?
+ level = compute_level(name)
+
+ logger = Steno::Logger.new(name, @config.sinks,
+ :level => level,
+ :context => @config.context)
+
+ @loggers[name] = logger
+ end
+
+ logger
+ end
+ end
+
+ # Sets all loggers whose name matches _regexp_ to _level_. Resets any
+ # loggers whose name matches the previous regexp but not the supplied regexp.
+ #
+ # @param [Regexp] regexp
+ # @param [Symbol] level
+ #
+ # @return [nil]
+ def set_logger_regexp(regexp, level)
+ @loggers_lock.synchronize do
+ @loggers.each do |name, logger|
+ if name =~ regexp
+ logger.level = level
+ elsif @logger_regexp && (name =~ @logger_regexp)
+ # Reset loggers affected by the old regexp but not by the new
+ logger.level = @config.default_log_level
+ end
+ end
+
+ @logger_regexp = regexp
+ @logger_regexp_level = level
+
+ nil
+ end
+ end
+
+ # Clears the logger regexp, if set. Resets the level of any loggers matching
+ # the regex to the default log level.
+ #
+ # @return [nil]
+ def clear_logger_regexp
+ @loggers_lock.synchronize do
+ return if @logger_regexp.nil?
+
+ @loggers.each do |name, logger|
+ if name =~ @logger_regexp
+ logger.level = @config.default_log_level
+ end
+ end
+
+ @logger_regexp = nil
+ @logger_regexp_level = nil
+ end
+
+ nil
+ end
+
+
+ # @return [Hash] Map of logger name => level
+ def logger_level_snapshot
+ @loggers_lock.synchronize do
+ snapshot = {}
+
+ @loggers.each { |name, logger| snapshot[name] = logger.level }
+
+ snapshot
+ end
+ end
+
+ private
+
+ def compute_level(name)
+ if @logger_regexp && name =~ @logger_regexp
+ @logger_regexp_level
+ else
+ @config.default_log_level
+ end
+ end
+ end
+end
2 lib/steno/codec.rb
@@ -0,0 +1,2 @@
+require "steno/codec/base"
+require "steno/codec/json"
34 lib/steno/codec/base.rb
@@ -0,0 +1,34 @@
+module Steno
+ module Codec
+ end
+end
+
+class Steno::Codec::Base
+ # Encodes the supplied record as a string.
+ #
+ # @param [Hash] record
+ #
+ # @return [String]
+ def encode_record(record)
+ raise NotImplementedError
+ end
+
+ private
+
+ # Hex encodes non-printable ascii characters.
+ #
+ # @param [String] data
+ #
+ # @return [String]
+ def escape_nonprintable_ascii(data)
+ data.chars.map do |c|
+ ord_val = c.ord
+
+ if (ord_val > 31) && (ord_val < 127)
+ c
+ else
+ "\\x%02x" % [ord_val]
+ end
+ end.join
+ end
+end
33 lib/steno/codec/json.rb
@@ -0,0 +1,33 @@
+require "yajl"
+
+require "steno/codec/base"
+
+module Steno
+ module Codec
+ end
+end
+
+class Steno::Codec::Json < Steno::Codec::Base
+ def encode_record(record)
+ msg =
+ if record.message.valid_encoding?
+ record.message
+ else
+ # Treat the message as an arbitrary sequence of bytes.
+ escape_nonprintable_ascii(record.message.dup.force_encoding("BINARY"))
+ end
+
+ h = {
+ "timestamp" => record.timestamp.to_f,
+ "message" => msg,
+ "log_level" => record.log_level.to_s,
+ "source" => record.source,
+ "data" => record.data,
+ "thread_id" => record.thread_id,
+ "fiber_id" => record.fiber_id,
+ "process_id" => record.process_id,
+ }
+
+ Yajl::Encoder.encode(h) + "\n"
+ end
+end
56 lib/steno/config.rb
@@ -0,0 +1,56 @@
+require "yaml"
+
+require "steno/codec"
+require "steno/context"
+require "steno/logger"
+require "steno/sink"
+
+module Steno
+end
+
+class Steno::Config
+ class << self
+ def from_file(path, overrides = {})
+ h = YAML.load_file(path)
+
+ opts = {
+ :sinks => [],
+ :default_log_level => h["level"].to_sym,
+ }
+
+ if h["file"]
+ opts[:sinks] << Steno::Sink::IO.for_file(h["file"])
+ end
+
+ if h["syslog"]
+ Steno::Sink::Syslog.instance.open(h["syslog"])
+ opts[:sinks] << Steno::Sink::Syslog.instance
+ end
+
+ if opts[:sinks].empty?
+ opts[:sinks] << Steno::Sink::IO.new(STDOUT)
+ end
+
+ new(opts.merge(overrides))
+ end
+ end
+
+ attr_reader :sinks
+ attr_reader :codec
+ attr_reader :context
+ attr_reader :default_log_level
+
+ def initialize(opts = {})
+ @sinks = opts[:sinks] || []
+ @codec = opts[:codec] || Steno::Codec::Json.new
+ @context = opts[:context] ||Steno::Context::Null.new
+
+ @sinks.each { |sink| sink.codec = @codec }
+
+ if opts[:default_log_level]
+ @default_log_level = opts[:default_log_level].to_sym
+ else
+ @default_log_level = :info
+ end
+ end
+end
59 lib/steno/context.rb
@@ -0,0 +1,59 @@
+require "fiber"
+require "thread"
+
+class Fiber
+ def __steno_context_data__
+ @__steno_context_data__ ||= {}
+ end
+
+ def __steno_clear_context_data__
+ @__steno_context_data__ = {}
+ end
+end
+
+module Steno
+end
+
+module Steno::Context
+ class Base
+ def data
+ raise NotImplementedError
+ end
+
+ def clear
+ raise NotImplementedError
+ end
+ end
+
+ class Null < Base
+ def data
+ {}
+ end
+
+ def clear
+ nil
+ end
+ end
+
+ class ThreadLocal < Base
+ THREAD_LOCAL_KEY = "__steno_locals__"
+
+ def data
+ Thread.current[THREAD_LOCAL_KEY] ||= {}
+ end
+
+ def clear
+ Thread.current[THREAD_LOCAL_KEY] = {}
+ end
+ end
+
+ class FiberLocal < Base
+ def data
+ Fiber.current.__steno_context_data__
+ end
+
+ def clear
+ Fiber.current.__steno_clear_context_data__
+ end
+ end
+end
3 lib/steno/errors.rb
@@ -0,0 +1,3 @@
+module Steno
+ class Error < StandardError; end
+end
42 lib/steno/http_handler.rb
@@ -0,0 +1,42 @@
+require "steno"
+
+require "grape"
+
+module Steno
+end
+
+class Steno::HttpHandler < Grape::API
+ format :json
+ error_format :json
+
+ resource :loggers do
+ get :levels do
+ Steno.logger_level_snapshot
+ end
+
+ put :levels do
+ missing = [:regexp, :level].select { |p| !params.key?(p) }.map(&:to_s)
+
+ if !missing.empty?
+ error!("Missing query parameters: #{missing}", 400)
+ end
+
+ regexp = nil
+ begin
+ regexp = Regexp.new(params[:regexp])
+ rescue => e
+ error!("Invalid regexp", 400)
+ end
+
+ level = params[:level].to_sym
+ if !Steno::Logger::LEVELS.key?(level)
+ levels = Steno::Logger::LEVELS.keys.map(&:to_s)
+ error!("Unknown level: #{level}. Supported levels are: #{levels}", 400)
+ end
+
+ Steno.set_logger_regexp(regexp, level)
+
+ "ok"
+ end
+ end
+end
24 lib/steno/log_level.rb
@@ -0,0 +1,24 @@
+module Steno
+end
+
+class Steno::LogLevel
+ include Comparable
+
+ attr_reader :name
+ attr_reader :priority
+
+ # @param [String] name "info", "debug", etc.
+ # @param [Integer] priority "info" > "debug", etc.
+ def initialize(name, priority)
+ @name = name
+ @priority = priority
+ end
+
+ def to_s
+ @name.to_s
+ end
+
+ def <=>(other)
+ @priority <=> other.priority
+ end
+end
167 lib/steno/logger.rb
@@ -0,0 +1,167 @@
+require "thread"
+
+require "steno/errors"
+require "steno/log_level"
+
+module Steno
+end
+
+class Steno::Logger
+ LEVELS = {
+ :off => Steno::LogLevel.new(:off, 0),
+ :fatal => Steno::LogLevel.new(:fatal, 1),
+ :error => Steno::LogLevel.new(:error, 5),
+ :warn => Steno::LogLevel.new(:warn, 10),
+ :info => Steno::LogLevel.new(:info, 15),
+ :debug => Steno::LogLevel.new(:debug, 16),
+ :debug1 => Steno::LogLevel.new(:debug1, 17),
+ :debug2 => Steno::LogLevel.new(:debug2, 18),
+ :all => Steno::LogLevel.new(:all, 30),
+ }
+
+ class << self
+ # The following helpers are used to create a new scope for binding the log
+ # level.
+
+ def define_log_method(name)
+ define_method(name) { |*args, &blk| log(name, *args, &blk) }
+ end
+
+ def define_logf_method(name)
+ define_method(name.to_s + "f") { |fmt, *args| log(name, fmt % args) }
+ end
+
+ def define_level_active_predicate(name)
+ define_method(name.to_s + "?") { level_active?(name) }
+ end
+
+ def lookup_level(name)
+ level = LEVELS[name]
+
+ if level.nil?
+ raise Steno::Error.new("Unknown level: #{name}")
+ end
+
+ level
+ end
+ end
+
+ # This is magic, however, it's vastly simpler than declaring each method
+ # manually.
+ LEVELS.each do |name, _|
+ # Define #debug, for example
+ define_log_method(name)
+
+ # Define #debugf, for example
+ define_logf_method(name)
+
+ # Define #debug?, for example. These are provided to ensure compatibility
+ # with Ruby's standard library Logger class.
+ define_level_active_predicate(name)
+ end
+
+ attr_reader :name
+
+ # @param [String] name The logger name.
+ # @param [Array<Steno::Sink::Base>] sinks
+ # @param [Hash] opts
+ # @option opts [Symbol] :level The minimum level for which this logger will
+ # emit log records. Defaults to :info.
+ # @option opts [Steno::Context] :context
+ def initialize(name, sinks, opts = {})
+ @name = name
+ @min_level = self.class.lookup_level(opts[:level] || :info)
+ @min_level_lock = Mutex.new
+ @sinks = sinks
+ @context = opts[:context] || Steno::Context::Null.new
+ end
+
+ # Sets the minimum level for which records will be added to sinks.
+ #
+ # @param [Symbol] name The level name
+ #
+ # @return [nil]
+ def level=(name)
+ level = self.class.lookup_level(name)
+
+ @min_level_lock.synchronize { @min_level = level }
+
+ nil
+ end
+
+ # Returns the name of the current log level
+ #
+ # @return [Symbol]
+ def level
+ @min_level_lock.synchronize { @min_level.name }
+ end
+
+ # Returns whether or not records for the given level would be forwarded to
+ # sinks.
+ #
+ # @param [Symbol] name
+ #
+ # @return [true || false]
+ def level_active?(name)
+ level = self.class.lookup_level(name)
+
+ @min_level_lock.synchronize { level <= @min_level }
+ end
+
+ # Convenience method for logging an exception, along with its backtrace.
+ #
+ # @param [Exception] ex
+
+ # @return [nil]
+ def log_exception(ex, user_data = {})
+ warn("Caught exception: #{ex}", user_data.merge(:backtrace => ex.backtrace))
+ end
+
+ # Adds a record to the configured sinks.
+ #
+ # @param [Symbol] name The level associated with the record
+ # @param [String] message
+ # @param [Hash] user_data
+ #
+ # @return [nil]
+ def log(name, message = nil, user_data = nil, &blk)
+ return unless level_active?(name)
+
+ level = self.class.lookup_level(name)
+
+ message = yield if block_given?
+
+ callstack = caller
+ loc = parse_record_loc(callstack)
+
+ data = @context.data.update(user_data || {})
+
+ record = Steno::Record.new(@name, level, message, loc, data)
+
+ @sinks.each { |sink| sink.add_record(record) }
+
+ nil
+ end
+
+ private
+
+ def parse_record_loc(callstack)
+ file, lineno, method = nil, nil, nil
+
+ callstack.each do |frame|
+ next if frame =~ /logger\.rb/
+
+ file, lineno, method = frame.split(":")
+
+ lineno = lineno.to_i
+
+ if method =~ /in `([^']+)/
+ method = $1
+ end
+
+ break
+ end
+
+ [file, lineno, method]
+ end
+end
39 lib/steno/record.rb
@@ -0,0 +1,39 @@
+require "digest/md5"
+require "thread"
+
+module Steno
+end
+
+class Steno::Record
+
+ attr_reader :timestamp
+ attr_reader :message
+ attr_reader :log_level
+ attr_reader :source
+ attr_reader :data
+ attr_reader :thread_id
+ attr_reader :fiber_id
+ attr_reader :process_id
+ attr_reader :file
+ attr_reader :lineno
+ attr_reader :method
+
+ # @param [String] source_id Identifies message source.
+ # @param [Symbol] log_level
+ # @param [String] message
+ # @param [Array] loc Location where the record was generated.
+ # Format is [<filename>, <lineno>, <method>].
+ # @param [Hash] data User-supplied data
+ def initialize(source_id, log_level, message, loc = [], data = {})
+ @timestamp = Time.now
+ @source_id = source_id
+ @log_level = log_level
+ @message = message
+ @data = {}.merge(data)
+ @thread_id = Thread.current.object_id
+ @fiber_id = Fiber.current.object_id
+ @process_id = Process.pid
+
+ @file, @lineno, @method = loc
+ end
+end
3 lib/steno/sink.rb
@@ -0,0 +1,3 @@
+require "steno/sink/base"
+require "steno/sink/io"
+require "steno/sink/syslog"
36 lib/steno/sink/base.rb
@@ -0,0 +1,36 @@
+require "thread"
+
+module Steno
+ module Sink
+ end
+end
+
+# Sinks represent the final destination for log records. They abstract storage
+# mediums (like files) and transport layers (like sockets).
+class Steno::Sink::Base
+
+ attr_accessor :codec
+
+ # @param [Steno::Codec::Base] formatter Transforms log records to their
+ # raw, string-based representation that will be written to the underlying
+ # sink.
+ def initialize(codec = nil)
+ @codec = codec
+ end
+
+ # Adds a record to be flushed at a later time.
+ #
+ # @param [Hash] record
+ #
+ # @return [nil]
+ def add_record(record)
+ raise NotImplementedError
+ end
+
+ # Flushes any buffered records.
+ #
+ # @return [nil]
+ def flush
+ raise NotImplementedError
+ end
+end
48 lib/steno/sink/io.rb
@@ -0,0 +1,48 @@
+require "steno/sink/base"
+
+module Steno
+ module Sink
+ end
+end
+
+class Steno::Sink::IO < Steno::Sink::Base
+ class << self
+ # Returns a new sink configured to append to the file at path.
+ #
+ # @param [String] path
+ # @param [True, False] autoflush If true, encoded records will not be
+ # buffered by Ruby.
+ #
+ # @return [Steno::Sink::IO]
+ def for_file(path, autoflush = true)
+ io = File.open(path, "a+")
+
+ io.sync = autoflush
+
+ new(io)
+ end
+ end
+
+ # @param [IO] io The IO object that will be written to
+ # @param [Steno::Codec::Base] codec
+ def initialize(io, codec = nil)
+ super(codec)
+
+ @io_lock = Mutex.new
+ @io = io
+ end
+
+ def add_record(record)
+ bytes = @codec.encode_record(record)
+
+ @io_lock.synchronize { @io.write(bytes) }
+
+ nil
+ end
+
+ def flush
+ @io_lock.synchronize { @io.flush }
+
+ nil
+ end
+end
38 lib/steno/sink/syslog.rb
@@ -0,0 +1,38 @@
+require "steno/sink/base"
+
+require "singleton"
+require "thread"
+require "syslog"
+
+class Steno::Sink::Syslog < Steno::Sink::Base
+ include Singleton
+
+ LOG_LEVEL_MAP = {
+ :fatal => Syslog::LOG_CRIT,
+ :error => Syslog::LOG_ERR,
+ :warn => Syslog::LOG_WARNING,
+ :info => Syslog::LOG_INFO,
+ :debug => Syslog::LOG_DEBUG,
+ :debug1 => Syslog::LOG_DEBUG,
+ :debug2 => Syslog::LOG_DEBUG,
+ }
+
+ def initialize
+ super
+
+ @syslog = nil
+ @syslog_lock = Mutex.new
+ end
+
+ def open(identity)
+ @identity = identity
+ @syslog = Syslog.open(@identity, Syslog::LOG_PID, Syslog::LOG_USER)
+ end
+
+ def add_record(record)
+ msg = @codec.encode_record(record)
+ pri = LOG_LEVEL_MAP[record.log_level]
+ @syslog_lock.synchronize { @syslog.log(pri, "%s", msg) }
+ end
+
+end
3 lib/steno/version.rb
@@ -0,0 +1,3 @@
+module Steno
+ VERSION = "0.0.1"
+end
6 spec/spec_helper.rb
@@ -0,0 +1,6 @@
+require "rack/test"
+require "rspec"
+
+require "steno"
+
+Dir["./spec/support/**/*.rb"].each { |file| require file }
22 spec/support/barrier.rb
@@ -0,0 +1,22 @@
+require "thread"
+
+class Barrier
+ def initialize
+ @lock = Mutex.new
+ @cvar = ConditionVariable.new
+ @done = false
+ end
+
+ def release
+ @lock.synchronize do
+ @done = true
+ @cvar.broadcast
+ end
+ end
+
+ def wait
+ @lock.synchronize do
+ @cvar.wait(@lock) if !@done
+ end
+ end
+end
7 spec/support/shared_context_specs.rb
@@ -0,0 +1,7 @@
+shared_context :steno_context do
+ it "should support clearing context local data" do
+ context.data["test"] = "value"
+ context.clear
+ context.data["test"].should be_nil
+ end
+end
62 spec/unit/context_spec.rb
@@ -0,0 +1,62 @@
+require "spec_helper"
+
+describe Steno::Context::Null do
+ include_context :steno_context
+
+ let(:context) { Steno::Context::Null.new }
+
+ it "should store no data" do
+ context.data.should == {}
+ context.data["foo"] = "bar"
+ context.data.should == {}
+ end
+end
+
+describe Steno::Context::ThreadLocal do
+ include_context :steno_context
+
+ let (:context) { Steno::Context::ThreadLocal.new }
+
+ it "should store data local to threads" do
+ b1 = Barrier.new
+ b2 = Barrier.new
+
+ t1 = Thread.new do
+ context.data["thread"] = "t1"
+ b1.release
+ b2.wait
+ context.data["thread"].should == "t1"
+ end
+
+ t2 = Thread.new do
+ b1.wait
+ context.data["thread"].should be_nil
+ context.data["thread"] = "t2"
+ b2.release
+ end
+
+ t1.join
+ t2.join
+ end
+end
+
+describe Steno::Context::FiberLocal do
+ include_context :steno_context
+
+ let(:context) { Steno::Context::FiberLocal.new }
+
+ it "should store data local to fibers" do
+ f2 = Fiber.new do
+ context.data["fiber"].should be_nil
+ context.data["fiber"] = "f2"
+ end
+
+ f1 = Fiber.new do
+ context.data["fiber"] = "f1"
+ f2.resume
+ context.data["fiber"].should == "f1"
+ end
+
+ f1.resume
+ end
+end
73 spec/unit/http_handler_spec.rb
@@ -0,0 +1,73 @@
+require "spec_helper"
+
+require "steno/http_handler"
+
+describe Steno::HttpHandler do
+ include Rack::Test::Methods
+
+ let(:config) { Steno::Config.new }
+
+ before :each do
+ Steno.init(config)
+ end
+
+ def app
+ Steno::HttpHandler
+ end
+
+ describe "GET /loggers/levels" do
+ it "returns a hash of logger name to level" do
+ get "/loggers/levels"
+ json_body.should == {}
+
+ foo = Steno.logger("foo")
+ foo.level = :debug
+
+ bar = Steno.logger("bar")
+ bar.level = :info
+
+ get "/loggers/levels"
+ json_body.should == { "foo" => "debug", "bar" => "info" }
+ end
+ end
+
+ describe "PUT /loggers/levels" do
+ it "returns an error on missing parameters" do
+ put "/loggers/levels"
+ last_response.status.should == 400
+ json_body["error"].should match(/Missing query parameters/)
+
+ put "/loggers/levels", :regexp => "hi"
+ last_response.status.should == 400
+ json_body["error"].should match(/Missing query parameters/)
+
+ put "/loggers/levels", :level => "debug"
+ last_response.status.should == 400
+ json_body["error"].should match(/Missing query parameters/)
+ end
+
+ it "returns an error on invalid log levels" do
+ put "/loggers/levels", :regexp => "hi", :level => "foobar"
+ last_response.status.should == 400
+ json_body["error"].should match(/Unknown level/)
+ end
+
+ it "updates log levels for loggers whose name matches the regexp" do
+ foo = Steno.logger("foo")
+ foo.level = :debug
+
+ bar = Steno.logger("bar")
+ bar.level = :warn
+
+ put "/loggers/levels", :regexp => "f", :level => "error"
+ last_response.status.should == 200
+
+ foo.level.should == :error
+ bar.level.should == :warn
+ end
+ end
+
+ def json_body
+ Yajl::Parser.parse(last_response.body)
+ end
+end
26 spec/unit/io_sink_spec.rb
@@ -0,0 +1,26 @@
+require "spec_helper"
+
+describe Steno::Sink::IO do
+ let(:record) { { :data => "test" } }
+
+ describe "#add_record" do
+ it "should encode the record and write it to the underlying io object" do
+ codec = mock("codec")
+ codec.should_receive(:encode_record).with(record).and_return(record[:data])
+
+ io = mock("io")
+ io.should_receive(:write).with(record[:data])
+
+ Steno::Sink::IO.new(io, codec).add_record(record)
+ end
+ end
+
+ describe "#flush" do
+ it "should call flush on the underlying io object" do
+ io = mock("io")
+ io.should_receive(:flush)
+
+ Steno::Sink::IO.new(io).flush
+ end
+ end
+end
48 spec/unit/json_codec_spec.rb
@@ -0,0 +1,48 @@
+require "spec_helper"
+
+describe Steno::Codec::Json do
+ let(:codec) { Steno::Codec::Json.new }
+ let(:record) { make_record(:data => { "user" => "data" }) }
+
+ describe "#encode_record" do
+ it "should encode records as json hashes" do
+ parsed = Yajl::Parser.parse(codec.encode_record(record))
+ parsed.class.should == Hash
+ end
+
+ it "should encode the timestamp as a float" do
+ parsed = Yajl::Parser.parse(codec.encode_record(record))
+ parsed["timestamp"].class.should == Float
+ end
+
+ it "should escape newlines" do
+ rec = make_record(:message => "newline\ntest")
+ codec.encode_record(rec).should match(/newline\\ntest/)
+ end
+
+ it "should escape carriage returns" do
+ rec = make_record(:message => "newline\rtest")
+ codec.encode_record(rec).should match(/newline\\rtest/)
+ end
+
+ it "should allow messages with valid encodings to pass through untouched" do
+ msg = "HI\u2600"
+ rec = make_record(:message => msg)
+ codec.encode_record(rec).should match(/#{msg}/)
+ end
+
+ it "should treat messages with invalid encodings as binary data" do
+ msg = "HI\u2026".force_encoding("US-ASCII")
+ rec = make_record(:message => msg)
+ codec.encode_record(rec).should match(/HI\\\\xe2\\\\x80\\\\xa6/)
+ end
+ end
+
+ def make_record(opts = {})
+ Steno::Record.new(opts[:source] || "my_source",
+ opts[:level] || Steno::LogLevel.new("debug", 0),
+ opts[:message] || "test message",
+ nil,
+ opts[:data] || {})
+ end
+end
18 spec/unit/log_level_spec.rb
@@ -0,0 +1,18 @@
+require "spec_helper"
+
+describe Steno::LogLevel do
+ let(:info_level) { Steno::LogLevel.new(:info, 2) }
+ let(:debug_level) { Steno::LogLevel.new(:debug, 1) }
+
+ it "should be comparable" do
+ (info_level > debug_level).should be_true
+ (debug_level > info_level).should be_false
+ (info_level == info_level).should be_true
+ end
+
+ describe "#to_s" do
+ it "should return the name of the level" do
+ info_level.to_s.should == "info"
+ end
+ end
+end
83 spec/unit/logger_spec.rb
@@ -0,0 +1,83 @@
+require "spec_helper"
+
+describe Steno::Logger do
+ let(:logger) { Steno::Logger.new("test", []) }
+
+ it "should provide #level, #levelf, and #level? methods for each log level" do
+ Steno::Logger::LEVELS.each do |name, _|
+ [name, name.to_s + "f", name.to_s + "?"].each do |meth|
+ logger.respond_to?(meth).should be_true
+ end
+ end
+ end
+
+ describe "#level_active?" do
+ it "should return a boolean indicating if the level is enabled" do
+ logger.level_active?(:error).should be_true
+ logger.level_active?(:info).should be_true
+ logger.level_active?(:debug).should be_false
+ end
+ end
+
+ describe "#<level>?" do
+ it "should return a boolean indiciating if <level> is enabled" do
+ logger.error?.should be_true
+ logger.info?.should be_true
+ logger.debug?.should be_false
+ end
+ end
+
+ describe "#level" do
+ it "should return the name of the currently active level" do
+ logger.level.should == :info
+ end
+ end
+
+ describe "#level=" do
+ it "should allow the level to be changed" do
+ logger.level = :warn
+ logger.level.should == :warn
+ logger.level_active?(:info).should be_false
+ logger.level_active?(:warn).should be_true
+ end
+ end
+
+ describe "#log" do
+ it "should not forward any messages for levels that are inactive" do
+ sink = mock("sink")
+ sink.should_not_receive(:add_record)
+
+ my_logger = Steno::Logger.new("test", [sink])
+
+ my_logger.debug("test")
+ end
+
+ it "should forward messages for levels that are active" do
+ sink = mock("sink")
+ sink.should_receive(:add_record).with(any_args())
+
+ my_logger = Steno::Logger.new("test", [sink])
+
+ my_logger.warn("test")
+ end
+
+ it "should not invoke a supplied block if the level is inactive" do
+ invoked = false
+ logger.debug { invoked = true }
+ invoked.should be_false
+ end
+
+ it "should invoke a supplied block if the level is active" do
+ invoked = false
+ logger.warn { invoked = true }
+ invoked.should be_true
+ end
+ end
+
+ describe "#logf" do
+ it "should format messages according to the supplied format string" do
+ logger.should_receive(:log).with(:debug, "test 1 2.20")
+ logger.debugf("test %d %0.2f", 1, 2.2)
+ end
+ end
+end
17 spec/unit/record_spec.rb
@@ -0,0 +1,17 @@
+require "spec_helper"
+
+describe Steno::Record do
+ let(:record) { Steno::Record.new("test", :info, "test message") }
+
+ it "should set the process id" do
+ record.process_id.should == Process.pid
+ end
+
+ it "should set the thread id" do
+ record.thread_id.should == Thread.current.object_id
+ end
+
+ it "should set the fiber id(if available)", :needs_fibers => true do
+ record.fiber_id.should == Fiber.current.object_id
+ end
+end
86 spec/unit/steno_spec.rb
@@ -0,0 +1,86 @@
+require "spec_helper"
+
+describe Steno do
+ let(:config) { Steno::Config.new }
+
+ before :each do
+ Steno.init(config)
+ end
+
+ describe "#logger" do
+ it "should return a new Steno::Logger instance" do
+ logger = Steno.logger("test")
+ logger.should_not be_nil
+ logger.name.should == "test"
+ end
+
+ it "should memoize loggers by name" do
+ logger1 = Steno.logger("test")
+ logger2 = Steno.logger("test")
+
+ logger1.object_id.should == logger2.object_id
+ end
+ end
+
+ describe "#set_logger_regexp" do
+ it "should modify the levels of existing loggers that match the regex" do
+ logger = Steno.logger("test")
+
+ logger.level.should == :info
+
+ Steno.set_logger_regexp(/te/, :debug)
+
+ logger.level.should == :debug
+ end
+
+ it "should modify the levels of new loggers after a regexp has been set" do
+ Steno.set_logger_regexp(/te/, :debug)
+
+ Steno.logger("te").level.should == :debug
+ end
+
+ it "should reset the levels of previously matching loggers when changed" do
+ Steno.set_logger_regexp(/foo/, :debug)
+
+ logger = Steno.logger("foo")
+ logger.level.should == :debug
+
+ Steno.set_logger_regexp(/bar/, :debug)
+
+ logger.level.should == :info
+ end
+ end
+
+ describe "#clear_logger_regexp" do
+ it "should reset any loggers matching the existing regexp" do
+ Steno.set_logger_regexp(/te/, :debug)
+
+ logger = Steno.logger("test")
+ logger.level.should == :debug
+
+ Steno.clear_logger_regexp
+
+ logger.level.should == :info
+ Steno.logger_regexp.should be_nil
+ end
+ end
+
+ describe "#logger_level_snapshot" do
+ it "should return a hash mapping logger name to level" do
+ loggers = []
+
+ expected = {
+ "foo" => :debug,
+ "bar" => :warn,
+ }
+
+ expected.each do |name, level|
+ # Prevent GC
+ loggers << Steno.logger(name)
+ loggers.last.level = level
+ end
+
+ Steno.logger_level_snapshot.should == expected
+ end
+ end
+end
27 spec/unit/syslog_sink_spec.rb
@@ -0,0 +1,27 @@
+require "spec_helper"
+
+describe Steno::Sink::Syslog do
+ describe "#add_record" do
+ it "should append an encoded record with the correct priority" do
+ identity = "test"
+
+ syslog = mock("syslog")
+ Syslog.should_receive(:open) \
+ .with(identity, Syslog::LOG_PID, Syslog::LOG_USER) \
+ .and_return(syslog)
+
+ sink = Steno::Sink::Syslog.instance
+ sink.open(identity)
+
+ record = Steno::Record.new("test", :info, "hello")
+
+ codec = mock("codec")
+ codec.should_receive(:encode_record).with(record).and_return("test")
+ sink.codec = codec
+
+ syslog.should_receive(:log).with(Syslog::LOG_INFO, "%s", "test")
+
+ sink.add_record(record)
+ end
+ end
+end
26 steno.gemspec
@@ -0,0 +1,26 @@
+# -*- encoding: utf-8 -*-
+require File.expand_path('../lib/steno/version', __FILE__)
+
+Gem::Specification.new do |gem|
+ gem.authors = ["mpage"]
+ gem.email = ["mpage@rbcon.com"]
+ gem.description = "A thread-safe logging library designed to support" \
+ + " multiple log destinations."
+ gem.summary = "A logging library."
+ gem.homepage = "http://www.cloudfoundry.org"
+
+ gem.files = `git ls-files`.split($\)
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
+ gem.name = "steno"
+ gem.require_paths = ["lib"]
+ gem.version = Steno::VERSION
+
+ gem.add_dependency("grape")
+ gem.add_dependency("yajl-ruby")
+
+ gem.add_development_dependency("ci_reporter")
+ gem.add_development_dependency("rack-test")
+ gem.add_development_dependency("rake")
+ gem.add_development_dependency("rspec")
+end

0 comments on commit a1a602a

Please sign in to comment.