forge_ops_tracker 0.17.0 → 0.19.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: 3d585c1acbde6c8bb06ec05505e21bf4d784974ed8e655c9398fef0c7e595d03
4
+ data.tar.gz: af67478c90a33dd594bbccf712e48dc817d5e99ae6b61edf0a0fca62e816e4ec
5
5
  SHA512:
6
- metadata.gz: dfd3a51d60e0decd113e2a8bd50db101ebd4ec57ebb468a522413b4f9ffa0c5c15d5440939c7a6f772561ce6ffe5c7078322d2903f0d3ec87006f1dd368c1718
7
- data.tar.gz: 1646c0b506c357122c513604d031974318df0086a0c2a962c99e80143624be459755fab5dfff3df7718f24bb6caebe699ba5325fafb79b35a92f76ffa0783b63
6
+ metadata.gz: ae806f1ba9cb14102494df6f6efca7685ce4900c281fda85c72fe9ad595f5ce3b03b7daf74762ef69de002e0c229d3155027eee4bd3c8d876646b824f4ee1ab4
7
+ data.tar.gz: b774e7abc22663d23f728c71ee7d358180994ccc31b23eb334d393c39da7d403561456b4b484bbe34f3f1678c7f619a40ac94c977a4086ccac66f852790cdb85
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0 (2026-10-20)
4
+
5
+ - Strict privacy: a raw request path now keeps only its first segment, and the rest is sent as
6
+ `:path` (`/patients/jane-doe/12` becomes `/patients/:path`; a first segment with a digit or over
7
+ 40 characters becomes `:id`). Before, only segments holding a digit were replaced, so a path like
8
+ `/patients/janedoe` went out whole. A route template (`/patients/:id/notes`, `{id}`, `<int:id>`,
9
+ `[slug]`, `*path`) is kept as it is. This applies everywhere strict privacy names a request or a
10
+ call by its path: span names and transaction names.
11
+
12
+ ## 0.18.0 (2026-10-20)
13
+
14
+ - New `config.strict_privacy` (off by default, or `FORGE_OPS_STRICT_PRIVACY=true`) for apps that
15
+ can't let personal or health data leave their servers. With it on, an error event carries no
16
+ message (a `message_fingerprint` computed in your process is sent instead, so errors still
17
+ group), no breadcrumbs, no `endpoint`, no SQL statement and no source context, and the user only
18
+ by `id`. Failed jobs and outbound calls keep their error class but not their message, and
19
+ outbound HTTP spans are named by method and host only, with no span data but `db.statement` and
20
+ `db.system`. A `transaction_name` that is a raw path, not a route or `Controller#action`, has its
21
+ ids taken out in error events, performance samples and failure events.
22
+ - New `config.privacy_allowed_fields` (or `FORGE_OPS_PRIVACY_ALLOWED_FIELDS=error_code,feature`):
23
+ the context and tag keys strict privacy still sends, when their value is a string, number or
24
+ boolean. Empty by default.
25
+
3
26
  ## 0.17.0 (2026-10-13)
4
27
 
