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.
@@ -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