From 15bb95dc87f6aa87bd82b7c44355a041ba4b3e11 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Wed, 9 Feb 2011 03:19:23 -0800 Subject: [PATCH 01/34] - Start working on issue/30 (refactor the search api to be pluggable) - Updated test_elasticsearch to use the new api, passes. --- lib/logstash/namespace.rb | 1 + lib/logstash/outputs/elasticsearch.rb | 9 +- lib/logstash/search/base.rb | 29 ++++++ lib/logstash/search/elasticsearch.rb | 101 ++++++++++++++++++++ lib/logstash/search/query.rb | 35 +++++++ lib/logstash/search/result.rb | 20 ++++ lib/logstash/web/lib/elasticsearch.rb | 86 ----------------- test/logstash/outputs/test_elasticsearch.rb | 95 +++++++++--------- 8 files changed, 245 insertions(+), 131 deletions(-) create mode 100644 lib/logstash/search/base.rb create mode 100644 lib/logstash/search/elasticsearch.rb create mode 100644 lib/logstash/search/query.rb create mode 100644 lib/logstash/search/result.rb delete mode 100644 lib/logstash/web/lib/elasticsearch.rb diff --git a/lib/logstash/namespace.rb b/lib/logstash/namespace.rb index a88cf5fb6..91f50ebe1 100644 --- a/lib/logstash/namespace.rb +++ b/lib/logstash/namespace.rb @@ -2,4 +2,5 @@ module LogStash module Inputs; end module Outputs; end module Filters; end + module Search; end end # module LogStash diff --git a/lib/logstash/outputs/elasticsearch.rb b/lib/logstash/outputs/elasticsearch.rb index 536ec77d1..4eca6f3c3 100644 --- a/lib/logstash/outputs/elasticsearch.rb +++ b/lib/logstash/outputs/elasticsearch.rb @@ -41,6 +41,9 @@ class LogStash::Outputs::Elasticsearch < LogStash::Outputs::Base }, # "settings" } # ES Index + #puts :waiting + puts @esurl.to_s + #sleep 10 indexurl = @esurl.to_s indexmap_http = EventMachine::HttpRequest.new(indexurl) indexmap_req = indexmap_http.put :body => indexmap.to_json @@ -49,8 +52,12 @@ class LogStash::Outputs::Elasticsearch < LogStash::Outputs::Base ready(params) end indexmap_req.errback do - @logger.warn(["Failure configuring index", @esurl.to_s, indexmap]) + @logger.warn(["Failure configuring index (http failed to connect?)", + @esurl.to_s, indexmap]) + @logger.warn([indexmap_req]) + #sleep 30 raise "Failure configuring index: #{@esurl.to_s}" + end end # def register diff --git a/lib/logstash/search/base.rb b/lib/logstash/search/base.rb new file mode 100644 index 000000000..c91d26fad --- /dev/null +++ b/lib/logstash/search/base.rb @@ -0,0 +1,29 @@ + +require "logstash/namespace" +require "logstash/logging" +require "logstash/event" + +class LogStash::Search::Base + # Do a search. + def search(query) + raise "The class #{self.class.name} must implement the 'search' method." + end # def search + + # Returns a histogram by field of a query. + def histogram(query, field, interval=nil) + raise "The class #{self.class.name} must implement the 'histogram' method." + end + + # Returns a list of popular terms from a query + def popular_terms(query, fields, count=10) + raise "The class #{self.class.name} must implement the 'popular_terms' method." + end + + # Count the results. + # The default count method provided by LogStash::Search::Base is not likely + # an optimal uery. + def count(query) + raise "The class #{self.class.name} must implement the 'count' method." + end + +end # class LogStash::Search::Base diff --git a/lib/logstash/search/elasticsearch.rb b/lib/logstash/search/elasticsearch.rb new file mode 100644 index 000000000..be7e16a1d --- /dev/null +++ b/lib/logstash/search/elasticsearch.rb @@ -0,0 +1,101 @@ + +require "em-http-request" +require "logstash/namespace" +require "logstash/logging" +require "logstash/event" +require "logstash/search/base" +require "logstash/search/query" +require "logstash/search/result" + +class LogStash::Search::ElasticSearch < LogStash::Search::Base + public + def initialize(settings) + @host = (settings[:host] || "localhost") + @port = (settings[:port] || 9200).to_i + @logger = LogStash::Logger.new(STDOUT) + end + + public + def search(query) + if query.is_a?(String) + query = LogStash::Search::Query.parse(query) + end + + # TODO(sissel): only search a specific index? + http = EventMachine::HttpRequest.new("http://#{@host}:#{@port}/_search") + + @logger.info(["Query", query]) + esreq = { + "sort" => [ + { "@timestamp" => "desc" } + ], + "query" => { + "query_string" => { + "query" => query.query_string, + "default_operator" => "AND" + } # query_string + }, # query + "from" => query.offset, + "size" => query.count + } # elasticsearch request + + @logger.info("ElasticSearch Query: #{esreq.to_json}") + start_time = Time.now + req = http.get :body => esreq.to_json + result = LogStash::Search::Result.new + req.callback do + data = JSON.parse(req.response) + result.duration = Time.now - start_time + + @logger.info(["Got search results", + { :query => query.query_string, :duration => data["duration"]}]) + if req.response_header.status != 200 + result.error_message = data["error"] || req.inspect + @error = data["error"] || req.inspect + end + + # We want to yield a list of LogStash::Event objects. + data["hits"]["hits"].each do |hit| + result.events << LogStash::Event.new(hit["_source"]) + end + yield result + end + + req.errback do + @logger.warn(["Query failed", query, req, req.response]) + result.duration = Time.now - start_time + result.error_message = req.response + #yield result + + yield({ "error" => req.response }) + end + end # def search + + def histogram(query, field, interval=nil) + # TODO(sissel): implement + end + + def anonymize + # TODO(sissel): Plugin-ify this (Search filters!) + # TODO(sissel): Implement + # Search anonymization + #require "digest/md5" + #data["hits"]["hits"].each do |hit| + [].each do |hit| + event = LogStash::Event.new(hit["_source"]) + event.to_hash.each do |key, value| + next unless value.is_a?(String) + value.gsub!(/[^ ]+\.loggly\.net/) { |match| "loggly-" + Digest::MD5.hexdigest(match)[0..6] + ".example.com"} + end + + event.fields.each do |key, value| + value = [value] if value.is_a?(String) + next unless value.is_a?(Array) + value.each do |v| + v.gsub!(/[^ ]+\.loggly\.net/) { |match| "loggly-" + Digest::MD5.hexdigest(match)[0..6] + ".example.com"} + end # value.each + end # hit._source.@fields.each + end # data.hits.hits.each + end # def anonymize + +end # class LogStash::Web::ElasticSearch diff --git a/lib/logstash/search/query.rb b/lib/logstash/search/query.rb new file mode 100644 index 000000000..5013373d5 --- /dev/null +++ b/lib/logstash/search/query.rb @@ -0,0 +1,35 @@ +require "logstash/namespace" +require "logstash/logging" + +class LogStash::Search::Query + # The query string + attr_accessor :query_string + + # The offset to start at (like SQL's SELECT ... OFFSET n) + attr_accessor :offset + + # The max number of results to return. (like SQL's SELECT ... LIMIT n) + attr_accessor :count + + # New query object. + # + # 'settings' should be a hash containing: + # + # * :query_string - a string query for searching + # * :offset - (optional, default 0) offset to search from + # * :count - (optional, default 50) max number of results to return + def initialize(settings) + @query_string = settings[:query_string] + @offset = settings[:offset] || 0 + @count = settings[:count] || 50 + end + + # Class method. Parses a query string and returns + # a LogStash::Search::Query instance + def self.parse(query_string) + # TODO(sissel): I would prefer not to invent my own query language. + # Can we be similar to Lucene, SQL, or other query languages? + return self.new(:query_string => query_string) + end + +end # class LogStash::Search::Query diff --git a/lib/logstash/search/result.rb b/lib/logstash/search/result.rb new file mode 100644 index 000000000..df90f4839 --- /dev/null +++ b/lib/logstash/search/result.rb @@ -0,0 +1,20 @@ +require "logstash/namespace" +require "logstash/logging" + +class LogStash::Search::Result + attr_accessor :events + attr_accessor :duration + + attr_accessor :error_message + + def initialize(settings={}) + @events = [] + @duration = nil + @error_message = nil + end + + def error? + return !@error_message.nil? + end +end # class LogStash::Search::Result + diff --git a/lib/logstash/web/lib/elasticsearch.rb b/lib/logstash/web/lib/elasticsearch.rb deleted file mode 100644 index 62efd7873..000000000 --- a/lib/logstash/web/lib/elasticsearch.rb +++ /dev/null @@ -1,86 +0,0 @@ - -require "em-http-request" -require "logstash/namespace" -require "logstash/logging" -require "logstash/event" - -module LogStash::Web; end - -class LogStash::Web::ElasticSearch - public - def initialize(settings) - @port = (settings[:port] || 9200).to_i - @logger = LogStash::Logger.new(STDOUT) - end - - public - def search(params) - http = EventMachine::HttpRequest.new("http://localhost:#{@port}/_search") - params[:offset] ||= 0 - params[:count] ||= 20 - - @logger.info(["Query", params]) - esreq = { - "sort" => [ - { "@timestamp" => "desc" } - ], - "query" => { - "query_string" => { - "query" => params[:q], - "default_operator" => "AND" - } # query_string - }, # query - "facets" => { - "by_hour" => { - "histogram" => { - "field" => "@timestamp", - "time_interval" => "1h", - }, # histogram - }, # by_hour - }, # facets - "from" => params[:offset], - "size" => params[:count], - } - - @logger.info("ElasticSearch Query: #{esreq.to_json}") - start_time = Time.now - req = http.get :body => esreq.to_json - req.callback do - #headers req.response_header - data = JSON.parse(req.response) - data["duration"] = Time.now - start_time - - # TODO(sissel): Plugin-ify this (Search filters!) - # Search anonymization - #require "digest/md5" - #data["hits"]["hits"].each do |hit| - [].each do |hit| - event = LogStash::Event.new(hit["_source"]) - event.to_hash.each do |key, value| - next unless value.is_a?(String) - value.gsub!(/[^ ]+\.loggly\.net/) { |match| "loggly-" + Digest::MD5.hexdigest(match)[0..6] + ".example.com"} - end - - event.fields.each do |key, value| - value = [value] if value.is_a?(String) - next unless value.is_a?(Array) - value.each do |v| - v.gsub!(/[^ ]+\.loggly\.net/) { |match| "loggly-" + Digest::MD5.hexdigest(match)[0..6] + ".example.com"} - end # value.each - end # hit._source.@fields.each - end # data.hits.hits.each - - @logger.info(["Got search results", - { :query => params[:q], :duration => data["duration"]}]) - #@logger.info(data) - if req.response_header.status != 200 - @error = data["error"] || req.inspect - end - yield data - end - req.errback do - @logger.warn(["Query failed", params, req, req.response]) - yield({ "error" => req.response }) - end - end # def search -end # class LogStash::Web::ElasticSearch diff --git a/test/logstash/outputs/test_elasticsearch.rb b/test/logstash/outputs/test_elasticsearch.rb index cb4f3381f..f5c226720 100644 --- a/test/logstash/outputs/test_elasticsearch.rb +++ b/test/logstash/outputs/test_elasticsearch.rb @@ -5,7 +5,8 @@ $:.unshift File.dirname(__FILE__) + "/../../" require "logstash/testcase" require "logstash/agent" require "logstash/logging" -require "logstash/web/lib/elasticsearch" +require "logstash/search/elasticsearch" +require "logstash/search/query" # For checking elasticsearch health require "net/http" @@ -85,54 +86,60 @@ class TestOutputElasticSearch < LogStash::TestCase EventMachine::run do em_setup - events = [] - myfile = File.basename(__FILE__) - 1.upto(5).each do |i| - events << LogStash::Event.new("@message" => "just another log rollin' #{i}", - "@source" => "logstash tests in #{myfile}") - end + # TODO(sissel): I think em-http-request may cross signals somehow + # if there are multiple requests to the same host/port? + # Confusing. If we don't sleep here, then the setup fails and blows + # a fail to configure exception. + EventMachine::add_timer(3) do - # TODO(sissel): Need a way to hook when the agent is ready? - EventMachine.next_tick do - events.each do |e| - @input.push e + events = [] + myfile = File.basename(__FILE__) + 1.upto(5).each do |i| + events << LogStash::Event.new("@message" => "just another log rollin' #{i}", + "@source" => "logstash tests in #{myfile}") end - end # next_tick, push our events - tries = 30 - EventMachine.add_periodic_timer(0.2) do - es = LogStash::Web::ElasticSearch.new(:port => @port) - es.search(:q => "*", :count => 5, :offset => 0) do |results| - hits = (results["hits"]["hits"] rescue []) - if events.size == hits.size - puts "Found #{hits.size} events, ready to verify!" - expected = events.clone - assert_equal(events.size, hits.size) - events.each { |e| p :expect => e } - hits.each do |hit| - event = LogStash::Event.new(hit["_source"]) - p :got => event - assert(expected.include?(event), "Found event in results that was not expected: #{event.inspect}\n\nExpected: #{events.map{ |a| a.inspect }.join("\n")}") - end - EventMachine.stop_event_loop - next # break out - else - tries -= 1 - if tries <= 0 - assert(false, "Gave up trying to query elasticsearch. Maybe we aren't indexing properly?") + # TODO(sissel): Need a way to hook when the agent is ready? + EventMachine.next_tick do + events.each do |e| + @input.push e + end + end # next_tick, push our events + + tries = 30 + EventMachine.add_periodic_timer(0.2) do + es = LogStash::Search::ElasticSearch.new(:port => @port, :host => "localhost") + query = LogStash::Search::Query.new(:query_string => "*", :count => 5) + es.search(query) do |result| + if events.size == result.events.size + puts "Found #{result.events.size} events, ready to verify!" + expected = events.clone + assert_equal(events.size, result.events.size) + events.each { |e| p :expect => e } + result.events.each do |event| + p :got => event + assert(expected.include?(event), "Found event in results that was not expected: #{event.inspect}\n\nExpected: #{events.map{ |a| a.inspect }.join("\n")}") + end EventMachine.stop_event_loop - end - end # if events.size == hits.size - end # es.search - end # add_periodic_timer(0.2) / query elasticsearch + next # break out + else + tries -= 1 + if tries <= 0 + assert(false, "Gave up trying to query elasticsearch. Maybe we aren't indexing properly?") + EventMachine.stop_event_loop + end + end # if events.size == hits.size + end # es.search + end # add_periodic_timer(0.2) / query elasticsearch + end # sleep for 3 seconds before going to allow the registration to work. end # EventMachine::run end # def test_elasticsearch_basic end # class TestOutputElasticSearch -class TestOutputElasticSearch0_13_1 < TestOutputElasticSearch - ELASTICSEARCH_VERSION = self.name[/[0-9_]+/].gsub("_", ".") -end # class TestOutputElasticSearch0_13_1 - -class TestOutputElasticSearch0_12_0 < TestOutputElasticSearch - ELASTICSEARCH_VERSION = self.name[/[0-9_]+/].gsub("_", ".") -end # class TestOutputElasticSearch0_12_0 +#class TestOutputElasticSearch0_13_1 < TestOutputElasticSearch + #ELASTICSEARCH_VERSION = self.name[/[0-9_]+/].gsub("_", ".") +#end # class TestOutputElasticSearch0_13_1 +# +#class TestOutputElasticSearch0_12_0 < TestOutputElasticSearch + #ELASTICSEARCH_VERSION = self.name[/[0-9_]+/].gsub("_", ".") +#end # class TestOutputElasticSearch0_12_0 From 7b0aef841bc206e0cf9ec822f3577083a8f64421 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Wed, 9 Feb 2011 03:21:52 -0800 Subject: [PATCH 02/34] add extra output for now. --- lib/logstash/search/elasticsearch.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/logstash/search/elasticsearch.rb b/lib/logstash/search/elasticsearch.rb index be7e16a1d..257bb7e62 100644 --- a/lib/logstash/search/elasticsearch.rb +++ b/lib/logstash/search/elasticsearch.rb @@ -48,7 +48,8 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base result.duration = Time.now - start_time @logger.info(["Got search results", - { :query => query.query_string, :duration => data["duration"]}]) + { :query => query.query_string, :duration => data["duration"], + :data => data }]) if req.response_header.status != 200 result.error_message = data["error"] || req.inspect @error = data["error"] || req.inspect From 31644cb5a29b72e1c202d3541ed6f13bf1bb4767 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Wed, 9 Feb 2011 22:17:13 -0800 Subject: [PATCH 03/34] - Update the web interface to use the new search api - Add offset and total attributes LogStash:;Search::Result - Added -b/--backend flag to logstash-web for specifying the url of the backend. Defaults to elasticsearch://localhost:9200 Still missing facets/graphs, but it's progress. --- lib/logstash/search/elasticsearch.rb | 10 ++- lib/logstash/search/result.rb | 10 +++ lib/logstash/web/public/js/logstash.js | 8 +- lib/logstash/web/server.rb | 110 +++++++++++++++++------- lib/logstash/web/views/search/ajax.haml | 12 +-- 5 files changed, 109 insertions(+), 41 deletions(-) diff --git a/lib/logstash/search/elasticsearch.rb b/lib/logstash/search/elasticsearch.rb index 257bb7e62..d454b943c 100644 --- a/lib/logstash/search/elasticsearch.rb +++ b/lib/logstash/search/elasticsearch.rb @@ -9,7 +9,7 @@ require "logstash/search/result" class LogStash::Search::ElasticSearch < LogStash::Search::Base public - def initialize(settings) + def initialize(settings={}) @host = (settings[:host] || "localhost") @port = (settings[:port] || 9200).to_i @logger = LogStash::Logger.new(STDOUT) @@ -17,6 +17,7 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base public def search(query) + raise "No block given for search call." if !block_given? if query.is_a?(String) query = LogStash::Search::Query.parse(query) end @@ -49,7 +50,7 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base @logger.info(["Got search results", { :query => query.query_string, :duration => data["duration"], - :data => data }]) + :results => data["hits"]["hits"].size }]) if req.response_header.status != 200 result.error_message = data["error"] || req.inspect @error = data["error"] || req.inspect @@ -59,6 +60,11 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base data["hits"]["hits"].each do |hit| result.events << LogStash::Event.new(hit["_source"]) end + + # Total hits this search could find if not limited + result.total = data["hits"]["total"] + result.offset = query.offset + yield result end diff --git a/lib/logstash/search/result.rb b/lib/logstash/search/result.rb index df90f4839..4ea59a37a 100644 --- a/lib/logstash/search/result.rb +++ b/lib/logstash/search/result.rb @@ -2,9 +2,19 @@ require "logstash/namespace" require "logstash/logging" class LogStash::Search::Result + # Array of LogStash::Event of results attr_accessor :events + + # How long this query took, in seconds (or fractions of). attr_accessor :duration + # Offset in search + attr_accessor :offset + + # Total records matched by this query, regardless of offset/count in query. + attr_accessor :total + + # Error message, if any. attr_accessor :error_message def initialize(settings={}) diff --git a/lib/logstash/web/public/js/logstash.js b/lib/logstash/web/public/js/logstash.js index b30292af1..609e2cf5f 100644 --- a/lib/logstash/web/public/js/logstash.js +++ b/lib/logstash/web/public/js/logstash.js @@ -155,8 +155,8 @@ /* TODO(sissel): recurse through the data */ var fields = new Array(); - for (var i in data._source["@fields"]) { - var value = data._source["@fields"][i] + for (var i in data["@fields"]) { + var value = data["@fields"][i] if (/^[, ]*$/.test(value)) { continue; /* Skip empty data fields */ } @@ -166,9 +166,9 @@ fields.push( { type: "field", field: i, value: value }) } - for (var i in data._source) { + for (var i in data) { if (i == "@fields") continue; - var value = data._source[i] + var value = data[i] if (!(value instanceof Array)) { value = [value]; } diff --git a/lib/logstash/web/server.rb b/lib/logstash/web/server.rb index 4273d774d..09caa3f52 100755 --- a/lib/logstash/web/server.rb +++ b/lib/logstash/web/server.rb @@ -6,13 +6,15 @@ $:.unshift(File.dirname(__FILE__)) require "eventmachine" require "json" -require "lib/elasticsearch" +require "logstash/search/elasticsearch" +require "logstash/search/query" require "logstash/namespace" require "rack" require "rubygems" require "sinatra/async" class EventMachine::ConnectionError < RuntimeError; end +module LogStash::Web; end class LogStash::Web::Server < Sinatra::Base register Sinatra::Async @@ -20,7 +22,19 @@ class LogStash::Web::Server < Sinatra::Base set :logging, true set :public, "#{File.dirname(__FILE__)}/public" set :views, "#{File.dirname(__FILE__)}/views" - elasticsearch = LogStash::Web::ElasticSearch.new + + use Rack::CommonLogger + #use Rack::ShowExceptions + + def initialize(settings={}) + super + # TODO(sissel): Support alternate backends + backend_url = URI.parse(settings.backend_url) + @backend = LogStash::Search::ElasticSearch.new( + :host => backend_url.host, + :port => backend_url.port + ) + end aget '/style.css' do headers "Content-Type" => "text/css; charset=utf8" @@ -32,8 +46,11 @@ class LogStash::Web::Server < Sinatra::Base end # '/' aget '/search' do - result_callback = proc do + result_callback = proc do |results| status 500 if @error + @results = results + + p :got => results params[:format] ||= "html" case params[:format] @@ -48,6 +65,7 @@ class LogStash::Web::Server < Sinatra::Base body erb :"search/results.txt", :layout => false when "json" headers({"Content-Type" => "text/plain" }) + # TODO(sissel): issue/30 - needs refactoring here. hits = @hits.collect { |h| h["_source"] } response = { "hits" => hits, @@ -63,19 +81,26 @@ class LogStash::Web::Server < Sinatra::Base # have javascript enabled, we need to show the results in # case a user doesn't have javascript. if params[:q] and params[:q] != "" - elasticsearch.search(params) do |results| - @results = results - @hits = (@results["hits"]["hits"] rescue []) + query = LogStash::Search::Query.new( + :query_string => params[:q], + :offset => params[:offset], + :count => params[:count] + ) + + @backend.search(query) do |results| + p :got => results begin - result_callback.call + result_callback.call results rescue => e - puts e + p :exception => e end - end # elasticsearch.search + end # @backend.search else - #@error = "No query given." - @hits = [] - result_callback.call + results = LogStash::Search::Result.new( + :events => [], + :error_mesage => "No query given" + ) + result_callback.call results end end # aget '/search' @@ -83,23 +108,34 @@ class LogStash::Web::Server < Sinatra::Base headers({"Content-Type" => "text/html" }) count = params["count"] = (params["count"] or 50).to_i offset = params["offset"] = (params["offset"] or 0).to_i - elasticsearch.search(params) do |results| + + query = LogStash::Search::Query.new( + :query_string => params[:q], + :offset => offset, + :count => count + ) + + @backend.search(query) do |results| @results = results - if @results.include?("error") + if @results.error? body haml :"search/error", :layout => !request.xhr? next end - @hits = (@results["hits"]["hits"] rescue []) - @total = (@results["hits"]["total"] rescue 0) - @graphpoints = [] - begin - @results["facets"]["by_hour"]["entries"].each do |entry| - @graphpoints << [entry["key"], entry["count"]] - end - rescue => e - puts e - end + @events = @results.events + @total = (@results.total rescue 0) + count = @results.events.size + + # TODO(sissel): move this to a facet query + #@graphpoints = [] + #begin + #@results["facets"]["by_hour"]["entries"].each do |entry| + #@graphpoints << [entry["key"], entry["count"]] + #end + #rescue => e + #p :exception => e + #puts e.backtrace.join("\n") + #end if count and offset if @total > (count + offset) @@ -132,16 +168,22 @@ class LogStash::Web::Server < Sinatra::Base end body haml :"search/ajax", :layout => !request.xhr? - end # elasticsearch.search + end # @backend.search end # apost '/search/ajax' + + aget '/*' do + status 404 if @error + body "Invalid path." + end # aget /* end # class LogStash::Web::Server require "optparse" -Settings = Struct.new(:daemonize, :logfile, :address, :port) +Settings = Struct.new(:daemonize, :logfile, :address, :port, :backend_url) settings = Settings.new -settings.address = "0.0.0.0" -settings.port = 9292 +settings.address = "0.0.0.0" +settings.port = 9292 +settings.backend_url = "elasticsearch://localhost:9200/" progname = File.basename($0) @@ -163,6 +205,11 @@ opts = OptionParser.new do |opts| opts.on("-p", "--port PORT", "Port on which to start webserver. Default is 9292.") do |port| settings.port = port.to_i end + + opts.on("-b", "--backend URL", + "The backend URL to use. Default is elasticserach://localhost:9200/") do |url| + settings.backend_url = url + end end opts.parse! @@ -189,5 +236,10 @@ end Rack::Handler::Thin.run( Rack::CommonLogger.new( \ Rack::ShowExceptions.new( \ - LogStash::Web::Server.new)), + LogStash::Web::Server.new(settings))), :Port => settings.port, :Host => settings.address) +#Rack::Handler::Thin.run( + #LogStash::Web::Server.new(settings), + #:Port => settings.port, + #:Host => settings.address +#) diff --git a/lib/logstash/web/views/search/ajax.haml b/lib/logstash/web/views/search/ajax.haml index 51d2ddb39..418ffe967 100644 --- a/lib/logstash/web/views/search/ajax.haml +++ b/lib/logstash/web/views/search/ajax.haml @@ -12,7 +12,7 @@ - if @total and @result_start and @result_end %small %strong - Results #{@result_start} - #{@result_end} of #{@total} + Results #{@result_start} - #{@result_end} of #{@results.total} | - if @first_href %a.pager{ :href => @first_href } first @@ -29,7 +29,7 @@ | %a.pager{ :href => @last_href } last - - if @hits.length == 0 + - if @results.events.length == 0 - if !params[:q] / We default to a '+2 days' in the future to capture 'today at 00:00' / plus tomorrow, inclusive, in case you are 23 hours behind the international @@ -42,8 +42,8 @@ %tr %th timestamp %th event - - @hits.reverse.each do |hit| + - @results.events.reverse.each do |event| %tr.event - %td.timestamp&= hit["_source"]["@timestamp"] - %td.message{ :"data-full" => hit.to_json } - %pre&= hit["_source"]["@message"] + %td.timestamp&= event.timestamp + %td.message{ :"data-full" => event.to_json } + %pre&= event.message From 59334d39bda90092c654fb3d4ee4b95aec8a767f Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Thu, 10 Feb 2011 00:16:52 -0800 Subject: [PATCH 04/34] - partial implementation LogStash::Search::ElasticSearch#histogram Example: EM.run { s = LogStash::Search::ElasticSearch.new(); s.histogram("REJECTED", "@timestamp", 60 * 60 * 1000) { |r| p :results => r } } Output: {:results=>#{1296759600000=>1, 1295344800000=>1, ... The FacetResult class is not fully baked. --- lib/logstash/search/elasticsearch.rb | 73 +++++++++++++++++++++++++++- lib/logstash/search/facetresult.rb | 25 ++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 lib/logstash/search/facetresult.rb diff --git a/lib/logstash/search/elasticsearch.rb b/lib/logstash/search/elasticsearch.rb index d454b943c..b1736800d 100644 --- a/lib/logstash/search/elasticsearch.rb +++ b/lib/logstash/search/elasticsearch.rb @@ -6,6 +6,7 @@ require "logstash/event" require "logstash/search/base" require "logstash/search/query" require "logstash/search/result" +require "logstash/search/facetresult" class LogStash::Search::ElasticSearch < LogStash::Search::Base public @@ -79,7 +80,75 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base end # def search def histogram(query, field, interval=nil) - # TODO(sissel): implement + if query.is_a?(String) + query = LogStash::Search::Query.parse(query) + end + + # TODO(sissel): only search a specific index? + http = EventMachine::HttpRequest.new("http://#{@host}:#{@port}/_search") + + @logger.info(["Query", query]) + histogram_settings = { + "field" => field + } + + if !interval.nil? && interval.is_a?(Numeric) + histogram_settings["interval"] = interval + end + + esreq = { + "query" => { + "query_string" => { + "query" => query.query_string, + "default_operator" => "AND" + } # query_string + }, # query + "from" => 0, + "size" => 0, + "facets" => { + "histo1" => { + "histogram" => histogram_settings, + }, + }, + } # elasticsearch request + + @logger.info("ElasticSearch Facet Query: #{esreq.to_json}") + start_time = Time.now + req = http.get :body => esreq.to_json + result = LogStash::Search::FacetResult.new + req.callback do + data = JSON.parse(req.response) + result.duration = Time.now - start_time + + @logger.info(["Got search results", + { :query => query.query_string, :duration => data["duration"], + :results => data["hits"]["hits"].size }]) + if req.response_header.status != 200 + result.error_message = data["error"] || req.inspect + @error = data["error"] || req.inspect + end + + # We want to yield a list of LogStash::Event objects. + result.facets = data["facets"] + data["facets"].each do |facetname, facetdata| + histogram_result = {} + facetdata["entries"].each do |entry| + # entry is a hash of keys 'total', 'mean', 'count', and 'key' + histogram_result[entry["key"]] = entry["count"] + end + result.facets[facetname] = histogram_result + end + yield result + end + + req.errback do + @logger.warn(["Query failed", query, req, req.response]) + result.duration = Time.now - start_time + result.error_message = req.response + #yield result + + yield({ "error" => req.response }) + end end def anonymize @@ -105,4 +174,4 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base end # data.hits.hits.each end # def anonymize -end # class LogStash::Web::ElasticSearch +end # class LogStash::Search::ElasticSearch diff --git a/lib/logstash/search/facetresult.rb b/lib/logstash/search/facetresult.rb new file mode 100644 index 000000000..b5be9f716 --- /dev/null +++ b/lib/logstash/search/facetresult.rb @@ -0,0 +1,25 @@ + +require "logstash/namespace" +require "logstash/logging" + +class LogStash::Search::FacetResult + # Array of LogStash::Event of results + attr_accessor :facets + + # How long this query took, in seconds (or fractions of). + attr_accessor :duration + + # Error message, if any. + attr_accessor :error_message + + def initialize(settings={}) + @facets = {} # TODO(sissel): need something better? + @duration = nil + @error_message = nil + end + + def error? + return !@error_message.nil? + end +end # class LogStash::Search::Result + From 594f20213c53841c0532f75ce30de23729aaca75 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Thu, 10 Feb 2011 09:45:31 -0800 Subject: [PATCH 05/34] - add more facet stuff --- lib/logstash/search/facetresult.rb | 7 ++++--- lib/logstash/search/facetresult/entry.rb | 6 ++++++ lib/logstash/search/facetresult/histogram.rb | 9 +++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 lib/logstash/search/facetresult/entry.rb create mode 100644 lib/logstash/search/facetresult/histogram.rb diff --git a/lib/logstash/search/facetresult.rb b/lib/logstash/search/facetresult.rb index b5be9f716..975f20d71 100644 --- a/lib/logstash/search/facetresult.rb +++ b/lib/logstash/search/facetresult.rb @@ -1,10 +1,11 @@ require "logstash/namespace" require "logstash/logging" +require "logstash/search/facetresult/entry" class LogStash::Search::FacetResult - # Array of LogStash::Event of results - attr_accessor :facets + # Array of LogStash::Search::FacetResult::Entry + attr_accessor :results # How long this query took, in seconds (or fractions of). attr_accessor :duration @@ -13,7 +14,7 @@ class LogStash::Search::FacetResult attr_accessor :error_message def initialize(settings={}) - @facets = {} # TODO(sissel): need something better? + @results = [] @duration = nil @error_message = nil end diff --git a/lib/logstash/search/facetresult/entry.rb b/lib/logstash/search/facetresult/entry.rb new file mode 100644 index 000000000..f09decca1 --- /dev/null +++ b/lib/logstash/search/facetresult/entry.rb @@ -0,0 +1,6 @@ + +require "logstash/search/facetresult" + +class LogStash::Search::FacetResult::Entry + # nothing here +end # class LogStash::Search::FacetResult::Entry diff --git a/lib/logstash/search/facetresult/histogram.rb b/lib/logstash/search/facetresult/histogram.rb new file mode 100644 index 000000000..78cb2b1d4 --- /dev/null +++ b/lib/logstash/search/facetresult/histogram.rb @@ -0,0 +1,9 @@ + +require "logstash/search/facetresult/entry" +class LogStash::Search::FacetResult::Histogram < LogStash::Search::FacetResult::Entry + # The name or key for this result. + attr_accessor :key + attr_accessor :mean + attr_accessor :total + attr_accessor :count +end From 563e925f4c9c164a446d9c2eb68e2f8e38b890f0 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Thu, 10 Feb 2011 09:58:52 -0800 Subject: [PATCH 06/34] - LogStash::Search::ElasticSearch#histogram now yields a proper LogStash::Search::FacetResult --- lib/logstash/search/elasticsearch.rb | 28 +++++++++----------- lib/logstash/search/facetresult.rb | 3 +-- lib/logstash/search/facetresult/histogram.rb | 1 + 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/lib/logstash/search/elasticsearch.rb b/lib/logstash/search/elasticsearch.rb index b1736800d..f821cf20c 100644 --- a/lib/logstash/search/elasticsearch.rb +++ b/lib/logstash/search/elasticsearch.rb @@ -7,6 +7,7 @@ require "logstash/search/base" require "logstash/search/query" require "logstash/search/result" require "logstash/search/facetresult" +require "logstash/search/facetresult/histogram" class LogStash::Search::ElasticSearch < LogStash::Search::Base public @@ -106,7 +107,7 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base "from" => 0, "size" => 0, "facets" => { - "histo1" => { + "amazingpants" => { # just a name for this histogram... "histogram" => histogram_settings, }, }, @@ -128,26 +129,22 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base @error = data["error"] || req.inspect end - # We want to yield a list of LogStash::Event objects. - result.facets = data["facets"] - data["facets"].each do |facetname, facetdata| - histogram_result = {} - facetdata["entries"].each do |entry| - # entry is a hash of keys 'total', 'mean', 'count', and 'key' - histogram_result[entry["key"]] = entry["count"] - end - result.facets[facetname] = histogram_result - end + data["facets"]["amazingpants"]["entries"].each do |entry| + # entry is a hash of keys 'total', 'mean', 'count', and 'key' + hist_entry = LogStash::Search::FacetResult::Histogram.new + hist_entry.key = entry["key"] + hist_entry.count = entry["count"] + result.results << hist_entry + end # for each histogram result yield result - end + end # request callback req.errback do @logger.warn(["Query failed", query, req, req.response]) result.duration = Time.now - start_time result.error_message = req.response - #yield result - - yield({ "error" => req.response }) + yield result + #yield({ "error" => req.response }) end end @@ -173,5 +170,4 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base end # hit._source.@fields.each end # data.hits.hits.each end # def anonymize - end # class LogStash::Search::ElasticSearch diff --git a/lib/logstash/search/facetresult.rb b/lib/logstash/search/facetresult.rb index 975f20d71..c42d76ee9 100644 --- a/lib/logstash/search/facetresult.rb +++ b/lib/logstash/search/facetresult.rb @@ -1,7 +1,6 @@ require "logstash/namespace" require "logstash/logging" -require "logstash/search/facetresult/entry" class LogStash::Search::FacetResult # Array of LogStash::Search::FacetResult::Entry @@ -22,5 +21,5 @@ class LogStash::Search::FacetResult def error? return !@error_message.nil? end -end # class LogStash::Search::Result +end # class LogStash::Search::FacetResult diff --git a/lib/logstash/search/facetresult/histogram.rb b/lib/logstash/search/facetresult/histogram.rb index 78cb2b1d4..f64749fd3 100644 --- a/lib/logstash/search/facetresult/histogram.rb +++ b/lib/logstash/search/facetresult/histogram.rb @@ -1,5 +1,6 @@ require "logstash/search/facetresult/entry" + class LogStash::Search::FacetResult::Histogram < LogStash::Search::FacetResult::Entry # The name or key for this result. attr_accessor :key From 35d262f463fea4529f4ea860f297e04caf147f85 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Thu, 10 Feb 2011 10:41:14 -0800 Subject: [PATCH 07/34] - Start working on /api/histogram --- lib/logstash/search/facetresult/histogram.rb | 10 +++++ lib/logstash/web/server.rb | 42 +++++++++++++------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/lib/logstash/search/facetresult/histogram.rb b/lib/logstash/search/facetresult/histogram.rb index f64749fd3..f48c5b117 100644 --- a/lib/logstash/search/facetresult/histogram.rb +++ b/lib/logstash/search/facetresult/histogram.rb @@ -1,4 +1,5 @@ +require "json" require "logstash/search/facetresult/entry" class LogStash::Search::FacetResult::Histogram < LogStash::Search::FacetResult::Entry @@ -7,4 +8,13 @@ class LogStash::Search::FacetResult::Histogram < LogStash::Search::FacetResult:: attr_accessor :mean attr_accessor :total attr_accessor :count + + def to_json + return { + "key" => @key, + "mean" => @mean, + "total" => @total, + "count" => @count, + }.to_json + end end diff --git a/lib/logstash/web/server.rb b/lib/logstash/web/server.rb index 09caa3f52..98bbbe526 100755 --- a/lib/logstash/web/server.rb +++ b/lib/logstash/web/server.rb @@ -98,16 +98,17 @@ class LogStash::Web::Server < Sinatra::Base else results = LogStash::Search::Result.new( :events => [], - :error_mesage => "No query given" + :error_message => "No query given" ) result_callback.call results end end # aget '/search' - apost '/search/ajax' do + aget '/api/search' do headers({"Content-Type" => "text/html" }) count = params["count"] = (params["count"] or 50).to_i offset = params["offset"] = (params["offset"] or 0).to_i + format = (params[:format] or "json") query = LogStash::Search::Query.new( :query_string => params[:q], @@ -126,17 +127,6 @@ class LogStash::Web::Server < Sinatra::Base @total = (@results.total rescue 0) count = @results.events.size - # TODO(sissel): move this to a facet query - #@graphpoints = [] - #begin - #@results["facets"]["by_hour"]["entries"].each do |entry| - #@graphpoints << [entry["key"], entry["count"]] - #end - #rescue => e - #p :exception => e - #puts e.backtrace.join("\n") - #end - if count and offset if @total > (count + offset) @result_end = (count + offset) @@ -169,7 +159,31 @@ class LogStash::Web::Server < Sinatra::Base body haml :"search/ajax", :layout => !request.xhr? end # @backend.search - end # apost '/search/ajax' + end # apost '/api/search' + + aget '/api/histogram' do + headers({"Content-Type" => "text/plain" }) + format = (params[:format] or "json") + field = (params[:field] or "@timestamp") + interval = (params[:interval] or 3600 * 1000) + @backend.histogram(params[:q], field, interval) do |results| + @results = results + if @results.error? + status 500 + body({ "error" => @results.error_message }.to_json) + next + end + + begin + p results.results.class + a = results.results.to_json + rescue => e + p e + raise e + end + body a + end # @backend.search + end # apost '/api/search' aget '/*' do status 404 if @error From 5a7b833bef3dec677dadfed5a9997e2eeee49178 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Thu, 10 Feb 2011 10:42:13 -0800 Subject: [PATCH 08/34] - to_json is called with a parameter? Hmm... This: JSON::Ext::Generator::State But, facet queries work now :) --- lib/logstash/search/facetresult/histogram.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/logstash/search/facetresult/histogram.rb b/lib/logstash/search/facetresult/histogram.rb index f48c5b117..0581d880a 100644 --- a/lib/logstash/search/facetresult/histogram.rb +++ b/lib/logstash/search/facetresult/histogram.rb @@ -9,7 +9,8 @@ class LogStash::Search::FacetResult::Histogram < LogStash::Search::FacetResult:: attr_accessor :total attr_accessor :count - def to_json + def to_json(*args) + p :to_json => args return { "key" => @key, "mean" => @mean, From e8f43c15d28b378cedc43c8ead3eca65c7aa8d99 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sat, 12 Feb 2011 22:40:18 -0800 Subject: [PATCH 09/34] - add additional library checks - add todo --- bin/logstash-test | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bin/logstash-test b/bin/logstash-test index af76b75d3..40642aa94 100755 --- a/bin/logstash-test +++ b/bin/logstash-test @@ -36,6 +36,8 @@ def check_libraries "needed for websocket output") results << check_lib("rack", "rack", true, "needed for logstash-web") + results << check_lib("thin", "thin", true, + "needed for logstash-web") results << check_lib("amqp", "amqp", true, "needed for AMQP input and output") results << check_lib("sinatra/async", "async_sinatra", true, @@ -46,6 +48,8 @@ def check_libraries "improve logstash debug logging output") results << check_lib("eventmachine", "eventmachine", false, "required for logstash to function") + results << check_lib("json", "json", false, + "required for logstash to function") missing_required = results.count { |r| !r[:optional] and !r[:found] } if missing_required == 0 @@ -66,6 +70,8 @@ end def main(args) report_ruby_version + # TODO(sissel): Add a way to call out specific things to test, like + # logstash-web, elasticsearch, mongodb, syslog, etc. if !check_libraries puts "Library check failed." return 1 From d674dd475e3030f4f81ef2662f3ee917800794ad Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sat, 12 Feb 2011 22:48:33 -0800 Subject: [PATCH 10/34] - More histogram work. - Have LogStash::Search::ElasticSearch#histogram yield an error if one occurs - /api/histogram reports an error now if 'q' param is missing --- lib/logstash/search/elasticsearch.rb | 14 +++++++++++--- lib/logstash/search/facetresult/histogram.rb | 2 +- lib/logstash/web/server.rb | 12 ++++++++++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/logstash/search/elasticsearch.rb b/lib/logstash/search/elasticsearch.rb index f821cf20c..534e12e27 100644 --- a/lib/logstash/search/elasticsearch.rb +++ b/lib/logstash/search/elasticsearch.rb @@ -122,14 +122,22 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base result.duration = Time.now - start_time @logger.info(["Got search results", - { :query => query.query_string, :duration => data["duration"], - :results => data["hits"]["hits"].size }]) + { :query => query.query_string, :duration => data["duration"] }]) if req.response_header.status != 200 result.error_message = data["error"] || req.inspect @error = data["error"] || req.inspect end - data["facets"]["amazingpants"]["entries"].each do |entry| + entries = data["facets"]["amazingpants"]["entries"] rescue nil + + if entries.nil? or !data["error"].nil? + # Use the error message if any, otherwise, return the whole + # data object as json as the error message for debugging later. + result.error_message = (data["error"] rescue false) || data.to_json + yield result + next + end + entries.each do |entry| # entry is a hash of keys 'total', 'mean', 'count', and 'key' hist_entry = LogStash::Search::FacetResult::Histogram.new hist_entry.key = entry["key"] diff --git a/lib/logstash/search/facetresult/histogram.rb b/lib/logstash/search/facetresult/histogram.rb index 0581d880a..1851334d5 100644 --- a/lib/logstash/search/facetresult/histogram.rb +++ b/lib/logstash/search/facetresult/histogram.rb @@ -9,8 +9,8 @@ class LogStash::Search::FacetResult::Histogram < LogStash::Search::FacetResult:: attr_accessor :total attr_accessor :count + # sometimes a parent call to to_json calls us with args? def to_json(*args) - p :to_json => args return { "key" => @key, "mean" => @mean, diff --git a/lib/logstash/web/server.rb b/lib/logstash/web/server.rb index 98bbbe526..42ccc849d 100755 --- a/lib/logstash/web/server.rb +++ b/lib/logstash/web/server.rb @@ -163,6 +163,11 @@ class LogStash::Web::Server < Sinatra::Base aget '/api/histogram' do headers({"Content-Type" => "text/plain" }) + if params[:q].nil? + status 500 + body({ "error" => "No query given (missing 'q' parameter)" }.to_json) + next + end format = (params[:format] or "json") field = (params[:field] or "@timestamp") interval = (params[:interval] or 3600 * 1000) @@ -175,15 +180,18 @@ class LogStash::Web::Server < Sinatra::Base end begin - p results.results.class a = results.results.to_json rescue => e + status 500 + body e.inspect + p :exception => e p e raise e end + status 200 body a end # @backend.search - end # apost '/api/search' + end # aget '/api/histogram' aget '/*' do status 404 if @error From d0a5dfb5a044c32013ae085fa95812629bbc2a8b Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 00:27:54 -0800 Subject: [PATCH 11/34] - Report errors from search --- lib/logstash/search/elasticsearch.rb | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/logstash/search/elasticsearch.rb b/lib/logstash/search/elasticsearch.rb index 534e12e27..5ee2c7852 100644 --- a/lib/logstash/search/elasticsearch.rb +++ b/lib/logstash/search/elasticsearch.rb @@ -50,16 +50,26 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base data = JSON.parse(req.response) result.duration = Time.now - start_time + hits = data["hits"]["hits"] rescue nil + + if hits.nil? or !data["error"].nil? + # Use the error message if any, otherwise, return the whole + # data object as json as the error message for debugging later. + result.error_message = (data["error"] rescue false) || data.to_json + yield result + next + end + @logger.info(["Got search results", { :query => query.query_string, :duration => data["duration"], - :results => data["hits"]["hits"].size }]) + :result_count => hits.size }]) if req.response_header.status != 200 result.error_message = data["error"] || req.inspect @error = data["error"] || req.inspect end # We want to yield a list of LogStash::Event objects. - data["hits"]["hits"].each do |hit| + hits.each do |hit| result.events << LogStash::Event.new(hit["_source"]) end From d669639c99646e91e23a234ba7e4d2fb931a2f6f Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 00:28:25 -0800 Subject: [PATCH 12/34] - LogStash::Search::Result#to_json --- lib/logstash/search/result.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/logstash/search/result.rb b/lib/logstash/search/result.rb index 4ea59a37a..a273a955e 100644 --- a/lib/logstash/search/result.rb +++ b/lib/logstash/search/result.rb @@ -26,5 +26,14 @@ class LogStash::Search::Result def error? return !@error_message.nil? end + + def to_json + return { + "events" => @events, + "duration" => @duration, + "offset" => @offset, + "total" => @total, + }.to_json + end # def to_json end # class LogStash::Search::Result From c8a55c18740bfaf41a052b96d1d4e2df2481b461 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 00:29:34 -0800 Subject: [PATCH 13/34] - Fix require_param --- lib/logstash/web/helpers/require_param.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 lib/logstash/web/helpers/require_param.rb diff --git a/lib/logstash/web/helpers/require_param.rb b/lib/logstash/web/helpers/require_param.rb new file mode 100644 index 000000000..bfbd44c99 --- /dev/null +++ b/lib/logstash/web/helpers/require_param.rb @@ -0,0 +1,17 @@ +require "sinatra/base" + +module Sinatra + module RequireParam + def require_param(*fields) + missing = [] + fields.each do |field| + if params[field].nil? + missing << field + end + end + return missing + end # def require_param + end # module RequireParam + + helpers RequireParam +end # module Sinatra From a1d375a4af9001c0d05164864e265bb2e506524d Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 00:29:56 -0800 Subject: [PATCH 14/34] - Graphs work again; uses new /api/histogram api This finallys splits search results from data requests. - Support POST and GET on /api/search - Support text/html/json for searches - Upgrade to jquery 1.5.0 --- lib/logstash/web/public/js/logstash.js | 19 ++++- lib/logstash/web/server.rb | 69 +++++++++++++++---- lib/logstash/web/views/layout.haml | 2 +- lib/logstash/web/views/search/ajax.haml | 7 -- lib/logstash/web/views/search/results.haml | 3 + lib/logstash/web/views/search/results.txt.erb | 7 +- lib/logstash/web/views/style.sass | 3 +- 7 files changed, 84 insertions(+), 26 deletions(-) diff --git a/lib/logstash/web/public/js/logstash.js b/lib/logstash/web/public/js/logstash.js index 609e2cf5f..d1c592167 100644 --- a/lib/logstash/web/public/js/logstash.js +++ b/lib/logstash/web/public/js/logstash.js @@ -1,4 +1,6 @@ (function() { + // TODO(sissel): Write something that will use history.pushState and fall back + // to document.location.hash madness. var logstash = { params: { @@ -17,7 +19,21 @@ //console.log(logstash.params) logstash.params.q = query; document.location.hash = escape(JSON.stringify(logstash.params)); - $("#results").load("/search/ajax", logstash.params); + + /* Load the search results */ + $("#results").load("/api/search?format=html", logstash.params); + + /* Load the default histogram graph */ + jQuery.getJSON("/api/histogram", logstash.params, function(histogram, text, jqxhr) { + /* Load the data into the graph */ + flot_data = []; + // histogram is an array of { "key": ..., "count": ... } + for (var i in histogram) { + flot_data.push([parseInt(histogram[i]["key"]), histogram[i]["count"]]) + } + + logstash.plot(flot_data); + }); $("#query").val(logstash.params.q); }, /* search */ @@ -50,6 +66,7 @@ plot: function(data) { var target = $("#visual"); + target.css("display", "block"); var plot = $.plot(target, [ { /* data */ data: data, diff --git a/lib/logstash/web/server.rb b/lib/logstash/web/server.rb index 42ccc849d..778cb741f 100755 --- a/lib/logstash/web/server.rb +++ b/lib/logstash/web/server.rb @@ -1,4 +1,7 @@ #!/usr/bin/env ruby +# I don't want folks to have to learn to use yet another tool (rackup) +# just to launch logstash-web. So let's work like a standard ruby +# executable. ##rackup -Ilib:../lib -s thin $:.unshift("%s/../lib" % File.dirname(__FILE__)) @@ -12,12 +15,15 @@ require "logstash/namespace" require "rack" require "rubygems" require "sinatra/async" +require "logstash/web/helpers/require_param" class EventMachine::ConnectionError < RuntimeError; end module LogStash::Web; end class LogStash::Web::Server < Sinatra::Base register Sinatra::Async + helpers Sinatra::RequireParam # logstash/web/helpers/require_param + set :haml, :format => :html5 set :logging, true set :public, "#{File.dirname(__FILE__)}/public" @@ -69,7 +75,6 @@ class LogStash::Web::Server < Sinatra::Base hits = @hits.collect { |h| h["_source"] } response = { "hits" => hits, - "facets" => (@results["facets"] rescue nil), } response["error"] = @error if @error @@ -104,7 +109,16 @@ class LogStash::Web::Server < Sinatra::Base end end # aget '/search' + apost '/api/search' do + api_search + end # apost /api/search + aget '/api/search' do + api_search + end # aget /api/search + + def api_search + headers({"Content-Type" => "text/html" }) count = params["count"] = (params["count"] or 50).to_i offset = params["offset"] = (params["offset"] or 0).to_i @@ -119,7 +133,26 @@ class LogStash::Web::Server < Sinatra::Base @backend.search(query) do |results| @results = results if @results.error? - body haml :"search/error", :layout => !request.xhr? + status 500 + case format + when "html" + headers({"Content-Type" => "text/html" }) + body haml :"search/error", :layout => !request.xhr? + when "text" + headers({"Content-Type" => "text/plain" }) + body erb :"search/error.txt", :layout => false + when "txt" + headers({"Content-Type" => "text/plain" }) + body erb :"search/error.txt", :layout => false + when "json" + headers({"Content-Type" => "text/plain" }) + # TODO(sissel): issue/30 - needs refactoring here. + if @results.error? + body({ "error" => @results.error_message }.to_json) + else + body @results.to_json + end + end # case params[:format] next end @@ -157,17 +190,34 @@ class LogStash::Web::Server < Sinatra::Base end end - body haml :"search/ajax", :layout => !request.xhr? + case format + when "html" + headers({"Content-Type" => "text/html" }) + body haml :"search/ajax", :layout => !request.xhr? + when "text" + headers({"Content-Type" => "text/plain" }) + body erb :"search/results.txt", :layout => false + when "txt" + headers({"Content-Type" => "text/plain" }) + body erb :"search/results.txt", :layout => false + when "json" + headers({"Content-Type" => "text/plain" }) + # TODO(sissel): issue/30 - needs refactoring here. + response = @results + body response.to_json + end # case params[:format] end # @backend.search - end # apost '/api/search' + end # def api_search aget '/api/histogram' do headers({"Content-Type" => "text/plain" }) - if params[:q].nil? + missing = require_param(:q) + if !missing.empty? status 500 - body({ "error" => "No query given (missing 'q' parameter)" }.to_json) + body({ "error" => "Missing requiremed parameters", + "missing" => missing }.to_json) next - end + end # if !missing.empty? format = (params[:format] or "json") field = (params[:field] or "@timestamp") interval = (params[:interval] or 3600 * 1000) @@ -260,8 +310,3 @@ Rack::Handler::Thin.run( Rack::ShowExceptions.new( \ LogStash::Web::Server.new(settings))), :Port => settings.port, :Host => settings.address) -#Rack::Handler::Thin.run( - #LogStash::Web::Server.new(settings), - #:Port => settings.port, - #:Host => settings.address -#) diff --git a/lib/logstash/web/views/layout.haml b/lib/logstash/web/views/layout.haml index 6d47534dd..37e2060ab 100644 --- a/lib/logstash/web/views/layout.haml +++ b/lib/logstash/web/views/layout.haml @@ -4,7 +4,7 @@ %title= @title || "logstash" %link{ :rel => "stylesheet", :href => "/style.css", :type => "text/css" } %link{ :rel => "stylesheet", :href => "/css/smoothness/jquery-ui-1.8.5.custom.css", :type => "text/css" } - %script{ :src => "https://ajax.googleapis.com/ajax/libs/jquery/1.4.3/jquery.min.js", + %script{ :src => "https://ajax.googleapis.com/ajax/libs/jquery/1.5.0/jquery.min.js", :type => "text/javascript" } %body #header diff --git a/lib/logstash/web/views/search/ajax.haml b/lib/logstash/web/views/search/ajax.haml index 418ffe967..9596169a1 100644 --- a/lib/logstash/web/views/search/ajax.haml +++ b/lib/logstash/web/views/search/ajax.haml @@ -2,13 +2,6 @@ - if (params[:q].strip.length > 0 rescue false) %h1 Search results for '#{params[:q]}' - - if @graphpoints - #visual - :javascript - $(function() { - var graphdata = #{@graphpoints.to_json}; - window.logstash.plot(graphdata); - }); - if @total and @result_start and @result_end %small %strong diff --git a/lib/logstash/web/views/search/results.haml b/lib/logstash/web/views/search/results.haml index 5fce8ba3e..66c242254 100644 --- a/lib/logstash/web/views/search/results.haml +++ b/lib/logstash/web/views/search/results.haml @@ -14,4 +14,7 @@ for that event. You can also click on the graph to zoom to that time period. The query language is that of Lucene's string query (docs). + +#visual + =haml :"search/ajax", :layout => false diff --git a/lib/logstash/web/views/search/results.txt.erb b/lib/logstash/web/views/search/results.txt.erb index d6d71db94..00e30c79e 100644 --- a/lib/logstash/web/views/search/results.txt.erb +++ b/lib/logstash/web/views/search/results.txt.erb @@ -1,9 +1,8 @@ <% # Sinatra currently doesn't do ERB with newline trimming, so we - # have to write this funky mishmosh that is hard to read. -if @error %>Error: <%= @error %><% else - @hits.each do |hit| - event = LogStash::Event.new(hit["_source"]) + # have to write this funky mishmosh on one line that is hard to read. +if @results.error? %>Error: <%= @results.error_message%><% else + @results.events.each do |event| %><%= event.message || event.to_hash.to_json %> <% end end diff --git a/lib/logstash/web/views/style.sass b/lib/logstash/web/views/style.sass index 59a3c7ac0..b1a9a490f 100644 --- a/lib/logstash/web/views/style.sass +++ b/lib/logstash/web/views/style.sass @@ -54,8 +54,9 @@ body margin: 0 #inspector font-size: 70% -#results #visual +#visual width: 850px height: 200px + display: none #results h1 font-size: 100% From 7dadd86f94e0e1aab5452c5ee57ed303b0c9f075 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 00:59:19 -0800 Subject: [PATCH 15/34] - Add error text template --- lib/logstash/web/views/search/error.txt.erb | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 lib/logstash/web/views/search/error.txt.erb diff --git a/lib/logstash/web/views/search/error.txt.erb b/lib/logstash/web/views/search/error.txt.erb new file mode 100644 index 000000000..d8b06e44a --- /dev/null +++ b/lib/logstash/web/views/search/error.txt.erb @@ -0,0 +1,4 @@ +An error occured in query '<%= params[:q] %>' +ERROR: + +<%= @results.error_message %> From c852a9eb4b51292b61185c0be066b4ad07f7fc43 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 01:20:53 -0800 Subject: [PATCH 16/34] - Fix search/error --- lib/logstash/web/views/search/error.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/logstash/web/views/search/error.haml b/lib/logstash/web/views/search/error.haml index ecc5da03d..3ca32eb71 100644 --- a/lib/logstash/web/views/search/error.haml +++ b/lib/logstash/web/views/search/error.haml @@ -1,3 +1,3 @@ #error %h4 The query '#{params["q"]}' resulted the following error: - %pre&= @results["error"] + %pre&= @results.error_message From 9c6a1495d9a5f150cb8c7593868976e431856be0 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 01:25:02 -0800 Subject: [PATCH 17/34] - Add twitter backend support. This isn't intended for use. The point of writing this was to make it easy to show a real use case that implements a logstash-web search backend other than ElasticSearch. --- lib/logstash/search/twitter.rb | 90 ++++++++++++++++++++++++++ lib/logstash/web/public/js/logstash.js | 4 +- lib/logstash/web/server.rb | 20 ++++-- 3 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 lib/logstash/search/twitter.rb diff --git a/lib/logstash/search/twitter.rb b/lib/logstash/search/twitter.rb new file mode 100644 index 000000000..643fa19b5 --- /dev/null +++ b/lib/logstash/search/twitter.rb @@ -0,0 +1,90 @@ +require "em-http-request" +require "logstash/namespace" +require "logstash/logging" +require "logstash/event" +require "logstash/search/base" +require "logstash/search/query" +require "logstash/search/result" +require "logstash/search/facetresult" +require "logstash/search/facetresult/histogram" + +class LogStash::Search::Twitter < LogStash::Search::Base + public + def initialize(settings={}) + @host = (settings[:host] || "search.twitter.com") + @port = (settings[:port] || 80).to_i + @logger = LogStash::Logger.new(STDOUT) + end + + public + def search(query) + raise "No block given for search call." if !block_given? + if query.is_a?(String) + query = LogStash::Search::Query.parse(query) + end + + # TODO(sissel): only search a specific index? + http = EventMachine::HttpRequest.new("http://#{@host}:#{@port}/search.json?q=#{URI.escape(query.query_string)}&rpp=#{URI.escape(query.count) rescue query.count}") + + @logger.info(["Query", query]) + + start_time = Time.now + req = http.get + + result = LogStash::Search::Result.new + req.callback do + data = JSON.parse(req.response) + result.duration = Time.now - start_time + + hits = (data["results"] || nil) rescue nil + + if hits.nil? or !data["error"].nil? + # Use the error message if any, otherwise, return the whole + # data object as json as the error message for debugging later. + result.error_message = (data["error"] rescue false) || data.to_json + yield result + next + end + + hits.each do |hit| + hit["@message"] = hit["text"] + hit["@timestamp"] = hit["created_at"] + hit.delete("text") + end + + @logger.info(["Got search results", + { :query => query.query_string, :duration => data["duration"], + :result_count => hits.size }]) + + if req.response_header.status != 200 + result.error_message = data["error"] || req.inspect + @error = data["error"] || req.inspect + end + + # We want to yield a list of LogStash::Event objects. + hits.each do |hit| + result.events << LogStash::Event.new(hit) + end + + # Total hits this search could find if not limited + result.total = hits.size + result.offset = 0 + + yield result + end + + req.errback do + @logger.warn(["Query failed", query, req, req.response]) + result.duration = Time.now - start_time + result.error_message = req.response + + yield result + end + end # def search + + def histogram(query, field, interval=nil) + # Nothing to histogram. + result = LogStash::Search::FacetResult.new + yield result + end +end # class LogStash::Search::ElasticSearch diff --git a/lib/logstash/web/public/js/logstash.js b/lib/logstash/web/public/js/logstash.js index d1c592167..0b954cedd 100644 --- a/lib/logstash/web/public/js/logstash.js +++ b/lib/logstash/web/public/js/logstash.js @@ -148,7 +148,9 @@ var result_row_selector = "table.results tr.event"; $(result_row_selector).live("click", function() { - var data = eval($("td.message", this).data("full")); + var data_json =$("td.message", this).data("full"); + //console.log(data_json); + var data = JSON.parse(data_json); /* Apply template to the dialog */ var query = $("#query").val().replace(/^\s+|\s+$/g, "") diff --git a/lib/logstash/web/server.rb b/lib/logstash/web/server.rb index 778cb741f..a59d0f1df 100755 --- a/lib/logstash/web/server.rb +++ b/lib/logstash/web/server.rb @@ -36,12 +36,22 @@ class LogStash::Web::Server < Sinatra::Base super # TODO(sissel): Support alternate backends backend_url = URI.parse(settings.backend_url) - @backend = LogStash::Search::ElasticSearch.new( - :host => backend_url.host, - :port => backend_url.port - ) - end + case backend_url.scheme + when "elasticsearch" + @backend = LogStash::Search::ElasticSearch.new( + :host => backend_url.host, + :port => backend_url.port + ) + when "twitter" + require "logstash/search/twitter" + @backend = LogStash::Search::Twitter.new( + :host => backend_url.host, + :port => backend_url.port + ) + end # backend_url.scheme + end # def initialize + aget '/style.css' do headers "Content-Type" => "text/css; charset=utf8" body sass :style From f97619a3739e96349d6be5f7b820ac1bbd33490a Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 01:36:07 -0800 Subject: [PATCH 18/34] include query time in results --- lib/logstash/web/views/search/ajax.haml | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/logstash/web/views/search/ajax.haml b/lib/logstash/web/views/search/ajax.haml index 9596169a1..eddfca178 100644 --- a/lib/logstash/web/views/search/ajax.haml +++ b/lib/logstash/web/views/search/ajax.haml @@ -2,6 +2,7 @@ - if (params[:q].strip.length > 0 rescue false) %h1 Search results for '#{params[:q]}' + #querytime= "(query time: %.3f seconds)" % @results.duration - if @total and @result_start and @result_end %small %strong From b74d7166ac91b897104a8060dd83fc3ff7e2dade Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 01:47:28 -0800 Subject: [PATCH 19/34] Add documentation for the search methods --- lib/logstash/search/base.rb | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/logstash/search/base.rb b/lib/logstash/search/base.rb index c91d26fad..8abb01cc9 100644 --- a/lib/logstash/search/base.rb +++ b/lib/logstash/search/base.rb @@ -5,23 +5,33 @@ require "logstash/event" class LogStash::Search::Base # Do a search. + # + # This method is async. You can expect a block and therefore + # should yield a result, not return one. + # + # Implementations should yield a LogStash::Search::Result + # LogStash::Search::Result#events must be an array of LogStash::Event def search(query) raise "The class #{self.class.name} must implement the 'search' method." end # def search - # Returns a histogram by field of a query. + # Yields a histogram by field of a query. + # + # This method is async. You should expect a block to be passed and therefore + # should yield a result, not return one. + # + # Implementations should yield a LogStash::Search::FacetResult::Histogram def histogram(query, field, interval=nil) raise "The class #{self.class.name} must implement the 'histogram' method." end # Returns a list of popular terms from a query + # TODO(sissel): Implement def popular_terms(query, fields, count=10) raise "The class #{self.class.name} must implement the 'popular_terms' method." end - # Count the results. - # The default count method provided by LogStash::Search::Base is not likely - # an optimal uery. + # Count the results given by a query. def count(query) raise "The class #{self.class.name} must implement the 'count' method." end From 6242ef2755c35f456fd8c5a2d6d94a65f7589d7e Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 01:49:25 -0800 Subject: [PATCH 20/34] - Style and doc fixes --- lib/logstash/search/elasticsearch.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/logstash/search/elasticsearch.rb b/lib/logstash/search/elasticsearch.rb index 5ee2c7852..2603b53f3 100644 --- a/lib/logstash/search/elasticsearch.rb +++ b/lib/logstash/search/elasticsearch.rb @@ -17,6 +17,7 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base @logger = LogStash::Logger.new(STDOUT) end + # See LogStash::Search;:Base#search public def search(query) raise "No block given for search call." if !block_given? @@ -90,6 +91,8 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base end end # def search + # See LogStash::Search;:Base#histogram + public def histogram(query, field, interval=nil) if query.is_a?(String) query = LogStash::Search::Query.parse(query) @@ -166,7 +169,9 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base end end - def anonymize + # Not used. Needs refactoring elsewhere. + private + def __anonymize # TODO(sissel): Plugin-ify this (Search filters!) # TODO(sissel): Implement # Search anonymization @@ -187,5 +192,5 @@ class LogStash::Search::ElasticSearch < LogStash::Search::Base end # value.each end # hit._source.@fields.each end # data.hits.hits.each - end # def anonymize + end # def __anonymize end # class LogStash::Search::ElasticSearch From 1655b080c4ea9c3d50c0159b3ba92d03ae923cda Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 02:01:05 -0800 Subject: [PATCH 21/34] - Always include first/prev/next/last; otherwise, hiding them makes you have to move the mouse if the next result set has a different list. --- lib/logstash/web/views/search/ajax.haml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/logstash/web/views/search/ajax.haml b/lib/logstash/web/views/search/ajax.haml index eddfca178..81c3cff24 100644 --- a/lib/logstash/web/views/search/ajax.haml +++ b/lib/logstash/web/views/search/ajax.haml @@ -2,7 +2,6 @@ - if (params[:q].strip.length > 0 rescue false) %h1 Search results for '#{params[:q]}' - #querytime= "(query time: %.3f seconds)" % @results.duration - if @total and @result_start and @result_end %small %strong @@ -10,19 +9,28 @@ | - if @first_href %a.pager{ :href => @first_href } first - | + - else + %span.unavailable first + | - if @prev_href %a.pager{ :href => @prev_href } prev - - if @next_href - | + - else + %span.unavailable prev + | - if @next_href %a.pager{ :href => @next_href } next + - else + %span.unavailable next + | - if @last_href - | %a.pager{ :href => @last_href } last + - else + %span.unavailable last + | + %span#querytime= "(%.3f seconds)" % @results.duration - if @results.events.length == 0 - if !params[:q] / We default to a '+2 days' in the future to capture 'today at 00:00' From 13481d80baf9d69cda4d94a92c78af4256db12a8 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 02:21:32 -0800 Subject: [PATCH 22/34] - Make the log messages into links so the iphone/ipad can click on them. - Don't reload the graph if we are just paging through results --- lib/logstash/web/public/js/logstash.js | 41 +++++++++++++++---------- lib/logstash/web/views/search/ajax.haml | 3 +- lib/logstash/web/views/style.sass | 3 ++ 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/lib/logstash/web/public/js/logstash.js b/lib/logstash/web/public/js/logstash.js index 0b954cedd..b95d9157f 100644 --- a/lib/logstash/web/public/js/logstash.js +++ b/lib/logstash/web/public/js/logstash.js @@ -8,14 +8,18 @@ count: 50, }, - search: function(query) { + search: function(query, options) { if (query == undefined || query == "") { return; } - //console.log("Searching: " + query); + + /* Default options */ + if (typeof(options) == 'undefined') { + options = { graph: true }; + } var display_query = query.replace("<", "<").replace(">", ">") - $("#querystatus").html("Loading query '" + display_query + "'") + $("#querystatus, #results h1").html("Loading query '" + display_query + "' (offset:" + logstash.params.offset + ", count:" + logstash.params.count + ")") //console.log(logstash.params) logstash.params.q = query; document.location.hash = escape(JSON.stringify(logstash.params)); @@ -23,17 +27,19 @@ /* Load the search results */ $("#results").load("/api/search?format=html", logstash.params); - /* Load the default histogram graph */ - jQuery.getJSON("/api/histogram", logstash.params, function(histogram, text, jqxhr) { - /* Load the data into the graph */ - flot_data = []; - // histogram is an array of { "key": ..., "count": ... } - for (var i in histogram) { - flot_data.push([parseInt(histogram[i]["key"]), histogram[i]["count"]]) - } + if (options.graph != false) { + /* Load the default histogram graph */ + jQuery.getJSON("/api/histogram", logstash.params, function(histogram, text, jqxhr) { + /* Load the data into the graph */ + flot_data = []; + // histogram is an array of { "key": ..., "count": ... } + for (var i in histogram) { + flot_data.push([parseInt(histogram[i]["key"]), histogram[i]["count"]]) + } - logstash.plot(flot_data); - }); + logstash.plot(flot_data); + }); + } /* if options.graph != false */ $("#query").val(logstash.params.q); }, /* search */ @@ -142,15 +148,16 @@ for (var p in params) { logstash.params[p] = params[p]; } - logstash.search(logstash.params.q) + logstash.search(logstash.params.q, { graph: false }) return false; }); var result_row_selector = "table.results tr.event"; $(result_row_selector).live("click", function() { - var data_json =$("td.message", this).data("full"); - //console.log(data_json); - var data = JSON.parse(data_json); + var data = $("td.message", this).data("full"); + if (typeof(data) == "string") { + data = JSON.parse(data); + } /* Apply template to the dialog */ var query = $("#query").val().replace(/^\s+|\s+$/g, "") diff --git a/lib/logstash/web/views/search/ajax.haml b/lib/logstash/web/views/search/ajax.haml index 81c3cff24..04e79e6f4 100644 --- a/lib/logstash/web/views/search/ajax.haml +++ b/lib/logstash/web/views/search/ajax.haml @@ -48,4 +48,5 @@ %tr.event %td.timestamp&= event.timestamp %td.message{ :"data-full" => event.to_json } - %pre&= event.message + %a{:href => "#"} + %pre&= event.message diff --git a/lib/logstash/web/views/style.sass b/lib/logstash/web/views/style.sass index b1a9a490f..273461140 100644 --- a/lib/logstash/web/views/style.sass +++ b/lib/logstash/web/views/style.sass @@ -29,6 +29,9 @@ body pre white-space: pre-wrap margin: 0 + a + text-decoration: none + color: black #content td.timestamp white-space: nowrap padding: 1px From 0c20391a91e3b9e267a4c7e088b7992bc152766e Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 02:29:20 -0800 Subject: [PATCH 23/34] - Add a throbber for more web2.0 sadness. Created with http://ajaxload.info/ --- lib/logstash/web/public/js/logstash.js | 2 +- lib/logstash/web/public/media/throbber.gif | Bin 0 -> 2608 bytes lib/logstash/web/views/style.sass | 2 ++ 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 lib/logstash/web/public/media/throbber.gif diff --git a/lib/logstash/web/public/js/logstash.js b/lib/logstash/web/public/js/logstash.js index b95d9157f..73a7e20bd 100644 --- a/lib/logstash/web/public/js/logstash.js +++ b/lib/logstash/web/public/js/logstash.js @@ -19,7 +19,7 @@ } var display_query = query.replace("<", "<").replace(">", ">") - $("#querystatus, #results h1").html("Loading query '" + display_query + "' (offset:" + logstash.params.offset + ", count:" + logstash.params.count + ")") + $("#querystatus, #results h1").html("Loading query '" + display_query + "' (offset:" + logstash.params.offset + ", count:" + logstash.params.count + ") ") //console.log(logstash.params) logstash.params.q = query; document.location.hash = escape(JSON.stringify(logstash.params)); diff --git a/lib/logstash/web/public/media/throbber.gif b/lib/logstash/web/public/media/throbber.gif new file mode 100644 index 0000000000000000000000000000000000000000..9e75bd4bae2917c2ad8e35b6e86db695e5e97c11 GIT binary patch literal 2608 zcmdVcc~nzZ9tZG8Uh4Y)lZ$dabGv~ba49h4?XuTSe}3P_|;!?uHhedZz>zuJ^a{EKU{ceqQ2bt;op9o zsTs=u%%C7RK$U1Uhk>Wu~dXUpa9D3+p-YZUQ<-H95NK%OK|eSe2M-ZOE#atGit zO$1=;*)FpS!ULnDFn>wl3Qup)#=5n9?n-kA05t2Dp$_5FC?qf=PA7EgsMVzU2~TbE zQ(Un4z7tiqFT-PN%jSY5>o$8|Rjx!kVnOst^XGin3kA}o3F*l01(EO$YNnLVfSCZn zj)$ra(cpmi{ZtlP&W9Yc6~yQ?pkmk7G_q);g2mZ|aad|faoX{-7&|RUO>c{&tAo`N zJ;qRns83!yp$=6S83CqR{OvW!G-_5rr2bL())&%VCe$~> z)SR4Gq!at##MYC!ec=eh*Ndf{Q4;gm9}pi6Q3>;GHvis8D-qC$KN|tx=libz3chT6 z!f^>D|FK%I!|xrityh7+WA4iKl>Ng>DI zma;u6ce?D)hVAX%JI<|P_=WPYIPQxpbO*o{%RbhACth42uy2MZAcdGV`|-Ox{H;Y~ z2HClp=U;hXABjfIulwbl5&!RvGM!F+iCc|IYD8p^e@Qc=aWfW*NXtTuh2sQnaG`L^ zoDq;GzzFD~yMM?nbPwJ>RF!Q*DG|K8-Jx0!LWhy~%ITLnn;b^TG%*~@))BJY!jAU+ zVnq6F8*!20VTtLc46-#3+nIptGLroy!kg-n#XUs%CY=;yJVXZD&f~|_szF`5mlRjp zsI`nQmPO%`k77H+4lWKq3S^&IZ6#BGv%`zfxyeHFq#xy_s;;V1gdvM`)+$5;*G^gF%M2<1DWd><&k)PuFyy?vF^ zE(6RM%cPc!A;9nA*{KA#`w?~9wgt&!jCUi2dtE&OV;ZLDfh!hLLL0#;OX=^EOvVF? zIyc&L-u5ztoN%o@9=I*2&|Q98Xf6}XJCXmz=|iEnEj-_B(XW!#OQp*NI*TiPppIPZ zvE#y)eXi@`Gpy}j-WUsto-|(^rg+Vy#U!LtfrUE*VA^Itf%5DmbM2-|pl!v7Wr!oh zm$O+}Dk4Ts2b^{RThn6!N(zhfwTM%#vL<*s`y9qjCx9t*UBI7J;O8nvHx~3IDW%*1 zr;wl&ra{C^fkq8x)yU|$YW=OrAyrsP-{?T1Uez_)vn8U<(4KOvR}{5O>bd$NP+c@p ze{$SZ;&h{Mt#`wM9 Date: Sun, 13 Feb 2011 02:35:17 -0800 Subject: [PATCH 24/34] - Ok, but really, geocities-era construction gif is way better. Picked one of these from http://www.textfiles.com/underconstruction/ --- lib/logstash/web/public/js/logstash.js | 2 +- lib/logstash/web/public/media/construction.gif | Bin 0 -> 1738 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 lib/logstash/web/public/media/construction.gif diff --git a/lib/logstash/web/public/js/logstash.js b/lib/logstash/web/public/js/logstash.js index 73a7e20bd..66445d5d2 100644 --- a/lib/logstash/web/public/js/logstash.js +++ b/lib/logstash/web/public/js/logstash.js @@ -19,7 +19,7 @@ } var display_query = query.replace("<", "<").replace(">", ">") - $("#querystatus, #results h1").html("Loading query '" + display_query + "' (offset:" + logstash.params.offset + ", count:" + logstash.params.count + ") ") + $("#querystatus, #results h1").html("Loading query '" + display_query + "' (offset:" + logstash.params.offset + ", count:" + logstash.params.count + ") ") //console.log(logstash.params) logstash.params.q = query; document.location.hash = escape(JSON.stringify(logstash.params)); diff --git a/lib/logstash/web/public/media/construction.gif b/lib/logstash/web/public/media/construction.gif new file mode 100644 index 0000000000000000000000000000000000000000..421f85f15c9d6834fa2bac3dd6128ddcf8941b50 GIT binary patch literal 1738 zcmZ?wbhEHbRAW$MXl4L`yu7@9`}Y063+50a6#sMkxrPKgI|jHK=@~FHzF=lh{K>*7 z$-u;*1JVmJmYL;bzzNUQED|@){ysBPz3N5+-;EVPPcxUjzOie)Sh;cR{pfY?_wYBc zDc!2D3;49-)50lMGfw@~ahtj0q5sOgU0yK-&&nr7McvJs6>}_ZSAoXuh?BfKSM2=m z83kyuQq%YdAql{s+Gm& z)bvYi?}%n#teKTHYl6f)rv;(8v)h;I6h=*%v|2{De#0vNHMZqjGFwb{ZrsN2tf@ZCEu*epuf3F&MzS0C+Exb@WOp%PZV9VNzZ556~ltTCNm*y_^Qwr=T)mn<)r%n>vB zrE{wwTI8+dv!+>>u`y?2}Qzi4Km(MKD@JtG+V3;LpJU6s7dtvU9wWU*6s5q~Qnznr9cE2T=yY>d} z*mThEu+gNrlUmE_&ib4-y=ZuTgX5wzH#c6m6PbA9zF5lD$Fmy-Qa`AFG<|JpN=2j~BX6B`o=NDxcD|qH5>+y00==tj@IOSI;m>L-Hayg}@mgMK@ zDEQ}X{Ty8!ff{(Zz%EJ7&&^HED^Wpc zkYwNx_|M?(3^mEAQo$v$EHg#HKc_e~Pr=aA(hTT}oYchPR0W_wiUQPS#R?_)3Pwqp R>FEx+K-Ymp^?(F}H2~ Date: Sun, 13 Feb 2011 02:37:23 -0800 Subject: [PATCH 25/34] - Let's use the underconstruction gif. --- lib/logstash/web/public/js/logstash.js | 2 +- lib/logstash/web/views/style.sass | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/logstash/web/public/js/logstash.js b/lib/logstash/web/public/js/logstash.js index 66445d5d2..e1f9fab7c 100644 --- a/lib/logstash/web/public/js/logstash.js +++ b/lib/logstash/web/public/js/logstash.js @@ -19,7 +19,7 @@ } var display_query = query.replace("<", "<").replace(">", ">") - $("#querystatus, #results h1").html("Loading query '" + display_query + "' (offset:" + logstash.params.offset + ", count:" + logstash.params.count + ") ") + $("#querystatus, #results h1").html("Loading query '" + display_query + "' (offset:" + logstash.params.offset + ", count:" + logstash.params.count + ") ") //console.log(logstash.params) logstash.params.q = query; document.location.hash = escape(JSON.stringify(logstash.params)); diff --git a/lib/logstash/web/views/style.sass b/lib/logstash/web/views/style.sass index ea995e973..974b61ada 100644 --- a/lib/logstash/web/views/style.sass +++ b/lib/logstash/web/views/style.sass @@ -63,5 +63,5 @@ body display: none #results h1 font-size: 100% - img - vertical-align: top +img.throbber + vertical-align: top From f021fd72beb3e3528b6bfd3d635f712fc457b286 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 02:42:58 -0800 Subject: [PATCH 26/34] - Add 'refresh' link to searches --- lib/logstash/web/server.rb | 3 +++ lib/logstash/web/views/search/ajax.haml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/lib/logstash/web/server.rb b/lib/logstash/web/server.rb index a59d0f1df..c6d30c55e 100755 --- a/lib/logstash/web/server.rb +++ b/lib/logstash/web/server.rb @@ -200,6 +200,9 @@ class LogStash::Web::Server < Sinatra::Base end end + # TODO(sissel): make a helper function taht goes hash -> cgi querystring + @refresh_href = "?" + params.collect { |k,v| [URI.escape(k.to_s), URI.escape(v.to_s)].join("=") }.join("&") + case format when "html" headers({"Content-Type" => "text/html" }) diff --git a/lib/logstash/web/views/search/ajax.haml b/lib/logstash/web/views/search/ajax.haml index 04e79e6f4..ee43c6496 100644 --- a/lib/logstash/web/views/search/ajax.haml +++ b/lib/logstash/web/views/search/ajax.haml @@ -30,6 +30,9 @@ - else %span.unavailable last | + %a.pager{ :href => @refresh_href } + refresh + | %span#querytime= "(%.3f seconds)" % @results.duration - if @results.events.length == 0 - if !params[:q] From d0e08d44e7814500bce7784488d3bcb50e146220 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 02:50:42 -0800 Subject: [PATCH 27/34] - always include 'first' link - fix bug in 'last' link --- lib/logstash/web/server.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/logstash/web/server.rb b/lib/logstash/web/server.rb index c6d30c55e..9c66d7f44 100755 --- a/lib/logstash/web/server.rb +++ b/lib/logstash/web/server.rb @@ -184,7 +184,7 @@ class LogStash::Web::Server < Sinatra::Base next_params["offset"] = [offset + count, @total - count].min @next_href = "?" + next_params.collect { |k,v| [URI.escape(k.to_s), URI.escape(v.to_s)].join("=") }.join("&") last_params = next_params.clone - last_params["offset"] = @total - offset + last_params["offset"] = @total - count @last_href = "?" + last_params.collect { |k,v| [URI.escape(k.to_s), URI.escape(v.to_s)].join("=") }.join("&") end @@ -193,11 +193,11 @@ class LogStash::Web::Server < Sinatra::Base prev_params["offset"] = [offset - count, 0].max @prev_href = "?" + prev_params.collect { |k,v| [URI.escape(k.to_s), URI.escape(v.to_s)].join("=") }.join("&") - if prev_params["offset"] > 0 + #if prev_params["offset"] > 0 first_params = prev_params.clone first_params["offset"] = 0 @first_href = "?" + first_params.collect { |k,v| [URI.escape(k.to_s), URI.escape(v.to_s)].join("=") }.join("&") - end + #end end # TODO(sissel): make a helper function taht goes hash -> cgi querystring From 19ce81467732cdf725c40049f1654673293c1c59 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 02:58:00 -0800 Subject: [PATCH 28/34] - Reset search offset when drilling into the histogram --- lib/logstash/web/public/js/logstash.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/logstash/web/public/js/logstash.js b/lib/logstash/web/public/js/logstash.js index e1f9fab7c..af992dce1 100644 --- a/lib/logstash/web/public/js/logstash.js +++ b/lib/logstash/web/public/js/logstash.js @@ -92,6 +92,10 @@ start = logstash.ms_to_iso8601(item.datapoint[0]); end = logstash.ms_to_iso8601(item.datapoint[0] + 3600000); + /* Clicking on the graph means a new search, means + * we probably don't want to keep the old offset since + * the search results will change. */ + logstash.params.offset = 0; logstash.appendquery("@timestamp:[" + start + " TO " + end + "]"); } }); From 6d98b64514708a8af88c22467b8e2e2ed3bc3a4a Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 03:13:55 -0800 Subject: [PATCH 29/34] - add doc comments --- lib/logstash/web/server.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/logstash/web/server.rb b/lib/logstash/web/server.rb index 9c66d7f44..8b44a1533 100755 --- a/lib/logstash/web/server.rb +++ b/lib/logstash/web/server.rb @@ -231,9 +231,10 @@ class LogStash::Web::Server < Sinatra::Base "missing" => missing }.to_json) next end # if !missing.empty? - format = (params[:format] or "json") - field = (params[:field] or "@timestamp") - interval = (params[:interval] or 3600 * 1000) + + format = (params[:format] or "json") # default json + field = (params[:field] or "@timestamp") # default @timestamp + interval = (params[:interval] or 3600000).to_i # default 1 hour @backend.histogram(params[:q], field, interval) do |results| @results = results if @results.error? From ecb0afad5dc3c5ed17fb081a2336427e3bbb391d Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 03:26:20 -0800 Subject: [PATCH 30/34] - Try to dynamically size the histogram based on number of data points found. This will incur multiple backend queries if a happy range of data points is not found. Currently will try again with a new histogram interval if point count, x is outside range [6, 50] --- lib/logstash/web/public/js/logstash.js | 56 ++++++++++++++++++++------ 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/lib/logstash/web/public/js/logstash.js b/lib/logstash/web/public/js/logstash.js index af992dce1..2dccef5d0 100644 --- a/lib/logstash/web/public/js/logstash.js +++ b/lib/logstash/web/public/js/logstash.js @@ -29,20 +29,50 @@ if (options.graph != false) { /* Load the default histogram graph */ - jQuery.getJSON("/api/histogram", logstash.params, function(histogram, text, jqxhr) { - /* Load the data into the graph */ - flot_data = []; - // histogram is an array of { "key": ..., "count": ... } - for (var i in histogram) { - flot_data.push([parseInt(histogram[i]["key"]), histogram[i]["count"]]) - } - - logstash.plot(flot_data); - }); + logstash.params.interval = 3600000; /* 1 hour, default */ + logstash.histogram(); } /* if options.graph != false */ $("#query").val(logstash.params.q); }, /* search */ + histogram: function(tries) { + if (typeof(tries) == 'undefined') { + tries = 5; + } + + /* Uncomment to activate GeoCities mode on the graph while waiting . */ + $("#visual").html("
"); + + jQuery.getJSON("/api/histogram", logstash.params, function(histogram, text, jqxhr) { + /* Load the data into the graph */ + flot_data = []; + // histogram is an array of { "key": ..., "count": ... } + for (var i in histogram) { + flot_data.push([parseInt(histogram[i]["key"]), histogram[i]["count"]]) + } + //console.log("Histo:" + flot_data.length); + + /* Try to be intelligent about how we choose the histogram interval. + * If there are too few data points, try a smaller interval. + * If there are too many data points, try a larger interval. + * Give up after a few tries and go with the last result. + * + * This queries the backend several times, but should be reasonably + * speedy as this behaves roughly as a binary search. */ + if (flot_data.length < 6 && tries > 0) { + //console.log("Histogram bucket " + logstash.params.interval + " has only " + flot_data.length + " data points, trying smaller..."); + logstash.params.interval /= 2; + logstash.histogram(tries - 1); + } else if (flot_data.length > 50 && tries > 0) { + //console.log("Histogram bucket " + logstash.params.interval + " too many (" + flot_data.length + ") data points, trying larger interval..."); + logstash.params.interval *= 2; + logstash.histogram(tries - 1); + } else { + logstash.plot(flot_data, logstash.params.interval); + } + }); + }, + parse_params: function(href) { var query = href.replace(/^[^?]*\?/, ""); if (query == href) { @@ -70,7 +100,7 @@ logstash.search(newquery.trim()); }, /* appendquery */ - plot: function(data) { + plot: function(data, interval) { var target = $("#visual"); target.css("display", "block"); var plot = $.plot(target, @@ -78,7 +108,7 @@ data: data, bars: { show: true, - barWidth: 3600000, + barWidth: interval, } } ], { /* options */ @@ -90,7 +120,7 @@ target.bind("plotclick", function(e, pos, item) { if (item) { start = logstash.ms_to_iso8601(item.datapoint[0]); - end = logstash.ms_to_iso8601(item.datapoint[0] + 3600000); + end = logstash.ms_to_iso8601(item.datapoint[0] + interval); /* Clicking on the graph means a new search, means * we probably don't want to keep the old offset since From 025ebbcf7d9f71a7818c3ff627012948e327a1b1 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 03:29:59 -0800 Subject: [PATCH 31/34] - Add another geocities gif for when the graph is updating. --- .../web/public/media/truckconstruction.gif | Bin 0 -> 22275 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 lib/logstash/web/public/media/truckconstruction.gif diff --git a/lib/logstash/web/public/media/truckconstruction.gif b/lib/logstash/web/public/media/truckconstruction.gif new file mode 100644 index 0000000000000000000000000000000000000000..462796afa26ed2b7f7b46cdf5572f691099f4b59 GIT binary patch literal 22275 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 km~ Date: Sun, 13 Feb 2011 03:44:37 -0800 Subject: [PATCH 32/34] - Bump tries to 7 on histogram sizing - Abort if somehow the histogram interval gets below 1 second --- lib/logstash/web/public/js/logstash.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/logstash/web/public/js/logstash.js b/lib/logstash/web/public/js/logstash.js index 2dccef5d0..f60e798d7 100644 --- a/lib/logstash/web/public/js/logstash.js +++ b/lib/logstash/web/public/js/logstash.js @@ -37,10 +37,11 @@ histogram: function(tries) { if (typeof(tries) == 'undefined') { - tries = 5; + tries = 7; } - /* Uncomment to activate GeoCities mode on the graph while waiting . */ + /* GeoCities mode on the graph while waiting ... + * This won't likely survive 1.0, but it's fun for now... */ $("#visual").html("
"); jQuery.getJSON("/api/histogram", logstash.params, function(histogram, text, jqxhr) { @@ -50,7 +51,7 @@ for (var i in histogram) { flot_data.push([parseInt(histogram[i]["key"]), histogram[i]["count"]]) } - //console.log("Histo:" + flot_data.length); + //console.log(histogram); /* Try to be intelligent about how we choose the histogram interval. * If there are too few data points, try a smaller interval. @@ -62,12 +63,18 @@ if (flot_data.length < 6 && tries > 0) { //console.log("Histogram bucket " + logstash.params.interval + " has only " + flot_data.length + " data points, trying smaller..."); logstash.params.interval /= 2; + if (logstash.params.interval < 1000) { + tries = 0; /* stop trying, too small... */ + logstash.plot(flot_data, logstash.params.interval); + return; + } logstash.histogram(tries - 1); } else if (flot_data.length > 50 && tries > 0) { //console.log("Histogram bucket " + logstash.params.interval + " too many (" + flot_data.length + ") data points, trying larger interval..."); logstash.params.interval *= 2; logstash.histogram(tries - 1); } else { + //console.log("Histo:" + logstash.params.interval); logstash.plot(flot_data, logstash.params.interval); } }); From 3b6ae5bd9d84293095d2bd37f60d8d112922e93f Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 03:53:31 -0800 Subject: [PATCH 33/34] - Don't bother retrying if we got 0 results for our histogram. --- lib/logstash/web/public/js/logstash.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/logstash/web/public/js/logstash.js b/lib/logstash/web/public/js/logstash.js index f60e798d7..7a6d2a621 100644 --- a/lib/logstash/web/public/js/logstash.js +++ b/lib/logstash/web/public/js/logstash.js @@ -60,7 +60,7 @@ * * This queries the backend several times, but should be reasonably * speedy as this behaves roughly as a binary search. */ - if (flot_data.length < 6 && tries > 0) { + if (flot_data.length < 6 && flot_data.length > 0 && tries > 0) { //console.log("Histogram bucket " + logstash.params.interval + " has only " + flot_data.length + " data points, trying smaller..."); logstash.params.interval /= 2; if (logstash.params.interval < 1000) { From 441f022bb0307b0618aa32656d8b9230e9d1bf97 Mon Sep 17 00:00:00 2001 From: Jordan Sissel Date: Sun, 13 Feb 2011 16:38:59 -0800 Subject: [PATCH 34/34] Local var. --- lib/logstash/web/public/js/logstash.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/logstash/web/public/js/logstash.js b/lib/logstash/web/public/js/logstash.js index 7a6d2a621..754e3432a 100644 --- a/lib/logstash/web/public/js/logstash.js +++ b/lib/logstash/web/public/js/logstash.js @@ -46,7 +46,7 @@ jQuery.getJSON("/api/histogram", logstash.params, function(histogram, text, jqxhr) { /* Load the data into the graph */ - flot_data = []; + var flot_data = []; // histogram is an array of { "key": ..., "count": ... } for (var i in histogram) { flot_data.push([parseInt(histogram[i]["key"]), histogram[i]["count"]])