forge_ops_tracker 0.16.0 → 0.18.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 +34 -0
- data/LICENSE.txt +1 -1
- data/README.md +36 -0
- data/lib/forge_ops_tracker/breadcrumb_buffer.rb +2 -1
- data/lib/forge_ops_tracker/change_snapshot.rb +1 -1
- data/lib/forge_ops_tracker/client.rb +13 -9
- data/lib/forge_ops_tracker/configuration.rb +25 -0
- data/lib/forge_ops_tracker/delivery_queue.rb +13 -2
- data/lib/forge_ops_tracker/delivery_status.rb +74 -0
- data/lib/forge_ops_tracker/event_builder.rb +14 -4
- data/lib/forge_ops_tracker/failure_event_buffer.rb +18 -5
- data/lib/forge_ops_tracker/infrastructure_metric_buffer.rb +8 -7
- data/lib/forge_ops_tracker/metric_buffer.rb +11 -9
- data/lib/forge_ops_tracker/middleware/span_tracing.rb +9 -1
- data/lib/forge_ops_tracker/performance_flusher.rb +7 -1
- data/lib/forge_ops_tracker/query_explainer.rb +2 -1
- data/lib/forge_ops_tracker/session_flusher.rb +4 -1
- data/lib/forge_ops_tracker/strict_privacy.rb +155 -0
- data/lib/forge_ops_tracker/version.rb +1 -1
- data/lib/forge_ops_tracker.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e9434056bed332dad65c4a8dc051bace54e7acae535043138833f60ba2586cb0
|
|
4
|
+
data.tar.gz: 270cec5a96267fc7d1a41be7c02ab1bfdb9271fb2d7a5e8ae91581a0181848d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 34730c4a7e8af87adb9bdaf490d72d923a0bb96b958a9131c28671d3198a43638dc5f13a2331c92b3d1dd506d24b08b6118d6d0559e4c64d5b12b231a0a8c97c
|
|
7
|
+
data.tar.gz: 0a9704a43c0c74397e082ab191deab1895de0d17210469b5906c123c33cfd7fe9130992ee10932106a093c4d6c329dadca6ca59da16f46173a063395e0eb7b9c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.0 (2026-10-20)
|
|
4
|
+
|
|
5
|
+
- New `config.strict_privacy` (off by default, or `FORGE_OPS_STRICT_PRIVACY=true`) for apps that
|
|
6
|
+
can't let personal or health data leave their servers. With it on, an error event carries no
|
|
7
|
+
message (a `message_fingerprint` computed in your process is sent instead, so errors still
|
|
8
|
+
group), no breadcrumbs, no `endpoint`, no SQL statement and no source context, and the user only
|
|
9
|
+
by `id`. Failed jobs and outbound calls keep their error class but not their message, and
|
|
10
|
+
outbound HTTP spans are named by method and host only, with no span data but `db.statement` and
|
|
11
|
+
`db.system`. A `transaction_name` that is a raw path, not a route or `Controller#action`, has its
|
|
12
|
+
ids taken out in error events, performance samples and failure events.
|
|
13
|
+
- New `config.privacy_allowed_fields` (or `FORGE_OPS_PRIVACY_ALLOWED_FIELDS=error_code,feature`):
|
|
14
|
+
the context and tag keys strict privacy still sends, when their value is a string, number or
|
|
15
|
+
boolean. Empty by default.
|
|
16
|
+
|
|
17
|
+
## 0.17.0 (2026-10-13)
|
|
18
|
+
|
|
19
|
+
- Fixed: a batch the server refused was re-sent at every flush for as long as the process lived,
|
|
20
|
+
with everything recorded since added to it. Performance samples, custom and infrastructure
|
|
21
|
+
metrics, session counts and failure events are now dropped when the server answers with a 4xx
|
|
22
|
+
other than 408 or 429 (a 400, 401, 403, 413 or 422, for example), since sending the same batch
|
|
23
|
+
again can only fail the same way. Once a metric buffer had filled up behind such a batch, every
|
|
24
|
+
new metric was dropped too; that no longer happens.
|
|
25
|
+
- A batch that could not be delivered for now (a network error, a timeout, a 5xx, a 408 or a 429)
|
|
26
|
+
is still kept for the next flush, but only for 5 flushes in a row. After the fifth it is dropped
|
|
27
|
+
and the buffer starts clean, so a long outage no longer holds the same data in memory forever.
|
|
28
|
+
- The failure event buffer now holds at most 1000 entries; further ones are dropped until a flush
|
|
29
|
+
lets go of them, the same way the metric buffers already behave.
|
|
30
|
+
- With a `config.logger`, each drop is logged once at debug with what was dropped and why, and a
|
|
31
|
+
non-2xx answer is logged with its status code.
|
|
32
|
+
- Internal: `ForgeOpsTracker::Client#deliver` and its sibling `deliver_*` methods now return
|
|
33
|
+
`:delivered`, `:rejected` or `:failed` instead of `true`/`false`. All three are truthy, so code
|
|
34
|
+
calling `Client` directly should compare against `:delivered`. Nothing in the documented API
|
|
35
|
+
changed.
|
|
36
|
+
|
|
3
37
|
## 0.16.0 (2026-10-02)
|
|
4
38
|
|
|
5
39
|
- Changed: outside Rails, the environment now defaults to `production` when nothing sets it (it
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -226,6 +226,42 @@ ForgeOpsTracker.configure do |config|
|
|
|
226
226
|
end
|
|
227
227
|
```
|
|
228
228
|
|
|
229
|
+
## Strict privacy
|
|
230
|
+
|
|
231
|
+
For apps that can't let personal or health data (PII, PHI) leave their own servers. With strict
|
|
232
|
+
privacy on, the gem sends only structured facts about an error (its class, its stack frames'
|
|
233
|
+
file, line and method, the release, environment, server, the request's `transaction_name` and
|
|
234
|
+
trace id, and the database tables or procedures involved), never free text that could hold a
|
|
235
|
+
name, an email or a diagnosis:
|
|
236
|
+
|
|
237
|
+
- No error message. A `message_fingerprint` is sent instead: a one-way hash of the message with
|
|
238
|
+
its numbers, hex ids and UUIDs taken out, computed in your process, so errors still group
|
|
239
|
+
without the message ever leaving it.
|
|
240
|
+
- No breadcrumbs (they aren't recorded at all), no `endpoint`, no SQL statement even with
|
|
241
|
+
`capture_sql_statement` on, and no source context (the source file is never read).
|
|
242
|
+
- The user is sent by `id` only, never `email` or `username`.
|
|
243
|
+
- Context and tags only keep the top-level keys you list in `privacy_allowed_fields`, and only
|
|
244
|
+
when the value is a string, number or boolean. Nothing else from them is sent.
|
|
245
|
+
- Failed jobs and outbound calls keep their error class, never their error message.
|
|
246
|
+
- A `transaction_name` that is a raw path rather than a route or `Controller#action` has its ids
|
|
247
|
+
taken out (`GET /patients/:id`), in error events, performance samples and failure events alike.
|
|
248
|
+
- Outbound HTTP spans are named by method and host only (`GET api.example.com`), and span data
|
|
249
|
+
keeps only the masked `db.statement` and `db.system`.
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
ForgeOpsTracker.configure do |config|
|
|
253
|
+
config.strict_privacy = true # default false
|
|
254
|
+
config.privacy_allowed_fields = %w[error_code feature] # default []
|
|
255
|
+
end
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Both can also come from the environment: `FORGE_OPS_STRICT_PRIVACY=true` and
|
|
259
|
+
`FORGE_OPS_PRIVACY_ALLOWED_FIELDS=error_code,feature` (comma separated). A value set in
|
|
260
|
+
`configure` wins over the environment.
|
|
261
|
+
|
|
262
|
+
ForgeOps applies the same rules again on arrival when the project's own Strict privacy setting is
|
|
263
|
+
on, so an app with an older gem, or one with this option off, still can't get that text stored.
|
|
264
|
+
|
|
229
265
|
## Source context
|
|
230
266
|
|
|
231
267
|
By default, each in-app backtrace frame (never a vendored gem) is captured along with the 5 lines
|
|
@@ -23,7 +23,8 @@ module ForgeOpsTracker
|
|
|
23
23
|
# ForgeOpsTracker.configure takes effect on whatever's added next, not just a buffer created
|
|
24
24
|
# afterward.
|
|
25
25
|
max_size = [ configuration.max_breadcrumbs, 0 ].max
|
|
26
|
-
|
|
26
|
+
# Strict privacy never sends breadcrumbs, so they're never recorded either.
|
|
27
|
+
return if max_size.zero? || configuration.strict_privacy
|
|
27
28
|
|
|
28
29
|
entries << {
|
|
29
30
|
category: category.to_s,
|
|
@@ -66,7 +66,7 @@ module ForgeOpsTracker
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
def deliver
|
|
69
|
-
client.deliver_change_snapshot(payload)
|
|
69
|
+
DeliveryStatus.from(client.deliver_change_snapshot(payload)) == DeliveryStatus::DELIVERED
|
|
70
70
|
rescue StandardError => e
|
|
71
71
|
configuration.logger&.debug { "[ForgeOpsTracker] change snapshot failed: #{e.class}: #{e.message}" }
|
|
72
72
|
false
|
|
@@ -5,8 +5,10 @@ require "uri"
|
|
|
5
5
|
module ForgeOpsTracker
|
|
6
6
|
# Delivers one payload over HTTP. Every failure mode; DNS, connection,
|
|
7
7
|
# timeout, TLS, a non-2xx response; is caught here and turned into a
|
|
8
|
-
#
|
|
9
|
-
# unreachable tracker must never be able to break the host app.
|
|
8
|
+
# DeliveryStatus rather than a raised exception, since a broken or
|
|
9
|
+
# unreachable tracker must never be able to break the host app. Every
|
|
10
|
+
# deliver_* method answers one of DeliveryStatus::DELIVERED, REJECTED or
|
|
11
|
+
# FAILED (all three are truthy, so compare, never test for truth).
|
|
10
12
|
class Client
|
|
11
13
|
def initialize(configuration)
|
|
12
14
|
@configuration = configuration
|
|
@@ -16,8 +18,8 @@ module ForgeOpsTracker
|
|
|
16
18
|
post(configuration.ingestion_uri, payload)
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
# Same delivery contract as deliver above (never raises, returns a
|
|
20
|
-
#
|
|
21
|
+
# Same delivery contract as deliver above (never raises, returns a
|
|
22
|
+
# DeliveryStatus), just a different endpoint under the same DSN's host and
|
|
21
23
|
# api_key; see Configuration#session_checkins_uri.
|
|
22
24
|
def deliver_session_checkin(payload)
|
|
23
25
|
post(configuration.session_checkins_uri, payload)
|
|
@@ -61,7 +63,7 @@ module ForgeOpsTracker
|
|
|
61
63
|
end
|
|
62
64
|
|
|
63
65
|
# Same delivery contract again; see Configuration#changes_uri and ForgeOpsTracker.record_change.
|
|
64
|
-
# One change per call. A plan without change tracking answers 403, which is
|
|
66
|
+
# One change per call. A plan without change tracking answers 403, which is a REJECTED here.
|
|
65
67
|
def deliver_change(payload)
|
|
66
68
|
post(configuration.changes_uri, payload)
|
|
67
69
|
end
|
|
@@ -72,7 +74,7 @@ module ForgeOpsTracker
|
|
|
72
74
|
end
|
|
73
75
|
|
|
74
76
|
# Same again; see Configuration#query_plans_uri and QueryExplainer. One plan per call. A plan
|
|
75
|
-
# without performance monitoring answers 403, which is
|
|
77
|
+
# without performance monitoring answers 403, which is a REJECTED here.
|
|
76
78
|
def deliver_query_plan(payload)
|
|
77
79
|
post(configuration.query_plans_uri, payload)
|
|
78
80
|
end
|
|
@@ -81,13 +83,15 @@ module ForgeOpsTracker
|
|
|
81
83
|
attr_reader :configuration
|
|
82
84
|
|
|
83
85
|
def post(uri, payload)
|
|
84
|
-
return
|
|
86
|
+
return DeliveryStatus::FAILED unless uri
|
|
85
87
|
|
|
86
88
|
response = http_for(uri).request(build_request(uri, payload))
|
|
87
|
-
response.
|
|
89
|
+
status = DeliveryStatus.for_code(response.code)
|
|
90
|
+
log { "delivery to #{uri.path} answered HTTP #{response.code}" } unless status == DeliveryStatus::DELIVERED
|
|
91
|
+
status
|
|
88
92
|
rescue StandardError => e
|
|
89
93
|
log { "delivery failed: #{e.class}: #{e.message}" }
|
|
90
|
-
|
|
94
|
+
DeliveryStatus::FAILED
|
|
91
95
|
end
|
|
92
96
|
|
|
93
97
|
def http_for(uri)
|
|
@@ -20,6 +20,8 @@ module ForgeOpsTracker
|
|
|
20
20
|
attr_accessor :explain_slow_queries, :explain_threshold_ms
|
|
21
21
|
attr_accessor :propagate_traces, :trace_propagation_targets
|
|
22
22
|
attr_accessor :detect_changes, :track_env_var_names
|
|
23
|
+
attr_accessor :strict_privacy
|
|
24
|
+
attr_reader :privacy_allowed_fields
|
|
23
25
|
|
|
24
26
|
def initialize
|
|
25
27
|
@dsn = ENV["FORGE_OPS_DSN"]
|
|
@@ -156,6 +158,24 @@ module ForgeOpsTracker
|
|
|
156
158
|
# but even names can describe more about an app than some teams want to share. See
|
|
157
159
|
# ChangeSnapshot::ENV_VAR_DENYLIST for the host-specific names that are always left out.
|
|
158
160
|
@track_env_var_names = false
|
|
161
|
+
# Off by default. On, nothing this gem sends carries free text that could hold personal or
|
|
162
|
+
# health data: no error message (a message_fingerprint is sent instead, so errors still
|
|
163
|
+
# group), no breadcrumbs, no request path, no SQL statement, no source context, no user email
|
|
164
|
+
# or username, no job failure message, and outbound HTTP spans named by method and host only.
|
|
165
|
+
# See StrictPrivacy for every rule. FORGE_OPS_STRICT_PRIVACY=true turns it on from the
|
|
166
|
+
# environment; setting it here wins over that.
|
|
167
|
+
@strict_privacy = self.class.env_flag(ENV["FORGE_OPS_STRICT_PRIVACY"])
|
|
168
|
+
# Context and tag keys strict privacy still sends (only plain string, number or boolean
|
|
169
|
+
# values under exactly these top-level keys), e.g. %w[error_code feature]. Empty by default,
|
|
170
|
+
# so nothing from context or tags goes out. FORGE_OPS_PRIVACY_ALLOWED_FIELDS=error_code,feature
|
|
171
|
+
# sets it from the environment.
|
|
172
|
+
self.privacy_allowed_fields = ENV["FORGE_OPS_PRIVACY_ALLOWED_FIELDS"].to_s.split(",")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Accepts an Array of names or one comma-separated String; always kept as stripped Strings.
|
|
176
|
+
def privacy_allowed_fields=(fields)
|
|
177
|
+
fields = fields.split(",") if fields.is_a?(String)
|
|
178
|
+
@privacy_allowed_fields = Array(fields).map { |field| field.to_s.strip }.reject(&:empty?)
|
|
159
179
|
end
|
|
160
180
|
|
|
161
181
|
# Whether an outbound request to host should carry a traceparent header; see
|
|
@@ -290,6 +310,11 @@ module ForgeOpsTracker
|
|
|
290
310
|
[ env["FORGE_OPS_ENVIRONMENT"], env["RAILS_ENV"], env["RACK_ENV"] ].find { |value| value && !value.strip.empty? } || "production"
|
|
291
311
|
end
|
|
292
312
|
|
|
313
|
+
# "true", "1", "yes" or "on" (any case) is on; anything else, or unset, is off.
|
|
314
|
+
def self.env_flag(value)
|
|
315
|
+
%w[true 1 yes on].include?(value.to_s.strip.downcase)
|
|
316
|
+
end
|
|
317
|
+
|
|
293
318
|
private
|
|
294
319
|
def environment_enabled?
|
|
295
320
|
enabled_environments.map(&:to_s).include?(environment.to_s)
|
|
@@ -39,7 +39,7 @@ module ForgeOpsTracker
|
|
|
39
39
|
rescue ThreadError
|
|
40
40
|
break
|
|
41
41
|
end
|
|
42
|
-
|
|
42
|
+
deliver(payload)
|
|
43
43
|
end
|
|
44
44
|
sleep 0.01 while @in_flight.positive? && monotonic_now < deadline
|
|
45
45
|
rescue StandardError => e
|
|
@@ -78,7 +78,7 @@ module ForgeOpsTracker
|
|
|
78
78
|
payload = @queue.pop
|
|
79
79
|
@in_flight += 1
|
|
80
80
|
begin
|
|
81
|
-
|
|
81
|
+
deliver(payload)
|
|
82
82
|
ensure
|
|
83
83
|
@in_flight -= 1
|
|
84
84
|
end
|
|
@@ -87,6 +87,17 @@ module ForgeOpsTracker
|
|
|
87
87
|
end
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# One attempt per payload, as it has always been: a queued event is never held back for a
|
|
91
|
+
# retry, whatever the outcome, so all that is left to do with a refusal is to say so once.
|
|
92
|
+
def deliver(payload)
|
|
93
|
+
case DeliveryStatus.from(client.public_send(@deliver_with, payload))
|
|
94
|
+
when DeliveryStatus::REJECTED
|
|
95
|
+
log { "dropping event: the server rejected it and will not accept a retry" }
|
|
96
|
+
when DeliveryStatus::FAILED
|
|
97
|
+
log { "dropping event: delivery failed" }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
90
101
|
# Registered on the first push, not at load, so a process that never reports anything never
|
|
91
102
|
# waits on exit at all.
|
|
92
103
|
def register_drain
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module ForgeOpsTracker
|
|
2
|
+
# What became of one delivery attempt, as every Client#deliver_* method reports it. A bare
|
|
3
|
+
# true/false could not tell "the server will never take this payload" apart from "try again
|
|
4
|
+
# later", so a batch a plan or a validation rule refused was re-sent at every flush for as long
|
|
5
|
+
# as the process lived.
|
|
6
|
+
module DeliveryStatus
|
|
7
|
+
# Any 2xx.
|
|
8
|
+
DELIVERED = :delivered
|
|
9
|
+
# A 4xx other than 408 and 429: sending the same payload again can only fail the same way.
|
|
10
|
+
REJECTED = :rejected
|
|
11
|
+
# A network error, a timeout, a 5xx, a 408 or a 429: worth another attempt later.
|
|
12
|
+
FAILED = :failed
|
|
13
|
+
|
|
14
|
+
RETRYABLE_CLIENT_ERRORS = [ 408, 429 ].freeze
|
|
15
|
+
|
|
16
|
+
def self.for_code(code)
|
|
17
|
+
code = code.to_i
|
|
18
|
+
return DELIVERED if code.between?(200, 299)
|
|
19
|
+
return REJECTED if code.between?(400, 499) && !RETRYABLE_CLIENT_ERRORS.include?(code)
|
|
20
|
+
|
|
21
|
+
FAILED
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Reads whatever a client handed back. A status passes through; anything else is read the way
|
|
25
|
+
# the old boolean contract was (truthy delivered, falsy failed for now), so a client injected
|
|
26
|
+
# through a `client:` keyword that still answers true/false keeps working.
|
|
27
|
+
def self.from(result)
|
|
28
|
+
return result if [ DELIVERED, REJECTED, FAILED ].include?(result)
|
|
29
|
+
|
|
30
|
+
result ? DELIVERED : FAILED
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Decides, after each flush of one buffer/flusher, whether the batch it just sent is done with.
|
|
35
|
+
# Delivered and rejected batches are let go of at once; one that failed for now is kept, but only
|
|
36
|
+
# for MAX_FAILED_FLUSHES flushes in a row, so an outage (or a server answering 500 to one
|
|
37
|
+
# particular batch) can never pin the same data in memory for the life of the process.
|
|
38
|
+
class FlushOutcome
|
|
39
|
+
MAX_FAILED_FLUSHES = 5
|
|
40
|
+
|
|
41
|
+
# what names the batch in the debug log ("metrics", "performance samples").
|
|
42
|
+
def initialize(configuration, what)
|
|
43
|
+
@configuration = configuration
|
|
44
|
+
@what = what
|
|
45
|
+
@failed_flushes = 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# True when the caller should remove what it just sent (delivered, or dropped), false when it
|
|
49
|
+
# should keep it for the next flush.
|
|
50
|
+
def release?(result)
|
|
51
|
+
case DeliveryStatus.from(result)
|
|
52
|
+
when DeliveryStatus::DELIVERED
|
|
53
|
+
@failed_flushes = 0
|
|
54
|
+
true
|
|
55
|
+
when DeliveryStatus::REJECTED
|
|
56
|
+
@failed_flushes = 0
|
|
57
|
+
log { "dropping buffered #{@what}: the server rejected them and will not accept a retry" }
|
|
58
|
+
true
|
|
59
|
+
else
|
|
60
|
+
@failed_flushes += 1
|
|
61
|
+
return false if @failed_flushes < MAX_FAILED_FLUSHES
|
|
62
|
+
|
|
63
|
+
@failed_flushes = 0
|
|
64
|
+
log { "dropping buffered #{@what}: #{MAX_FAILED_FLUSHES} flushes in a row failed" }
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
def log
|
|
71
|
+
@configuration.logger&.debug { "[ForgeOpsTracker] #{yield}" }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -35,6 +35,12 @@ module ForgeOpsTracker
|
|
|
35
35
|
# request (a background job, a console session) and each key is left out of the payload
|
|
36
36
|
# entirely when nil, the same "omit rather than send an empty value" treatment user and
|
|
37
37
|
# breadcrumbs already get.
|
|
38
|
+
#
|
|
39
|
+
# With configuration.strict_privacy on, the finished payload goes through StrictPrivacy.event:
|
|
40
|
+
# the message is replaced by its message_fingerprint (computed from the message as it would
|
|
41
|
+
# otherwise have been sent, so after scrub_pii, which is also what the server fingerprints from
|
|
42
|
+
# a gem without strict privacy, and keeps a message holding an email grouping together), and breadcrumbs, endpoint, the SQL statement, source context, the user's
|
|
43
|
+
# email/username and every context/tag key not in privacy_allowed_fields are left out.
|
|
38
44
|
def build(error, context: {}, user: nil, breadcrumbs: [], transaction_name: nil, endpoint: nil, trace_id: nil)
|
|
39
45
|
payload = {
|
|
40
46
|
exception_class: error.class.name,
|
|
@@ -54,7 +60,10 @@ module ForgeOpsTracker
|
|
|
54
60
|
payload[:endpoint] = endpoint if endpoint
|
|
55
61
|
payload[:trace_id] = trace_id if trace_id
|
|
56
62
|
attach_sql(payload, error)
|
|
57
|
-
scrub(payload)
|
|
63
|
+
payload = scrub(payload)
|
|
64
|
+
return payload unless configuration.strict_privacy
|
|
65
|
+
|
|
66
|
+
StrictPrivacy.event(payload, allowed_fields: configuration.privacy_allowed_fields)
|
|
58
67
|
end
|
|
59
68
|
|
|
60
69
|
private
|
|
@@ -96,7 +105,7 @@ module ForgeOpsTracker
|
|
|
96
105
|
|
|
97
106
|
objects = SqlStatement.objects(masked)
|
|
98
107
|
payload[:sql_objects] = objects if objects && configuration.capture_sql_objects
|
|
99
|
-
payload[:sql_statement] = masked if configuration.capture_sql_statement
|
|
108
|
+
payload[:sql_statement] = masked if configuration.capture_sql_statement && !configuration.strict_privacy
|
|
100
109
|
end
|
|
101
110
|
|
|
102
111
|
def backtrace_frames(error)
|
|
@@ -135,9 +144,10 @@ module ForgeOpsTracker
|
|
|
135
144
|
# to turn it off). Best-effort: any file that can't be read (deleted,
|
|
136
145
|
# permission denied, a path that only ever existed inside a build
|
|
137
146
|
# step and isn't present in this deployment) just means this one
|
|
138
|
-
# frame gets no source context, not a raised error of its own.
|
|
147
|
+
# frame gets no source context, not a raised error of its own. Never read at all under
|
|
148
|
+
# strict privacy, which never sends it.
|
|
139
149
|
def attach_source_context(frame)
|
|
140
|
-
return frame unless configuration.capture_source_context && frame[:in_app]
|
|
150
|
+
return frame unless configuration.capture_source_context && frame[:in_app] && !configuration.strict_privacy
|
|
141
151
|
|
|
142
152
|
lines = File.readlines(frame[:file])
|
|
143
153
|
index = frame[:line] - 1
|
|
@@ -8,12 +8,17 @@ module ForgeOpsTracker
|
|
|
8
8
|
# kind of thing worth an accurate individual record of, not something to pre-aggregate away the
|
|
9
9
|
# way PerformanceFlusher's own count/sum/max buckets duration.
|
|
10
10
|
class FailureEventBuffer
|
|
11
|
+
# Same cap, and the same "drop the newest, not the oldest" reasoning, as MetricBuffer's own
|
|
12
|
+
# MAX_ENTRIES: a burst of failures during an outage must not grow this without bound.
|
|
13
|
+
MAX_ENTRIES = 1000
|
|
14
|
+
|
|
11
15
|
def initialize(configuration, client: Client.new(configuration))
|
|
12
16
|
@configuration = configuration
|
|
13
17
|
@client = client
|
|
14
18
|
@mutex = Mutex.new
|
|
15
19
|
@entries = []
|
|
16
20
|
@thread = nil
|
|
21
|
+
@outcome = FlushOutcome.new(configuration, "failure events")
|
|
17
22
|
|
|
18
23
|
# Flushes whatever's already buffered on a normal process exit, so the last partial window
|
|
19
24
|
# isn't silently dropped; same reasoning every other buffer/flusher in this gem documents.
|
|
@@ -24,27 +29,35 @@ module ForgeOpsTracker
|
|
|
24
29
|
ensure_worker_started
|
|
25
30
|
|
|
26
31
|
@mutex.synchronize do
|
|
27
|
-
@entries
|
|
32
|
+
return false if @entries.size >= MAX_ENTRIES
|
|
33
|
+
|
|
34
|
+
entry = {
|
|
28
35
|
kind: kind, transaction_name: transaction_name, error_class: error_class, error_message: error_message,
|
|
29
36
|
environment: configuration.environment.to_s, release: configuration.release,
|
|
30
37
|
occurred_at: Time.now.utc.iso8601
|
|
31
38
|
}
|
|
39
|
+
# Strict privacy keeps the error's class, never its message.
|
|
40
|
+
@entries << (configuration.strict_privacy ? StrictPrivacy.failure_event(entry) : entry)
|
|
32
41
|
end
|
|
33
42
|
end
|
|
34
43
|
|
|
35
44
|
# Snapshots and resets the buffered entries, then delivers them as one batch. Same "a failed
|
|
36
|
-
# delivery keeps every entry for the next attempt" contract as MetricBuffer#flush
|
|
45
|
+
# delivery keeps every entry for the next attempt" contract as MetricBuffer#flush, with the
|
|
46
|
+
# same two exceptions: a rejected batch, and one that failed FlushOutcome::MAX_FAILED_FLUSHES
|
|
47
|
+
# flushes in a row, are dropped.
|
|
37
48
|
def flush
|
|
38
49
|
snapshot = nil
|
|
39
50
|
|
|
40
51
|
@mutex.synchronize do
|
|
41
52
|
return if @entries.empty?
|
|
42
|
-
snapshot = @entries
|
|
53
|
+
snapshot = @entries.dup
|
|
43
54
|
end
|
|
44
55
|
|
|
45
|
-
return unless client.deliver_failure_events(snapshot)
|
|
56
|
+
return unless @outcome.release?(client.deliver_failure_events(snapshot))
|
|
46
57
|
|
|
47
|
-
|
|
58
|
+
# Exactly the entries just sent: anything recorded while the request was in flight sits after
|
|
59
|
+
# them and stays for the next flush.
|
|
60
|
+
@mutex.synchronize { @entries.shift(snapshot.size) }
|
|
48
61
|
end
|
|
49
62
|
|
|
50
63
|
private
|
|
@@ -3,16 +3,16 @@ require "thread"
|
|
|
3
3
|
module ForgeOpsTracker
|
|
4
4
|
# Collects individual ForgeOpsTracker.capture_infrastructure_metric readings in-process and
|
|
5
5
|
# periodically flushes them as one batch, same shape as MetricBuffer (a sibling class, not a
|
|
6
|
-
# shared base
|
|
6
|
+
# shared base; this gem keeps SessionFlusher/PerformanceFlusher as separate, independently-
|
|
7
7
|
# readable classes despite their own similar shape too, and these two follow that precedent).
|
|
8
8
|
# Its main real caller is a short-lived cron/script process that exits right after a handful of
|
|
9
9
|
# capture calls, so the at_exit flush below is what actually matters in practice; the periodic
|
|
10
10
|
# background-thread timer still runs for a long-lived process that chooses to call this
|
|
11
11
|
# continuously instead, but rarely gets the chance to fire in the cron case.
|
|
12
12
|
class InfrastructureMetricBuffer
|
|
13
|
-
# Once this many entries are buffered, further ones are dropped until a flush
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# Once this many entries are buffered, further ones are dropped until a flush lets go of them:
|
|
14
|
+
# an unreachable server fails every flush, and an uncapped buffer would keep growing until
|
|
15
|
+
# FlushOutcome gives up on the batch. Dropping the newest rather than the oldest keeps the entries a flush
|
|
16
16
|
# is delivering at the front of the array, which is what makes removing exactly them exact.
|
|
17
17
|
MAX_ENTRIES = 1000
|
|
18
18
|
|
|
@@ -22,6 +22,7 @@ module ForgeOpsTracker
|
|
|
22
22
|
@mutex = Mutex.new
|
|
23
23
|
@entries = []
|
|
24
24
|
@thread = nil
|
|
25
|
+
@outcome = FlushOutcome.new(configuration, "infrastructure metrics")
|
|
25
26
|
|
|
26
27
|
at_exit { flush }
|
|
27
28
|
end
|
|
@@ -38,7 +39,7 @@ module ForgeOpsTracker
|
|
|
38
39
|
end
|
|
39
40
|
end
|
|
40
41
|
|
|
41
|
-
# Same reset-on-success/keep-on-failure contract as MetricBuffer#flush; see that class's own
|
|
42
|
+
# Same reset-on-success/keep-on-failure/drop-on-rejection contract as MetricBuffer#flush; see that class's own
|
|
42
43
|
# comment for the accepted race this shares with it.
|
|
43
44
|
def flush
|
|
44
45
|
snapshot = nil
|
|
@@ -48,9 +49,9 @@ module ForgeOpsTracker
|
|
|
48
49
|
snapshot = @entries.dup
|
|
49
50
|
end
|
|
50
51
|
|
|
51
|
-
return unless client.deliver_infrastructure_metrics(snapshot)
|
|
52
|
+
return unless @outcome.release?(client.deliver_infrastructure_metrics(snapshot))
|
|
52
53
|
|
|
53
|
-
# Exactly the entries just
|
|
54
|
+
# Exactly the entries just sent: anything recorded while the request was in flight sits
|
|
54
55
|
# after them and stays for the next flush (resetting the whole array here lost it).
|
|
55
56
|
@mutex.synchronize { @entries.shift(snapshot.size) }
|
|
56
57
|
end
|
|
@@ -10,9 +10,9 @@ module ForgeOpsTracker
|
|
|
10
10
|
# they'll want a genuinely accurate count/sum of later, not something to pre-aggregate away
|
|
11
11
|
# client-side, so CustomMetricsController stores one row per entry as-is.
|
|
12
12
|
class MetricBuffer
|
|
13
|
-
# Once this many entries are buffered, further ones are dropped until a flush
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# Once this many entries are buffered, further ones are dropped until a flush lets go of them:
|
|
14
|
+
# an unreachable server fails every flush, and an uncapped buffer would keep growing until
|
|
15
|
+
# FlushOutcome gives up on the batch. Dropping the newest rather than the oldest keeps the entries a flush
|
|
16
16
|
# is delivering at the front of the array, which is what makes removing exactly them exact.
|
|
17
17
|
MAX_ENTRIES = 1000
|
|
18
18
|
|
|
@@ -22,6 +22,7 @@ module ForgeOpsTracker
|
|
|
22
22
|
@mutex = Mutex.new
|
|
23
23
|
@entries = []
|
|
24
24
|
@thread = nil
|
|
25
|
+
@outcome = FlushOutcome.new(configuration, "metrics")
|
|
25
26
|
|
|
26
27
|
# Flushes whatever's already buffered on a normal process exit, so the last partial window
|
|
27
28
|
# isn't silently dropped; same reasoning every other buffer/flusher in this gem documents.
|
|
@@ -44,10 +45,11 @@ module ForgeOpsTracker
|
|
|
44
45
|
end
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
# Snapshots and resets the buffered entries, then delivers them as one batch. A
|
|
48
|
-
#
|
|
49
|
-
# just grows instead of losing what was already buffered
|
|
50
|
-
#
|
|
48
|
+
# Snapshots and resets the buffered entries, then delivers them as one batch. A delivery that
|
|
49
|
+
# failed for now keeps every entry where it is rather than resetting, so the next flush's batch
|
|
50
|
+
# just grows instead of losing what was already buffered (a rejected batch, or one that failed
|
|
51
|
+
# FlushOutcome::MAX_FAILED_FLUSHES flushes in a row, is dropped instead); same reasoning
|
|
52
|
+
# SessionFlusher#flush already documents, including the same small, accepted race (an entry recorded during the
|
|
51
53
|
# in-flight HTTP request gets folded into this snapshot's own reset rather than kept for the
|
|
52
54
|
# next one) that class already lives with, for the same reason: simple and consistent beats a
|
|
53
55
|
# cleverer dedup that would only trade one rare edge case for a subtler one.
|
|
@@ -59,9 +61,9 @@ module ForgeOpsTracker
|
|
|
59
61
|
snapshot = @entries.dup
|
|
60
62
|
end
|
|
61
63
|
|
|
62
|
-
return unless client.deliver_metrics(snapshot)
|
|
64
|
+
return unless @outcome.release?(client.deliver_metrics(snapshot))
|
|
63
65
|
|
|
64
|
-
# Exactly the entries just
|
|
66
|
+
# Exactly the entries just sent: anything recorded while the request was in flight sits
|
|
65
67
|
# after them and stays for the next flush (resetting the whole array here lost it).
|
|
66
68
|
@mutex.synchronize { @entries.shift(snapshot.size) }
|
|
67
69
|
end
|
|
@@ -41,7 +41,7 @@ module ForgeOpsTracker
|
|
|
41
41
|
app.call(env)
|
|
42
42
|
ensure
|
|
43
43
|
if buffer && send_trace?(buffer, state)
|
|
44
|
-
Railtie.span_queue.push(trace_id: buffer.trace_id, spans: buffer
|
|
44
|
+
Railtie.span_queue.push(trace_id: buffer.trace_id, spans: outgoing_spans(buffer))
|
|
45
45
|
end
|
|
46
46
|
Thread.current[:forge_ops_tracker_spans] = nil
|
|
47
47
|
end
|
|
@@ -54,6 +54,14 @@ module ForgeOpsTracker
|
|
|
54
54
|
|
|
55
55
|
state.errored? && buffer.root_recorded?
|
|
56
56
|
end
|
|
57
|
+
|
|
58
|
+
# Under strict privacy, every span goes through StrictPrivacy.span: an outbound call named
|
|
59
|
+
# by its method and host only, and no span data but the masked statement and db.system.
|
|
60
|
+
def outgoing_spans(buffer)
|
|
61
|
+
return buffer.spans unless configuration.strict_privacy
|
|
62
|
+
|
|
63
|
+
buffer.spans.map { |span| StrictPrivacy.span(span) }
|
|
64
|
+
end
|
|
57
65
|
end
|
|
58
66
|
end
|
|
59
67
|
end
|
|
@@ -17,6 +17,7 @@ module ForgeOpsTracker
|
|
|
17
17
|
@buckets = {}
|
|
18
18
|
@period_started_at = Time.now.utc
|
|
19
19
|
@thread = nil
|
|
20
|
+
@outcome = FlushOutcome.new(configuration, "performance samples")
|
|
20
21
|
|
|
21
22
|
# Flushes whatever's already tallied on a normal process exit, so the last partial window
|
|
22
23
|
# (anything shorter than a full performance_flush_interval) isn't silently dropped; same
|
|
@@ -30,6 +31,8 @@ module ForgeOpsTracker
|
|
|
30
31
|
# without colliding into the same bucket.
|
|
31
32
|
def record(transaction_name:, duration_ms:, kind: "controller")
|
|
32
33
|
ensure_worker_started
|
|
34
|
+
# Under strict privacy a raw-path name loses its ids; a route or Controller#action is kept.
|
|
35
|
+
transaction_name = StrictPrivacy.transaction_name(transaction_name) if configuration.strict_privacy
|
|
33
36
|
|
|
34
37
|
@mutex.synchronize do
|
|
35
38
|
bucket = (@buckets[[ transaction_name, kind ]] ||= { count: 0, duration_sum_ms: 0.0, max_duration_ms: 0.0, histogram: Hash.new(0) })
|
|
@@ -48,6 +51,9 @@ module ForgeOpsTracker
|
|
|
48
51
|
# just grows instead of losing what was already tallied; same reasoning SessionFlusher#flush
|
|
49
52
|
# already documents, and the same reason PerformanceSamplesController accepts a batch rather
|
|
50
53
|
# than a single row: a failed flush shouldn't have to re-deliver by transaction one at a time.
|
|
54
|
+
# That only holds for a delivery that failed for now, and only FlushOutcome::MAX_FAILED_FLUSHES
|
|
55
|
+
# times in a row: a batch the server rejected, or one that keeps failing, is removed exactly
|
|
56
|
+
# the way a delivered one is, since re-sending it could never succeed.
|
|
51
57
|
#
|
|
52
58
|
# Only exactly what this snapshot delivered is removed afterward (subtracted from whatever is in
|
|
53
59
|
# each bucket by then), never the whole hash reset: #record can run on another thread while the
|
|
@@ -84,7 +90,7 @@ module ForgeOpsTracker
|
|
|
84
90
|
end
|
|
85
91
|
end
|
|
86
92
|
|
|
87
|
-
return unless snapshot && client.deliver_performance_samples(snapshot)
|
|
93
|
+
return unless snapshot && @outcome.release?(client.deliver_performance_samples(snapshot))
|
|
88
94
|
|
|
89
95
|
@mutex.synchronize do
|
|
90
96
|
sent.each do |key, delivered|
|
|
@@ -135,10 +135,11 @@ module ForgeOpsTracker
|
|
|
135
135
|
return false
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
client.deliver_query_plan(
|
|
138
|
+
result = client.deliver_query_plan(
|
|
139
139
|
statement: job.statement, db_system: "postgresql", plan: masked_plan,
|
|
140
140
|
duration_ms: job.duration_ms, environment: configuration.environment, captured_at: job.captured_at
|
|
141
141
|
)
|
|
142
|
+
DeliveryStatus.from(result) == DeliveryStatus::DELIVERED
|
|
142
143
|
rescue StandardError => e
|
|
143
144
|
log { "EXPLAIN failed: #{e.class}: #{e.message}" }
|
|
144
145
|
false
|
|
@@ -16,6 +16,7 @@ module ForgeOpsTracker
|
|
|
16
16
|
@crashed_sessions_count = 0
|
|
17
17
|
@period_started_at = Time.now.utc
|
|
18
18
|
@thread = nil
|
|
19
|
+
@outcome = FlushOutcome.new(configuration, "session counts")
|
|
19
20
|
|
|
20
21
|
# Flushes whatever's already tallied on a normal process exit, so the last partial window
|
|
21
22
|
# (anything shorter than a full session_flush_interval) isn't silently dropped.
|
|
@@ -34,6 +35,8 @@ module ForgeOpsTracker
|
|
|
34
35
|
# Snapshots and resets the in-process counters, then delivers them. A failed delivery keeps
|
|
35
36
|
# the counts where they are rather than resetting, so the next flush's window just grows
|
|
36
37
|
# instead of losing what was already tallied; there's no other copy of this data anywhere.
|
|
38
|
+
# Counts the server rejected, or that failed FlushOutcome::MAX_FAILED_FLUSHES flushes in a
|
|
39
|
+
# row, are reset all the same: sending them again could never succeed.
|
|
37
40
|
def flush
|
|
38
41
|
snapshot = nil
|
|
39
42
|
|
|
@@ -50,7 +53,7 @@ module ForgeOpsTracker
|
|
|
50
53
|
}
|
|
51
54
|
end
|
|
52
55
|
|
|
53
|
-
return unless snapshot && client.deliver_session_checkin(snapshot)
|
|
56
|
+
return unless snapshot && @outcome.release?(client.deliver_session_checkin(snapshot))
|
|
54
57
|
|
|
55
58
|
@mutex.synchronize do
|
|
56
59
|
@sessions_count = 0
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module ForgeOpsTracker
|
|
4
|
+
# What Configuration#strict_privacy changes in everything this gem sends, for apps that can't let
|
|
5
|
+
# personal or health data leave their own servers. With it on, only structured facts go out (the
|
|
6
|
+
# error's class, its stack frames, release, environment, server, which request it happened in,
|
|
7
|
+
# timings and counts); free text, which is where a name, an email or a diagnosis hides, never
|
|
8
|
+
# leaves this process: no error message, no breadcrumbs, no raw request path, no SQL statement,
|
|
9
|
+
# no source context, no user email or username, and only the context/tag keys listed in
|
|
10
|
+
# Configuration#privacy_allowed_fields.
|
|
11
|
+
#
|
|
12
|
+
# The same rules as the server's own StrictPrivacy (app/services/strict_privacy.rb), which
|
|
13
|
+
# applies them again on arrival when the project's Strict privacy setting is on;
|
|
14
|
+
# sdks/strict_privacy_vectors.json pins the results both have to agree on, byte for byte.
|
|
15
|
+
module StrictPrivacy
|
|
16
|
+
FINGERPRINT_LENGTH = 32
|
|
17
|
+
|
|
18
|
+
# ASCII "word" characters; boundaries are defined against these, the same in every SDK, so
|
|
19
|
+
# Ruby's own idea of a word never changes the result.
|
|
20
|
+
WORD = "A-Za-z0-9_".freeze
|
|
21
|
+
HEX_OBJECT_ID = /0x[0-9a-fA-F]{4,}/
|
|
22
|
+
UUID = /(?<![#{WORD}])[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(?![#{WORD}])/
|
|
23
|
+
NUMBER = /(?<![#{WORD}])[0-9]+(?![#{WORD}])/
|
|
24
|
+
|
|
25
|
+
# Span data that's structure, not content: the masked statement and which database it ran on.
|
|
26
|
+
SPAN_DATA_KEYS = %w[db.statement db.system].freeze
|
|
27
|
+
HTTP_METHOD = /\A(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S+)/i
|
|
28
|
+
SCHEME = %r{\A[a-z][a-z0-9+.-]*://}i
|
|
29
|
+
|
|
30
|
+
# Error event fields that are never sent, and the per-frame source context.
|
|
31
|
+
EVENT_FIELDS_DROPPED = %i[message breadcrumbs endpoint sql_statement].freeze
|
|
32
|
+
SOURCE_CONTEXT_FIELDS = %i[context_line pre_context post_context].freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# The message with hex object ids, UUIDs and standalone numbers replaced, in that order.
|
|
37
|
+
def normalize_message(message)
|
|
38
|
+
message.to_s.gsub(HEX_OBJECT_ID, "0x").gsub(UUID, "<uuid>").gsub(NUMBER, "<n>")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Lets ForgeOps group errors without ever seeing their message: the first 32 hex characters of
|
|
42
|
+
# the SHA-256 of the normalized message's UTF-8 bytes, so two messages that differ only in ids
|
|
43
|
+
# hash the same, and nothing about the text can be read back from it.
|
|
44
|
+
#
|
|
45
|
+
# A message labelled binary (some C extensions raise with one) is read as the UTF-8 it almost
|
|
46
|
+
# always is, rather than converted byte by byte, which would turn every non-ASCII character into
|
|
47
|
+
# replacement characters and hash differently from the same text arriving as UTF-8.
|
|
48
|
+
def message_fingerprint(message)
|
|
49
|
+
normalized = normalize_message(message)
|
|
50
|
+
normalized = normalized.dup.force_encoding(Encoding::UTF_8) if normalized.encoding == Encoding::BINARY
|
|
51
|
+
normalized = normalized.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless normalized.encoding == Encoding::UTF_8
|
|
52
|
+
Digest::SHA256.hexdigest(normalized.scrub)[0, FINGERPRINT_LENGTH]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# An error event payload (EventBuilder#build's shape, symbol keys) reduced to what strict
|
|
56
|
+
# privacy sends. message is sent as "" alongside message_fingerprint, exactly what the server
|
|
57
|
+
# stores for a strict project.
|
|
58
|
+
def event(payload, allowed_fields: [])
|
|
59
|
+
payload.reject { |key, _value| EVENT_FIELDS_DROPPED.include?(key) }.merge(
|
|
60
|
+
message: "",
|
|
61
|
+
message_fingerprint: message_fingerprint(payload[:message]),
|
|
62
|
+
transaction_name: transaction_name(payload[:transaction_name]),
|
|
63
|
+
context: allowed(payload[:context], allowed_fields),
|
|
64
|
+
tags: allowed(payload[:tags], allowed_fields),
|
|
65
|
+
user: user(payload[:user]),
|
|
66
|
+
backtrace: Array(payload[:backtrace]).map { |frame| frame.reject { |key, _value| SOURCE_CONTEXT_FIELDS.include?(key) } }
|
|
67
|
+
).compact
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A background job or dependency failure: its class is kept, its message never is, and its
|
|
71
|
+
# transaction_name goes through transaction_name above.
|
|
72
|
+
def failure_event(entry)
|
|
73
|
+
entry.reject { |key, _value| key == :error_message }.merge(transaction_name: transaction_name(entry[:transaction_name]))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A trace's span: an outbound call's name keeps its method and host, never its path or query,
|
|
77
|
+
# and its data keeps only the masked statement and database system. data is left out entirely
|
|
78
|
+
# when nothing is left of it, the same as the server does.
|
|
79
|
+
def span(span)
|
|
80
|
+
data = (span[:data] || {}).select { |key, _value| SPAN_DATA_KEYS.include?(key.to_s) }
|
|
81
|
+
span.merge(name: span_name(span[:name]), data: (data unless data.empty?)).compact
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# "GET https://api.example.com/patients/42?x=1" or "GET api.example.com/patients/42" becomes
|
|
85
|
+
# "GET api.example.com". A bare path ("/patients/42"), or a method and a path
|
|
86
|
+
# ("PUT /orders/17/items"), has its ids taken out, like a page path. A name that's neither
|
|
87
|
+
# ("OrdersController#create", "patients#show") is kept.
|
|
88
|
+
def span_name(name)
|
|
89
|
+
return path_without_ids(name) if name.to_s.start_with?("/")
|
|
90
|
+
|
|
91
|
+
match = name.to_s.match(HTTP_METHOD)
|
|
92
|
+
return name unless match
|
|
93
|
+
|
|
94
|
+
method = match[1].upcase
|
|
95
|
+
target = match[2]
|
|
96
|
+
return "#{method} #{path_without_ids(target)}" if target.start_with?("/")
|
|
97
|
+
|
|
98
|
+
"#{method} #{host(target) || path_without_ids(target)}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Which request something happened in: a route or a controller action is kept, but a raw path
|
|
102
|
+
# (what a request no route matched falls back to) has its ids stripped, by the span name rule.
|
|
103
|
+
def transaction_name(name)
|
|
104
|
+
name.nil? || name.to_s.empty? ? name : span_name(name)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# An outbound call's host, read by hand rather than with URI.parse (an address a parser rejects
|
|
108
|
+
# must never be sent whole): what comes before the first "/", "?" or "#", without a scheme,
|
|
109
|
+
# credentials (up to the last "@", since a password can hold one) or port. Only a host when the
|
|
110
|
+
# address had a scheme or a port, or the name has a dot or is "localhost"; nil otherwise (a
|
|
111
|
+
# relative path).
|
|
112
|
+
def host(target)
|
|
113
|
+
scheme = target.match?(SCHEME)
|
|
114
|
+
authority = target.sub(SCHEME, "").split(%r{[/?#]}, 2).first.to_s.sub(/\A.*@/m, "")
|
|
115
|
+
port = authority.match?(/:\d+\z/)
|
|
116
|
+
host = authority.sub(/:\d+\z/, "")
|
|
117
|
+
return nil if host.empty?
|
|
118
|
+
|
|
119
|
+
host if scheme || port || host.include?(".") || host.casecmp?("localhost")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A path with every segment that holds a digit, or is long enough to be a token or a name
|
|
123
|
+
# slug, replaced by ":id"; no query or fragment. "/patients/42/notes?q=x" is "/patients/:id/notes".
|
|
124
|
+
def path_without_ids(path)
|
|
125
|
+
segments = path.to_s.split(/[?#]/).first.to_s.split("/").map do |segment|
|
|
126
|
+
segment.match?(/\d/) || segment.length > 40 ? ":id" : segment
|
|
127
|
+
end
|
|
128
|
+
joined = segments.join("/")
|
|
129
|
+
joined.empty? ? "/" : joined
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Only top-level keys named in allowed_fields, and only plain values under them; nil (left out
|
|
133
|
+
# of the payload) when nothing is left.
|
|
134
|
+
def allowed(fields, allowed_fields)
|
|
135
|
+
allowed_fields = Array(allowed_fields).map(&:to_s)
|
|
136
|
+
return nil if allowed_fields.empty? || !fields.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
kept = fields.each_with_object({}) do |(key, value), result|
|
|
139
|
+
next unless allowed_fields.include?(key.to_s)
|
|
140
|
+
next unless value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
|
|
141
|
+
|
|
142
|
+
result[key.to_s] = value
|
|
143
|
+
end
|
|
144
|
+
kept unless kept.empty?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The user's id, as a string, and nothing else; nil when there's no id.
|
|
148
|
+
def user(user)
|
|
149
|
+
return nil unless user.is_a?(Hash)
|
|
150
|
+
|
|
151
|
+
id = user[:id] || user["id"]
|
|
152
|
+
{ id: id.to_s } unless id.nil? || id.to_s.strip.empty?
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
data/lib/forge_ops_tracker.rb
CHANGED
|
@@ -3,7 +3,9 @@ require "forge_ops_tracker/version"
|
|
|
3
3
|
require "forge_ops_tracker/configuration"
|
|
4
4
|
require "forge_ops_tracker/pii_scrubber"
|
|
5
5
|
require "forge_ops_tracker/sql_statement"
|
|
6
|
+
require "forge_ops_tracker/strict_privacy"
|
|
6
7
|
require "forge_ops_tracker/event_builder"
|
|
8
|
+
require "forge_ops_tracker/delivery_status"
|
|
7
9
|
require "forge_ops_tracker/client"
|
|
8
10
|
require "forge_ops_tracker/delivery_queue"
|
|
9
11
|
require "forge_ops_tracker/change"
|
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.18.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ForgeOps
|
|
@@ -109,6 +109,7 @@ files:
|
|
|
109
109
|
- lib/forge_ops_tracker/client.rb
|
|
110
110
|
- lib/forge_ops_tracker/configuration.rb
|
|
111
111
|
- lib/forge_ops_tracker/delivery_queue.rb
|
|
112
|
+
- lib/forge_ops_tracker/delivery_status.rb
|
|
112
113
|
- lib/forge_ops_tracker/error_subscriber.rb
|
|
113
114
|
- lib/forge_ops_tracker/event_builder.rb
|
|
114
115
|
- lib/forge_ops_tracker/failure_event_buffer.rb
|
|
@@ -137,6 +138,7 @@ files:
|
|
|
137
138
|
- lib/forge_ops_tracker/span_buffer.rb
|
|
138
139
|
- lib/forge_ops_tracker/span_queue.rb
|
|
139
140
|
- lib/forge_ops_tracker/sql_statement.rb
|
|
141
|
+
- lib/forge_ops_tracker/strict_privacy.rb
|
|
140
142
|
- lib/forge_ops_tracker/trace_parent.rb
|
|
141
143
|
- lib/forge_ops_tracker/version.rb
|
|
142
144
|
homepage: https://getforgeops.net
|