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 +4 -4
- data/CHANGELOG.md +14 -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/configuration.rb +25 -0
- data/lib/forge_ops_tracker/event_builder.rb +14 -4
- data/lib/forge_ops_tracker/failure_event_buffer.rb +3 -1
- data/lib/forge_ops_tracker/middleware/span_tracing.rb +9 -1
- data/lib/forge_ops_tracker/performance_flusher.rb +2 -0
- 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 +1 -0
- metadata +2 -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,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
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,
|
|
@@ -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
|
-
|
|
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
|
|
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
|
data/lib/forge_ops_tracker.rb
CHANGED
|
@@ -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.
|
|
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
|