skylight 0.2.0.beta.3 → 0.2.0.beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/skylight +15 -2
- data/lib/skylight.rb +18 -9
- data/lib/skylight/instrumenter.rb +25 -9
- data/lib/skylight/messages/trace.rb +34 -13
- data/lib/skylight/normalizers.rb +34 -5
- data/lib/skylight/normalizers/process_action.rb +6 -2
- data/lib/skylight/normalizers/render_collection.rb +3 -1
- data/lib/skylight/normalizers/render_partial.rb +3 -1
- data/lib/skylight/normalizers/render_template.rb +3 -1
- data/lib/skylight/normalizers/send_file.rb +24 -5
- data/lib/skylight/normalizers/sql.rb +8 -9
- data/lib/skylight/util/allocation_free.rb +15 -0
- data/lib/skylight/vendor/beefcake.rb +44 -25
- data/lib/skylight/vendor/beefcake/buffer.rb +4 -2
- data/lib/skylight/vendor/beefcake/encode.rb +20 -3
- data/lib/skylight/version.rb +1 -1
- data/lib/skylight/worker/collector.rb +6 -0
- data/lib/sql_lexer/lexer.rb +159 -56
- metadata +4 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6c34ce7c2bd1892ae5f05d4e9cc0ee5054cbb9f1
|
4
|
+
data.tar.gz: ce556c192f1f5b1d72be7680d358d8373a80cfe7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a9b4be609c1d76a2075cbb9ad116d724fc35be01dfe133e00978c3b09a8e32765a78d2db0ccb2f3c43a60182891a57e42ee8bcecd53ec8d50750a6fc2f38f138
|
7
|
+
data.tar.gz: 082e9dd9bfc81ea18b53b830c52bf26796b90943c6af65b78723b21a8d1207478f87ac835a8386c4f0741a4fb97d9d89200ed2746b8fdadb8d9945b869c1a975
|
data/bin/skylight
CHANGED
@@ -1,3 +1,16 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
|
-
|
3
|
-
|
2
|
+
#
|
3
|
+
# This file was generated by Bundler.
|
4
|
+
#
|
5
|
+
# The application 'skylight' is installed as part of a gem, and
|
6
|
+
# this file is here to facilitate running it.
|
7
|
+
#
|
8
|
+
|
9
|
+
require 'pathname'
|
10
|
+
ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile",
|
11
|
+
Pathname.new(__FILE__).realpath)
|
12
|
+
|
13
|
+
require 'rubygems'
|
14
|
+
require 'bundler/setup'
|
15
|
+
|
16
|
+
load Gem.bin_path('skylight', 'skylight')
|
data/lib/skylight.rb
CHANGED
@@ -59,9 +59,10 @@ module Skylight
|
|
59
59
|
noise
|
60
60
|
other)
|
61
61
|
|
62
|
-
TIER_REGEX = /^(?:#{TIERS.join('|')})(?:\.|$)/
|
63
|
-
CATEGORY_REGEX = /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/
|
62
|
+
TIER_REGEX = /^(?:#{TIERS.join('|')})(?:\.|$)/u
|
63
|
+
CATEGORY_REGEX = /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/iu
|
64
64
|
DEFAULT_CATEGORY = "app.block".freeze
|
65
|
+
DEFAULT_OPTIONS = { category: DEFAULT_CATEGORY }
|
65
66
|
|
66
67
|
def self.start!(*args)
|
67
68
|
Instrumenter.start!(*args)
|
@@ -71,23 +72,27 @@ module Skylight
|
|
71
72
|
Instrumenter.stop!(*args)
|
72
73
|
end
|
73
74
|
|
74
|
-
def self.trace(
|
75
|
+
def self.trace(title=nil, desc=nil, annot=nil)
|
75
76
|
unless inst = Instrumenter.instance
|
76
77
|
return yield if block_given?
|
77
78
|
return
|
78
79
|
end
|
79
80
|
|
80
|
-
|
81
|
+
if block_given?
|
82
|
+
inst.trace(title, desc, annot) { yield }
|
83
|
+
else
|
84
|
+
inst.trace(title, desc, annot)
|
85
|
+
end
|
81
86
|
end
|
82
87
|
|
83
|
-
def self.instrument(opts =
|
88
|
+
def self.instrument(opts = DEFAULT_OPTIONS)
|
84
89
|
unless inst = Instrumenter.instance
|
85
90
|
return yield if block_given?
|
86
91
|
return
|
87
92
|
end
|
88
93
|
|
89
94
|
if Hash === opts
|
90
|
-
category = opts.delete(:category)
|
95
|
+
category = opts.delete(:category)
|
91
96
|
title = opts.delete(:title)
|
92
97
|
desc = opts.delete(:description)
|
93
98
|
else
|
@@ -96,16 +101,20 @@ module Skylight
|
|
96
101
|
desc = nil
|
97
102
|
end
|
98
103
|
|
99
|
-
|
104
|
+
if block_given?
|
105
|
+
inst.instrument(category, title, desc) { yield }
|
106
|
+
else
|
107
|
+
inst.instrument(category, title, desc)
|
108
|
+
end
|
100
109
|
end
|
101
110
|
|
102
|
-
def self.disable
|
111
|
+
def self.disable
|
103
112
|
unless inst = Instrumenter.instance
|
104
113
|
return yield if block_given?
|
105
114
|
return
|
106
115
|
end
|
107
116
|
|
108
|
-
inst.disable
|
117
|
+
inst.disable { yield }
|
109
118
|
end
|
110
119
|
|
111
120
|
RUBYBIN = File.join(
|
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'thread'
|
2
2
|
require 'set'
|
3
|
+
require 'base64'
|
3
4
|
|
4
5
|
module Skylight
|
5
6
|
class Instrumenter
|
@@ -55,7 +56,7 @@ module Skylight
|
|
55
56
|
@subscriber = Subscriber.new(config, self)
|
56
57
|
|
57
58
|
@trace_info = @config[:trace_info] || TraceInfo.new
|
58
|
-
@descriptions = Hash.new { |h,k| h[k] =
|
59
|
+
@descriptions = Hash.new { |h,k| h[k] = {} }
|
59
60
|
end
|
60
61
|
|
61
62
|
def current_trace
|
@@ -87,7 +88,7 @@ module Skylight
|
|
87
88
|
@worker.shutdown
|
88
89
|
end
|
89
90
|
|
90
|
-
def trace(endpoint, cat,
|
91
|
+
def trace(endpoint, cat, title=nil, desc=nil, annot=nil)
|
91
92
|
# If a trace is already in progress, continue with that one
|
92
93
|
if trace = @trace_info.current
|
93
94
|
t { "already tracing" }
|
@@ -96,7 +97,7 @@ module Skylight
|
|
96
97
|
end
|
97
98
|
|
98
99
|
begin
|
99
|
-
trace = Messages::Trace::Builder.new(self, endpoint, Util::Clock.micros, cat,
|
100
|
+
trace = Messages::Trace::Builder.new(self, endpoint, Util::Clock.micros, cat, title, desc, annot)
|
100
101
|
rescue Exception => e
|
101
102
|
error e.message
|
102
103
|
t { e.backtrace.join("\n") }
|
@@ -126,7 +127,17 @@ module Skylight
|
|
126
127
|
@disabled
|
127
128
|
end
|
128
129
|
|
129
|
-
|
130
|
+
@scanner = StringScanner.new('')
|
131
|
+
def self.match?(string, regex)
|
132
|
+
@scanner.string = string
|
133
|
+
@scanner.match?(regex)
|
134
|
+
end
|
135
|
+
|
136
|
+
def match?(string, regex)
|
137
|
+
self.class.match?(string, regex)
|
138
|
+
end
|
139
|
+
|
140
|
+
def instrument(cat, title=nil, desc=nil, annot=nil)
|
130
141
|
unless trace = @trace_info.current
|
131
142
|
return yield if block_given?
|
132
143
|
return
|
@@ -134,15 +145,15 @@ module Skylight
|
|
134
145
|
|
135
146
|
cat = cat.to_s
|
136
147
|
|
137
|
-
unless cat
|
148
|
+
unless match?(cat, CATEGORY_REGEX)
|
138
149
|
warn "invalid skylight instrumentation category; value=%s", cat
|
139
150
|
return yield if block_given?
|
140
151
|
return
|
141
152
|
end
|
142
153
|
|
143
|
-
cat = "other.#{cat}" unless cat
|
154
|
+
cat = "other.#{cat}" unless match?(cat, TIER_REGEX)
|
144
155
|
|
145
|
-
unless sp = trace.instrument(cat,
|
156
|
+
unless sp = trace.instrument(cat, title, desc, annot)
|
146
157
|
return yield if block_given?
|
147
158
|
return
|
148
159
|
end
|
@@ -157,16 +168,17 @@ module Skylight
|
|
157
168
|
end
|
158
169
|
|
159
170
|
def limited_description(description)
|
171
|
+
endpoint = nil
|
160
172
|
endpoint = @trace_info.current.endpoint
|
161
173
|
|
162
174
|
DESC_LOCK.synchronize do
|
163
175
|
set = @descriptions[endpoint]
|
164
176
|
|
165
177
|
if set.size >= 100
|
166
|
-
return TOO_MANY_UNIQUES
|
178
|
+
return TOO_MANY_UNIQUES
|
167
179
|
end
|
168
180
|
|
169
|
-
set
|
181
|
+
set[description] = true
|
170
182
|
description
|
171
183
|
end
|
172
184
|
end
|
@@ -174,6 +186,10 @@ module Skylight
|
|
174
186
|
def error(reason, body)
|
175
187
|
t { fmt "processing error; reason=%s; body=%s", reason, body }
|
176
188
|
|
189
|
+
if body.encoding == Encoding::BINARY || !body.valid_encoding?
|
190
|
+
body = Base64.encode64(body)
|
191
|
+
end
|
192
|
+
|
177
193
|
message = Skylight::Messages::Error.new(reason: reason, body: body)
|
178
194
|
|
179
195
|
unless @worker.submit(message)
|
@@ -13,14 +13,17 @@ module Skylight
|
|
13
13
|
|
14
14
|
include Util::Logging
|
15
15
|
|
16
|
-
|
17
|
-
attr_reader :spans, :notifications
|
16
|
+
attr_reader :endpoint, :spans, :notifications
|
18
17
|
|
19
|
-
def
|
18
|
+
def endpoint=(value)
|
19
|
+
@endpoint = value.freeze
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(instrumenter, endpoint, start, cat, title=nil, desc=nil, annot=nil)
|
20
23
|
raise ArgumentError, 'instrumenter is required' unless instrumenter
|
21
24
|
|
22
25
|
@instrumenter = instrumenter
|
23
|
-
@endpoint = endpoint
|
26
|
+
@endpoint = endpoint.freeze
|
24
27
|
@start = start
|
25
28
|
@spans = []
|
26
29
|
@stack = []
|
@@ -32,9 +35,13 @@ module Skylight
|
|
32
35
|
# Track time
|
33
36
|
@last_seen_time = start
|
34
37
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
+
if Hash === title
|
39
|
+
annot = title
|
40
|
+
title = desc = nil
|
41
|
+
elsif Hash === desc
|
42
|
+
annot = desc
|
43
|
+
desc = nil
|
44
|
+
end
|
38
45
|
|
39
46
|
# Create the root node
|
40
47
|
@root = start(@start, cat, title, desc, annot)
|
@@ -60,13 +67,27 @@ module Skylight
|
|
60
67
|
nil
|
61
68
|
end
|
62
69
|
|
63
|
-
def instrument(cat,
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
70
|
+
def instrument(cat, title=nil, desc=nil, annot=nil)
|
71
|
+
if Hash === title
|
72
|
+
annot = title
|
73
|
+
title = desc = nil
|
74
|
+
elsif Hash === desc
|
75
|
+
annot = desc
|
76
|
+
desc = nil
|
77
|
+
end
|
68
78
|
|
69
|
-
|
79
|
+
title.freeze
|
80
|
+
desc.freeze
|
81
|
+
|
82
|
+
original_desc = desc
|
83
|
+
now = adjust_for_skew(Util::Clock.micros)
|
84
|
+
desc = @instrumenter.limited_description(desc)
|
85
|
+
|
86
|
+
if desc == Instrumenter::TOO_MANY_UNIQUES
|
87
|
+
debug "[SKYLIGHT] A payload description produced <too many uniques>"
|
88
|
+
debug "original desc=%s", original_desc
|
89
|
+
debug "cat=%s, title=%s, desc=%s, annot=%s", cat, title, desc, annot.inspect
|
90
|
+
end
|
70
91
|
|
71
92
|
start(now - gc_time, cat, title, desc, annot)
|
72
93
|
end
|
data/lib/skylight/normalizers.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
require 'skylight/normalizers/default'
|
2
|
+
require 'skylight/util/allocation_free'
|
2
3
|
|
3
4
|
module Skylight
|
4
5
|
# Convert AS::N events to Skylight events
|
@@ -40,6 +41,8 @@ module Skylight
|
|
40
41
|
end
|
41
42
|
|
42
43
|
class RenderNormalizer < Normalizer
|
44
|
+
include AllocationFree
|
45
|
+
|
43
46
|
def setup
|
44
47
|
@paths = config['normalizers.render.view_paths'] || []
|
45
48
|
end
|
@@ -54,19 +57,45 @@ module Skylight
|
|
54
57
|
end
|
55
58
|
|
56
59
|
def relative_path(path, annotations)
|
57
|
-
return path if
|
60
|
+
return path if relative_path?(path)
|
58
61
|
|
59
|
-
root = @paths
|
62
|
+
root = array_find(@paths) { |p| path.start_with?(p) }
|
60
63
|
|
61
64
|
if root
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
+
start = root.size
|
66
|
+
start += 1 if path.getbyte(start) == SEPARATOR_BYTE
|
67
|
+
path[start, path.size]
|
65
68
|
else
|
66
69
|
annotations[:skylight_error] = ["absolute_path", path]
|
67
70
|
"Absolute Path"
|
68
71
|
end
|
69
72
|
end
|
73
|
+
|
74
|
+
private
|
75
|
+
def relative_path?(path)
|
76
|
+
!absolute_path?(path)
|
77
|
+
end
|
78
|
+
|
79
|
+
SEPARATOR_BYTE = File::SEPARATOR.ord
|
80
|
+
|
81
|
+
if File::NULL == "NUL"
|
82
|
+
ALT_SEPARATOR_BYTE = File::ALT_SEPARATOR && File::ALT_SEPARATOR.ord
|
83
|
+
COLON_BYTE = ":".ord
|
84
|
+
def absolute_path?(path)
|
85
|
+
if alpha?(path.getbyte(0)) && path.getbyte(1) == COLON_BYTE
|
86
|
+
byte2 = path.getbyte(2)
|
87
|
+
byte2 == SEPARATOR_BYTE || byte2 == ALT_SEPARATOR_BYTE
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def alpha?(byte)
|
92
|
+
byte >= 65 and byte <= 90 || byte >= 97 and byte <= 122
|
93
|
+
end
|
94
|
+
else
|
95
|
+
def absolute_path?(path)
|
96
|
+
path.getbyte(0) == SEPARATOR_BYTE
|
97
|
+
end
|
98
|
+
end
|
70
99
|
end
|
71
100
|
|
72
101
|
class Container
|
@@ -3,9 +3,11 @@ module Skylight
|
|
3
3
|
class ProcessAction < Normalizer
|
4
4
|
register "process_action.action_controller"
|
5
5
|
|
6
|
+
CAT = "app.controller.request".freeze
|
7
|
+
|
6
8
|
def normalize(trace, name, payload)
|
7
9
|
trace.endpoint = controller_action(payload)
|
8
|
-
[
|
10
|
+
[ CAT, trace.endpoint, nil, normalize_payload(payload) ]
|
9
11
|
end
|
10
12
|
|
11
13
|
private
|
@@ -17,7 +19,9 @@ module Skylight
|
|
17
19
|
def normalize_payload(payload)
|
18
20
|
normalized = {}
|
19
21
|
|
20
|
-
payload.
|
22
|
+
payload.each_key do |key|
|
23
|
+
value = payload[key]
|
24
|
+
|
21
25
|
value = value.inspect unless value.is_a?(String) || value.is_a?(Numeric)
|
22
26
|
normalized[key] = value
|
23
27
|
end
|
@@ -3,9 +3,11 @@ module Skylight
|
|
3
3
|
class RenderCollection < RenderNormalizer
|
4
4
|
register "render_collection.action_view"
|
5
5
|
|
6
|
+
CAT = "view.render.collection".freeze
|
7
|
+
|
6
8
|
def normalize(trace, name, payload)
|
7
9
|
normalize_render(
|
8
|
-
|
10
|
+
CAT,
|
9
11
|
payload,
|
10
12
|
count: payload[:count])
|
11
13
|
end
|
@@ -3,9 +3,11 @@ module Skylight
|
|
3
3
|
class RenderPartial < RenderNormalizer
|
4
4
|
register "render_partial.action_view"
|
5
5
|
|
6
|
+
CAT = "view.render.template".freeze
|
7
|
+
|
6
8
|
def normalize(trace, name, payload)
|
7
9
|
normalize_render(
|
8
|
-
|
10
|
+
CAT,
|
9
11
|
payload,
|
10
12
|
partial: 1)
|
11
13
|
end
|
@@ -3,9 +3,11 @@ module Skylight
|
|
3
3
|
class RenderTemplate < RenderNormalizer
|
4
4
|
register "render_template.action_view"
|
5
5
|
|
6
|
+
CAT = "view.render.template".freeze
|
7
|
+
|
6
8
|
def normalize(trace, name, payload)
|
7
9
|
normalize_render(
|
8
|
-
|
10
|
+
CAT,
|
9
11
|
payload,
|
10
12
|
partial: 0)
|
11
13
|
end
|
@@ -10,6 +10,9 @@ module Skylight
|
|
10
10
|
class SendFile < Normalizer
|
11
11
|
register "send_file.action_controller"
|
12
12
|
|
13
|
+
CAT = "app.controller.send_file".freeze
|
14
|
+
TITLE = "send file".freeze
|
15
|
+
|
13
16
|
def normalize(trace, name, payload)
|
14
17
|
path = payload[:path]
|
15
18
|
|
@@ -20,31 +23,47 @@ module Skylight
|
|
20
23
|
disposition: normalize_disposition(payload),
|
21
24
|
status: normalize_status(payload) }
|
22
25
|
|
23
|
-
title =
|
26
|
+
title = TITLE
|
24
27
|
|
25
28
|
# depending on normalization, we probably want this to eventually
|
26
29
|
# include the full path, but we need to make sure we have a good
|
27
30
|
# deduping strategy first.
|
28
31
|
desc = nil
|
29
32
|
|
30
|
-
[
|
33
|
+
[ CAT, title, desc, annotations ]
|
31
34
|
end
|
32
35
|
|
33
36
|
private
|
34
37
|
|
38
|
+
OCTET_STREAM = "application/octet-stream".freeze
|
39
|
+
ATTACHMENT = "attachment".freeze
|
40
|
+
|
41
|
+
def initialize(*)
|
42
|
+
super
|
43
|
+
|
44
|
+
@mimes = Mime::SET.reduce({}) do |hash, mime|
|
45
|
+
hash[mime.symbol] = mime.to_s.dup.freeze
|
46
|
+
hash
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
35
50
|
def normalize_type(payload)
|
36
|
-
type = payload[:type] ||
|
37
|
-
type =
|
51
|
+
type = payload[:type] || OCTET_STREAM
|
52
|
+
type = @mimes[type] if type.is_a?(Symbol)
|
38
53
|
type
|
39
54
|
end
|
40
55
|
|
56
|
+
def mime_for(type)
|
57
|
+
@mimes[type] ||= Mime[type].to_s.freeze
|
58
|
+
end
|
59
|
+
|
41
60
|
def normalize_status(payload)
|
42
61
|
status = payload[:status] || 200
|
43
62
|
Rack::Utils.status_code(status)
|
44
63
|
end
|
45
64
|
|
46
65
|
def normalize_disposition(payload)
|
47
|
-
payload[:disposition] ||
|
66
|
+
payload[:disposition] || ATTACHMENT
|
48
67
|
end
|
49
68
|
end
|
50
69
|
|
@@ -6,25 +6,24 @@ module Skylight
|
|
6
6
|
class SQL < Normalizer
|
7
7
|
register "sql.active_record"
|
8
8
|
|
9
|
+
CAT = "db.sql.query".freeze
|
10
|
+
|
9
11
|
def normalize(trace, name, payload)
|
10
12
|
case payload[:name]
|
11
13
|
when "SCHEMA", "CACHE"
|
12
14
|
return :skip
|
13
15
|
else
|
14
|
-
name =
|
16
|
+
name = CAT
|
15
17
|
title = payload[:name] || "SQL"
|
16
18
|
end
|
17
19
|
|
18
|
-
|
19
|
-
|
20
|
-
else
|
21
|
-
extracted_title, _, _, error = extract_binds(payload)
|
22
|
-
binds = payload[:binds].map { |col, val| val.inspect }
|
20
|
+
unless payload[:binds].empty?
|
21
|
+
payload[:binds] = payload[:binds].map { |col, val| val.inspect }
|
23
22
|
end
|
24
23
|
|
24
|
+
extracted_title, payload[:sql], binds, error = extract_binds(payload, payload[:binds])
|
25
25
|
title = extracted_title if extracted_title
|
26
26
|
|
27
|
-
|
28
27
|
if payload[:sql]
|
29
28
|
annotations = {
|
30
29
|
sql: payload[:sql],
|
@@ -40,8 +39,8 @@ module Skylight
|
|
40
39
|
end
|
41
40
|
|
42
41
|
private
|
43
|
-
def extract_binds(payload)
|
44
|
-
title, sql, binds = SqlLexer::Lexer.bindify(payload[:sql])
|
42
|
+
def extract_binds(payload, precalculated)
|
43
|
+
title, sql, binds = SqlLexer::Lexer.bindify(payload[:sql], precalculated)
|
45
44
|
[ title, sql, binds, nil ]
|
46
45
|
rescue
|
47
46
|
[ nil, nil, nil, ["sql_parse", payload[:sql]] ]
|
@@ -54,13 +54,20 @@ module Skylight
|
|
54
54
|
end
|
55
55
|
|
56
56
|
def field(rule, name, type, fn, opts)
|
57
|
-
|
57
|
+
field = Field.new(rule, name, type, fn, opts)
|
58
|
+
indexed_fields[fn] = field
|
59
|
+
fields << field
|
60
|
+
fields.sort!
|
58
61
|
__write_initializer
|
59
62
|
attr_accessor name
|
60
63
|
end
|
61
64
|
|
62
65
|
def fields
|
63
|
-
@fields ||=
|
66
|
+
@fields ||= []
|
67
|
+
end
|
68
|
+
|
69
|
+
def indexed_fields
|
70
|
+
@indexed_fields ||= {}
|
64
71
|
end
|
65
72
|
|
66
73
|
def __write_initializer
|
@@ -68,7 +75,7 @@ module Skylight
|
|
68
75
|
|
69
76
|
lines << "def initialize(attrs=nil)"
|
70
77
|
lines << "return unless attrs"
|
71
|
-
fields.
|
78
|
+
fields.each do |fld|
|
72
79
|
lines << "@#{fld.name} = attrs[:#{fld.name}]"
|
73
80
|
end
|
74
81
|
|
@@ -93,7 +100,7 @@ module Skylight
|
|
93
100
|
|
94
101
|
# TODO: Error if any required fields at nil
|
95
102
|
|
96
|
-
fields.
|
103
|
+
fields.each do |fld|
|
97
104
|
if fld.opts[:packed]
|
98
105
|
bytes = encode!(Buffer.new, fld, 0)
|
99
106
|
buf.append_info(fld.fn, Buffer.wire_for(fld.type))
|
@@ -109,27 +116,36 @@ module Skylight
|
|
109
116
|
|
110
117
|
def encode!(buf, fld, fn)
|
111
118
|
v = self[fld.name]
|
112
|
-
v = v.is_a?(Array) ? v : [v]
|
113
|
-
|
114
|
-
v.compact.each do |val|
|
115
|
-
case fld.type
|
116
|
-
when Class # encodable
|
117
|
-
# TODO: raise error if type != val.class
|
118
|
-
buf.append(:string, val.encode, fn)
|
119
|
-
when Module # enum
|
120
|
-
if ! valid_enum?(fld.type, val)
|
121
|
-
raise InvalidValueError.new(fld.name, val)
|
122
|
-
end
|
123
119
|
|
124
|
-
|
125
|
-
|
126
|
-
buf
|
120
|
+
if v.is_a?(Array)
|
121
|
+
v.each do |val|
|
122
|
+
encode_field!(buf, fld.type, val, fn)
|
127
123
|
end
|
124
|
+
else
|
125
|
+
encode_field!(buf, fld.type, v, fn)
|
128
126
|
end
|
129
127
|
|
130
128
|
buf
|
131
129
|
end
|
132
130
|
|
131
|
+
def encode_field!(buf, type, val, fn)
|
132
|
+
return if val.nil?
|
133
|
+
|
134
|
+
case type
|
135
|
+
when Class # encodable
|
136
|
+
# TODO: raise error if type != val.class
|
137
|
+
buf.append(:string, val.encode, fn)
|
138
|
+
when Module # enum
|
139
|
+
if ! valid_enum?(type, val)
|
140
|
+
raise InvalidValueError.new(fld.name, val)
|
141
|
+
end
|
142
|
+
|
143
|
+
buf.append(:int32, val, fn)
|
144
|
+
else
|
145
|
+
buf.append(type, val, fn)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
133
149
|
def valid_enum?(mod, val)
|
134
150
|
!!name_for(mod, val)
|
135
151
|
end
|
@@ -144,13 +160,12 @@ module Skylight
|
|
144
160
|
end
|
145
161
|
|
146
162
|
def validate!
|
147
|
-
fields.
|
163
|
+
fields.each do |fld|
|
148
164
|
if fld.rule == :required && self[fld.name].nil?
|
149
165
|
raise RequiredFieldNotSetError, fld.name
|
150
166
|
end
|
151
167
|
end
|
152
168
|
end
|
153
|
-
|
154
169
|
end
|
155
170
|
|
156
171
|
|
@@ -165,7 +180,7 @@ module Skylight
|
|
165
180
|
while buf.length > 0
|
166
181
|
fn, wire = buf.read_info
|
167
182
|
|
168
|
-
fld =
|
183
|
+
fld = indexed_fields[fn]
|
169
184
|
|
170
185
|
# We don't have a field for with index fn.
|
171
186
|
# Ignore this data and move on.
|
@@ -196,7 +211,7 @@ module Skylight
|
|
196
211
|
end
|
197
212
|
|
198
213
|
# Set defaults
|
199
|
-
fields.
|
214
|
+
fields.each do |f|
|
200
215
|
next if o[f.name] == false
|
201
216
|
o[f.name] ||= f.opts[:default]
|
202
217
|
end
|
@@ -218,6 +233,10 @@ module Skylight
|
|
218
233
|
self.class.fields
|
219
234
|
end
|
220
235
|
|
236
|
+
def indexed_fields
|
237
|
+
self.class.indexed_fields
|
238
|
+
end
|
239
|
+
|
221
240
|
def [](k)
|
222
241
|
__send__(k)
|
223
242
|
end
|
@@ -229,7 +248,7 @@ module Skylight
|
|
229
248
|
def ==(o)
|
230
249
|
return false if (o == nil) || (o == false)
|
231
250
|
return false unless o.respond_to?(:[])
|
232
|
-
fields.
|
251
|
+
fields.all? do |fld|
|
233
252
|
if fld.rule == :repeated
|
234
253
|
Array(self[fld.name]) == Array(o[fld.name])
|
235
254
|
else
|
@@ -239,7 +258,7 @@ module Skylight
|
|
239
258
|
end
|
240
259
|
|
241
260
|
def inspect
|
242
|
-
set = fields.
|
261
|
+
set = fields.select {|fld| self[fld.name] != nil }
|
243
262
|
|
244
263
|
flds = set.map do |fld|
|
245
264
|
val = self[fld.name]
|
@@ -259,7 +278,7 @@ module Skylight
|
|
259
278
|
end
|
260
279
|
|
261
280
|
def to_hash
|
262
|
-
fields.
|
281
|
+
fields.inject({}) do |h, fld|
|
263
282
|
if v = self[fld.name]
|
264
283
|
h[fld.name] = v
|
265
284
|
end
|
@@ -11,6 +11,8 @@ module Skylight
|
|
11
11
|
MinInt64 = -(1<<63)
|
12
12
|
MaxInt64 = (1<<63)-1
|
13
13
|
|
14
|
+
MaxFixnum = (1 << (1.size * 8 - 2) - 1)
|
15
|
+
|
14
16
|
def self.wire_for(type)
|
15
17
|
case type
|
16
18
|
when Class
|
@@ -70,7 +72,7 @@ module Skylight
|
|
70
72
|
|
71
73
|
if ''.respond_to?(:force_encoding)
|
72
74
|
def buf=(buf)
|
73
|
-
@buf = buf.force_encoding(
|
75
|
+
@buf = buf.force_encoding(BINARY)
|
74
76
|
end
|
75
77
|
end
|
76
78
|
|
@@ -78,7 +80,7 @@ module Skylight
|
|
78
80
|
@buf.respond_to?(:bytesize) ? @buf.bytesize : @buf.length
|
79
81
|
end
|
80
82
|
|
81
|
-
BINARY = 'BINARY'
|
83
|
+
BINARY = 'BINARY'.freeze
|
82
84
|
|
83
85
|
# Detect a ruby encodings bug, as far as I know, this exists in
|
84
86
|
# most versions fo JRuby as well as 1.9.2
|
@@ -10,7 +10,7 @@ module Skylight
|
|
10
10
|
append_info(fn, wire)
|
11
11
|
end
|
12
12
|
|
13
|
-
__send__(
|
13
|
+
__send__(HANDLERS[type], val)
|
14
14
|
end
|
15
15
|
|
16
16
|
def append_info(fn, wire)
|
@@ -26,7 +26,7 @@ module Skylight
|
|
26
26
|
end
|
27
27
|
|
28
28
|
def append_fixed64(n)
|
29
|
-
if n
|
29
|
+
if uint64?(n)
|
30
30
|
raise OutOfRangeError, n
|
31
31
|
end
|
32
32
|
|
@@ -77,8 +77,18 @@ module Skylight
|
|
77
77
|
append_fixed64((n << 1) ^ (n >> 63))
|
78
78
|
end
|
79
79
|
|
80
|
+
def uint64?(n)
|
81
|
+
if n < MinUint64
|
82
|
+
false
|
83
|
+
elsif n < MaxFixnum
|
84
|
+
true
|
85
|
+
else
|
86
|
+
n <= MaxUint64
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
80
90
|
def append_uint64(n)
|
81
|
-
|
91
|
+
unless uint64?(n)
|
82
92
|
raise OutOfRangeError, n
|
83
93
|
end
|
84
94
|
|
@@ -110,6 +120,13 @@ module Skylight
|
|
110
120
|
end
|
111
121
|
alias :append_bytes :append_string
|
112
122
|
|
123
|
+
HANDLERS = instance_methods.reduce({}) do |hash, meth|
|
124
|
+
if meth.to_s =~ /^append_(.*)$/
|
125
|
+
hash[$1.to_sym] = meth
|
126
|
+
end
|
127
|
+
|
128
|
+
hash
|
129
|
+
end
|
113
130
|
end
|
114
131
|
end
|
115
132
|
end
|
data/lib/skylight/version.rb
CHANGED
@@ -67,6 +67,9 @@ module Skylight
|
|
67
67
|
def send_error(msg)
|
68
68
|
res = @http_auth.post("/agent/error?hostname=#{escape(config[:'hostname'])}", reason: msg.reason, body: msg.body)
|
69
69
|
|
70
|
+
# error already handled in Util::HTTP
|
71
|
+
return unless res
|
72
|
+
|
70
73
|
unless res.success?
|
71
74
|
if (400..499).include? res.status
|
72
75
|
warn "error wasn't sent successfully; status=%s", res.status
|
@@ -108,6 +111,9 @@ module Skylight
|
|
108
111
|
def refresh_report_token(now)
|
109
112
|
res = @http_auth.get("/agent/authenticate?hostname=#{escape(config[:'hostname'])}")
|
110
113
|
|
114
|
+
# error already handled in Util::HTTP
|
115
|
+
return unless res
|
116
|
+
|
111
117
|
unless res.success?
|
112
118
|
if (400..499).include? res.status
|
113
119
|
warn "token request rejected; status=%s", res.status
|
data/lib/sql_lexer/lexer.rb
CHANGED
@@ -59,21 +59,22 @@ module SqlLexer
|
|
59
59
|
|
60
60
|
Literals = %Q<(?:NULL|TRUE|FALSE)(?=(?:[#{WS}]|#{OpPart}|#{End}))>
|
61
61
|
|
62
|
-
TkWS = %r<[#{WS}]+>
|
63
|
-
TkOptWS = %r<[#{WS}]*>
|
64
|
-
TkOp = %r<[#{OpPart}]>
|
65
|
-
TkPlaceholder = %r<#{Placeholder}>
|
66
|
-
TkNonBind = %r<#{NonBind}>
|
67
|
-
TkQuotedTable = %r<#{QuotedTable}>
|
68
|
-
TkUpdateTable = %r<UPDATE#{TableNext}>
|
69
|
-
TkInsertTable = %r<INSERT[#{WS}]+INTO#{TableNext}>
|
70
|
-
TkDeleteTable = %r<DELETE[#{WS}]+FROM#{TableNext}>
|
71
|
-
TkFromTable = %r<FROM#{TableNext}>
|
72
|
-
TkID = %r<#{ID}>
|
73
|
-
TkEnd = %r<;?[#{WS}]*>
|
74
|
-
TkBind = %r<#{String}|#{Number}|#{Literals}>
|
75
|
-
TkIn = %r<#{InOp}>
|
76
|
-
TkSpecialOp = %r<#{SpecialOps}>
|
62
|
+
TkWS = %r<[#{WS}]+>u
|
63
|
+
TkOptWS = %r<[#{WS}]*>u
|
64
|
+
TkOp = %r<[#{OpPart}]>u
|
65
|
+
TkPlaceholder = %r<#{Placeholder}>u
|
66
|
+
TkNonBind = %r<#{NonBind}>u
|
67
|
+
TkQuotedTable = %r<#{QuotedTable}>iu
|
68
|
+
TkUpdateTable = %r<UPDATE#{TableNext}>iu
|
69
|
+
TkInsertTable = %r<INSERT[#{WS}]+INTO#{TableNext}>iu
|
70
|
+
TkDeleteTable = %r<DELETE[#{WS}]+FROM#{TableNext}>iu
|
71
|
+
TkFromTable = %r<FROM#{TableNext}>iu
|
72
|
+
TkID = %r<#{ID}>u
|
73
|
+
TkEnd = %r<;?[#{WS}]*>u
|
74
|
+
TkBind = %r<#{String}|#{Number}|#{Literals}>u
|
75
|
+
TkIn = %r<#{InOp}>iu
|
76
|
+
TkSpecialOp = %r<#{SpecialOps}>iu
|
77
|
+
TkStartSelect = %r<SELECT(?=(?:[#{WS}]|#{OpPart}))>iu
|
77
78
|
|
78
79
|
STATE_HANDLERS = {
|
79
80
|
begin: :process_begin,
|
@@ -81,91 +82,159 @@ module SqlLexer
|
|
81
82
|
tokens: :process_tokens,
|
82
83
|
bind: :process_bind,
|
83
84
|
non_bind: :process_non_bind,
|
85
|
+
placeholder: :process_placeholder,
|
84
86
|
table_name: :process_table_name,
|
85
87
|
end: :process_end,
|
86
88
|
special: :process_special,
|
87
89
|
in: :process_in
|
88
90
|
}
|
89
91
|
|
90
|
-
def self.bindify(string)
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
end
|
92
|
+
def self.bindify(string, binds=nil)
|
93
|
+
scanner = instance(string)
|
94
|
+
scanner.process(binds)
|
95
|
+
[scanner.title, scanner.output, scanner.binds]
|
95
96
|
end
|
96
97
|
|
97
98
|
attr_reader :output, :binds, :title
|
98
99
|
|
99
|
-
def
|
100
|
-
|
100
|
+
def self.pooled_value(name, default)
|
101
|
+
key = :"__skylight_sql_#{name}"
|
102
|
+
|
103
|
+
singleton_class.class_eval do
|
104
|
+
define_method(name) do
|
105
|
+
value = Thread.current[key] ||= default.dup
|
106
|
+
value.clear
|
107
|
+
value
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
__send__(name)
|
112
|
+
end
|
113
|
+
|
114
|
+
SCANNER_KEY = :__skylight_sql_scanner
|
115
|
+
LEXER_KEY = :__skylight_sql_lexer
|
116
|
+
|
117
|
+
def self.scanner(string='')
|
118
|
+
scanner = Thread.current[SCANNER_KEY] ||= StringScanner.new('')
|
119
|
+
scanner.string = string
|
120
|
+
scanner
|
121
|
+
end
|
122
|
+
|
123
|
+
def self.instance(string)
|
124
|
+
lexer = Thread.current[LEXER_KEY] ||= new
|
125
|
+
lexer.init(string)
|
126
|
+
lexer
|
127
|
+
end
|
128
|
+
|
129
|
+
pooled_value :binds, []
|
130
|
+
pooled_value :table, "*" * 20
|
131
|
+
|
132
|
+
SPACE = " ".freeze
|
133
|
+
|
134
|
+
DEBUG = ENV["DEBUG"]
|
135
|
+
|
136
|
+
def init(string)
|
101
137
|
@state = :begin
|
102
|
-
@
|
103
|
-
@
|
104
|
-
@
|
138
|
+
@debug = DEBUG
|
139
|
+
@binds = self.class.binds
|
140
|
+
@table = self.class.table
|
141
|
+
@title = nil
|
142
|
+
@bind = 0
|
143
|
+
|
144
|
+
self.string = string
|
145
|
+
end
|
146
|
+
|
147
|
+
def string=(value)
|
148
|
+
@input = value
|
149
|
+
|
150
|
+
@scanner = self.class.scanner(value)
|
151
|
+
|
152
|
+
# intentionally allocates; we need to return a new
|
153
|
+
# string as part of this API
|
154
|
+
@output = value.dup
|
105
155
|
end
|
106
156
|
|
107
|
-
|
108
|
-
|
157
|
+
PLACEHOLDER = "?".freeze
|
158
|
+
UNKNOWN = "<unknown>".freeze
|
159
|
+
|
160
|
+
def process(binds)
|
161
|
+
@operation = nil
|
162
|
+
@provided_binds = binds
|
109
163
|
|
110
164
|
while @state
|
111
|
-
if
|
165
|
+
if @debug
|
112
166
|
p @state
|
113
167
|
p @scanner
|
114
168
|
end
|
115
169
|
|
116
|
-
|
170
|
+
__send__ STATE_HANDLERS[@state]
|
117
171
|
end
|
118
172
|
|
119
173
|
pos = 0
|
120
174
|
removed = 0
|
175
|
+
|
176
|
+
# intentionally allocates; the returned binds must
|
177
|
+
# be in a newly produced array
|
121
178
|
extracted_binds = Array.new(@binds.size / 2)
|
122
179
|
|
123
|
-
if @operation &&
|
124
|
-
|
125
|
-
@title = "#{@operation} #{table}"
|
180
|
+
if @operation && !@table.empty?
|
181
|
+
@title = "" << @operation << SPACE << @table
|
126
182
|
end
|
127
183
|
|
128
184
|
while pos < @binds.size
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
185
|
+
if @binds[pos] == nil
|
186
|
+
extracted_binds[pos/2] = @binds[pos+1]
|
187
|
+
else
|
188
|
+
slice = @output[@binds[pos] - removed, @binds[pos+1]]
|
189
|
+
@output[@binds[pos] - removed, @binds[pos+1]] = PLACEHOLDER
|
190
|
+
|
191
|
+
extracted_binds[pos/2] = slice
|
192
|
+
removed += (@binds[pos+1] - 1)
|
193
|
+
end
|
194
|
+
|
133
195
|
pos += 2
|
134
196
|
end
|
135
197
|
|
136
198
|
@binds = extracted_binds
|
199
|
+
nil
|
137
200
|
end
|
138
201
|
|
202
|
+
EMPTY = "".freeze
|
203
|
+
|
139
204
|
def process_begin
|
140
|
-
@scanner.
|
205
|
+
@scanner.skip(TkOptWS)
|
141
206
|
@state = :first_token
|
142
207
|
end
|
143
208
|
|
209
|
+
OP_SELECT_FROM = "SELECT FROM".freeze
|
210
|
+
OP_UPDATE = "UPDATE".freeze
|
211
|
+
OP_INSERT_INTO = "INSERT INTO".freeze
|
212
|
+
OP_DELETE_FROM = "DELETE FROM".freeze
|
213
|
+
|
144
214
|
def process_first_token
|
145
|
-
if @scanner.skip(
|
146
|
-
@operation =
|
215
|
+
if @scanner.skip(TkStartSelect)
|
216
|
+
@operation = OP_SELECT_FROM
|
147
217
|
@state = :tokens
|
148
|
-
|
149
|
-
|
218
|
+
else
|
219
|
+
if @scanner.skip(TkUpdateTable)
|
220
|
+
@operation = OP_UPDATE
|
221
|
+
elsif @scanner.skip(TkInsertTable)
|
222
|
+
@operation = OP_INSERT_INTO
|
223
|
+
elsif @scanner.skip(TkDeleteTable)
|
224
|
+
@operation = OP_DELETE_FROM
|
225
|
+
end
|
150
226
|
|
151
|
-
|
152
|
-
@operation = :UPDATE
|
153
|
-
elsif @scanner.skip(TkInsertTable)
|
154
|
-
@operation = :"INSERT INTO"
|
155
|
-
elsif @scanner.skip(TkDeleteTable)
|
156
|
-
@operation = :"DELETE FROM"
|
227
|
+
@state = :table_name
|
157
228
|
end
|
158
|
-
|
159
|
-
@state = :table_name
|
160
229
|
end
|
161
230
|
|
162
231
|
def process_table_name
|
163
232
|
pos = @scanner.pos
|
164
233
|
|
165
234
|
if @scanner.skip(TkQuotedTable)
|
166
|
-
@table
|
235
|
+
copy_substr(@input, @table, pos + 1, @scanner.pos - 1)
|
167
236
|
elsif @scanner.skip(TkID)
|
168
|
-
@table
|
237
|
+
copy_substr(@input, @table, pos, @scanner.pos)
|
169
238
|
end
|
170
239
|
|
171
240
|
@state = :tokens
|
@@ -174,12 +243,14 @@ module SqlLexer
|
|
174
243
|
def process_tokens
|
175
244
|
@scanner.skip(TkOptWS)
|
176
245
|
|
177
|
-
if @operation ==
|
246
|
+
if @operation == OP_SELECT_FROM && @table.empty? && @scanner.skip(TkFromTable)
|
178
247
|
@state = :table_name
|
179
248
|
elsif @scanner.match?(TkSpecialOp)
|
180
249
|
@state = :special
|
181
250
|
elsif @scanner.match?(TkBind)
|
182
251
|
@state = :bind
|
252
|
+
elsif @scanner.match?(TkPlaceholder)
|
253
|
+
@state = :placeholder
|
183
254
|
elsif @scanner.match?(TkNonBind)
|
184
255
|
@state = :non_bind
|
185
256
|
else
|
@@ -187,10 +258,28 @@ module SqlLexer
|
|
187
258
|
end
|
188
259
|
end
|
189
260
|
|
261
|
+
def process_placeholder
|
262
|
+
@scanner.skip(TkPlaceholder)
|
263
|
+
|
264
|
+
binds << nil
|
265
|
+
|
266
|
+
if !@provided_binds
|
267
|
+
@binds << UNKNOWN
|
268
|
+
elsif !@provided_binds[@bind]
|
269
|
+
@binds << UNKNOWN
|
270
|
+
else
|
271
|
+
@binds << @provided_binds[@bind]
|
272
|
+
end
|
273
|
+
|
274
|
+
@bind += 1
|
275
|
+
|
276
|
+
@state = :tokens
|
277
|
+
end
|
278
|
+
|
190
279
|
def process_special
|
191
280
|
if @scanner.skip(TkIn)
|
192
281
|
@scanner.skip(TkOptWS)
|
193
|
-
@scanner.skip(/\(/)
|
282
|
+
@scanner.skip(/\(/u)
|
194
283
|
@state = :in
|
195
284
|
end
|
196
285
|
end
|
@@ -209,16 +298,16 @@ module SqlLexer
|
|
209
298
|
raise "The SQL '#{@scanner.string}' could not be parsed because of too many iterations in IN"
|
210
299
|
end
|
211
300
|
|
212
|
-
if
|
301
|
+
if @debug
|
213
302
|
p @state
|
214
303
|
p @scanner
|
215
304
|
p nest
|
216
305
|
end
|
217
306
|
|
218
|
-
if @scanner.skip(/\(/)
|
307
|
+
if @scanner.skip(/\(/u)
|
219
308
|
nest += 1
|
220
309
|
process_tokens
|
221
|
-
elsif @scanner.skip(/\)/)
|
310
|
+
elsif @scanner.skip(/\)/u)
|
222
311
|
nest -= 1
|
223
312
|
break if nest.zero?
|
224
313
|
process_tokens
|
@@ -263,5 +352,19 @@ module SqlLexer
|
|
263
352
|
|
264
353
|
@state = nil
|
265
354
|
end
|
355
|
+
|
356
|
+
private
|
357
|
+
def copy_substr(source, target, start_pos, end_pos)
|
358
|
+
pos = start_pos
|
359
|
+
|
360
|
+
while pos < end_pos
|
361
|
+
target.concat source.getbyte(pos)
|
362
|
+
pos += 1
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
scanner
|
367
|
+
instance('')
|
368
|
+
|
266
369
|
end
|
267
370
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: skylight
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.0.beta.
|
4
|
+
version: 0.2.0.beta.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tilde, Inc.
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2013-11-
|
11
|
+
date: 2013-11-09 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -62,6 +62,7 @@ files:
|
|
62
62
|
- lib/skylight/normalizers/sql.rb
|
63
63
|
- lib/skylight/railtie.rb
|
64
64
|
- lib/skylight/subscriber.rb
|
65
|
+
- lib/skylight/util/allocation_free.rb
|
65
66
|
- lib/skylight/util/clock.rb
|
66
67
|
- lib/skylight/util/gzip.rb
|
67
68
|
- lib/skylight/util/http.rb
|
@@ -153,7 +154,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
153
154
|
version: 1.3.1
|
154
155
|
requirements: []
|
155
156
|
rubyforge_project:
|
156
|
-
rubygems_version: 2.
|
157
|
+
rubygems_version: 2.1.9
|
157
158
|
signing_key:
|
158
159
|
specification_version: 4
|
159
160
|
summary: Skylight is a ruby application monitoring tool.
|