forge_ops_tracker 0.2.1 → 0.5.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 +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +50 -9
- data/lib/forge_ops_tracker/client.rb +20 -10
- data/lib/forge_ops_tracker/configuration.rb +33 -3
- data/lib/forge_ops_tracker/delivery_queue.rb +1 -1
- data/lib/forge_ops_tracker/event_builder.rb +59 -5
- data/lib/forge_ops_tracker/middleware/session_tracking.rb +38 -0
- data/lib/forge_ops_tracker/pii_scrubber.rb +2 -2
- data/lib/forge_ops_tracker/railtie.rb +4 -0
- data/lib/forge_ops_tracker/session_flusher.rb +85 -0
- data/lib/forge_ops_tracker/version.rb +1 -1
- data/lib/forge_ops_tracker.rb +2 -0
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f15d33570709adad8c061ee0c432d9a6484fc50881076fae1493b892562188e7
|
|
4
|
+
data.tar.gz: f5fada725654a4006a6a44ee0680fb5f7f34cb808087305596892101c743a0ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66513f4aebaeb443198a73bad548faaab86653841d213c37000bd343de406926053faa58b65be68934d90c8e12cc1393061ee763e7b5541c4545e90713910fb9
|
|
7
|
+
data.tar.gz: 86eb61aca41a42a55a6df37df0d8ef7789b5342055239640a593310da438cda20e3d6e959a0b229c4dbcbe137089e31d8bd537b1eb8d4e91c72bec4acfd6ec8c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
- Every reported event now carries `sdk_name` ("ruby"), so a project's language on the ForgeOps
|
|
6
|
+
dashboard auto-detects from whichever SDK is actually reporting into it, rather than staying
|
|
7
|
+
stuck on whatever it was created with. Never overrides a language picked by hand in project
|
|
8
|
+
settings.
|
|
9
|
+
|
|
10
|
+
## 0.4.0
|
|
11
|
+
|
|
12
|
+
- Session tracking (release health): every request is now counted as a session, crash-free unless
|
|
13
|
+
an unhandled exception actually escapes it, so the ForgeOps dashboard can show a crash-free rate
|
|
14
|
+
per release rather than only ever hearing about the requests that broke. Counted in-process and
|
|
15
|
+
flushed as a small periodic aggregate (`config.session_flush_interval`, default 60 seconds) on
|
|
16
|
+
its own background thread, not one network call per request. On by default, the same as error
|
|
17
|
+
tracking itself (`config.track_sessions = false` opts out); requires a ForgeOps plan that
|
|
18
|
+
includes release health, checked server-side on every flush.
|
|
19
|
+
|
|
20
|
+
## 0.3.0
|
|
21
|
+
|
|
22
|
+
- Source context capture: each in-app backtrace frame can now carry the 5 lines of source on
|
|
23
|
+
either side of the culprit line, read straight off disk at raise-time, so an issue's detail page
|
|
24
|
+
can show the actual code that broke, not just a file:line:method reference. On by default
|
|
25
|
+
(`config.capture_source_context = false` opts out), but the real, durable protection is a
|
|
26
|
+
per-project setting on the ForgeOps server, not this flag: turning it off there stops the server
|
|
27
|
+
from ever storing captured source lines for that project again, regardless of what any individual
|
|
28
|
+
app's own local setting is still set to. Never applies to a frame outside your app's own code,
|
|
29
|
+
and fails silently for any file that can't be read.
|
|
30
|
+
|
|
3
31
|
## 0.2.0
|
|
4
32
|
|
|
5
33
|
- Client-side PII scrubbing: payloads are scrubbed (email addresses, formatted SSNs/credit
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ForgeOpsTracker
|
|
2
2
|
|
|
3
|
-
Rails exception reporting client for a
|
|
3
|
+
Rails exception reporting client for a [ForgeOps](../../) instance.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -22,17 +22,17 @@ end
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Delivery happens on a small background thread with a bounded queue and short HTTP timeouts. Every
|
|
25
|
-
failure mode
|
|
25
|
+
failure mode (network errors, timeouts, a full queue, a malformed DSN) is caught and dropped
|
|
26
26
|
rather than raised, so a broken or unreachable tracker can never take down the host app.
|
|
27
27
|
|
|
28
28
|
## What gets reported automatically, and what doesn't
|
|
29
29
|
|
|
30
30
|
**Unhandled exceptions need no further wiring at all.** The gem's Railtie subscribes to
|
|
31
31
|
`Rails.error` automatically, and Rails itself reports anything that crashes a request or job
|
|
32
|
-
through that same channel
|
|
32
|
+
through that same channel: install the gem, set a DSN, and those show up in ForgeOps with zero
|
|
33
33
|
other code changes.
|
|
34
34
|
|
|
35
|
-
**Handled exceptions
|
|
35
|
+
**Handled exceptions (code that catches its own error to keep running) are a different story.**
|
|
36
36
|
A plain `rescue` the gem never hears about, no matter what:
|
|
37
37
|
|
|
38
38
|
```ruby
|
|
@@ -40,19 +40,19 @@ begin
|
|
|
40
40
|
charge_card(order)
|
|
41
41
|
rescue Stripe::CardError => e
|
|
42
42
|
logger.warn("card declined: #{e.message}")
|
|
43
|
-
# ForgeOps never sees this
|
|
43
|
+
# ForgeOps never sees this: nothing here goes through Rails.error at all.
|
|
44
44
|
end
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
To report it *and* keep swallowing it, swap the bare `rescue` for Rails' own built-in
|
|
48
|
-
`Rails.error.handle
|
|
48
|
+
`Rails.error.handle`: this isn't a ForgeOps-specific API, it's Rails' own error-reporting
|
|
49
49
|
convention (Rails 7+), which the gem just happens to already be subscribed to:
|
|
50
50
|
|
|
51
51
|
```ruby
|
|
52
52
|
Rails.error.handle(fallback: -> { nil }) do
|
|
53
53
|
charge_card(order)
|
|
54
54
|
end
|
|
55
|
-
# Reported to every Rails.error subscriber, including this gem, then swallowed
|
|
55
|
+
# Reported to every Rails.error subscriber, including this gem, then swallowed:
|
|
56
56
|
# execution continues past the block either way.
|
|
57
57
|
```
|
|
58
58
|
|
|
@@ -70,8 +70,8 @@ code already catches and handles it, route that specific `rescue` through `Rails
|
|
|
70
70
|
## PII scrubbing
|
|
71
71
|
|
|
72
72
|
By default, the message, backtrace, and any context/tags you attach are scanned for likely
|
|
73
|
-
personal data
|
|
74
|
-
anything under a suspiciously-named key (`password`, `api_key`, `ssn`, and similar)
|
|
73
|
+
personal data: email addresses, formatted SSNs/credit cards, known API key/token formats, and
|
|
74
|
+
anything under a suspiciously-named key (`password`, `api_key`, `ssn`, and similar); redacted
|
|
75
75
|
before the payload ever leaves this process. ForgeOps itself scrubs again on arrival regardless, so
|
|
76
76
|
this is a second, earlier layer, not the only one.
|
|
77
77
|
|
|
@@ -83,3 +83,44 @@ ForgeOpsTracker.configure do |config|
|
|
|
83
83
|
config.scrub_pii = false
|
|
84
84
|
end
|
|
85
85
|
```
|
|
86
|
+
|
|
87
|
+
## Source context
|
|
88
|
+
|
|
89
|
+
By default, each in-app backtrace frame (never a vendored gem) is captured along with the 5 lines
|
|
90
|
+
of source on either side of the culprit line, read straight off disk at raise-time, so an issue's
|
|
91
|
+
detail page can show the actual code that broke, not just a `file:line:method` reference. This
|
|
92
|
+
never applies to a frame outside `app_root`, and it fails silently (no context, not an error) for
|
|
93
|
+
any file that can't be read for whatever reason.
|
|
94
|
+
|
|
95
|
+
This is a real, deliberate exception to "off by default is safer": literal source code is being
|
|
96
|
+
transmitted, not just a reference to it, and the real protection here is not this flag. Every
|
|
97
|
+
project on ForgeOps has its own setting (on by default, off durably and immediately once an org
|
|
98
|
+
owner turns it off, regardless of what any individual app's own `config.capture_source_context` is
|
|
99
|
+
still set to) that governs whether the server will ever actually store what an SDK sends; see the
|
|
100
|
+
in-app help docs. Use this flag if you'd rather this gem never even attempt the disk read in the
|
|
101
|
+
first place:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
ForgeOpsTracker.configure do |config|
|
|
105
|
+
config.capture_source_context = false
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Session tracking (release health)
|
|
110
|
+
|
|
111
|
+
By default, every request is counted as a session: crash-free unless an unhandled exception
|
|
112
|
+
actually escapes it, giving ForgeOps a crash-free rate per release to show alongside the errors
|
|
113
|
+
themselves, not just the errors on their own. Counted in-process and flushed as a small periodic
|
|
114
|
+
aggregate on a background thread (never one network call per request), the same delivery
|
|
115
|
+
philosophy as everything else in this gem: a broken or unreachable tracker never affects the host
|
|
116
|
+
app either way.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
ForgeOpsTracker.configure do |config|
|
|
120
|
+
config.track_sessions = false # opt out entirely
|
|
121
|
+
config.session_flush_interval = 30 # seconds; default 60
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Requires a ForgeOps plan that includes release health; on a plan that doesn't, the periodic
|
|
126
|
+
flushes are simply rejected server-side and dropped, exactly like any other delivery failure.
|
|
@@ -3,8 +3,8 @@ require "json"
|
|
|
3
3
|
require "uri"
|
|
4
4
|
|
|
5
5
|
module ForgeOpsTracker
|
|
6
|
-
# Delivers one payload over HTTP. Every failure mode
|
|
7
|
-
# timeout, TLS, a non-2xx response
|
|
6
|
+
# Delivers one payload over HTTP. Every failure mode; DNS, connection,
|
|
7
|
+
# timeout, TLS, a non-2xx response; is caught here and turned into a
|
|
8
8
|
# `false` return rather than a raised exception, since a broken or
|
|
9
9
|
# unreachable tracker must never be able to break the host app.
|
|
10
10
|
class Client
|
|
@@ -13,19 +13,29 @@ module ForgeOpsTracker
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def deliver(payload)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
post(configuration.ingestion_uri, payload)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Same delivery contract as deliver above (never raises, returns a bare
|
|
20
|
+
# true/false), just a different endpoint under the same DSN's host and
|
|
21
|
+
# api_key; see Configuration#session_checkins_uri.
|
|
22
|
+
def deliver_session_checkin(payload)
|
|
23
|
+
post(configuration.session_checkins_uri, payload)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
private
|
|
27
27
|
attr_reader :configuration
|
|
28
28
|
|
|
29
|
+
def post(uri, payload)
|
|
30
|
+
return false unless uri
|
|
31
|
+
|
|
32
|
+
response = http_for(uri).request(build_request(uri, payload))
|
|
33
|
+
response.is_a?(Net::HTTPSuccess)
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
log { "delivery failed: #{e.class}: #{e.message}" }
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
29
39
|
def http_for(uri)
|
|
30
40
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
31
41
|
http.use_ssl = uri.scheme == "https"
|
|
@@ -3,10 +3,12 @@ require "socket"
|
|
|
3
3
|
|
|
4
4
|
module ForgeOpsTracker
|
|
5
5
|
class Configuration
|
|
6
|
-
# A single
|
|
6
|
+
# A single DSN string carries both the ingestion URL and
|
|
7
7
|
# the project's api_key: "https://<api_key>@host/api/v1/events".
|
|
8
8
|
attr_accessor :dsn, :environment, :release, :server_name, :app_root, :logger
|
|
9
9
|
attr_accessor :enabled_environments, :queue_size, :open_timeout, :read_timeout, :scrub_pii
|
|
10
|
+
attr_accessor :capture_source_context
|
|
11
|
+
attr_accessor :track_sessions, :session_flush_interval
|
|
10
12
|
|
|
11
13
|
def initialize
|
|
12
14
|
@dsn = ENV["FORGE_OPS_DSN"]
|
|
@@ -18,13 +20,29 @@ module ForgeOpsTracker
|
|
|
18
20
|
@open_timeout = 2
|
|
19
21
|
@read_timeout = 2
|
|
20
22
|
@logger = nil
|
|
21
|
-
# See PiiScrubber
|
|
23
|
+
# See PiiScrubber; redacts likely-sensitive content (emails,
|
|
22
24
|
# credit cards, known API key formats, anything under a
|
|
23
25
|
# suspiciously-named key) before a payload ever leaves this
|
|
24
26
|
# process. ForgeOps itself scrubs again on arrival regardless, so
|
|
25
|
-
# this is a second, earlier layer, not the only one
|
|
27
|
+
# this is a second, earlier layer, not the only one; but "on" is
|
|
26
28
|
# the only sane default.
|
|
27
29
|
@scrub_pii = true
|
|
30
|
+
# Whether EventBuilder reads a few lines of source off disk around
|
|
31
|
+
# each in-app frame's culprit line (see EventBuilder#attach_source_context).
|
|
32
|
+
# Defaults on so a snippet shows up without extra setup, but this flag
|
|
33
|
+
# alone isn't the real protection against sending proprietary source
|
|
34
|
+
# code somewhere it shouldn't go: ForgeOps' own per-project setting is
|
|
35
|
+
# the durable, server-enforced off switch, since it applies regardless
|
|
36
|
+
# of what this flag happens to be set to on any given deployment, and
|
|
37
|
+
# can't drift back on the way a local config value could. Turn this
|
|
38
|
+
# off here too if this host app never wants that disk read attempted
|
|
39
|
+
# in the first place.
|
|
40
|
+
@capture_source_context = true
|
|
41
|
+
# Auto-instruments every request the moment the gem loads, the same "on unless you turn it
|
|
42
|
+
# off" default error tracking itself already has; nothing else in this gem is opt-in. See
|
|
43
|
+
# ForgeOpsTracker::Middleware::SessionTracking for what this actually wraps.
|
|
44
|
+
@track_sessions = true
|
|
45
|
+
@session_flush_interval = 60
|
|
28
46
|
end
|
|
29
47
|
|
|
30
48
|
def api_key
|
|
@@ -42,6 +60,18 @@ module ForgeOpsTracker
|
|
|
42
60
|
uri
|
|
43
61
|
end
|
|
44
62
|
|
|
63
|
+
# Same host and api_key as ingestion_uri, a sibling path under the same DSN rather than a
|
|
64
|
+
# second DSN to configure; the DSN's own path is always /api/v1/events (what ForgeOps issues
|
|
65
|
+
# to every project), so the session-checkins endpoint is derived by substitution.
|
|
66
|
+
def session_checkins_uri
|
|
67
|
+
uri = ingestion_uri
|
|
68
|
+
return nil unless uri
|
|
69
|
+
|
|
70
|
+
uri = uri.dup
|
|
71
|
+
uri.path = uri.path.sub(%r{/events\z}, "/session_checkins")
|
|
72
|
+
uri
|
|
73
|
+
end
|
|
74
|
+
|
|
45
75
|
def enabled?
|
|
46
76
|
!blank?(dsn) && !blank?(api_key) && enabled_environments.map(&:to_s).include?(environment.to_s)
|
|
47
77
|
end
|
|
@@ -17,7 +17,7 @@ module ForgeOpsTracker
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
# Enqueues a payload, dropping it silently (never blocking the caller)
|
|
20
|
-
# if the queue is already full
|
|
20
|
+
# if the queue is already full; a burst of exceptions must never apply
|
|
21
21
|
# backpressure to the host app.
|
|
22
22
|
def push(payload)
|
|
23
23
|
ensure_worker_started
|
|
@@ -2,13 +2,27 @@ require "time"
|
|
|
2
2
|
|
|
3
3
|
module ForgeOpsTracker
|
|
4
4
|
# Turns a raised exception into the JSON-able payload shape the ingestion
|
|
5
|
-
# API expects. Backtrace parsing is a simple regex, not a full parser
|
|
5
|
+
# API expects. Backtrace parsing is a simple regex, not a full parser:
|
|
6
6
|
# good enough for standard MRI backtrace lines across Ruby versions
|
|
7
7
|
# (both the older `in \`method'` and newer `in 'method'` quoting styles).
|
|
8
8
|
class EventBuilder
|
|
9
9
|
LINE_PATTERN = /\A(?<file>.+?):(?<line>\d+)(?::in\s+(?<method>.+))?\z/
|
|
10
10
|
MAX_FRAMES = 500
|
|
11
11
|
|
|
12
|
+
# Identifies this gem to the server's auto language-detection on the project the event lands
|
|
13
|
+
# in (see Project#note_sdk_platform server-side); "ruby", matching this repo's own sdks/
|
|
14
|
+
# directory naming for every other language's client.
|
|
15
|
+
SDK_NAME = "ruby".freeze
|
|
16
|
+
|
|
17
|
+
# How many lines of source to grab on either side of the culprit line
|
|
18
|
+
# (see #attach_source_context), and the longest a single captured line
|
|
19
|
+
# is allowed to be before getting truncated; guards against a single
|
|
20
|
+
# pathological minified/generated line ballooning the payload. ForgeOps
|
|
21
|
+
# itself re-truncates on arrival too, the same "don't just trust the
|
|
22
|
+
# SDK" posture MAX_FRAMES already gets on the server side.
|
|
23
|
+
CONTEXT_LINES = 5
|
|
24
|
+
MAX_CONTEXT_LINE_LENGTH = 500
|
|
25
|
+
|
|
12
26
|
def initialize(configuration)
|
|
13
27
|
@configuration = configuration
|
|
14
28
|
end
|
|
@@ -23,7 +37,8 @@ module ForgeOpsTracker
|
|
|
23
37
|
release: configuration.release,
|
|
24
38
|
server_name: configuration.server_name,
|
|
25
39
|
context: context || {},
|
|
26
|
-
tags: {}
|
|
40
|
+
tags: {},
|
|
41
|
+
sdk_name: SDK_NAME
|
|
27
42
|
}
|
|
28
43
|
scrub(payload)
|
|
29
44
|
end
|
|
@@ -31,8 +46,8 @@ module ForgeOpsTracker
|
|
|
31
46
|
private
|
|
32
47
|
attr_reader :configuration
|
|
33
48
|
|
|
34
|
-
# exception_class/occurred_at/environment/release/server_name are
|
|
35
|
-
# left alone
|
|
49
|
+
# exception_class/occurred_at/environment/release/server_name/sdk_name are
|
|
50
|
+
# left alone; structured fields this gem or the host app sets
|
|
36
51
|
# deliberately, not free text an exception or its context could
|
|
37
52
|
# accidentally spill sensitive data into.
|
|
38
53
|
def scrub(payload)
|
|
@@ -55,12 +70,13 @@ module ForgeOpsTracker
|
|
|
55
70
|
return nil unless match
|
|
56
71
|
|
|
57
72
|
file = match[:file]
|
|
58
|
-
{
|
|
73
|
+
frame = {
|
|
59
74
|
file: file,
|
|
60
75
|
line: match[:line].to_i,
|
|
61
76
|
method: match[:method]&.gsub(/\A[`'"]|['"]\z/, ""),
|
|
62
77
|
in_app: in_app?(file)
|
|
63
78
|
}
|
|
79
|
+
attach_source_context(frame)
|
|
64
80
|
end
|
|
65
81
|
|
|
66
82
|
def in_app?(file)
|
|
@@ -69,5 +85,43 @@ module ForgeOpsTracker
|
|
|
69
85
|
|
|
70
86
|
file.start_with?(root.to_s) && !file.include?("/gems/") && !file.include?("/bundle/")
|
|
71
87
|
end
|
|
88
|
+
|
|
89
|
+
# Reads a few lines of source straight off disk around the culprit
|
|
90
|
+
# line, at raise-time, in the same running process the exception came
|
|
91
|
+
# from. Gated on two things: the frame has to be in-app (never a
|
|
92
|
+
# vendored dependency; there'd be nothing meaningful to show, and
|
|
93
|
+
# it's not the host app's own code to begin with), and
|
|
94
|
+
# configuration.capture_source_context has to be on (see
|
|
95
|
+
# Configuration for why it defaults to true and why ForgeOps' own
|
|
96
|
+
# per-project setting, not this flag, is the durable, protected way
|
|
97
|
+
# to turn it off). Best-effort: any file that can't be read (deleted,
|
|
98
|
+
# permission denied, a path that only ever existed inside a build
|
|
99
|
+
# step and isn't present in this deployment) just means this one
|
|
100
|
+
# frame gets no source context, not a raised error of its own.
|
|
101
|
+
def attach_source_context(frame)
|
|
102
|
+
return frame unless configuration.capture_source_context && frame[:in_app]
|
|
103
|
+
|
|
104
|
+
lines = File.readlines(frame[:file])
|
|
105
|
+
index = frame[:line] - 1
|
|
106
|
+
return frame unless index.between?(0, lines.length - 1)
|
|
107
|
+
|
|
108
|
+
from = [ index - CONTEXT_LINES, 0 ].max
|
|
109
|
+
to = [ index + CONTEXT_LINES, lines.length - 1 ].min
|
|
110
|
+
|
|
111
|
+
frame.merge(
|
|
112
|
+
context_line: truncate_line(lines[index]),
|
|
113
|
+
pre_context: lines[from...index].map { |source_line| truncate_line(source_line) },
|
|
114
|
+
post_context: lines[(index + 1)..to].map { |source_line| truncate_line(source_line) }
|
|
115
|
+
)
|
|
116
|
+
rescue SystemCallError, IOError
|
|
117
|
+
frame
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def truncate_line(line)
|
|
121
|
+
line = line.chomp
|
|
122
|
+
return line if line.length <= MAX_CONTEXT_LINE_LENGTH
|
|
123
|
+
|
|
124
|
+
"#{line[0, MAX_CONTEXT_LINE_LENGTH]}..."
|
|
125
|
+
end
|
|
72
126
|
end
|
|
73
127
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module ForgeOpsTracker
|
|
2
|
+
module Middleware
|
|
3
|
+
# Wraps every request: counts it, and separately counts it as crashed only when an exception
|
|
4
|
+
# actually escapes to here, never inferred from whether ErrorSubscriber happened to report
|
|
5
|
+
# anything, since ErrorSubscriber reports handled errors too (see Rails.error.handle), so "an
|
|
6
|
+
# event was reported" and "this request crashed" aren't the same signal. Always re-raises,
|
|
7
|
+
# so the host app's own error handling and ErrorSubscriber both run completely unaffected by
|
|
8
|
+
# this middleware's presence; it only ever observes, never intercepts.
|
|
9
|
+
class SessionTracking
|
|
10
|
+
def initialize(app, configuration: ForgeOpsTracker.configuration, flusher: nil)
|
|
11
|
+
@app = app
|
|
12
|
+
@configuration = configuration
|
|
13
|
+
@flusher = flusher
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
return app.call(env) unless configuration.track_sessions && configuration.enabled?
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
response = app.call(env)
|
|
21
|
+
rescue StandardError
|
|
22
|
+
flusher_instance.record_session(crashed: true)
|
|
23
|
+
raise
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
flusher_instance.record_session(crashed: false)
|
|
27
|
+
response
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
attr_reader :app, :configuration
|
|
32
|
+
|
|
33
|
+
def flusher_instance
|
|
34
|
+
@flusher ||= SessionFlusher.new(configuration)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
module ForgeOpsTracker
|
|
2
2
|
# Redacts likely-sensitive content out of a payload before it ever leaves
|
|
3
|
-
# this process
|
|
3
|
+
# this process; the same patterns ForgeOps itself applies again on
|
|
4
4
|
# arrival (defense in depth: this layer keeps the data off the wire and
|
|
5
5
|
# out of any request logging in between; the server-side layer is what
|
|
6
6
|
# actually protects the database, and doesn't depend on every reporting
|
|
7
7
|
# app running an up-to-date version of this gem). See the main
|
|
8
|
-
# application's PiiScrubber for the shared design rationale
|
|
8
|
+
# application's PiiScrubber for the shared design rationale; kept as a
|
|
9
9
|
# separate, dependency-free implementation here rather than requiring the
|
|
10
10
|
# private app's code, since this gem has to work standalone in any host
|
|
11
11
|
# app regardless of what's reporting into it.
|
|
@@ -10,5 +10,9 @@ module ForgeOpsTracker
|
|
|
10
10
|
|
|
11
11
|
Rails.error.subscribe(ForgeOpsTracker::ErrorSubscriber.new(configuration))
|
|
12
12
|
end
|
|
13
|
+
|
|
14
|
+
initializer "forge_ops_tracker.track_sessions" do |app|
|
|
15
|
+
app.middleware.use ForgeOpsTracker::Middleware::SessionTracking, configuration: ForgeOpsTracker.configuration
|
|
16
|
+
end
|
|
13
17
|
end
|
|
14
18
|
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require "thread"
|
|
2
|
+
|
|
3
|
+
module ForgeOpsTracker
|
|
4
|
+
# Counts requests and crashes in-process (see Middleware::SessionTracking) and periodically
|
|
5
|
+
# flushes the totals as one small aggregate report, rather than one network call per request.
|
|
6
|
+
# Same "lazily start a background thread on first use, not at load time" pattern DeliveryQueue
|
|
7
|
+
# already uses, so each forked Puma/Passenger worker gets its own fresh thread instead of
|
|
8
|
+
# inheriting a dead one across fork; here it's triggered by a sleep-loop timer instead of a
|
|
9
|
+
# queue push, since there's no per-event signal to wait on, just a clock.
|
|
10
|
+
class SessionFlusher
|
|
11
|
+
def initialize(configuration, client: Client.new(configuration))
|
|
12
|
+
@configuration = configuration
|
|
13
|
+
@client = client
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@sessions_count = 0
|
|
16
|
+
@crashed_sessions_count = 0
|
|
17
|
+
@period_started_at = Time.now.utc
|
|
18
|
+
@thread = nil
|
|
19
|
+
|
|
20
|
+
# Flushes whatever's already tallied on a normal process exit, so the last partial window
|
|
21
|
+
# (anything shorter than a full session_flush_interval) isn't silently dropped.
|
|
22
|
+
at_exit { flush }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record_session(crashed:)
|
|
26
|
+
ensure_worker_started
|
|
27
|
+
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
@sessions_count += 1
|
|
30
|
+
@crashed_sessions_count += 1 if crashed
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Snapshots and resets the in-process counters, then delivers them. A failed delivery keeps
|
|
35
|
+
# the counts where they are rather than resetting, so the next flush's window just grows
|
|
36
|
+
# instead of losing what was already tallied; there's no other copy of this data anywhere.
|
|
37
|
+
def flush
|
|
38
|
+
snapshot = nil
|
|
39
|
+
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
return if @sessions_count.zero?
|
|
42
|
+
|
|
43
|
+
snapshot = {
|
|
44
|
+
release: configuration.release,
|
|
45
|
+
environment: configuration.environment.to_s,
|
|
46
|
+
period_started_at: @period_started_at.iso8601,
|
|
47
|
+
period_ended_at: Time.now.utc.iso8601,
|
|
48
|
+
sessions_count: @sessions_count,
|
|
49
|
+
crashed_sessions_count: @crashed_sessions_count
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
return unless snapshot && client.deliver_session_checkin(snapshot)
|
|
54
|
+
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
@sessions_count = 0
|
|
57
|
+
@crashed_sessions_count = 0
|
|
58
|
+
@period_started_at = Time.now.utc
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
attr_reader :configuration, :client
|
|
64
|
+
|
|
65
|
+
def ensure_worker_started
|
|
66
|
+
return if @thread&.alive?
|
|
67
|
+
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
return if @thread&.alive?
|
|
70
|
+
|
|
71
|
+
@thread = Thread.new { run }
|
|
72
|
+
@thread.abort_on_exception = false
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def run
|
|
77
|
+
loop do
|
|
78
|
+
sleep configuration.session_flush_interval
|
|
79
|
+
flush
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
configuration.logger&.debug { "[ForgeOpsTracker] session flush thread error: #{e.class}: #{e.message}" }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/forge_ops_tracker.rb
CHANGED
|
@@ -5,6 +5,8 @@ require "forge_ops_tracker/event_builder"
|
|
|
5
5
|
require "forge_ops_tracker/client"
|
|
6
6
|
require "forge_ops_tracker/delivery_queue"
|
|
7
7
|
require "forge_ops_tracker/error_subscriber"
|
|
8
|
+
require "forge_ops_tracker/session_flusher"
|
|
9
|
+
require "forge_ops_tracker/middleware/session_tracking"
|
|
8
10
|
|
|
9
11
|
module ForgeOpsTracker
|
|
10
12
|
class << self
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: forge_ops_tracker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ForgeOps
|
|
@@ -23,8 +23,8 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '3.13'
|
|
26
|
-
description: Hooks Rails' error reporter and reports exceptions to a
|
|
27
|
-
|
|
26
|
+
description: Hooks Rails' error reporter and reports exceptions to a ForgeOps exception
|
|
27
|
+
tracker instance over HTTP, without ever raising back into the host application.
|
|
28
28
|
executables: []
|
|
29
29
|
extensions: []
|
|
30
30
|
extra_rdoc_files: []
|
|
@@ -38,8 +38,10 @@ files:
|
|
|
38
38
|
- lib/forge_ops_tracker/delivery_queue.rb
|
|
39
39
|
- lib/forge_ops_tracker/error_subscriber.rb
|
|
40
40
|
- lib/forge_ops_tracker/event_builder.rb
|
|
41
|
+
- lib/forge_ops_tracker/middleware/session_tracking.rb
|
|
41
42
|
- lib/forge_ops_tracker/pii_scrubber.rb
|
|
42
43
|
- lib/forge_ops_tracker/railtie.rb
|
|
44
|
+
- lib/forge_ops_tracker/session_flusher.rb
|
|
43
45
|
- lib/forge_ops_tracker/version.rb
|
|
44
46
|
homepage: https://getforgeops.net
|
|
45
47
|
licenses:
|
|
@@ -63,5 +65,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
63
65
|
requirements: []
|
|
64
66
|
rubygems_version: 4.0.11
|
|
65
67
|
specification_version: 4
|
|
66
|
-
summary: Rails exception reporting client for a
|
|
68
|
+
summary: Rails exception reporting client for a ForgeOps tracker
|
|
67
69
|
test_files: []
|