5
28
  - 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,44 @@ 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` keeps only
247
+ its first segment (`GET /patients/jane-doe/12` is sent as `GET /patients/:path`; a first
248
+ segment with a digit becomes `:id`), in error events, performance samples, failure events and
249
+ spans alike. A route template such as `/patients/:id/notes` is kept as it is.
250
+ - Outbound HTTP spans are named by method and host only (`GET api.example.com`), and span data
251
+ keeps only the masked `db.statement` and `db.system`.
252
+
253
+ ```ruby
254
+ ForgeOpsTracker.configure do |config|
255
+ config.strict_privacy = true # default false
256
+ config.privacy_allowed_fields = %w[error_code feature] # default []
257
+ end
258
+ ```
259
+
260
+ Both can also come from the environment: `FORGE_OPS_STRICT_PRIVACY=true` and
261
+ `FORGE_OPS_PRIVACY_ALLOWED_FIELDS=error_code,feature` (comma separated). A value set in
262
+ `configure` wins over the environment.
263
+
264
+ ForgeOps applies the same rules again on arrival when the project's own Strict privacy setting is
265
+ on, so an app with an older gem, or one with this option off, still can't get that text stored.
266
+
229
267
  ## Source context
230
268
 
231
269
  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,9 @@ 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 keeps only its first segment; a route or
35
+ # Controller#action is kept.
36
+ transaction_name = StrictPrivacy.transaction_name(transaction_name) if configuration.strict_privacy
34
37
 
35
38
  @mutex.synchronize do
36
39
  bucket = (@buckets[[ transaction_name, kind ]] ||= { count: 0, duration_sum_ms: 0.0, max_duration_ms: 0.0, histogram: Hash.new(0) })
@@ -0,0 +1,171 @@
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
+ ROUTE_PARAMETER = /\A(?::.+|\{.+\}|<.+>|\[.+\]|\*.*)\z/
30
+
31
+ # Error event fields that are never sent, and the per-frame source context.
32
+ EVENT_FIELDS_DROPPED = %i[message breadcrumbs endpoint sql_statement].freeze
33
+ SOURCE_CONTEXT_FIELDS = %i[context_line pre_context post_context].freeze
34
+
35
+ module_function
36
+
37
+ # The message with hex object ids, UUIDs and standalone numbers replaced, in that order.
38
+ def normalize_message(message)
39
+ message.to_s.gsub(HEX_OBJECT_ID, "0x").gsub(UUID, "<uuid>").gsub(NUMBER, "<n>")
40
+ end
41
+
42
+ # Lets ForgeOps group errors without ever seeing their message: the first 32 hex characters of
43
+ # the SHA-256 of the normalized message's UTF-8 bytes, so two messages that differ only in ids
44
+ # hash the same, and nothing about the text can be read back from it.
45
+ #
46
+ # A message labelled binary (some C extensions raise with one) is read as the UTF-8 it almost
47
+ # always is, rather than converted byte by byte, which would turn every non-ASCII character into
48
+ # replacement characters and hash differently from the same text arriving as UTF-8.
49
+ def message_fingerprint(message)
50
+ normalized = normalize_message(message)
51
+ normalized = normalized.dup.force_encoding(Encoding::UTF_8) if normalized.encoding == Encoding::BINARY
52
+ normalized = normalized.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless normalized.encoding == Encoding::UTF_8
53
+ Digest::SHA256.hexdigest(normalized.scrub)[0, FINGERPRINT_LENGTH]
54
+ end
55
+
56
+ # An error event payload (EventBuilder#build's shape, symbol keys) reduced to what strict
57
+ # privacy sends. message is sent as "" alongside message_fingerprint, exactly what the server
58
+ # stores for a strict project.
59
+ def event(payload, allowed_fields: [])
60
+ payload.reject { |key, _value| EVENT_FIELDS_DROPPED.include?(key) }.merge(
61
+ message: "",
62
+ message_fingerprint: message_fingerprint(payload[:message]),
63
+ transaction_name: transaction_name(payload[:transaction_name]),
64
+ context: allowed(payload[:context], allowed_fields),
65
+ tags: allowed(payload[:tags], allowed_fields),
66
+ user: user(payload[:user]),
67
+ backtrace: Array(payload[:backtrace]).map { |frame| frame.reject { |key, _value| SOURCE_CONTEXT_FIELDS.include?(key) } }
68
+ ).compact
69
+ end
70
+
71
+ # A background job or dependency failure: its class is kept, its message never is, and its
72
+ # transaction_name goes through transaction_name above.
73
+ def failure_event(entry)
74
+ entry.reject { |key, _value| key == :error_message }.merge(transaction_name: transaction_name(entry[:transaction_name]))
75
+ end
76
+
77
+ # A trace's span: an outbound call's name keeps its method and host, never its path or query,
78
+ # and its data keeps only the masked statement and database system. data is left out entirely
79
+ # when nothing is left of it, the same as the server does.
80
+ def span(span)
81
+ data = (span[:data] || {}).select { |key, _value| SPAN_DATA_KEYS.include?(key.to_s) }
82
+ span.merge(name: span_name(span[:name]), data: (data unless data.empty?)).compact
83
+ end
84
+
85
+ # "GET https://api.example.com/patients/42?x=1" or "GET api.example.com/patients/42" becomes
86
+ # "GET api.example.com". A bare path ("/patients/42"), or a method and a path
87
+ # ("PUT /orders/17/items"), goes through the path rule, like a page path. A name that's neither
88
+ # ("OrdersController#create", "patients#show") is kept.
89
+ def span_name(name)
90
+ return strict_path(name) if name.to_s.start_with?("/")
91
+
92
+ match = name.to_s.match(HTTP_METHOD)
93
+ return name unless match
94
+
95
+ method = match[1].upcase
96
+ target = match[2]
97
+ return "#{method} #{strict_path(target)}" if target.start_with?("/")
98
+
99
+ "#{method} #{host(target) || strict_path(target)}"
100
+ end
101
+
102
+ # Which request something happened in: a route or a controller action is kept, but a raw path
103
+ # (what a request no route matched falls back to) keeps only its first segment, by the span name
104
+ # rule.
105
+ def transaction_name(name)
106
+ name.nil? || name.to_s.empty? ? name : span_name(name)
107
+ end
108
+
109
+ # An outbound call's host, read by hand rather than with URI.parse (an address a parser rejects
110
+ # must never be sent whole): what comes before the first "/", "?" or "#", without a scheme,
111
+ # credentials (up to the last "@", since a password can hold one) or port. Only a host when the
112
+ # address had a scheme or a port, or the name has a dot or is "localhost"; nil otherwise (a
113
+ # relative path).
114
+ def host(target)
115
+ scheme = target.match?(SCHEME)
116
+ authority = target.sub(SCHEME, "").split(%r{[/?#]}, 2).first.to_s.sub(/\A.*@/m, "")
117
+ port = authority.match?(/:\d+\z/)
118
+ host = authority.sub(/:\d+\z/, "")
119
+ return nil if host.empty?
120
+
121
+ host if scheme || port || host.include?(".") || host.casecmp?("localhost")
122
+ end
123
+
124
+ # A request or page path under strict privacy, with no query or fragment. A route template
125
+ # ("/patients/:id/notes", "/patients/{id}", "users/<int:id>/", "/files/*path", "/posts/[slug]")
126
+ # is kept as it is: its words are a developer's, and its parameters are already placeholders. A
127
+ # raw path keeps only its first segment, and the rest becomes ":path": "/patients/jane-doe/12"
128
+ # is "/patients/:path". Anything after the first segment of a raw path could name a person, and
129
+ # no rule can tell a name from a word ("/patients/janedoe"). A first segment that holds a digit
130
+ # or is over 40 characters is ":id" itself.
131
+ def strict_path(path)
132
+ path = path.to_s.split(/[?#]/, 2).first.to_s
133
+ return path if route_template?(path)
134
+
135
+ leading = path.start_with?("/") ? "/" : ""
136
+ segments = path.split("/").reject(&:empty?)
137
+ return "/" if segments.empty?
138
+
139
+ first = segments.first.match?(/[0-9]/) || segments.first.length > 40 ? ":id" : segments.first
140
+ "#{leading}#{first}#{"/:path" if segments.size > 1}"
141
+ end
142
+
143
+ # Whether any segment is a route parameter: ":id", "{id}", "<int:id>", "[slug]" or "*path".
144
+ def route_template?(path)
145
+ path.split("/").any? { |segment| segment.match?(ROUTE_PARAMETER) }
146
+ end
147
+
148
+ # Only top-level keys named in allowed_fields, and only plain values under them; nil (left out
149
+ # of the payload) when nothing is left.
150
+ def allowed(fields, allowed_fields)
151
+ allowed_fields = Array(allowed_fields).map(&:to_s)
152
+ return nil if allowed_fields.empty? || !fields.is_a?(Hash)
153
+
154
+ kept = fields.each_with_object({}) do |(key, value), result|
155
+ next unless allowed_fields.include?(key.to_s)
156
+ next unless value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
157
+
158
+ result[key.to_s] = value
159
+ end
160
+ kept unless kept.empty?
161
+ end
162
+
163
+ # The user's id, as a string, and nothing else; nil when there's no id.
164
+ def user(user)
165
+ return nil unless user.is_a?(Hash)
166
+
167
+ id = user[:id] || user["id"]
168
+ { id: id.to_s } unless id.nil? || id.to_s.strip.empty?
169
+ end
170
+ end
171
+ end
@@ -1,3 +1,3 @@
1
1
  module ForgeOpsTracker
2
- VERSION = "0.17.0"
2
+ VERSION = "0.19.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.19.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