forge_ops_tracker 0.12.0 → 0.13.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: 70707edca2368561ce9b785b91dc9e8a26280071ec3db9ed34fa71db6c402fcf
4
- data.tar.gz: a6fc1013df0404e46e0683749f2cedd688ac30273bda4ac113a7dd6334a19430
3
+ metadata.gz: ccbeed25a3172994d20455c19fa79a8336f3c73324ed667fd2f31d2cc05a19f5
4
+ data.tar.gz: 3cb7e1562daf2ccb8f09226bc915b7f00f6e8486ebbf361f8adf0a16c9851afe
5
5
  SHA512:
6
- metadata.gz: 420ff144de5e84636348c245fef836cf240c94fe6bccb49217f8d51455a3c08ce81c18dadcf7260ccc46f35dd7b2470d809b1eecd92d9bea70e17ecf149f1a78
7
- data.tar.gz: 74fc60b3d7451827618e0d63deef8cd6563d29eab144c8556bc912e91bbb4a7c8467110ae25940a7e4a7457db8718cad8b763ff752311014ed325b22ce5c13b0
6
+ metadata.gz: 6287470f8bc768e95d1d1afe55f2c7a0e640a4d66ded8a0e3c518e0248031a7f5f76621adf0436d2de3bc7da029733b9debab07e31d2f1b37c7867cfd3aee7e5
7
+ data.tar.gz: b7c996fbf89c2c0a90f6c39c848542cde734242a3fa4efaf450a36b1e7362815322417ceafc89d05a71a453a9965d7b4ab0c4deb2eedad0fdafd6b21808ad790
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0 (2026-09-25)
4
+
5
+ - New `ForgeOpsTracker.record_change(kind:, title:, details: {}, environment: nil, service: nil,
6
+ actor: nil, url: nil, id: nil, occurred_at: nil)` records something that changed in your system
7
+ (a feature flag, a config value, a hand-run migration) so ForgeOps can show it next to the errors
8
+ that followed. `kind` is one of `feature_flag`, `config`, `migration`, `dependency`,
9
+ `infrastructure`, or `other`; anything else is sent as `other`. Sent from a background thread,
10
+ never raises, and a no-op wherever reporting is off.
11
+ - Changes between deploys are now detected automatically. Once per process, after Rails finishes
12
+ booting (or on `ForgeOpsTracker.configure` outside Rails), a background thread sends a snapshot of
13
+ the Ruby version, the gem versions in `Gemfile.lock`, and the current Active Record migration
14
+ version, and ForgeOps records whatever changed since the previous boot. New
15
+ `config.detect_changes` (default true) turns this off.
16
+ - New `config.track_env_var_names` (default false) adds environment variable names, never values,
17
+ to that snapshot. Host-specific names (`HOSTNAME`, `PATH`, `PORT`, `LC_*`, Kubernetes service
18
+ variables, and others) and the gem's own `FORGE_OPS_*` settings are always left out.
19
+
3
20
  ## 0.12.0
4
21
 
