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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a535fb7cb193fd614ee40259a9b20e62832149c7a2dbeb7d1d40253c0630f65a
4
- data.tar.gz: 559a19cd52a56addacc87c5533bc0954c7ca2de6eac81a6afb18769f280c06c5
3
+ metadata.gz: e9434056bed332dad65c4a8dc051bace54e7acae535043138833f60ba2586cb0
4
+ data.tar.gz: 270cec5a96267fc7d1a41be7c02ab1bfdb9271fb2d7a5e8ae91581a0181848d0
5
5
  SHA512:
6
- metadata.gz: 158342795c1dbb5e7575da43ddcd239c25b52111c268ede3fdfdbb9b5621cf1a10cd120e602f75fe2e57bd46faed9483aaa1a61f8c9f91770474210414400e8f
7
- data.tar.gz: 5b4f11f1758833c58f8925f1354f8a7da49b80d04344cc2c340a2662a186fcfc0a8451709faef4c78a60d618c9f0ea936c460c97b3af8bad2687fbc8f6fbfdae
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
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 ForgeOps
3
+ Copyright (c) 2026 Luke Popwell
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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
- return if max_size.zero?
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
- # `false` return rather than a raised exception, since a broken or
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 bare
20
- # true/false), just a different endpoint under the same DSN's host and
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 just a false here.
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 just a false here.
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 false unless uri
86
+ return DeliveryStatus::FAILED unless uri
85
87
 
86
88
  response = http_for(uri).request(build_request(uri, payload))
87
- response.is_a?(Net::HTTPSuccess)
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
- false
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
- client.public_send(@deliver_with, payload)
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
- client.public_send(@deliver_with, payload)
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
- @mutex.synchronize { @entries = [] }
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 -- this gem keeps SessionFlusher/PerformanceFlusher as separate, independently-
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 succeeds: a plan
14
- # without the feature answers 403 on every flush, and an uncapped buffer would then grow for as
15
- # long as the process lives. Dropping the newest rather than the oldest keeps the entries a flush
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 delivered: anything recorded while the request was in flight sits
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 succeeds: a plan
14
- # without the feature answers 403 on every flush, and an uncapped buffer would then grow for as
15
- # long as the process lives. Dropping the newest rather than the oldest keeps the entries a flush
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 failed
48
- # delivery keeps every entry where it is rather than resetting, so the next flush's batch
49
- # just grows instead of losing what was already buffered; same reasoning SessionFlusher#flush
50
- # already documents, including the same small, accepted race (an entry recorded during the
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 delivered: anything recorded while the request was in flight sits
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.spans)
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
@@ -1,3 +1,3 @@
1
1
  module ForgeOpsTracker
2
- VERSION = "0.16.0"
2
+ VERSION = "0.18.0"
3
3
  end
@@ -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.16.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