sillydev 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../monitoring"
4
+
5
+ module SillyDev
6
+ module Monitoring
7
+ # discordrb integration.
8
+ #
9
+ # require "discordrb"
10
+ # require "sillydev/monitoring/discordrb"
11
+ #
12
+ # SillyDev::Monitoring.init
13
+ # bot = Discordrb::Commands::CommandBot.new(token: ENV["TOKEN"], prefix: "!")
14
+ # SillyDev::Monitoring::Discordrb.monitor(bot)
15
+ #
16
+ # Records discord.command.duration (tags command, outcome = ok/error) for prefix commands and
17
+ # application (slash) commands, reports errors raised in event handlers and commands, and sends
18
+ # the discord.guilds gauge every window. Errors still reach discordrb's own logging as before.
19
+ # Loading this file does not load discordrb.
20
+ module Discordrb
21
+ module_function
22
+
23
+ # Instruments a Discordrb::Bot or Discordrb::Commands::CommandBot. Returns it.
24
+ def monitor(bot)
25
+ return bot if bot.instance_variable_defined?(:@__sillydev_monitoring)
26
+
27
+ bot.instance_variable_set(:@__sillydev_monitoring, true)
28
+ bot.singleton_class.prepend(EventHook) if bot.respond_to?(:call_event, true)
29
+ bot.singleton_class.prepend(CommandHook) if bot.respond_to?(:execute_command, true)
30
+ bot_ref = bot
31
+ collector = lambda do |client|
32
+ servers = bot_ref.instance_variable_get(:@servers)
33
+ client.gauge("discord.guilds", servers.size) if servers.respond_to?(:size)
34
+ end
35
+ Monitoring.collectors << collector
36
+ bot
37
+ end
38
+
39
+ def record(command, ms, outcome)
40
+ Monitoring.timing("discord.command.duration", ms, tags: { "command" => command, "outcome" => outcome })
41
+ client = Monitoring.client
42
+ return unless client && ms >= client.slow_threshold_ms
43
+
44
+ Monitoring.record_slow(command, ms, kind: "discord", tags: { "outcome" => outcome })
45
+ end
46
+
47
+ def context(event)
48
+ out = {}
49
+ server = event.respond_to?(:server) ? event.server : nil
50
+ channel = event.respond_to?(:channel) ? event.channel : nil
51
+ out["guild_id"] = server.id.to_s if server.respond_to?(:id)
52
+ out["channel_id"] = channel.id.to_s if channel.respond_to?(:id)
53
+ out
54
+ rescue StandardError
55
+ {}
56
+ end
57
+
58
+ def application_command_name(event)
59
+ return nil unless event.class.name.to_s.end_with?("ApplicationCommandEvent") && event.respond_to?(:command_name)
60
+
61
+ [event.command_name, event.respond_to?(:subcommand_group) ? event.subcommand_group : nil,
62
+ event.respond_to?(:subcommand) ? event.subcommand : nil].compact.join(" ")
63
+ end
64
+
65
+ def now
66
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ end
68
+
69
+ # Wraps an event handler so its run is timed (for application commands) and its errors are
70
+ # reported, then raised again for discordrb to log.
71
+ class Handler
72
+ def initialize(handler)
73
+ @handler = handler
74
+ end
75
+
76
+ def call(event)
77
+ return @handler.call(event) unless Monitoring.enabled?
78
+
79
+ command = Discordrb.application_command_name(event)
80
+ start = Discordrb.now
81
+ begin
82
+ result = @handler.call(event)
83
+ Discordrb.record(command, (Discordrb.now - start) * 1000.0, "ok") if command
84
+ result
85
+ rescue StandardError => e
86
+ Discordrb.record(command, (Discordrb.now - start) * 1000.0, "error") if command
87
+ tags = command ? { "command" => command } : { "event" => event.class.name.to_s.split("::").last }
88
+ Monitoring.capture_exception(e, handled: false, tags: tags, context: Discordrb.context(event))
89
+ raise
90
+ end
91
+ end
92
+
93
+ def respond_to_missing?(name, include_all = false)
94
+ @handler.respond_to?(name, include_all)
95
+ end
96
+
97
+ def method_missing(name, ...)
98
+ @handler.respond_to?(name) ? @handler.public_send(name, ...) : super
99
+ end
100
+ end
101
+
102
+ # Prepended to the bot: runs every handler through Handler.
103
+ module EventHook
104
+ private
105
+
106
+ def call_event(handler, event)
107
+ super(Handler.new(handler), event)
108
+ end
109
+ end
110
+
111
+ # Prepended to a CommandBot: times prefix commands.
112
+ module CommandHook
113
+ def execute_command(name, event, *rest)
114
+ return super unless Monitoring.enabled?
115
+
116
+ start = Discordrb.now
117
+ begin
118
+ result = super
119
+ Discordrb.record(name.to_s, (Discordrb.now - start) * 1000.0, "ok")
120
+ result
121
+ rescue StandardError => e
122
+ Discordrb.record(name.to_s, (Discordrb.now - start) * 1000.0, "error")
123
+ Monitoring.capture_exception(e, handled: false, tags: { "command" => name.to_s },
124
+ context: Discordrb.context(event))
125
+ raise
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SillyDev
4
+ module Monitoring
5
+ # Helpers shared by the HTTP integrations.
6
+ module HTTP
7
+ ID_SEGMENT = /\A(?:\d+|\h{8}-\h{4}-\h{4}-\h{4}-\h{12}|\h{8,}|[A-Za-z0-9_-]{24,})\z/
8
+
9
+ module_function
10
+
11
+ def status_class(status)
12
+ return "5xx" if status.nil? || status < 100 || status > 599
13
+
14
+ "#{status / 100}xx"
15
+ end
16
+
17
+ # A route-like path for apps whose router gives no template: ids become :id. Keeps the number
18
+ # of series down (each distinct route is a series).
19
+ def normalize_path(path)
20
+ path = (path.nil? || path.empty? ? "/" : path).split("?", 2).first
21
+ parts = path.split("/", -1).map { |part| ID_SEGMENT.match?(part) ? ":id" : part }
22
+ joined = parts.join("/")
23
+ (joined.empty? ? "/" : joined)[0, 200]
24
+ end
25
+
26
+ def route_label(method, template)
27
+ "#{method.to_s.upcase} #{template}"[0, 64]
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../monitoring"
4
+ require_relative "http"
5
+
6
+ module SillyDev
7
+ module Monitoring
8
+ # Rack middleware (Rails, Sinatra, Hanami, Roda and any other Rack app).
9
+ #
10
+ # # config.ru
11
+ # require "sillydev/monitoring/rack"
12
+ # SillyDev::Monitoring.init
13
+ # use SillyDev::Monitoring::Rack
14
+ #
15
+ # Rails apps can require "sillydev/monitoring/rails" instead, which adds it for you. Records
16
+ # http.server.duration (tags route, method, status), reports unhandled exceptions and slow
17
+ # requests. The route is the router's template where Rails (7.1 and later) or Sinatra say it;
18
+ # otherwise the path with ids replaced by :id. Call Rack.set_route(env, "/orders/:id") to name it
19
+ # yourself.
20
+ class Rack
21
+ ROUTE_KEY = "sillydev.monitoring.route"
22
+
23
+ def self.set_route(env, template)
24
+ env[ROUTE_KEY] = template
25
+ end
26
+
27
+ def initialize(app, slow_threshold_ms: nil, route_name: nil)
28
+ @app = app
29
+ @slow_threshold_ms = slow_threshold_ms
30
+ @route_name = route_name
31
+ end
32
+
33
+ def call(env)
34
+ return @app.call(env) unless Monitoring.enabled?
35
+
36
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
+ begin
38
+ status, headers, body = @app.call(env)
39
+ rescue Exception => e # rubocop:disable Lint/RescueException
40
+ record(env, nil, start, e)
41
+ raise
42
+ end
43
+ [status, headers, BodyProxy.new(body) { record(env, status.to_i, start, nil) }]
44
+ end
45
+
46
+ # Calls the block once the server has closed the body, so the time includes sending it.
47
+ class BodyProxy
48
+ def initialize(body, &done)
49
+ @body = body
50
+ @done = done
51
+ @finished = false
52
+ end
53
+
54
+ def respond_to_missing?(name, include_all = false)
55
+ name != :to_ary && @body.respond_to?(name, include_all)
56
+ end
57
+
58
+ def method_missing(name, ...)
59
+ return super if name == :to_ary
60
+
61
+ @body.__send__(name, ...)
62
+ end
63
+
64
+ def each(&)
65
+ @body.each(&)
66
+ end
67
+
68
+ def close
69
+ @body.close if @body.respond_to?(:close)
70
+ ensure
71
+ unless @finished
72
+ @finished = true
73
+ @done.call
74
+ end
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def record(env, status, start, failed)
81
+ ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0
82
+ status = 500 if failed && (status.nil? || status < 500)
83
+ # Rails and Sinatra render an exception into a 500 before it reaches here, and keep it in the env.
84
+ failed ||= env["action_dispatch.exception"] || env["sinatra.error"] if status && status >= 500
85
+ method = env["REQUEST_METHOD"].to_s.upcase
86
+ method = "GET" if method.empty?
87
+ route = HTTP.route_label(method, template(env, status))
88
+ Monitoring.timing("http.server.duration", ms,
89
+ tags: { "route" => route, "method" => method, "status" => HTTP.status_class(status) })
90
+ Monitoring.capture_exception(failed, handled: false, tags: { "route" => route, "method" => method }) if failed.is_a?(Exception)
91
+ client = Monitoring.client
92
+ threshold = @slow_threshold_ms || (client ? client.slow_threshold_ms : 1000)
93
+ Monitoring.record_slow(route, ms, kind: "http", tags: { "status" => (status || 500).to_s }) if ms >= threshold
94
+ rescue StandardError
95
+ nil
96
+ end
97
+
98
+ def template(env, status)
99
+ named = @route_name&.call(env)
100
+ return named.to_s if named
101
+
102
+ route = env[ROUTE_KEY] || env["action_dispatch.route_uri_pattern"]
103
+ return route.to_s.sub(/\(\.:format\)\z/, "") if route
104
+
105
+ sinatra = env["sinatra.route"]
106
+ return sinatra.to_s.split(" ", 2).last if sinatra
107
+
108
+ return "<unmatched>" if status == 404
109
+
110
+ HTTP.normalize_path("#{env['SCRIPT_NAME']}#{env['PATH_INFO']}")
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rack"
4
+
5
+ module SillyDev
6
+ module Monitoring
7
+ # Rails integration: adds SillyDev::Monitoring::Rack to the app's middleware and reports the
8
+ # errors Rails.error sees (Rails 7 and later). Monitoring still does nothing until
9
+ # SillyDev::Monitoring.init is called, for example in config/initializers/sillydev.rb:
10
+ #
11
+ # # Gemfile
12
+ # gem "sillydev", require: ["sillydev", "sillydev/monitoring/rails"]
13
+ #
14
+ # # config/initializers/sillydev.rb
15
+ # SillyDev::Monitoring.init(service: "web")
16
+ class Railtie < ::Rails::Railtie
17
+ initializer "sillydev.monitoring.middleware" do |app|
18
+ app.middleware.insert(0, SillyDev::Monitoring::Rack)
19
+ end
20
+
21
+ config.after_initialize do
22
+ if defined?(::Rails.error) && ::Rails.error.respond_to?(:subscribe)
23
+ ::Rails.error.subscribe(SillyDev::Monitoring::Railtie::ErrorSubscriber.new)
24
+ end
25
+ end
26
+
27
+ # Passes errors reported through Rails.error (Rails.error.handle, .record and .report, and the
28
+ # jobs and requests Rails reports itself) to monitoring.
29
+ class ErrorSubscriber
30
+ def report(error, handled:, severity: nil, context: {}, source: nil)
31
+ tags = {}
32
+ tags["severity"] = severity.to_s if severity
33
+ tags["source"] = source.to_s if source
34
+ SillyDev::Monitoring.capture_exception(error, handled: handled, tags: tags, context: context)
35
+ rescue StandardError
36
+ nil
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module SillyDev
6
+ module Monitoring
7
+ # Process and runtime metrics, sampled once per flush window by the client's thread.
8
+ class RuntimeCollector
9
+ def initialize
10
+ reset
11
+ end
12
+
13
+ # Starts again from now (in a child process after fork).
14
+ def reset
15
+ @wall = monotonic
16
+ @cpu = cpu_time
17
+ @gc_count = GC.count
18
+ @gc_time = gc_time
19
+ @started = process_start
20
+ end
21
+
22
+ # [[name, type, value], ...]
23
+ def collect
24
+ samples = []
25
+ wall = monotonic
26
+ cpu = cpu_time
27
+ samples << ["process.cpu.percent", "gauge", ((cpu - @cpu) / (wall - @wall) * 100.0).round(3)] if wall > @wall
28
+ @wall = wall
29
+ @cpu = cpu
30
+
31
+ rss = self.rss
32
+ samples << ["process.memory.rss", "gauge", rss] if rss
33
+
34
+ count = GC.count
35
+ collections = [count - @gc_count, 0].max
36
+ samples << ["runtime.gc.count", "counter", collections.to_f]
37
+ time = gc_time
38
+ if time && @gc_time && collections.positive?
39
+ # Ruby keeps the total time spent in GC, not each pause: record the average pause once
40
+ # per collection in the window.
41
+ average = [(time - @gc_time).to_f / collections, 0.0].max
42
+ [collections, 1000].min.times { samples << ["runtime.gc.pause", "histogram", average] }
43
+ end
44
+ @gc_count = count
45
+ @gc_time = time
46
+
47
+ samples << ["process.threads", "gauge", Thread.list.size.to_f]
48
+ samples << ["process.uptime", "gauge", [Time.now.to_f - @started, 0.0].max.round(3)]
49
+ samples
50
+ end
51
+
52
+ def rss
53
+ statm = File.read("/proc/self/statm").split
54
+ statm[1].to_i * page_size.to_f
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ private
60
+
61
+ def monotonic
62
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
63
+ end
64
+
65
+ def cpu_time
66
+ Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
67
+ end
68
+
69
+ # Milliseconds spent in GC so far, where Ruby keeps it (3.1 and later).
70
+ def gc_time
71
+ GC.stat(:time)
72
+ rescue ArgumentError
73
+ nil
74
+ end
75
+
76
+ def page_size
77
+ @page_size ||= begin
78
+ Etc.sysconf(Etc::SC_PAGESIZE)
79
+ rescue StandardError
80
+ 4096
81
+ end
82
+ end
83
+
84
+ # Wall-clock time the process started (falls back to now).
85
+ def process_start
86
+ fields = File.read("/proc/self/stat").split(")").last.split
87
+ start_ticks = fields[19].to_i
88
+ uptime = File.read("/proc/uptime").split.first.to_f
89
+ ticks = Etc.sysconf(Etc::SC_CLK_TCK)
90
+ Time.now.to_f - (uptime - (start_ticks.to_f / ticks))
91
+ rescue StandardError
92
+ Time.now.to_f
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rbconfig"
5
+ require "set"
6
+
7
+ module SillyDev
8
+ module Monitoring
9
+ # Turning names, tags, exceptions and attributes into the shapes of feathers/docs/ingest.md.
10
+ module Serialize
11
+ NAME_RE = /\A[a-z][a-z0-9_.]{0,99}\z/
12
+ TAG_KEY_RE = /\A[a-z_][a-z0-9_.]{0,31}\z/
13
+ SENSITIVE_RE = /password|passwd|secret|token|authorization|cookie|api[_-]?key/i
14
+ REDACTED = "[redacted]"
15
+
16
+ MAX_TAGS = 8
17
+ MAX_TAG_VALUE = 64
18
+ MAX_TYPE = 128
19
+ MAX_MESSAGE = 1024
20
+ MAX_FRAME_FIELD = 256
21
+ MAX_FRAMES = 64
22
+ MAX_CONTEXT_BYTES = 16 * 1024
23
+ MAX_ATTRIBUTES_BYTES = 4 * 1024
24
+ MAX_FINGERPRINT = 128
25
+ SLOW_KINDS = %w[http discord task custom].freeze
26
+
27
+ module_function
28
+
29
+ def valid_name?(name)
30
+ name.is_a?(String) && NAME_RE.match?(name)
31
+ end
32
+
33
+ # Makes a metric name out of arbitrary text.
34
+ def to_name(text)
35
+ name = text.to_s.downcase.gsub(/[^a-z0-9_.]+/, "_").gsub(/\A[_.]+|[_.]+\z/, "").gsub(/\.{2,}/, ".")
36
+ name = "fn.#{name}" if name.empty? || !name[0].match?(/[a-z]/)
37
+ name = name[0, 100].sub(/\.+\z/, "")
38
+ name.empty? ? "fn" : name
39
+ end
40
+
41
+ def text(value)
42
+ return value if value.is_a?(String)
43
+ return value.to_s if value.is_a?(Symbol)
44
+
45
+ JSON.generate(value)
46
+ rescue StandardError
47
+ value.to_s
48
+ end
49
+
50
+ # At most 8 valid tags as sorted [key, value] pairs, values as strings of at most 64
51
+ # characters, sensitive keys redacted.
52
+ def clean_tags(tags)
53
+ return [] if tags.nil? || tags.empty?
54
+
55
+ out = {}
56
+ tags.each do |key, value|
57
+ key = key.to_s if key.is_a?(Symbol)
58
+ next unless key.is_a?(String) && TAG_KEY_RE.match?(key)
59
+
60
+ out[key] = SENSITIVE_RE.match?(key) ? REDACTED : text(value)[0, MAX_TAG_VALUE]
61
+ end
62
+ out.sort.first(MAX_TAGS).freeze
63
+ end
64
+
65
+ # A JSON-safe copy of value with the values of sensitive keys replaced.
66
+ def redact(value, depth = 0)
67
+ return "[too deep]" if depth > 8
68
+
69
+ case value
70
+ when Hash
71
+ value.to_h { |k, v| [k.to_s, SENSITIVE_RE.match?(k.to_s) ? REDACTED : redact(v, depth + 1)] }
72
+ when Array, Set then value.map { |v| redact(v, depth + 1) }
73
+ when nil, true, false, Integer, String then value
74
+ when Float then value.finite? ? value : value.to_s
75
+ else value.to_s
76
+ end
77
+ end
78
+
79
+ # A redacted JSON object, or {"_truncated" => true} when its JSON text is over limit bytes.
80
+ def bounded_object(value, limit)
81
+ return {} if value.nil? || (value.respond_to?(:empty?) && value.empty?)
82
+
83
+ value = { "value" => value } unless value.is_a?(Hash)
84
+ cleaned = redact(value)
85
+ JSON.generate(cleaned).bytesize > limit ? { "_truncated" => true } : cleaned
86
+ end
87
+
88
+ LIBRARY_DIRS = [RbConfig::CONFIG["rubylibdir"], RbConfig::CONFIG["rubyarchdir"]].compact.map do |dir|
89
+ File.expand_path(dir)
90
+ end.freeze
91
+
92
+ # [path to report, in_app] for a frame's file.
93
+ def split_path(path)
94
+ return [path.to_s, false] if path.nil? || path.start_with?("<") || path.start_with?("(")
95
+
96
+ normalized = path.tr("\\", "/")
97
+ if (index = normalized.rindex("/gems/"))
98
+ rest = normalized[(index + 6)..]
99
+ return [rest, false]
100
+ end
101
+ full = File.expand_path(path)
102
+ LIBRARY_DIRS.each do |lib|
103
+ return [full.delete_prefix("#{lib}/"), false] if full.start_with?("#{lib}/")
104
+ end
105
+ cwd = Dir.pwd
106
+ return [full.delete_prefix("#{cwd}/"), true] if full.start_with?("#{cwd}/")
107
+
108
+ [normalized, true]
109
+ rescue StandardError
110
+ [path.to_s, true]
111
+ end
112
+
113
+ OWN_DIR = File.expand_path("../..", __dir__)
114
+
115
+ # Frames innermost first, at most 64.
116
+ def stack_frames(exception)
117
+ locations = exception.backtrace_locations
118
+ if locations
119
+ locations.first(MAX_FRAMES).map do |location|
120
+ frame(location.absolute_path || location.path, location.lineno, location.label)
121
+ end
122
+ else
123
+ (exception.backtrace || []).first(MAX_FRAMES).map do |line|
124
+ path, lineno, label = line.match(/\A(.*?):(\d+)(?::in [`'](.*)')?\z/)&.captures
125
+ frame(path || line, lineno.to_i, label)
126
+ end
127
+ end
128
+ end
129
+
130
+ def frame(path, lineno, label)
131
+ file, in_app = split_path(path.to_s)
132
+ in_app = false if path.to_s.start_with?("#{OWN_DIR}/")
133
+ {
134
+ "file" => file[0, MAX_FRAME_FIELD],
135
+ "function" => label.to_s[0, MAX_FRAME_FIELD],
136
+ "line" => lineno,
137
+ "column" => nil,
138
+ "module" => nil,
139
+ "in_app" => in_app,
140
+ }
141
+ end
142
+
143
+ def exception_type(exception)
144
+ (exception.class.name || exception.class.to_s)[0, MAX_TYPE]
145
+ end
146
+
147
+ def exception_message(exception)
148
+ exception.message.to_s[0, MAX_MESSAGE]
149
+ rescue StandardError
150
+ "<unprintable exception>"
151
+ end
152
+
153
+ def serialize_exception(exception)
154
+ item = {
155
+ "type" => exception_type(exception),
156
+ "message" => exception_message(exception),
157
+ "stack" => stack_frames(exception),
158
+ }
159
+ cause = exception.cause
160
+ if cause && !cause.equal?(exception)
161
+ item["cause"] = { "type" => exception_type(cause), "message" => exception_message(cause),
162
+ "stack" => stack_frames(cause) }
163
+ end
164
+ item
165
+ end
166
+ end
167
+ end
168
+ end