forge_ops_tracker 0.17.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: 304c9a15155ecdf53fe8e181dd7f43b56a08d7397e7d63555e428185ece06560
4
- data.tar.gz: d4d2cb88ffec650e579c7279c590e4eaee8f288bc6ef7041d2c2f5785767fa2f
3
+ metadata.gz: e9434056bed332dad65c4a8dc051bace54e7acae535043138833f60ba2586cb0
4
+ data.tar.gz: 270cec5a96267fc7d1a41be7c02ab1bfdb9271fb2d7a5e8ae91581a0181848d0
5
5
  SHA512:
6
- metadata.gz: dfd3a51d60e0decd113e2a8bd50db101ebd4ec57ebb468a522413b4f9ffa0c5c15d5440939c7a6f772561ce6ffe5c7078322d2903f0d3ec87006f1dd368c1718
7
- data.tar.gz: 1646c0b506c357122c513604d031974318df0086a0c2a962c99e80143624be459755fab5dfff3df7718f24bb6caebe699ba5325fafb79b35a92f76ffa0783b63
6
+ metadata.gz: 34730c4a7e8af87adb9bdaf490d72d923a0bb96b958a9131c28671d3198a43638dc5f13a2331c92b3d1dd506d24b08b6118d6d0559e4c64d5b12b231a0a8c97c
7
+ data.tar.gz: 0a9704a43c0c74397e082ab191deab1895de0d17210469b5906c123c33cfd7fe9130992ee10932106a093c4d6c329dadca6ca59da16f46173a063395e0eb7b9c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
3
17
  ## 0.17.0 (2026-10-13)
4
18
 
5
19
  - Fixed: a batch the server refused was re-sent at every flush for as long as the process lived,
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,
@@ -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)
@@ -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
@@ -31,11 +31,13 @@ module ForgeOpsTracker
31
31
  @mutex.synchronize do
32
32
  return false if @entries.size >= MAX_ENTRIES
33
33
 
34
- @entries << {
34
+ entry = {
35
35
  kind: kind, transaction_name: transaction_name, error_class: error_class, error_message: error_message,
36
36
  environment: configuration.environment.to_s, release: configuration.release,
37
37
  occurred_at: Time.now.utc.iso8601
38
38
  }
39
+ # Strict privacy keeps the error's class, never its message.
40
+ @entries << (configuration.strict_privacy ? StrictPrivacy.failure_event(entry) : entry)
39
41
  end
40
42
  end
41
43
 
@@ -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
@@ -31,6 +31,8 @@ module ForgeOpsTracker
31
31
  # without colliding into the same bucket.
32
32
  def record(transaction_name:, duration_ms:, kind: "controller")
33
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
34
36
 
35
37
  @mutex.synchronize do
36
38
  bucket = (@buckets[[ transaction_name, kind ]] ||= { count: 0, duration_sum_ms: 0.0, max_duration_ms: 0.0, histogram: Hash.new(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.17.0"
2
+ VERSION = "0.18.0"
3
3
  end
@@ -3,6 +3,7 @@ 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"
7
8
  require "forge_ops_tracker/delivery_status"
8
9
  require "forge_ops_tracker/client"
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.17.0
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ForgeOps
@@ -138,6 +138,7 @@ files:
138
138
  - lib/forge_ops_tracker/span_buffer.rb
139
139
  - lib/forge_ops_tracker/span_queue.rb
140
140
  - lib/forge_ops_tracker/sql_statement.rb
141
+ - lib/forge_ops_tracker/strict_privacy.rb
141
142
  - lib/forge_ops_tracker/trace_parent.rb
142
143
  - lib/forge_ops_tracker/version.rb
143
144
  homepage: https://getforgeops.net