sitegoose 0.3.3
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 +342 -0
- data/lib/sitegoose/analytics.rb +113 -0
- data/lib/sitegoose/challenge.rb +177 -0
- data/lib/sitegoose/core.rb +523 -0
- data/lib/sitegoose/crawlers.rb +195 -0
- data/lib/sitegoose/errors.rb +456 -0
- data/lib/sitegoose/firewall.rb +417 -0
- data/lib/sitegoose/inventory.rb +131 -0
- data/lib/sitegoose/ip.rb +159 -0
- data/lib/sitegoose/middleware.rb +52 -0
- data/lib/sitegoose/performance.rb +114 -0
- data/lib/sitegoose/platform.rb +557 -0
- data/lib/sitegoose/posture.rb +272 -0
- data/lib/sitegoose/proxy.rb +260 -0
- data/lib/sitegoose/queries.rb +399 -0
- data/lib/sitegoose/query_text.rb +215 -0
- data/lib/sitegoose/railtie.rb +124 -0
- data/lib/sitegoose/request_rules.rb +390 -0
- data/lib/sitegoose/resources.rb +449 -0
- data/lib/sitegoose/sidekiq.rb +95 -0
- data/lib/sitegoose/small_modules.rb +250 -0
- data/lib/sitegoose/version.rb +5 -0
- data/lib/sitegoose.rb +261 -0
- metadata +92 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Crawlers module: counts the search engines and AI bots that fetch the site
|
|
4
|
+
# and sends one row a minute per bot, page and status to POST /events/crawlers
|
|
5
|
+
# (the protocol is sdk/protocols/crawlers.md). This traffic is invisible to the
|
|
6
|
+
# browser tracker, because crawlers don't run JavaScript: the server is the
|
|
7
|
+
# only place it can be counted.
|
|
8
|
+
#
|
|
9
|
+
# Only requests whose user agent names a known crawler are counted, so
|
|
10
|
+
# visitors are never counted twice. Nothing about a visitor is sent; bot
|
|
11
|
+
# addresses go with the batch only so the collector can check a crawler is
|
|
12
|
+
# itself, and are not stored.
|
|
13
|
+
|
|
14
|
+
module SiteGoose
|
|
15
|
+
class Crawlers
|
|
16
|
+
FLUSH_EVERY = 60
|
|
17
|
+
MAX_KEYS = 300
|
|
18
|
+
MAX_ROWS = 500
|
|
19
|
+
MAX_IPS_PER_BOT = 5
|
|
20
|
+
MAX_PATH = 400
|
|
21
|
+
|
|
22
|
+
# The catalogue of the protocol, most specific first: "ChatGPT-User" must
|
|
23
|
+
# be tried before "GPTBot", "Claude-User" before "ClaudeBot". Keep in step
|
|
24
|
+
# with the collector (expresscollect/Analytics/crawlers.js CATALOGUE).
|
|
25
|
+
BOTS = [
|
|
26
|
+
# Fetched because a person asked an AI about the page.
|
|
27
|
+
["ChatGPT-User", "ChatGPT-User"],
|
|
28
|
+
["OAI-SearchBot", "OAI-SearchBot"],
|
|
29
|
+
["Perplexity-User", "Perplexity-User"],
|
|
30
|
+
["Claude-User", "Claude-User"],
|
|
31
|
+
["Claude-SearchBot", "Claude-SearchBot"],
|
|
32
|
+
["MistralAI-User", "MistralAI-User"],
|
|
33
|
+
["MistralAI-SearchBot", "MistralAI-SearchBot"],
|
|
34
|
+
["Meta-ExternalFetcher", "meta-externalfetcher"],
|
|
35
|
+
# Bulk collection, for training or a dataset.
|
|
36
|
+
["GPTBot", "GPTBot"],
|
|
37
|
+
["ClaudeBot", "ClaudeBot|Claude-Web"],
|
|
38
|
+
["anthropic-ai", "anthropic-ai"],
|
|
39
|
+
["CCBot", "CCBot"],
|
|
40
|
+
["Bytespider", "Bytespider"],
|
|
41
|
+
["Meta-ExternalAgent", "meta-externalagent|facebookbot"],
|
|
42
|
+
["Amazonbot", "Amazonbot"],
|
|
43
|
+
["Diffbot", "Diffbot"],
|
|
44
|
+
["omgili", "omgili"],
|
|
45
|
+
["AI2Bot", "AI2Bot"],
|
|
46
|
+
["Timpibot", "Timpibot"],
|
|
47
|
+
["Webzio-Extended", "Webzio-Extended"],
|
|
48
|
+
["PerplexityBot", "PerplexityBot"],
|
|
49
|
+
["DuckAssistBot", "DuckAssistBot"],
|
|
50
|
+
["YouBot", "YouBot"],
|
|
51
|
+
# Search engines. Google's tools before Googlebot itself.
|
|
52
|
+
["Google-InspectionTool", "Google-InspectionTool"],
|
|
53
|
+
["Storebot-Google", "Storebot-Google"],
|
|
54
|
+
["AdsBot-Google", "AdsBot-Google"],
|
|
55
|
+
["Googlebot", "Googlebot|Google-Site-Verification"],
|
|
56
|
+
["bingbot", "bingbot|BingPreview|adidxbot"],
|
|
57
|
+
["YandexBot", "Yandex(Bot|Mobile|Images|Webmaster)?"],
|
|
58
|
+
["Baiduspider", "Baiduspider"],
|
|
59
|
+
["DuckDuckBot", "DuckDuckBot|DuckDuckGo-Favicons"],
|
|
60
|
+
["Applebot", "Applebot"],
|
|
61
|
+
["PetalBot", "PetalBot|AspiegelBot"],
|
|
62
|
+
["SeznamBot", "SeznamBot"],
|
|
63
|
+
["Yeti", "Yeti/"],
|
|
64
|
+
["MojeekBot", "MojeekBot"],
|
|
65
|
+
["Qwantbot", "Qwantbot|Qwantify"],
|
|
66
|
+
# SEO and backlink tools.
|
|
67
|
+
["AhrefsBot", "AhrefsBot|AhrefsSiteAudit"],
|
|
68
|
+
["SemrushBot", "SemrushBot|SiteAuditBot"],
|
|
69
|
+
["DotBot", "DotBot"],
|
|
70
|
+
["MJ12bot", "MJ12bot"],
|
|
71
|
+
["BLEXBot", "BLEXBot"],
|
|
72
|
+
["DataForSeoBot", "DataForSeoBot"],
|
|
73
|
+
["Barkrowler", "Barkrowler"],
|
|
74
|
+
["ScreamingFrog", "Screaming\\s?Frog"],
|
|
75
|
+
["ZoominfoBot", "ZoominfoBot"],
|
|
76
|
+
# Link previews.
|
|
77
|
+
["facebookexternalhit", "facebookexternalhit"],
|
|
78
|
+
["Twitterbot", "Twitterbot"],
|
|
79
|
+
["LinkedInBot", "LinkedInBot"],
|
|
80
|
+
["Slackbot", "Slackbot|Slack-ImgProxy"],
|
|
81
|
+
["Discordbot", "Discordbot"],
|
|
82
|
+
["TelegramBot", "TelegramBot"],
|
|
83
|
+
["WhatsApp", "WhatsApp"],
|
|
84
|
+
["Pinterestbot", "Pinterest(bot)?/"],
|
|
85
|
+
["redditbot", "redditbot"],
|
|
86
|
+
# Uptime and speed checks.
|
|
87
|
+
["UptimeRobot", "UptimeRobot"],
|
|
88
|
+
["Pingdom", "Pingdom"],
|
|
89
|
+
["BetterUptime", "Better\\s?Uptime|betteruptime"],
|
|
90
|
+
["Lighthouse", "Chrome-Lighthouse|Google Page Speed"],
|
|
91
|
+
["GTmetrix", "GTmetrix"],
|
|
92
|
+
# Archives.
|
|
93
|
+
["ia_archiver", "ia_archiver"],
|
|
94
|
+
["archive.org_bot", "archive\\.org_bot|Wayback"]
|
|
95
|
+
].map { |name, pattern| [name, Regexp.new(pattern, Regexp::IGNORECASE)] }.freeze
|
|
96
|
+
|
|
97
|
+
# Anything else that names itself a bot: counted as "Other bot", so a new
|
|
98
|
+
# crawler shows up as traffic instead of disappearing.
|
|
99
|
+
GENERIC = Regexp.new(
|
|
100
|
+
"bot\\b|crawler|crawling|spider|scrapy|curl/|wget/|python-requests|headlesschrome|http-client|okhttp|java/|go-http-client",
|
|
101
|
+
Regexp::IGNORECASE
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# The crawler's catalogue name, or "" when the user agent isn't one.
|
|
105
|
+
def self.detect(user_agent)
|
|
106
|
+
ua = SiteGoose.text(user_agent)
|
|
107
|
+
return "" if ua.empty?
|
|
108
|
+
|
|
109
|
+
BOTS.each { |name, pattern| return name if pattern.match?(ua) }
|
|
110
|
+
GENERIC.match?(ua) ? "Other bot" : ""
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
attr_reader :rows, :agents
|
|
114
|
+
|
|
115
|
+
# Options: server_name (default the host name).
|
|
116
|
+
def initialize(core, server_name: nil, **_)
|
|
117
|
+
@core = core
|
|
118
|
+
@server_name = SiteGoose.text(server_name || SiteGoose.hostname, 100)
|
|
119
|
+
@rows = {}
|
|
120
|
+
@agents = {}
|
|
121
|
+
@lock = Mutex.new
|
|
122
|
+
@job = core.valid? ? Periodic.new(FLUSH_EVERY) { flush(everything: false); FLUSH_EVERY } : nil
|
|
123
|
+
Forking.register(self)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def after_fork_in_child
|
|
127
|
+
@lock = Mutex.new
|
|
128
|
+
@rows = {}
|
|
129
|
+
@agents = {}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# One finished request from a crawler. bot: Crawlers.detect(user_agent).
|
|
133
|
+
def record(bot, path, status, ms = 0, size = 0, ip = "")
|
|
134
|
+
return if bot.to_s.empty? || !@core.on?("crawlers")
|
|
135
|
+
|
|
136
|
+
clean = SiteGoose.text(path.to_s.empty? ? "/" : path).split(/[?#]/, 2)[0].to_s
|
|
137
|
+
clean = "/" if clean.empty?
|
|
138
|
+
clean = clean[0, MAX_PATH]
|
|
139
|
+
minute = (Time.now.to_i / 60) * 60
|
|
140
|
+
@lock.synchronize do
|
|
141
|
+
key = [minute, bot, clean, status.to_i]
|
|
142
|
+
# Past MAX_KEYS a crawler asking for thousands of URLs becomes one row.
|
|
143
|
+
key = [minute, bot, "(other)", status.to_i] if !@rows.key?(key) && @rows.size >= MAX_KEYS
|
|
144
|
+
return if !@rows.key?(key) && @rows.size >= MAX_KEYS + 100
|
|
145
|
+
|
|
146
|
+
row = (@rows[key] ||= { count: 0, bytes: 0, sum_ms: 0 })
|
|
147
|
+
row[:count] += 1
|
|
148
|
+
row[:bytes] += [0, size.to_i].max
|
|
149
|
+
row[:sum_ms] += [0, ms.to_i].max
|
|
150
|
+
unless ip.to_s.empty?
|
|
151
|
+
seen = (@agents[bot] ||= [])
|
|
152
|
+
seen << ip.to_s if seen.size < MAX_IPS_PER_BOT && !seen.include?(ip.to_s)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Records a finished request when it came from a crawler.
|
|
158
|
+
def middleware(user_agent, path, status, ms = 0, size = 0, ip = "")
|
|
159
|
+
record(Crawlers.detect(user_agent), path, status, ms, size, ip)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def flush(everything: true)
|
|
163
|
+
current = (Time.now.to_i / 60) * 60
|
|
164
|
+
ready, agents = @lock.synchronize do
|
|
165
|
+
taken = @rows.select { |k, _| everything || k[0] < current }
|
|
166
|
+
taken.each_key { |k| @rows.delete(k) }
|
|
167
|
+
sent = taken.keys.map { |k| k[1] }.uniq
|
|
168
|
+
list = @agents.select { |bot, _| sent.include?(bot) }.map { |bot, ips| { "bot" => bot, "ips" => ips.dup } }
|
|
169
|
+
unless taken.empty?
|
|
170
|
+
# A bot with rows still waiting (this minute isn't over) keeps its
|
|
171
|
+
# addresses, so the next batch can be checked as well.
|
|
172
|
+
waiting = @rows.keys.map { |k| k[1] }.uniq
|
|
173
|
+
@agents = @agents.select { |bot, _| waiting.include?(bot) }
|
|
174
|
+
end
|
|
175
|
+
[taken, list]
|
|
176
|
+
end
|
|
177
|
+
rows = ready.map do |k, v|
|
|
178
|
+
{ "minute" => Time.at(k[0]).utc.iso8601(3), "bot" => k[1], "path" => k[2], "status" => k[3],
|
|
179
|
+
"count" => v[:count], "bytes" => v[:bytes], "sum_ms" => v[:sum_ms] }
|
|
180
|
+
end
|
|
181
|
+
rows.each_slice(MAX_ROWS).with_index do |batch, i|
|
|
182
|
+
@core.request("crawlers", "/events/crawlers", body: {
|
|
183
|
+
"server_name" => @server_name, "runtime" => RUNTIME,
|
|
184
|
+
"rows" => batch, "agents" => i.zero? ? agents : []
|
|
185
|
+
})
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def close
|
|
190
|
+
@job&.stop
|
|
191
|
+
Forking.unregister(self)
|
|
192
|
+
flush
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Error monitoring module: the server's errors go to the collector (POST
|
|
4
|
+
# /events/server-errors), where they join the browser's errors in the Errors
|
|
5
|
+
# report. An exception that ends the program is captured at exit; the Rack
|
|
6
|
+
# middleware captures request errors (5xx, including the ones Rails and
|
|
7
|
+
# Sinatra turn into an error page); capture_exception() captures errors you
|
|
8
|
+
# catch; Rails.error reports (handled exceptions) are sent too; and
|
|
9
|
+
# capture_logging sends the exceptions your code logs. An error is sent once
|
|
10
|
+
# however many of those see it. Errors are sent in small batches, a batch the
|
|
11
|
+
# collector couldn't take is tried once more, and sending never raises. What
|
|
12
|
+
# still can't be sent waits in a file and is sent once it can be, after a
|
|
13
|
+
# restart too; each error carries when it happened, and the trail of its
|
|
14
|
+
# request or job: the steps you add with add_breadcrumb().
|
|
15
|
+
|
|
16
|
+
require "digest"
|
|
17
|
+
require "json"
|
|
18
|
+
require "logger"
|
|
19
|
+
require "tmpdir"
|
|
20
|
+
|
|
21
|
+
module SiteGoose
|
|
22
|
+
# The request or job being handled, per thread (fiber-local, so a fiber
|
|
23
|
+
# server keeps requests apart too): a callable returning {method, path,
|
|
24
|
+
# route, session_id}, so an error captured without a request (logged, or
|
|
25
|
+
# caught deep in your code) still gets its route; and its trail of steps.
|
|
26
|
+
module Context
|
|
27
|
+
REQUEST = :sitegoose_request
|
|
28
|
+
TRAIL = :sitegoose_trail
|
|
29
|
+
QUERIES = :sitegoose_queries
|
|
30
|
+
CRUMB_MAX = 15
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def request
|
|
35
|
+
Thread.current[REQUEST]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def trail
|
|
39
|
+
Thread.current[TRAIL]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Runs the block as one request or job: fields (a callable or a hash), a
|
|
43
|
+
# new trail and no database queries yet (Queries), all put back as they
|
|
44
|
+
# were afterwards.
|
|
45
|
+
def with(fields)
|
|
46
|
+
saved = [Thread.current[REQUEST], Thread.current[TRAIL], Thread.current[QUERIES]]
|
|
47
|
+
Thread.current[REQUEST] = fields
|
|
48
|
+
Thread.current[TRAIL] = []
|
|
49
|
+
Thread.current[QUERIES] = nil
|
|
50
|
+
yield
|
|
51
|
+
ensure
|
|
52
|
+
Thread.current[REQUEST], Thread.current[TRAIL], Thread.current[QUERIES] = saved
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def add_breadcrumb(text, type = "note")
|
|
56
|
+
trail = Thread.current[TRAIL]
|
|
57
|
+
return if trail.nil?
|
|
58
|
+
|
|
59
|
+
trail << { "type" => type, "text" => SiteGoose.text(text, 200), "at" => Time.now.to_f }
|
|
60
|
+
trail.shift while trail.size > CRUMB_MAX
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The trail as the tracker sends it: how long before now each step happened.
|
|
64
|
+
def breadcrumbs
|
|
65
|
+
now = Time.now.to_f
|
|
66
|
+
(Thread.current[TRAIL] || []).map do |crumb|
|
|
67
|
+
{ "type" => crumb["type"], "text" => crumb["text"], "ms" => [0, ((now - crumb["at"]) * 1000).round].max }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def request_fields
|
|
72
|
+
fields = Thread.current[REQUEST]
|
|
73
|
+
return nil if fields.nil?
|
|
74
|
+
|
|
75
|
+
fields.respond_to?(:call) ? fields.call : fields
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class Errors
|
|
82
|
+
BATCH = 10
|
|
83
|
+
QUEUE_MAX = 100
|
|
84
|
+
FLUSH_EVERY = 2
|
|
85
|
+
RESEND_EVERY = 60
|
|
86
|
+
SPOOL_BYTES = 1024 * 1024
|
|
87
|
+
MAX_FRAMES = 200
|
|
88
|
+
MAX_CAUSES = 3
|
|
89
|
+
# An exception's message can hold a whole response body or SQL dump.
|
|
90
|
+
MAX_MESSAGE = 2000
|
|
91
|
+
SEEN = :@__sitegoose_sent
|
|
92
|
+
FIELDS = %w[method path route session_id].freeze
|
|
93
|
+
|
|
94
|
+
class << self
|
|
95
|
+
# A batch the collector couldn't take (unreachable, 429, 5xx) is sent
|
|
96
|
+
# once more after this many seconds.
|
|
97
|
+
attr_accessor :retry_after
|
|
98
|
+
end
|
|
99
|
+
self.retry_after = 2
|
|
100
|
+
|
|
101
|
+
def self.failed?(res)
|
|
102
|
+
res.nil? || res.status == 429 || res.status >= 500
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.os_name
|
|
106
|
+
host = RbConfig::CONFIG["host_os"].to_s
|
|
107
|
+
case host
|
|
108
|
+
when /linux/ then "linux"
|
|
109
|
+
when /darwin/ then "darwin"
|
|
110
|
+
when /mswin|mingw|cygwin/ then "win32"
|
|
111
|
+
when /freebsd/ then "freebsd"
|
|
112
|
+
else host[0, 32]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
attr_reader :queue, :spool_file
|
|
117
|
+
|
|
118
|
+
# Options: server_name (default the host name), release (which build this
|
|
119
|
+
# is; default the ANALYTICS_RELEASE environment variable), root (paths in
|
|
120
|
+
# backtraces are made relative to it; default Rails.root or the working
|
|
121
|
+
# directory), capture_uncaught (default true: the exception that ends the
|
|
122
|
+
# program), capture_logging (default false: exceptions logged at ERROR or
|
|
123
|
+
# above through a Logger are sent too; Rails.logger on its own, any other
|
|
124
|
+
# with capture_logger), before_send (->(event, error) to change an event
|
|
125
|
+
# or drop it: return nil), spool_dir (where errors that couldn't be sent
|
|
126
|
+
# wait; default the system's temp folder, false to keep none).
|
|
127
|
+
def initialize(core, server_name: nil, root: nil, release: nil, capture_uncaught: true, before_send: nil,
|
|
128
|
+
capture_logging: false, spool_dir: nil, **_)
|
|
129
|
+
@core = core
|
|
130
|
+
@server_name = SiteGoose.text(server_name || SiteGoose.hostname, 100)
|
|
131
|
+
@root = (root || default_root).to_s.sub(%r{[/\\]+\z}, "")
|
|
132
|
+
# Which build this is, for "introduced in" in the Errors report.
|
|
133
|
+
@release = (release || ENV["ANALYTICS_RELEASE"]).to_s.strip.gsub(/[^\w.+\-]/, "")[0, 64]
|
|
134
|
+
@before_send = before_send
|
|
135
|
+
@capture_uncaught = capture_uncaught
|
|
136
|
+
@capture_logging = capture_logging
|
|
137
|
+
@queue = []
|
|
138
|
+
@lock = Mutex.new
|
|
139
|
+
@resend_lock = Mutex.new
|
|
140
|
+
@resending = []
|
|
141
|
+
@last_resend = 0.0
|
|
142
|
+
# Named after the site and its secret, not the site id alone, so the
|
|
143
|
+
# folder's listing tells nobody which site it is.
|
|
144
|
+
@spool_file = nil
|
|
145
|
+
if spool_dir != false && core.valid?
|
|
146
|
+
name = Digest::SHA256.hexdigest("#{core.site_id}|#{core.secret}")[0, 16]
|
|
147
|
+
@spool_file = File.join((spool_dir || Dir.tmpdir).to_s, "analytics-errors-#{name}.jsonl")
|
|
148
|
+
end
|
|
149
|
+
@job = core.valid? ? Periodic.new(FLUSH_EVERY) { flush; FLUSH_EVERY } : nil
|
|
150
|
+
Forking.register(self)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
attr_reader :capture_uncaught, :capture_logging
|
|
154
|
+
|
|
155
|
+
def default_root
|
|
156
|
+
defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root ? ::Rails.root.to_s : Dir.pwd
|
|
157
|
+
rescue StandardError
|
|
158
|
+
Dir.pwd
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# In a forked worker: what was waiting is the parent's to send, or it
|
|
162
|
+
# would be sent twice, and a lock the parent held at the fork would stay held.
|
|
163
|
+
def after_fork_in_child
|
|
164
|
+
@lock = Mutex.new
|
|
165
|
+
@resend_lock = Mutex.new
|
|
166
|
+
@queue = []
|
|
167
|
+
@resending = []
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The program is ending with an exception (at_exit, $!): sent, and what
|
|
171
|
+
# can't be sent waits in the file for the next start.
|
|
172
|
+
def capture_exit(error)
|
|
173
|
+
return unless @capture_uncaught && error && !error.is_a?(SystemExit) && !error.is_a?(SignalException)
|
|
174
|
+
|
|
175
|
+
capture_exception(error, kind: "error")
|
|
176
|
+
flush(resend: false)
|
|
177
|
+
keep_resending
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The process is exiting: what a resend hasn't sent yet goes back in the file.
|
|
181
|
+
def keep_resending
|
|
182
|
+
left = @lock.synchronize do
|
|
183
|
+
taken = @resending
|
|
184
|
+
@resending = []
|
|
185
|
+
taken
|
|
186
|
+
end
|
|
187
|
+
spool(left)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def relative(line)
|
|
191
|
+
return line if @root.empty?
|
|
192
|
+
|
|
193
|
+
line.start_with?("#{@root}/") ? line[(@root.size + 1)..] : line.gsub("#{@root}/", "")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The backtrace in the shape the collector reads Ruby frames in
|
|
197
|
+
# (expresscollect/utils/jsError.js RUBY_FRAME): "Class: message", then one
|
|
198
|
+
# "path:line:in `method'" per line, then each cause the same way.
|
|
199
|
+
def stack_of(error)
|
|
200
|
+
out = []
|
|
201
|
+
seen = {}
|
|
202
|
+
current = error
|
|
203
|
+
depth = 0
|
|
204
|
+
while current.is_a?(Exception) && !seen[current.object_id] && depth <= MAX_CAUSES
|
|
205
|
+
seen[current.object_id] = true
|
|
206
|
+
first = SiteGoose.text(current.message, MAX_MESSAGE).lines.first.to_s.chomp
|
|
207
|
+
out << "#{depth.zero? ? '' : 'Caused by: '}#{current.class.name}: #{first}"
|
|
208
|
+
(current.backtrace || []).first(MAX_FRAMES).each { |frame| out << "\t#{relative(SiteGoose.text(frame))}" }
|
|
209
|
+
current = current.cause
|
|
210
|
+
depth += 1
|
|
211
|
+
end
|
|
212
|
+
out.join("\n")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# request: {"method", "path", "route", "session_id"} (the integrations fill it in).
|
|
216
|
+
def capture_exception(error, request: nil, status: 0, kind: "handled")
|
|
217
|
+
return unless @core.on?("errors")
|
|
218
|
+
|
|
219
|
+
is_error = error.is_a?(Exception)
|
|
220
|
+
request ||= Context.request_fields
|
|
221
|
+
# The framework, your handler and logging can all see one error: sent
|
|
222
|
+
# once, and a later capture fills in the event while it waits.
|
|
223
|
+
waiting = is_error ? (error.instance_variable_get(SEEN) rescue nil) : nil # rubocop:disable Style/RescueModifier
|
|
224
|
+
return upgrade(error, waiting, request, status, kind) if waiting
|
|
225
|
+
|
|
226
|
+
message = SiteGoose.text(is_error ? error.message : error, MAX_MESSAGE)
|
|
227
|
+
type = is_error ? error.class.name.to_s : "Error"
|
|
228
|
+
event = {
|
|
229
|
+
"message" => message.empty? ? (is_error ? type : "Unknown error") : message,
|
|
230
|
+
"error_type" => type,
|
|
231
|
+
"stack" => is_error ? stack_of(error) : "",
|
|
232
|
+
"kind" => kind.to_s,
|
|
233
|
+
"status_code" => status.to_i,
|
|
234
|
+
"runtime" => "Ruby",
|
|
235
|
+
"runtime_version" => RUBY_VERSION,
|
|
236
|
+
"os" => Errors.os_name,
|
|
237
|
+
"server_name" => @server_name,
|
|
238
|
+
"release" => @release,
|
|
239
|
+
# When it happened: a crash sent after a restart keeps its own time.
|
|
240
|
+
"at" => SiteGoose.iso_now
|
|
241
|
+
}
|
|
242
|
+
trail = Context.breadcrumbs
|
|
243
|
+
event["breadcrumbs"] = trail unless trail.empty?
|
|
244
|
+
(request || {}).each do |k, v|
|
|
245
|
+
key = k.to_s
|
|
246
|
+
event[key] = SiteGoose.text(v) if FIELDS.include?(key)
|
|
247
|
+
end
|
|
248
|
+
if event["path"] && !event["path"].empty?
|
|
249
|
+
event["path"] = event["path"].split("?", 2)[0].to_s
|
|
250
|
+
# Named as Server speed names the request, so the two match.
|
|
251
|
+
event["route"] = Performance.normalize_route(event["path"]) if event["route"].to_s.empty?
|
|
252
|
+
end
|
|
253
|
+
if @before_send
|
|
254
|
+
event = run_before_send(event, error)
|
|
255
|
+
return unless event
|
|
256
|
+
end
|
|
257
|
+
full = @lock.synchronize do
|
|
258
|
+
if @queue.size >= QUEUE_MAX
|
|
259
|
+
@core.log("errors", "queue full, error dropped")
|
|
260
|
+
return
|
|
261
|
+
end
|
|
262
|
+
@queue << event
|
|
263
|
+
@queue.size >= BATCH
|
|
264
|
+
end
|
|
265
|
+
begin
|
|
266
|
+
error.instance_variable_set(SEEN, event) if is_error && !error.frozen?
|
|
267
|
+
rescue StandardError
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
@job&.run_now if full
|
|
271
|
+
nil
|
|
272
|
+
rescue StandardError => e
|
|
273
|
+
@core.log("errors", "could not capture error:", e.message)
|
|
274
|
+
nil
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Your before_send's answer: a Hash is the event to send, nil drops it,
|
|
278
|
+
# and anything else (a string, true, a raise) drops it too, said once.
|
|
279
|
+
def run_before_send(event, error)
|
|
280
|
+
out = @before_send.call(event, error)
|
|
281
|
+
return out if out.nil? || out.is_a?(Hash)
|
|
282
|
+
|
|
283
|
+
warn_once("before_send returned a #{out.class}, not a Hash or nil: the error was dropped")
|
|
284
|
+
nil
|
|
285
|
+
rescue StandardError => e
|
|
286
|
+
warn_once("before_send raised #{e.class}: #{e.message}; the error was dropped")
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def warn_once(text)
|
|
291
|
+
return if @warned
|
|
292
|
+
|
|
293
|
+
@warned = true
|
|
294
|
+
warn "[analytics-platform] #{text}"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# An exception logged and then raised on is captured first as "logged",
|
|
298
|
+
# with no status; the framework's capture that follows knows the status,
|
|
299
|
+
# the kind and the request.
|
|
300
|
+
def upgrade(error, waiting, request, status, kind)
|
|
301
|
+
return if kind.to_s == "logged"
|
|
302
|
+
|
|
303
|
+
@lock.synchronize do
|
|
304
|
+
index = @queue.index { |queued| queued.equal?(waiting) }
|
|
305
|
+
return if index.nil?
|
|
306
|
+
|
|
307
|
+
event = waiting.merge(
|
|
308
|
+
"kind" => waiting["kind"] == "logged" ? kind.to_s : waiting["kind"],
|
|
309
|
+
"status_code" => waiting["status_code"].to_i.positive? ? waiting["status_code"] : status.to_i
|
|
310
|
+
)
|
|
311
|
+
FIELDS.each do |key|
|
|
312
|
+
value = request && (request[key] || request[key.to_sym])
|
|
313
|
+
event[key] = SiteGoose.text(value) if event[key].to_s.empty? && value && !value.to_s.empty?
|
|
314
|
+
end
|
|
315
|
+
event["path"] = event["path"].split("?", 2)[0].to_s if event["path"] && !event["path"].empty?
|
|
316
|
+
if event["route"].to_s.empty? && event["path"] && !event["path"].empty?
|
|
317
|
+
event["route"] = Performance.normalize_route(event["path"])
|
|
318
|
+
end
|
|
319
|
+
event = run_before_send(event, error) if @before_send
|
|
320
|
+
if event
|
|
321
|
+
@queue[index] = event
|
|
322
|
+
error.instance_variable_set(SEEN, event) unless error.frozen?
|
|
323
|
+
else
|
|
324
|
+
@queue.delete_at(index)
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
nil
|
|
328
|
+
rescue StandardError => e
|
|
329
|
+
@core.log("errors", "could not capture error:", e.message)
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def flush(resend: true)
|
|
334
|
+
waiting = @lock.synchronize do
|
|
335
|
+
taken = @queue
|
|
336
|
+
@queue = []
|
|
337
|
+
taken
|
|
338
|
+
end
|
|
339
|
+
sent = true
|
|
340
|
+
waiting.each_slice(BATCH) do |batch|
|
|
341
|
+
body = { "errors" => batch }
|
|
342
|
+
res = @core.request("errors", "/events/server-errors", body: body)
|
|
343
|
+
# Tried once more a little later, but not when the process is
|
|
344
|
+
# ending: what's left waits in the file instead.
|
|
345
|
+
if Errors.failed?(res) && SiteGoose.time_left.nil?
|
|
346
|
+
sleep(Errors.retry_after) if Errors.retry_after.positive?
|
|
347
|
+
res = @core.request("errors", "/events/server-errors", body: body)
|
|
348
|
+
end
|
|
349
|
+
if Errors.failed?(res)
|
|
350
|
+
sent = false
|
|
351
|
+
spool(batch)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
# Not straight after the collector failed this flush: it would only
|
|
355
|
+
# wait out another timeout.
|
|
356
|
+
return unless resend && sent && @spool_file && SiteGoose.time_left.nil?
|
|
357
|
+
|
|
358
|
+
self.resend if Time.now.to_f - @last_resend >= RESEND_EVERY
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Appends events to the file. Credentials are added when sent, never written down.
|
|
362
|
+
def spool(events)
|
|
363
|
+
return if @spool_file.nil? || events.nil? || events.empty?
|
|
364
|
+
|
|
365
|
+
lines = events.map { |event| "#{JSON.generate(event)}\n" }
|
|
366
|
+
have = File.exist?(@spool_file) ? File.size(@spool_file) : 0
|
|
367
|
+
# What fits, oldest first: the file never grows past SPOOL_BYTES.
|
|
368
|
+
room = SPOOL_BYTES - have
|
|
369
|
+
kept = lines.take_while { |line| (room -= line.bytesize) >= 0 }
|
|
370
|
+
@core.log("errors", "waiting errors file is full, #{lines.size - kept.size} errors dropped") if kept.size < lines.size
|
|
371
|
+
return if kept.empty?
|
|
372
|
+
|
|
373
|
+
File.open(@spool_file, File::WRONLY | File::CREAT | File::APPEND, 0o600) do |out|
|
|
374
|
+
out.write(kept.join)
|
|
375
|
+
end
|
|
376
|
+
rescue StandardError => e
|
|
377
|
+
@core.log("errors", "could not keep errors to send later:", e.message)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Sends what earlier attempts (or an earlier run) couldn't. Taken by
|
|
381
|
+
# renaming the file first, so two processes never send the same errors.
|
|
382
|
+
def resend
|
|
383
|
+
@last_resend = Time.now.to_f
|
|
384
|
+
return if @spool_file.nil? || !@resend_lock.try_lock
|
|
385
|
+
|
|
386
|
+
begin
|
|
387
|
+
resend_taken
|
|
388
|
+
ensure
|
|
389
|
+
@resend_lock.unlock
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def resend_taken
|
|
394
|
+
taken = "#{@spool_file}.#{Process.pid}.#{Thread.current.object_id}.sending"
|
|
395
|
+
begin
|
|
396
|
+
File.rename(@spool_file, taken)
|
|
397
|
+
rescue SystemCallError
|
|
398
|
+
return # nothing waiting
|
|
399
|
+
end
|
|
400
|
+
events = []
|
|
401
|
+
begin
|
|
402
|
+
File.foreach(taken, encoding: "UTF-8") do |line|
|
|
403
|
+
event = JSON.parse(line)
|
|
404
|
+
events << event if event.is_a?(Hash)
|
|
405
|
+
rescue JSON::ParserError
|
|
406
|
+
next
|
|
407
|
+
end
|
|
408
|
+
ensure
|
|
409
|
+
File.delete(taken) rescue nil # rubocop:disable Style/RescueModifier
|
|
410
|
+
end
|
|
411
|
+
@lock.synchronize { @resending = events }
|
|
412
|
+
loop do
|
|
413
|
+
batch = @lock.synchronize do
|
|
414
|
+
break nil if !@resending.equal?(events) || events.empty? # all sent, or written back by a crash
|
|
415
|
+
|
|
416
|
+
events.first(BATCH)
|
|
417
|
+
end
|
|
418
|
+
break if batch.nil?
|
|
419
|
+
|
|
420
|
+
if Errors.failed?(@core.request("errors", "/events/server-errors", body: { "errors" => batch }))
|
|
421
|
+
left = @lock.synchronize do
|
|
422
|
+
l = @resending.equal?(events) ? events : []
|
|
423
|
+
@resending = []
|
|
424
|
+
l
|
|
425
|
+
end
|
|
426
|
+
spool(left)
|
|
427
|
+
return
|
|
428
|
+
end
|
|
429
|
+
@lock.synchronize { events.shift(batch.size) }
|
|
430
|
+
end
|
|
431
|
+
@lock.synchronize { @resending = [] if @resending.equal?(events) }
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def close
|
|
435
|
+
@job&.stop
|
|
436
|
+
Forking.unregister(self)
|
|
437
|
+
flush
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Sends the exception a Logger is given at ERROR or above (logger.error(e),
|
|
442
|
+
# or the exception as the message); still logged as before. A line of text
|
|
443
|
+
# isn't sent: it has no backtrace to group it by.
|
|
444
|
+
module LoggerCapture
|
|
445
|
+
def add(severity, message = nil, progname = nil, &block)
|
|
446
|
+
begin
|
|
447
|
+
error = [message, progname].find { |m| m.is_a?(Exception) }
|
|
448
|
+
hook = @__sitegoose_capture
|
|
449
|
+
hook.call(error) if error && hook && severity.to_i >= ::Logger::ERROR
|
|
450
|
+
rescue StandardError
|
|
451
|
+
nil
|
|
452
|
+
end
|
|
453
|
+
super
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|