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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/exe/sillydev +6 -0
- data/lib/sillydev/cli.rb +295 -0
- data/lib/sillydev/client.rb +199 -0
- data/lib/sillydev/console.rb +1131 -0
- data/lib/sillydev/envelope.rb +84 -0
- data/lib/sillydev/errors.rb +160 -0
- data/lib/sillydev/mcp_server.rb +193 -0
- data/lib/sillydev/monitoring/aggregator.rb +107 -0
- data/lib/sillydev/monitoring/client.rb +674 -0
- data/lib/sillydev/monitoring/discordrb.rb +131 -0
- data/lib/sillydev/monitoring/http.rb +31 -0
- data/lib/sillydev/monitoring/rack.rb +114 -0
- data/lib/sillydev/monitoring/rails.rb +41 -0
- data/lib/sillydev/monitoring/runtime.rb +96 -0
- data/lib/sillydev/monitoring/serialize.rb +168 -0
- data/lib/sillydev/monitoring.rb +227 -0
- data/lib/sillydev/resources.rb +844 -0
- data/lib/sillydev/transport.rb +71 -0
- data/lib/sillydev/version.rb +5 -0
- data/lib/sillydev/websocket.rb +248 -0
- data/lib/sillydev.rb +20 -0
- metadata +67 -0
|
@@ -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
|