5
22
  - Every error event reported during a request now says where it happened: `transaction_name` (the
data/README.md CHANGED
@@ -411,6 +411,52 @@ Requires a ForgeOps plan that includes distributed tracing; on a plan that doesn
411
411
  simply rejected server-side and dropped, exactly like any other delivery failure. The `trace_id`
412
412
  on error events is sent either way.
413
413
 
414
+ ## What changed
415
+
416
+ ForgeOps can show what changed in your system next to the errors and slowdowns that followed it.
417
+ Two ways in:
418
+
419
+ **Record a change yourself** when something changes that no deploy captures, like a feature flag
420
+ flipped, a config value edited, or a migration run by hand:
421
+
422
+ ```ruby
423
+ ForgeOpsTracker.record_change(
424
+ kind: "feature_flag", # feature_flag, config, migration, dependency, infrastructure, or other
425
+ title: "Enabled new_checkout for 10% of users",
426
+ details: { flag: "new_checkout", rollout_percent: 10 },
427
+ actor: current_user.email,
428
+ url: "https://flags.example.com/new_checkout"
429
+ )
430
+ ```
431
+
432
+ `kind` and `title` are required; `details`, `environment` (defaults to `config.environment`),
433
+ `service`, `actor`, `url`, `id` (an idempotency key, so sending the same change twice records it
434
+ once), and `occurred_at` (defaults to now) are optional. An unknown `kind` is sent as `other`. It's
435
+ queued and sent from a background thread, so it never slows down the caller, never raises, and is
436
+ a no-op wherever reporting is off.
437
+
438
+ **Changes between deploys are detected for you.** Once per process, right after Rails finishes
439
+ booting, a background thread sends ForgeOps a snapshot of what the app is running: the Ruby
440
+ version, every gem version in your `Gemfile.lock`, and the current Active Record migration version
441
+ (left out if the database can't be reached). ForgeOps compares it with the previous boot's and
442
+ records whatever changed, such as a gem upgrade or a new migration. Outside Rails the snapshot goes
443
+ out when you call `ForgeOpsTracker.configure`, without the migration version.
444
+
445
+ ```ruby
446
+ ForgeOpsTracker.configure do |config|
447
+ config.detect_changes = true # default; false sends no startup snapshot
448
+ config.track_env_var_names = false # default; true also sends environment variable names
449
+ end
450
+ ```
451
+
452
+ With `track_env_var_names` on, the snapshot lists the names of your environment variables (never
453
+ their values), so an added or removed variable shows up as a change. Names that differ from host to
454
+ host, like `HOSTNAME`, `PATH`, `PORT`, `LC_*`, and Kubernetes service variables, are left out, as
455
+ are the gem's own `FORGE_OPS_*` settings.
456
+
457
+ Requires a ForgeOps plan that includes change tracking; on a plan that doesn't, both are rejected
458
+ server-side and dropped, exactly like any other delivery failure.
459
+
414
460
  ## Custom metrics
415
461
 
416
462
  Unlike session/performance tracking above, there's no automatic instrumentation here at all: a
@@ -0,0 +1,47 @@
1
+ require "time"
2
+
3
+ module ForgeOpsTracker
4
+ # Builds the body for one ForgeOpsTracker.record_change call (POST /api/v1/changes): something
5
+ # that changed in a running system (a feature flag flipped, a config value edited, a migration
6
+ # run by hand), so ForgeOps can line it up against the errors and slowdowns around it.
7
+ module Change
8
+ # The server rejects any other kind outright (422), so an unknown one is sent as "other"
9
+ # rather than dropped: the change still gets recorded, just less specifically categorized.
10
+ KINDS = %w[feature_flag config migration dependency infrastructure other].freeze
11
+ MAX_TITLE_LENGTH = 200
12
+
13
+ module_function
14
+
15
+ # Returns nil when there's nothing sendable (a blank title): the server requires one, so
16
+ # posting it anyway would only ever come back 422.
17
+ def build(configuration, kind:, title:, details: {}, environment: nil, service: nil, actor: nil, url: nil, id: nil, occurred_at: nil)
18
+ title = title.to_s.strip
19
+ return nil if title.empty?
20
+
21
+ {
22
+ kind: normalize_kind(kind),
23
+ title: title[0, MAX_TITLE_LENGTH],
24
+ details: details.is_a?(Hash) ? details : {},
25
+ environment: (environment || configuration.environment)&.to_s,
26
+ service: service&.to_s,
27
+ actor: actor&.to_s,
28
+ url: url&.to_s,
29
+ id: id&.to_s,
30
+ occurred_at: iso8601(occurred_at || Time.now)
31
+ }.compact
32
+ end
33
+
34
+ def normalize_kind(kind)
35
+ kind = kind.to_s
36
+ KINDS.include?(kind) ? kind : "other"
37
+ end
38
+
39
+ def iso8601(value)
40
+ case value
41
+ when Time then value.utc.iso8601(3)
42
+ when String then value
43
+ else value.respond_to?(:to_time) ? value.to_time.utc.iso8601(3) : Time.now.utc.iso8601(3)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,133 @@
1
+ require "thread"
2
+
3
+ module ForgeOpsTracker
4
+ # Tells ForgeOps what this process sees at startup (the Ruby version, the locked gem versions, the
5
+ # current Active Record migration version, and optionally the names of its environment variables),
6
+ # once per process, from a background thread. The server diffs it against the last snapshot for
7
+ # the same project and environment and records whatever changed as a change, so this gem stays
8
+ # stateless: it never remembers what it sent last time.
9
+ #
10
+ # Any key that can't be determined reliably is left out rather than guessed at, since the server
11
+ # reads a missing key as "unknown", never as "everything was removed".
12
+ class ChangeSnapshot
13
+ MAX_DEPENDENCIES = 3000
14
+ MAX_NAME_LENGTH = 200
15
+ MAX_VERSION_LENGTH = 100
16
+ MAX_ENV_VAR_NAMES = 3000
17
+
18
+ # Host-specific variables that differ from one box to the next (or one boot to the next) without
19
+ # anything about the deploy having changed, so a fleet doesn't look like it's changing on every
20
+ # restart. The gem's own FORGE_OPS_* settings are dropped too.
21
+ ENV_VAR_DENYLIST = %w[
22
+ HOSTNAME HOST HOME PATH PWD OLDPWD SHLVL _ TERM USER LOGNAME SHELL LANG TMPDIR TZ PORT DYNO
23
+ INVOCATION_ID JOURNAL_STREAM
24
+ ].freeze
25
+ ENV_VAR_DENYLIST_PATTERNS = [
26
+ /\ALC_/, /\ASYSTEMD_/, /\AMEMORY_PRESSURE_/, /\AKUBERNETES_/, /\AFORGE_OPS_/,
27
+ /_SERVICE_HOST\z/, /_SERVICE_PORT/, /_PORT_.*_TCP/
28
+ ].freeze
29
+
30
+ MUTEX = Mutex.new
31
+
32
+ class << self
33
+ # Starts the one snapshot this process sends, on its own thread so it never delays boot. A
34
+ # second call is a no-op, so the Railtie and a plain ForgeOpsTracker.configure can't both
35
+ # send one. Does nothing (and doesn't use up the once) when the gem isn't enabled or
36
+ # detect_changes is off. Never raises.
37
+ def start(configuration)
38
+ return false unless configuration.enabled? && configuration.detect_changes
39
+
40
+ MUTEX.synchronize do
41
+ return false if @started
42
+
43
+ @started = true
44
+ end
45
+
46
+ thread = Thread.new { new(configuration).deliver }
47
+ thread.report_on_exception = false
48
+ true
49
+ rescue StandardError
50
+ false
51
+ end
52
+
53
+ # Names only, never values, sorted so the same set always serializes the same way.
54
+ def env_var_names(env = ENV)
55
+ env.keys.reject { |name| denied_env_var?(name) }.sort.first(MAX_ENV_VAR_NAMES)
56
+ end
57
+
58
+ def denied_env_var?(name)
59
+ ENV_VAR_DENYLIST.include?(name) || ENV_VAR_DENYLIST_PATTERNS.any? { |pattern| pattern.match?(name) }
60
+ end
61
+ end
62
+
63
+ def initialize(configuration, client: Client.new(configuration))
64
+ @configuration = configuration
65
+ @client = client
66
+ end
67
+
68
+ def deliver
69
+ client.deliver_change_snapshot(payload)
70
+ rescue StandardError => e
71
+ configuration.logger&.debug { "[ForgeOpsTracker] change snapshot failed: #{e.class}: #{e.message}" }
72
+ false
73
+ end
74
+
75
+ def payload
76
+ {
77
+ environment: configuration.environment.to_s,
78
+ state: {
79
+ schema_version: schema_version,
80
+ runtime: runtime,
81
+ dependencies: dependencies,
82
+ env_var_names: configuration.track_env_var_names ? self.class.env_var_names : nil
83
+ }.compact
84
+ }
85
+ end
86
+
87
+ def runtime
88
+ "#{RUBY_ENGINE} #{RUBY_ENGINE_VERSION}"
89
+ end
90
+
91
+ # The app's Gemfile.lock (through Bundler, so it's the exact resolved versions), falling back to
92
+ # whatever gems are actually activated when there's no bundle at all. nil, and so left out,
93
+ # when neither is available.
94
+ def dependencies
95
+ specs = bundler_specs || Gem.loaded_specs.values
96
+ return nil if specs.empty?
97
+
98
+ specs.each_with_object({}) do |spec, deps|
99
+ deps[spec.name.to_s[0, MAX_NAME_LENGTH]] = spec.version.to_s[0, MAX_VERSION_LENGTH]
100
+ end.sort.first(MAX_DEPENDENCIES).to_h
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ # The newest migration Active Record has run against this app's database, as a string. Left
106
+ # out when Active Record isn't loaded, the database can't be reached, or nothing has been
107
+ # migrated yet: a snapshot must never fail (or fail boot) because the database is down.
108
+ def schema_version
109
+ return nil unless defined?(::ActiveRecord::Base)
110
+
111
+ version = ::ActiveRecord::Base.connection_pool.with_connection do |connection|
112
+ pool = ::ActiveRecord::Base.connection_pool
113
+ context = pool.respond_to?(:migration_context) ? pool.migration_context : connection.migration_context
114
+ context.current_version
115
+ end
116
+ version.to_i.positive? ? version.to_s : nil
117
+ rescue StandardError, ScriptError
118
+ nil
119
+ end
120
+
121
+ private
122
+ attr_reader :configuration, :client
123
+
124
+ def bundler_specs
125
+ return nil unless defined?(::Bundler)
126
+
127
+ specs = ::Bundler.locked_gems&.specs
128
+ specs.nil? || specs.empty? ? nil : specs
129
+ rescue StandardError
130
+ nil
131
+ end
132
+ end
133
+ end
@@ -60,6 +60,17 @@ module ForgeOpsTracker
60
60
  post(configuration.spans_uri, { trace_id: trace_id, spans: spans })
61
61
  end
62
62
 
63
+ # 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.
65
+ def deliver_change(payload)
66
+ post(configuration.changes_uri, payload)
67
+ end
68
+
69
+ # Same again; see Configuration#change_snapshots_uri and ChangeSnapshot.
70
+ def deliver_change_snapshot(payload)
71
+ post(configuration.change_snapshots_uri, payload)
72
+ end
73
+
63
74
  private
64
75
  attr_reader :configuration
65
76
 
@@ -18,6 +18,7 @@ module ForgeOpsTracker
18
18
  attr_accessor :gauge_poll_interval
19
19
  attr_accessor :track_tracing, :trace_capture_threshold_ms
20
20
  attr_accessor :propagate_traces, :trace_propagation_targets
21
+ attr_accessor :detect_changes, :track_env_var_names
21
22
 
22
23
  def initialize
23
24
  @dsn = ENV["FORGE_OPS_DSN"]
@@ -138,6 +139,15 @@ module ForgeOpsTracker
138
139
  # Regexp is matched against the host. Worth narrowing for an app that calls third-party APIs
139
140
  # which reject unknown headers, or that shouldn't learn this app's trace ids at all.
140
141
  @trace_propagation_targets = nil
142
+ # Sends one ChangeSnapshot per process at startup (Ruby version, locked gem versions, the
143
+ # current migration version), which ForgeOps diffs against the previous boot's to record what
144
+ # changed between deploys. On by default, like every other automatic behavior here.
145
+ @detect_changes = true
146
+ # Whether that snapshot also lists the names of this process's environment variables, so an
147
+ # added or removed variable shows up as a change. Off by default: names only, never values,
148
+ # but even names can describe more about an app than some teams want to share. See
149
+ # ChangeSnapshot::ENV_VAR_DENYLIST for the host-specific names that are always left out.
150
+ @track_env_var_names = false
141
151
  end
142
152
 
143
153
  # Whether an outbound request to host should carry a traceparent header; see
@@ -231,11 +241,30 @@ module ForgeOpsTracker
231
241
  uri
232
242
  end
233
243
 
244
+ def changes_uri
245
+ sibling_uri("/changes")
246
+ end
247
+
248
+ def change_snapshots_uri
249
+ sibling_uri("/change_snapshots")
250
+ end
251
+
234
252
  def enabled?
235
253
  !blank?(dsn) && !blank?(api_key) && enabled_environments.map(&:to_s).include?(environment.to_s)
236
254
  end
237
255
 
238
256
  private
257
+ # Same substitution every *_uri method above spells out by hand: a sibling path under the
258
+ # DSN's own /api/v1/events.
259
+ def sibling_uri(path)
260
+ uri = ingestion_uri
261
+ return nil unless uri
262
+
263
+ uri = uri.dup
264
+ uri.path = uri.path.sub(%r{/events\z}, path)
265
+ uri
266
+ end
267
+
239
268
  def parsed_dsn
240
269
  return nil if blank?(dsn)
241
270
 
@@ -8,9 +8,12 @@ module ForgeOpsTracker
8
8
  # in each forked Puma/Passenger worker process rather than being carried
9
9
  # across a fork, which would leave it dead in the child.
10
10
  class DeliveryQueue
11
- def initialize(configuration, client: Client.new(configuration))
11
+ # deliver_with names the Client method each payload goes through: :deliver (error events) by
12
+ # default, :deliver_change for ForgeOpsTracker.record_change's own queue.
13
+ def initialize(configuration, client: Client.new(configuration), deliver_with: :deliver)
12
14
  @configuration = configuration
13
15
  @client = client
16
+ @deliver_with = deliver_with
14
17
  @queue = SizedQueue.new(configuration.queue_size)
15
18
  @mutex = Mutex.new
16
19
  @thread = nil
@@ -44,7 +47,7 @@ module ForgeOpsTracker
44
47
 
45
48
  def run
46
49
  loop do
47
- client.deliver(@queue.pop)
50
+ client.public_send(@deliver_with, @queue.pop)
48
51
  rescue StandardError => e
49
52
  log { "delivery thread error: #{e.class}: #{e.message}" }
50
53
  end
@@ -305,6 +305,14 @@ module ForgeOpsTracker
305
305
  # sidekiq, ActiveJob's redis queue adapter, ...), which this gem has no control over; deferring
306
306
  # the defined? check to after every railtie (including this app's own dependencies) has
307
307
  # finished initializing avoids a load-order-dependent false negative.
308
+ # After initialization, not in a plain initializer, so the app's own
309
+ # config/initializers/forge_ops_tracker.rb has already run (detect_changes/track_env_var_names
310
+ # and the DSN are final) and Active Record is configured enough to report the migration
311
+ # version. ChangeSnapshot does the actual work on its own thread, so boot never waits on it.
312
+ config.after_initialize do
313
+ ForgeOpsTracker::ChangeSnapshot.start(ForgeOpsTracker.configuration)
314
+ end
315
+
308
316
  config.after_initialize do
309
317
  next unless defined?(::RedisClient)
310
318
 
@@ -1,3 +1,3 @@
1
1
  module ForgeOpsTracker
2
- VERSION = "0.12.0"
2
+ VERSION = "0.13.0"
3
3
  end
@@ -6,6 +6,8 @@ require "forge_ops_tracker/sql_statement"
6
6
  require "forge_ops_tracker/event_builder"
7
7
  require "forge_ops_tracker/client"
8
8
  require "forge_ops_tracker/delivery_queue"
9
+ require "forge_ops_tracker/change"
10
+ require "forge_ops_tracker/change_snapshot"
9
11
  require "forge_ops_tracker/error_subscriber"
10
12
  require "forge_ops_tracker/session_flusher"
11
13
  require "forge_ops_tracker/histogram_bucketer"
@@ -32,8 +34,33 @@ module ForgeOpsTracker
32
34
  @configuration ||= Configuration.new
33
35
  end
34
36
 
37
+ # Outside Rails this is also where the startup ChangeSnapshot goes out, since there's no
38
+ # Railtie to send it once the app has booted; under Rails the Railtie sends it instead, after
39
+ # initialization, when Active Record can report the migration version.
35
40
  def configure
36
41
  yield configuration
42
+ ChangeSnapshot.start(configuration) unless defined?(::Rails::Railtie)
43
+ end
44
+
45
+ # Records one thing that changed in a running system, e.g.
46
+ # `ForgeOpsTracker.record_change(kind: "feature_flag", title: "Enabled new_checkout for 10%")`,
47
+ # so ForgeOps can show it next to the errors and slowdowns that followed. kind is one of
48
+ # Change::KINDS (anything else is sent as "other"); environment defaults to the configured one.
49
+ # Delivered from a background thread like error events, so it never blocks the caller, and a
50
+ # no-op when the gem isn't enabled. Never raises: returns false when nothing was queued.
51
+ def record_change(kind:, title:, details: {}, environment: nil, service: nil, actor: nil, url: nil, id: nil, occurred_at: nil)
52
+ return false unless configuration.enabled?
53
+
54
+ payload = Change.build(
55
+ configuration, kind: kind, title: title, details: details, environment: environment,
56
+ service: service, actor: actor, url: url, id: id, occurred_at: occurred_at
57
+ )
58
+ return false unless payload
59
+
60
+ change_queue.push(payload)
61
+ rescue StandardError => e
62
+ configuration.logger&.debug { "[ForgeOpsTracker] record_change failed: #{e.class}: #{e.message}" }
63
+ false
37
64
  end
38
65
 
39
66
  # Records a named business metric (a signup, a payment, anything a customer wants to name),
@@ -120,6 +147,10 @@ module ForgeOpsTracker
120
147
  end
121
148
 
122
149
  private
150
+ def change_queue
151
+ @change_queue ||= DeliveryQueue.new(configuration, deliver_with: :deliver_change)
152
+ end
153
+
123
154
  def metric_buffer
124
155
  @metric_buffer ||= MetricBuffer.new(configuration)
125
156
  end
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.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ForgeOps
@@ -90,6 +90,8 @@ files:
90
90
  - README.md
91
91
  - lib/forge_ops_tracker.rb
92
92
  - lib/forge_ops_tracker/breadcrumb_buffer.rb
93
+ - lib/forge_ops_tracker/change.rb
94
+ - lib/forge_ops_tracker/change_snapshot.rb
93
95
  - lib/forge_ops_tracker/client.rb
94
96
  - lib/forge_ops_tracker/configuration.rb
95
97
  - lib/forge_ops_tracker/delivery_queue.rb