tracetells 0.1.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 +49 -0
- data/lib/tracetells/client.rb +189 -0
- data/lib/tracetells/rack.rb +30 -0
- data/lib/tracetells/railtie.rb +43 -0
- data/lib/tracetells/scrub.rb +96 -0
- data/lib/tracetells/version.rb +3 -0
- data/lib/tracetells.rb +72 -0
- metadata +52 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5537eb8ff5c01294ae19f4cfbecc455d69b6908e28afc72a5598d690a04d3753
|
|
4
|
+
data.tar.gz: 7d8e577665f1846717d2ce5c214425735479208178ebc5afa54cd86c8bd3b474
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 78067850640ffa59dc4c9149107d5a8b80f069d33e301d9a50c562c9478068f4b3eda695c95289124659958218d2e364b2aa236dfc5be8a544ee68d3109fdb8a
|
|
7
|
+
data.tar.gz: 4e0b84ef279742c39dd0f7db79247f9ca28cdfe4ad47ae7e66489ddd06ad4d8cc41c6f00868023ac1b87f94150e06a25e222c01c6baf29325557184c3176ff46
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Deyan Dobrinov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# tracetells
|
|
2
|
+
|
|
3
|
+
Report a Ruby server's errors to [Trace](https://tracetells.com): what failed, where in your code, on which route, since which release. Trace groups and counts them, shows how many visits each one broke in the browser, and tells you whether a fix stopped it. No runtime dependencies; Ruby 3.1+.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
# Gemfile
|
|
7
|
+
gem "tracetells"
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
**Rails:** nothing else. The gem configures itself from the environment, adds its Rack middleware, and subscribes to `Rails.error`, which sees unhandled errors in controllers and jobs (Rails 7+). Routes come from Rails (7.1+): `/orders/:id`, never the URL. Errors Rails answers with a 4xx (`RecordNotFound`, `RoutingError`) aren't reported.
|
|
11
|
+
|
|
12
|
+
**Rack and Sinatra:**
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
# config.ru
|
|
16
|
+
require "tracetells"
|
|
17
|
+
Tracetells.configure
|
|
18
|
+
use Tracetells::Rack # reports exceptions with their route and re-raises them
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Report an error you handled with `Tracetells.report(error)`, or `Tracetells.report_message("Billing", "no plan for account")`. During a request, its route goes with it.
|
|
22
|
+
|
|
23
|
+
## Configuration
|
|
24
|
+
|
|
25
|
+
| Variable | Default | |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `TRACE_SERVER_KEY` | – | The website's server key. Your coding agent creates it (`/trace:setup server errors`), or an admin in the dashboard. Keep it in your credentials, never in the repository. Without it the gem does nothing. |
|
|
28
|
+
| `TRACE_ENDPOINT` | `https://app.tracetells.com` | Your own server, when you self-host Trace |
|
|
29
|
+
| `TRACE_ENVIRONMENT` | `RAILS_ENV`, `RACK_ENV`, else `production` | |
|
|
30
|
+
| `TRACE_RELEASE` | `SOURCE_VERSION`, `HEROKU_SLUG_COMMIT`, `RENDER_GIT_COMMIT`, `GIT_COMMIT` or `GITHUB_SHA`, if set | The release the code belongs to |
|
|
31
|
+
|
|
32
|
+
Or in code: `Tracetells.configure(key:, endpoint:, environment:, release:)`.
|
|
33
|
+
|
|
34
|
+
## What it sends, and what it never does
|
|
35
|
+
|
|
36
|
+
**Sent:** the error's class, its message (with up to three causes), the backtrace (files relative to your project, lines, methods, and whether each frame is your code), the route template, the HTTP method and status, the environment, the release, the Ruby version, when it happened and how often.
|
|
37
|
+
|
|
38
|
+
**Never sent:** request or response bodies, params, query strings, headers, cookies, sessions, environment variables, source code, host names or who was signed in. There is no option to send them.
|
|
39
|
+
|
|
40
|
+
Messages and file paths are scrubbed before they leave: emails, tokens, card numbers, IBANs and phone-like numbers are replaced, and home directories are cut from paths. Trace's server scrubs everything again. Keep personal data out of exception messages anyway.
|
|
41
|
+
|
|
42
|
+
## How it behaves
|
|
43
|
+
|
|
44
|
+
- Errors are sent from a background thread, in batches, at most once a second. Requests never wait for it. After a fork (Puma, Unicorn) a new thread starts by itself.
|
|
45
|
+
- The first occurrence of an error is sent right away; repeats within the next minute are sent once, as a count.
|
|
46
|
+
- A full queue, a network error, or Trace answering 429 or 5xx: errors are dropped, and the client backs off (5 seconds, doubling up to 5 minutes). It never raises into your code.
|
|
47
|
+
- At exit it sends what's queued, waiting at most two seconds.
|
|
48
|
+
|
|
49
|
+
Licence: MIT.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracetells
|
|
4
|
+
# Collects errors and sends them from a background thread: in batches, at
|
|
5
|
+
# most once a second, with repeats within a minute folded into a count, and
|
|
6
|
+
# backing off when Trace is unreachable or asks to slow down.
|
|
7
|
+
class Client
|
|
8
|
+
QUEUE = 1000
|
|
9
|
+
BATCH = 100
|
|
10
|
+
FOLD = 60
|
|
11
|
+
SEND_EVERY = 1.0
|
|
12
|
+
|
|
13
|
+
attr_reader :environment, :release
|
|
14
|
+
|
|
15
|
+
def initialize(key:, endpoint:, environment:, release:)
|
|
16
|
+
@key = key
|
|
17
|
+
@uri = URI("#{endpoint}/v1/errors")
|
|
18
|
+
@environment = environment.to_s
|
|
19
|
+
@release = release.to_s
|
|
20
|
+
@queue = Thread::Queue.new
|
|
21
|
+
@recent = {}
|
|
22
|
+
@outgoing = []
|
|
23
|
+
@paused_until = 0
|
|
24
|
+
@backoff = 5
|
|
25
|
+
@lock = Mutex.new
|
|
26
|
+
@pid = Process.pid
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def report(error, request)
|
|
30
|
+
enqueue(event(error.class.name || "Error", Scrub.message(chain(error)), error.backtrace || caller(2), request))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def report_message(class_name, message, request)
|
|
34
|
+
enqueue(event(class_name.to_s, Scrub.message(message), caller(2), request))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def flush(timeout)
|
|
38
|
+
done = Thread::Queue.new
|
|
39
|
+
start
|
|
40
|
+
@queue << [:flush, done]
|
|
41
|
+
done.pop(timeout: timeout)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def shutdown
|
|
45
|
+
@queue << [:stop, nil]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def chain(error)
|
|
51
|
+
msg = error.message.to_s
|
|
52
|
+
cause = error.cause
|
|
53
|
+
3.times do
|
|
54
|
+
break unless cause
|
|
55
|
+
|
|
56
|
+
msg += ": #{cause.message}"
|
|
57
|
+
cause = cause.cause
|
|
58
|
+
end
|
|
59
|
+
msg
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def event(class_name, message, backtrace, request)
|
|
63
|
+
request ||= {}
|
|
64
|
+
status = request[:status].to_i
|
|
65
|
+
{
|
|
66
|
+
"class" => Scrub.truncate(Scrub.one_line(class_name), 200),
|
|
67
|
+
"message" => Scrub.truncate(message, 300),
|
|
68
|
+
"backtrace" => frames(backtrace),
|
|
69
|
+
"route" => request[:route].to_s.empty? ? "" : Scrub.route(request[:route]),
|
|
70
|
+
"method" => request[:method].to_s.upcase,
|
|
71
|
+
"status" => status.between?(100, 599) ? status : nil,
|
|
72
|
+
"occurred_at" => (Time.now.to_f * 1000).to_i,
|
|
73
|
+
"count" => 1
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
RUBY_LIB = RbConfig::CONFIG["rubylibdir"].to_s
|
|
78
|
+
|
|
79
|
+
# "/srv/app/app/models/order.rb:12:in 'Order#total'" (Ruby 3.4+ quotes, older ones backtick-quote).
|
|
80
|
+
def frames(lines)
|
|
81
|
+
Array(lines).first(60).filter_map do |line|
|
|
82
|
+
m = /\A(.+?):(\d+)(?::in [`'](.+)')?\z/.match(line.to_s)
|
|
83
|
+
next unless m
|
|
84
|
+
|
|
85
|
+
file = m[1]
|
|
86
|
+
next if file.include?("/lib/tracetells")
|
|
87
|
+
|
|
88
|
+
dependency = file.include?("/gems/") || file.start_with?("<internal:") || (!RUBY_LIB.empty? && file.start_with?(RUBY_LIB))
|
|
89
|
+
{ "file" => Scrub.file(file), "line" => m[2].to_i, "function" => Scrub.truncate(Scrub.one_line(m[3].to_s), 200), "in_app" => !dependency }
|
|
90
|
+
end.first(40)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def enqueue(e)
|
|
94
|
+
start
|
|
95
|
+
@queue << [:event, e] if @queue.size < QUEUE
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# After a fork (Puma, Unicorn) the thread is gone: start a new one.
|
|
99
|
+
def start
|
|
100
|
+
@lock.synchronize do
|
|
101
|
+
if @pid != Process.pid
|
|
102
|
+
@pid = Process.pid
|
|
103
|
+
@thread = nil
|
|
104
|
+
end
|
|
105
|
+
@thread = Thread.new { run } unless @thread&.alive?
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run
|
|
110
|
+
Thread.current.name = "tracetells"
|
|
111
|
+
Thread.current.report_on_exception = false
|
|
112
|
+
last_send = monotonic
|
|
113
|
+
loop do
|
|
114
|
+
kind, payload = @queue.pop(timeout: SEND_EVERY)
|
|
115
|
+
flushing = nil
|
|
116
|
+
case kind
|
|
117
|
+
when :event then admit(payload)
|
|
118
|
+
when :flush then flushing = payload
|
|
119
|
+
when :stop then return
|
|
120
|
+
end
|
|
121
|
+
fold_expired(!flushing.nil?)
|
|
122
|
+
if !@outgoing.empty? && (flushing || @outgoing.size >= BATCH || monotonic - last_send >= SEND_EVERY)
|
|
123
|
+
send_all
|
|
124
|
+
last_send = monotonic
|
|
125
|
+
end
|
|
126
|
+
flushing&.push(true)
|
|
127
|
+
end
|
|
128
|
+
rescue StandardError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def admit(e)
|
|
133
|
+
key = fold_key(e)
|
|
134
|
+
seen = @recent[key]
|
|
135
|
+
if seen && monotonic - seen[:start] < FOLD
|
|
136
|
+
seen[:repeats] += 1
|
|
137
|
+
seen[:latest] = e
|
|
138
|
+
else
|
|
139
|
+
@recent[key] = { start: monotonic, repeats: 0, latest: nil }
|
|
140
|
+
@outgoing << e
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def fold_key(e)
|
|
145
|
+
top = e["backtrace"].find { |f| f["in_app"] }
|
|
146
|
+
[e["class"], top ? "#{top['file']}|#{top['function']}" : e["message"], e["route"], e["status"]].join("|")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Minutes that ended: their repeats go out as one error with a count.
|
|
150
|
+
def fold_expired(all)
|
|
151
|
+
now = monotonic
|
|
152
|
+
@recent.delete_if do |_, r|
|
|
153
|
+
ended = now - r[:start] >= FOLD
|
|
154
|
+
if (ended || all) && r[:latest] && r[:repeats].positive?
|
|
155
|
+
@outgoing << r[:latest].merge("count" => r[:repeats])
|
|
156
|
+
r[:repeats] = 0
|
|
157
|
+
r[:latest] = nil
|
|
158
|
+
end
|
|
159
|
+
ended
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def send_all
|
|
164
|
+
until @outgoing.empty?
|
|
165
|
+
batch = @outgoing.shift(BATCH)
|
|
166
|
+
next if monotonic < @paused_until # backing off: drop rather than pile up
|
|
167
|
+
|
|
168
|
+
runtime = { "name" => RUBY_ENGINE, "version" => RUBY_VERSION }
|
|
169
|
+
body = JSON.generate("errors" => batch.map { |e| e.merge("environment" => @environment, "release" => @release, "runtime" => runtime) })
|
|
170
|
+
begin
|
|
171
|
+
res = Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == "https", open_timeout: 5, read_timeout: 5, write_timeout: 5) do |http|
|
|
172
|
+
http.post(@uri.path, body, "authorization" => "Bearer #{@key}", "content-type" => "application/json")
|
|
173
|
+
end
|
|
174
|
+
code = res.code.to_i
|
|
175
|
+
raise "HTTP #{code}" if code == 429 || code >= 500
|
|
176
|
+
|
|
177
|
+
@backoff = 5
|
|
178
|
+
rescue StandardError
|
|
179
|
+
@paused_until = monotonic + @backoff
|
|
180
|
+
@backoff = [@backoff * 2, 300].min
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def monotonic
|
|
186
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracetells
|
|
4
|
+
# Rack middleware: reports exceptions raised while handling a request, with
|
|
5
|
+
# its route, method and status, and re-raises them so the app's own error
|
|
6
|
+
# handling is unchanged. In Rails the Railtie adds it.
|
|
7
|
+
class Rack
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
previous = Thread.current[:tracetells_env]
|
|
14
|
+
Thread.current[:tracetells_env] = env
|
|
15
|
+
@app.call(env)
|
|
16
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
17
|
+
Tracetells.report(e, request: self.class.request_info(env, status: 500)) unless env["tracetells.reported"]
|
|
18
|
+
raise
|
|
19
|
+
ensure
|
|
20
|
+
Thread.current[:tracetells_env] = previous
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The route template when the framework knows it (Rails 7.1+, Sinatra), else
|
|
24
|
+
# the path, which is scrubbed of ids before it's sent. Nothing else of the request.
|
|
25
|
+
def self.request_info(env, status: nil)
|
|
26
|
+
route = env["action_dispatch.route_uri_pattern"] || env["sinatra.route"]&.split(" ", 2)&.last || env["PATH_INFO"].to_s
|
|
27
|
+
{ route: route.to_s.sub(/\(\.:format\)\z/, ""), method: env["REQUEST_METHOD"], status: status }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracetells
|
|
4
|
+
# In Rails: configures from the environment, adds the Rack middleware, and
|
|
5
|
+
# subscribes to Rails.error, which sees unhandled errors in controllers and
|
|
6
|
+
# jobs (Rails 7+). Handled errors reported with `handled: true` are left out
|
|
7
|
+
# unless their severity is :error, and so are errors Rails answers with a 4xx.
|
|
8
|
+
class Railtie < ::Rails::Railtie
|
|
9
|
+
initializer "tracetells.middleware" do |app|
|
|
10
|
+
Tracetells.configure unless Tracetells.client
|
|
11
|
+
app.middleware.insert_before(0, Tracetells::Rack)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
config.after_initialize do
|
|
15
|
+
Rails.error.subscribe(Tracetells::Railtie::Subscriber.new) if Rails.respond_to?(:error)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Subscriber
|
|
19
|
+
def report(error, handled:, severity:, context:, source: nil)
|
|
20
|
+
return if handled && severity != :error
|
|
21
|
+
|
|
22
|
+
env = Thread.current[:tracetells_env]
|
|
23
|
+
status = status_for(error)
|
|
24
|
+
# 404s and other client errors (RecordNotFound, RoutingError) aren't server errors.
|
|
25
|
+
return if env && status < 500
|
|
26
|
+
|
|
27
|
+
request = env ? Tracetells::Rack.request_info(env, status: status) : nil
|
|
28
|
+
env["tracetells.reported"] = true if env
|
|
29
|
+
Tracetells.report(error, request: request)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def status_for(error)
|
|
35
|
+
if defined?(ActionDispatch::ExceptionWrapper)
|
|
36
|
+
ActionDispatch::ExceptionWrapper.status_code_for_exception(error.class.name)
|
|
37
|
+
else
|
|
38
|
+
500
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tracetells
|
|
4
|
+
# The same scrubbing Trace's server does (crates/trace-core/src/privacy.rs
|
|
5
|
+
# and errors.rs), done here first so nothing personal leaves the server.
|
|
6
|
+
# clients/test-vectors.json holds the cases every client must agree on.
|
|
7
|
+
module Scrub
|
|
8
|
+
EMAIL = /[a-z0-9._%+-]+(@|%40)[a-z0-9.-]+\.[a-z]{2,}/i
|
|
9
|
+
JWT = /\beyJ[\w-]+\.[\w-]+\.[\w-]+/
|
|
10
|
+
URL_QUERY = %r{(https?://[^\s?#]+)[?#][^\s]*}
|
|
11
|
+
IBAN = /\b[A-Z]{2}\d{2}(?:\s?[A-Z0-9]{4}){2,7}(?:\s?[A-Z0-9]{1,4})?\b/
|
|
12
|
+
CARD = /\b(?:\d[ -]?){12,18}\d\b/
|
|
13
|
+
TOKEN = %r{[A-Za-z0-9_\-+/=]{20,}}
|
|
14
|
+
LONG_NUMBER = /\+?\d(?:[\s().-]{0,2}\d){6,}/
|
|
15
|
+
HIDDEN = /[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/
|
|
16
|
+
|
|
17
|
+
DEPENDENCY_ROOTS = ["node_modules/", "gems/", "registry/src/", "site-packages/", "vendor/"].freeze
|
|
18
|
+
PROJECT_ROOTS = ["src/", "app/", "lib/", "crates/", "packages/", "config/", "bin/", "spec/", "test/", "tests/"].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def token?(s)
|
|
23
|
+
s.match?(/\d/) && s.match?(/[A-Za-z]/)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# On one line: control and invisible characters become spaces, backticks quotes.
|
|
27
|
+
def one_line(s)
|
|
28
|
+
s.to_s.scrub("?").gsub(HIDDEN, " ").tr("`", "'").squeeze(" ").strip
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def truncate(s, max)
|
|
32
|
+
s.length > max ? s[0, max] : s
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Emails, tokens, card numbers, IBANs, phone-like numbers and URL query strings.
|
|
36
|
+
def message(s)
|
|
37
|
+
one_line(s)
|
|
38
|
+
.gsub(URL_QUERY, '\1?[redacted]')
|
|
39
|
+
.gsub(EMAIL, "[email]")
|
|
40
|
+
.gsub(JWT, "[token]")
|
|
41
|
+
.gsub(IBAN, "[iban]")
|
|
42
|
+
.gsub(CARD, "[card]")
|
|
43
|
+
.gsub(TOKEN) { |m| token?(m) ? "[token]" : m }
|
|
44
|
+
.gsub(LONG_NUMBER, "[number]")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A route as Trace stores it: no query string, segments that look like ids,
|
|
48
|
+
# emails or tokens replaced. Route parameters (:id, {id}) pass through.
|
|
49
|
+
def route(path)
|
|
50
|
+
p = one_line(path.to_s.strip).gsub(" ", "%20").split(/[?#]/).first.to_s
|
|
51
|
+
return "" unless p.start_with?("/")
|
|
52
|
+
|
|
53
|
+
out = p.split("/", -1).map do |seg|
|
|
54
|
+
if seg.length > 2 && seg.start_with?("{") && seg.end_with?("}")
|
|
55
|
+
":#{seg[1..-2].delete_prefix('*')}"
|
|
56
|
+
elsif seg.empty? || seg.start_with?(":") || seg == "*"
|
|
57
|
+
seg
|
|
58
|
+
elsif seg.include?("@") || seg.downcase.include?("%40")
|
|
59
|
+
":email"
|
|
60
|
+
elsif seg.include?("=") || (seg.count(".") >= 2 && seg.length >= 24)
|
|
61
|
+
":token"
|
|
62
|
+
elsif seg.count("0-9") >= 3 || (seg.length >= 16 && token?(seg)) || (seg.length >= 16 && seg.match?(/\A\h+\z/))
|
|
63
|
+
":id"
|
|
64
|
+
else
|
|
65
|
+
seg
|
|
66
|
+
end
|
|
67
|
+
end.join("/")
|
|
68
|
+
truncate(out, 200)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A frame's file, relative to the project or the dependency it is in.
|
|
72
|
+
def file(name)
|
|
73
|
+
f = one_line(name).tr("\\", "/")
|
|
74
|
+
f = f.delete_prefix("file://")
|
|
75
|
+
f = f.split(/[?#]/).first.to_s
|
|
76
|
+
absolute = f.start_with?("/") || f[1] == ":" || f.start_with?("~/")
|
|
77
|
+
rel =
|
|
78
|
+
if !absolute
|
|
79
|
+
f
|
|
80
|
+
elsif (dep = DEPENDENCY_ROOTS.filter_map { |m| (i = f.rindex(m)) && [i, m] }.max_by(&:first))
|
|
81
|
+
i, m = dep
|
|
82
|
+
if m == "registry/src/"
|
|
83
|
+
rest = f[(i + m.length)..]
|
|
84
|
+
(slash = rest.index("/")) ? rest[(slash + 1)..] : rest
|
|
85
|
+
else
|
|
86
|
+
f[i..]
|
|
87
|
+
end
|
|
88
|
+
elsif (i = PROJECT_ROOTS.lazy.map { |m| f.index("/#{m}") }.find(&:itself))
|
|
89
|
+
f[(i + 1)..]
|
|
90
|
+
else
|
|
91
|
+
f.split("/").last.to_s
|
|
92
|
+
end
|
|
93
|
+
truncate(rel.split("/", -1).map { |seg| seg.include?("@") ? ":email" : seg }.join("/"), 200)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/tracetells.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Report a Ruby server's errors to Trace (https://tracetells.com).
|
|
4
|
+
#
|
|
5
|
+
# Tracetells.configure # TRACE_SERVER_KEY, TRACE_ENDPOINT, TRACE_ENVIRONMENT, TRACE_RELEASE
|
|
6
|
+
# use Tracetells::Rack # in config.ru; Rails apps get it, and Rails.error, on their own
|
|
7
|
+
#
|
|
8
|
+
# What is sent: the error's class, its message, the backtrace (files relative
|
|
9
|
+
# to your project, lines, methods), the route template, the HTTP method and
|
|
10
|
+
# status, the environment, the release, the Ruby version, when and how often.
|
|
11
|
+
# Never sent: request or response bodies, params, query strings, headers,
|
|
12
|
+
# cookies, sessions, environment variables, source code, host names or who was
|
|
13
|
+
# signed in. There is no option to send them. Messages and paths are scrubbed first.
|
|
14
|
+
|
|
15
|
+
require "json"
|
|
16
|
+
require "net/http"
|
|
17
|
+
require "uri"
|
|
18
|
+
require "rbconfig"
|
|
19
|
+
require_relative "tracetells/version"
|
|
20
|
+
require_relative "tracetells/scrub"
|
|
21
|
+
require_relative "tracetells/client"
|
|
22
|
+
require_relative "tracetells/rack"
|
|
23
|
+
require_relative "tracetells/railtie" if defined?(::Rails::Railtie)
|
|
24
|
+
|
|
25
|
+
module Tracetells
|
|
26
|
+
class << self
|
|
27
|
+
# Starts the client. Without a key it does nothing. Options override the environment.
|
|
28
|
+
def configure(key: nil, endpoint: nil, environment: nil, release: nil)
|
|
29
|
+
env = ->(k) { (v = ENV[k].to_s.strip).empty? ? nil : v }
|
|
30
|
+
key ||= env.("TRACE_SERVER_KEY")
|
|
31
|
+
return @client = nil unless key
|
|
32
|
+
|
|
33
|
+
@client&.shutdown
|
|
34
|
+
@client = Client.new(
|
|
35
|
+
key: key,
|
|
36
|
+
endpoint: (endpoint || env.("TRACE_ENDPOINT") || "https://app.tracetells.com").sub(%r{/+\z}, ""),
|
|
37
|
+
environment: environment || env.("TRACE_ENVIRONMENT") || env.("RAILS_ENV") || env.("RACK_ENV") || "production",
|
|
38
|
+
release: release || %w[TRACE_RELEASE SOURCE_VERSION HEROKU_SLUG_COMMIT RENDER_GIT_COMMIT GIT_COMMIT GITHUB_SHA].lazy.map { |k| env.(k) }.find(&:itself) || ""
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def client
|
|
43
|
+
@client
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Reports an error you handled. `request` takes :route, :method and :status.
|
|
47
|
+
def report(error, request: nil)
|
|
48
|
+
@client&.report(error, request || current_request)
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def report_message(class_name, message, request: nil)
|
|
54
|
+
@client&.report_message(class_name, message, request || current_request)
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Sends what's queued, waiting at most `timeout` seconds.
|
|
60
|
+
def flush(timeout = 2)
|
|
61
|
+
@client&.flush(timeout)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The request this thread is handling, as Tracetells::Rack saw it.
|
|
65
|
+
def current_request
|
|
66
|
+
env = Thread.current[:tracetells_env]
|
|
67
|
+
env && Tracetells::Rack.request_info(env)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
at_exit { Tracetells.flush(2) }
|
metadata
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tracetells
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Deyan Dobrinov
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Sends the class, message, backtrace, route, status and release of your
|
|
13
|
+
app's errors to Trace, which groups and counts them and ties them to the visits
|
|
14
|
+
they broke. Never request bodies, params, headers, cookies or users.
|
|
15
|
+
email: hello@tracetells.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/tracetells.rb
|
|
23
|
+
- lib/tracetells/client.rb
|
|
24
|
+
- lib/tracetells/rack.rb
|
|
25
|
+
- lib/tracetells/railtie.rb
|
|
26
|
+
- lib/tracetells/scrub.rb
|
|
27
|
+
- lib/tracetells/version.rb
|
|
28
|
+
homepage: https://tracetells.com/docs/server-errors/
|
|
29
|
+
licenses:
|
|
30
|
+
- MIT
|
|
31
|
+
metadata:
|
|
32
|
+
source_code_uri: https://github.com/dobrinov/tracetells/tree/main/clients/ruby
|
|
33
|
+
rubygems_mfa_required: 'true'
|
|
34
|
+
rdoc_options: []
|
|
35
|
+
require_paths:
|
|
36
|
+
- lib
|
|
37
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
38
|
+
requirements:
|
|
39
|
+
- - ">="
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '3.1'
|
|
42
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
requirements: []
|
|
48
|
+
rubygems_version: 4.0.3
|
|
49
|
+
specification_version: 4
|
|
50
|
+
summary: 'Report Ruby server errors to Trace (tracetells.com): error facts only, scrubbed,
|
|
51
|
+
in the background'
|
|
52
|
+
test_files: []
|